-- luatex-cn-punct-table.lua -- Single data source for Chinese punctuation, digitized from the W3C clreq -- appendix "Punctuation marks in Chinese" (pause/stop marks, indication marks, -- inter-line marks, and the Unbreakable / Rotated-90°-in-vertical columns), -- plus the width / adjustable-space rules from clreq "Punctuation width -- adjustment" and "Procedures for inter-character spacing reduction". -- -- Pure Lua, zero TeX dependency. Shared by the horizontal and vertical -- backends. Interface contract: ai_must_read/clreq-shared-core.md -- -- Units: all widths / spaces are em ratios (font size = 1). Backends convert -- to sp by multiplying with the font size. local M = {} -- ============================================================================ -- Kinsoku level ordering (none < basic < gb < strict) -- ============================================================================ local LEVEL_RANK = { none = 0, basic = 1, gb = 2, strict = 3 } M.LEVELS = LEVEL_RANK -- ============================================================================ -- Entry construction helpers -- ============================================================================ -- Most entries share per-style symmetric values; these helpers keep the -- table below readable. local function both(v) return { mainland = v, taiwan = v } end -- Shorthand entry builders per clreq punctuation category. -- Fields: name, class, width, space, shrink, shrink_class, -- unbreakable, vert_rotate, forbid_start, forbid_end -- Pause/stop marks in mainland (GB) style sit at the start of the frame with -- the blank at the end; Taiwan style centers them with blank on both sides. local function point_mark(name, class, shrink_class, shrink_amount) return { name = name, class = class, width = both(1), space = { mainland = "end", taiwan = "both" }, shrink = both(shrink_amount), shrink_class = shrink_class, forbid_start = "basic", } end local function open_bracket(name) return { name = name, class = "open", width = both(1), space = both("start"), shrink = both(0.5), shrink_class = "bracket", vert_rotate = true, forbid_end = "basic", } end local function close_bracket(name) return { name = name, class = "close", width = both(1), space = both("end"), shrink = both(0.5), shrink_class = "bracket", vert_rotate = true, forbid_start = "basic", } end -- Two-em unit members (dash / ellipsis): fixed width, unbreakable as a pair, -- rotated in vertical mode. Line-start forbidden only at the strict level. local function two_em_member(name, class, width) return { name = name, class = class, width = both(width), space = both("none"), shrink = both(0), unbreakable = true, vert_rotate = true, forbid_start = "strict", } end -- ============================================================================ -- The table (keyed by Unicode codepoint) -- ============================================================================ local TABLE = { -- ------------------------------------------------------------------ -- Pause or stop punctuation marks (点号) -- ------------------------------------------------------------------ [0x3002] = point_mark("句号", "fullstop", "fullstop_group", 0.5), -- 。 [0xFF0E] = point_mark("句号", "fullstop", "fullstop_group", 0.5), -- . [0xFF0C] = point_mark("逗号", "comma", "comma_group", 0.5), -- , [0x3001] = point_mark("顿号", "comma", "comma_group", 0.5), -- 、 -- Colon is not in the clreq compression priority list: not shrinkable. [0xFF1A] = point_mark("冒号", "colon", nil, 0), -- : [0xFF1B] = point_mark("分号", "semicolon", "comma_group", 0.5), -- ; [0xFF01] = point_mark("叹号", "exclamation", "fullstop_group", 0.5), -- ! [0xFF1F] = point_mark("问号", "question", "fullstop_group", 0.5), -- ? -- Stacked forms take exactly 1 em (clreq appendix note), never adjusted. [0x203C] = point_mark("双叹号", "exclamation", nil, 0), -- ‼ [0x2047] = point_mark("双问号", "question", nil, 0), -- ⁇ -- ------------------------------------------------------------------ -- Indication punctuation marks (标号) -- ------------------------------------------------------------------ -- Dash (破折号): —— pair or single ⸺ takes two em as one unit. [0x2014] = two_em_member("破折号", "dash", 1), -- — [0x2E3A] = two_em_member("破折号", "dash", 2), -- ⸺ -- Ellipsis (省略号): …… / ⋯⋯ pair takes two em as one unit. [0x2026] = two_em_member("省略号", "ellipsis", 1), -- … [0x22EF] = two_em_member("省略号", "ellipsis", 1), -- ⋯ -- Connector (连接号): GB half-width forms are fixed at 0.5 em -- (clreq: 不可调整的标点…中国大陆GB式的半字连接号). [0xFF5E] = { name = "连接号", class = "connector", -- ~ width = both(1), space = both("none"), shrink = both(0), vert_rotate = true, forbid_start = "basic" }, [0x002D] = { name = "连接号", class = "connector", -- - width = both(0.5), space = both("none"), shrink = both(0), vert_rotate = true, forbid_start = "basic" }, [0x2013] = { name = "连接号", class = "connector", -- – width = both(0.5), space = both("none"), shrink = both(0), vert_rotate = true, forbid_start = "basic" }, -- Interpunct (间隔号): mainland GB style is fixed half width; Taiwan -- style takes one em centered, compressible from both sides down to -- half width (clreq shrink step 3: 最小挤到 0,即变成半个汉字字宽). [0x00B7] = { name = "间隔号", class = "interpunct", -- · width = { mainland = 0.5, taiwan = 1 }, space = { mainland = "none", taiwan = "both" }, shrink = { mainland = 0, taiwan = 0.5 }, shrink_class = "interpunct", forbid_start = "basic" }, [0x30FB] = { name = "间隔号", class = "interpunct", -- ・ width = both(1), space = both("both"), shrink = both(0.5), shrink_class = "interpunct", forbid_start = "basic" }, [0x2027] = { name = "间隔号", class = "interpunct", -- ‧ (Big5) width = both(1), space = both("both"), shrink = both(0.5), shrink_class = "interpunct", forbid_start = "basic" }, -- Solidus (分隔号): mainland half-width / fixed; fullwidth form is used -- in Traditional Chinese, fixed. Line-end forbidden from the GB level. [0x002F] = { name = "分隔号", class = "solidus", -- / width = both(0.5), space = both("none"), shrink = both(0), vert_rotate = true, forbid_start = "basic", forbid_end = "gb" }, [0xFF0F] = { name = "分隔号", class = "solidus", -- / width = both(1), space = both("none"), shrink = both(0), forbid_start = "basic", forbid_end = "gb" }, -- Quotes (引号) [0x300C] = open_bracket("引号"), -- 「 [0x300D] = close_bracket("引号"), -- 」 [0x300E] = open_bracket("引号"), -- 『 [0x300F] = close_bracket("引号"), -- 』 -- Curly quotes take one character space in Simplified Chinese (clreq -- appendix note); in Traditional Chinese text they are usually Western -- proportional glyphs — backends decide whether to treat them as CJK. [0x201C] = open_bracket("引号"), -- " [0x201D] = close_bracket("引号"), -- " [0x2018] = open_bracket("引号"), -- ' [0x2019] = close_bracket("引号"), -- ' -- Parentheses / brackets (夹注号 及其他括号) [0xFF08] = open_bracket("括号"), -- ( [0xFF09] = close_bracket("括号"), -- ) [0x3010] = open_bracket("括号"), -- 【 [0x3011] = close_bracket("括号"), -- 】 [0x3016] = open_bracket("括号"), -- 〖 [0x3017] = close_bracket("括号"), -- 〗 [0x3014] = open_bracket("括号"), -- 〔 [0x3015] = close_bracket("括号"), -- 〕 [0xFF3B] = open_bracket("括号"), -- [ [0xFF3D] = close_bracket("括号"), -- ] [0xFF5B] = open_bracket("括号"), -- { [0xFF5D] = close_bracket("括号"), -- } -- Book title marks (书名号乙式 / 篇名号) [0x300A] = open_bracket("书名号"), -- 《 [0x300B] = close_bracket("书名号"), -- 》 [0x3008] = open_bracket("篇名号"), -- 〈 [0x3009] = close_bracket("篇名号"), -- 〉 -- ------------------------------------------------------------------ -- Inter-line indication marks (行间标号): no inline advance of their -- own; kept for classification completeness. LOW LINE / WAVY LOW LINE -- are listed as rotated in the clreq appendix. -- ------------------------------------------------------------------ [0xFF3F] = { name = "专名号", class = "linemark", vert_rotate = true }, -- _ [0xFE4F] = { name = "书名号甲式", class = "linemark", vert_rotate = true }, -- ﹏ [0x25CF] = { name = "着重号", class = "emphasis" }, -- ● [0x2022] = { name = "着重号", class = "emphasis" }, -- • } -- ============================================================================ -- Queries -- ============================================================================ --- Get the raw table entry for a codepoint. -- @param char (number) Unicode codepoint -- @return (table|nil) function M.get(char) return TABLE[char] end --- Get the clreq class of a codepoint. -- @param char (number) Unicode codepoint -- @return (string|nil) function M.class_of(char) local e = TABLE[char] return e and e.class end local POINT_CLASSES = { fullstop = true, comma = true, colon = true, semicolon = true, exclamation = true, question = true, } --- Whether the codepoint is a pause/stop mark (点号). -- @param char (number) Unicode codepoint -- @return (boolean) function M.is_point(char) local e = TABLE[char] return (e and POINT_CLASSES[e.class]) or false end -- Classes whose width is fixed to one em in vertical mode regardless of -- style (clreq: 直排的冒号、分号、问号、感叹号固定一个字宽). local VERT_FIXED_CLASSES = { colon = true, semicolon = true, exclamation = true, question = true, } local NO_SPACE = { side = "none", shrink = 0 } --- Adjustable-space info for a codepoint under a style and writing mode. -- Applies the clreq mode/style overrides: -- * vertical: colon/semicolon/question/exclamation fixed (both styles) -- * horizontal taiwan: question/exclamation fixed -- @param char (number) Unicode codepoint -- @param style (string) "mainland" | "taiwan" -- @param mode (string) "horizontal" | "vertical" -- @return (table|nil) { side = "start"|"end"|"both"|"none", shrink = em } function M.space_info(char, style, mode) local e = TABLE[char] if not e or not e.space then return nil end if mode == "vertical" and VERT_FIXED_CLASSES[e.class] then return NO_SPACE end if mode == "horizontal" and style == "taiwan" and (e.class == "question" or e.class == "exclamation") then return NO_SPACE end local side = e.space[style] local shrink = e.shrink and e.shrink[style] or 0 if not side or side == "none" or shrink <= 0 then return { side = side or "none", shrink = 0 } end return { side = side, shrink = shrink } end --- Shrink priority group for adjust.lua, honoring mode/style overrides. -- @param char (number) Unicode codepoint -- @param style (string) "mainland" | "taiwan" -- @param mode (string) "horizontal" | "vertical" -- @return (string|nil) group name (matches adjust.SHRINK_ORDER) or nil function M.shrink_class_of(char, style, mode) local e = TABLE[char] if not e or not e.shrink_class then return nil end local info = M.space_info(char, style, mode) if not info or info.shrink <= 0 then return nil end return e.shrink_class end --- Nominal advance width (em) for a codepoint under a style. -- @param char (number) Unicode codepoint -- @param style (string) "mainland" | "taiwan" -- @return (number|nil) em ratio, or nil for inter-line marks / unknown chars function M.width_of(char, style) local e = TABLE[char] return e and e.width and e.width[style] end --- Whether a codepoint is forbidden at line start under a kinsoku level. -- @param char (number) Unicode codepoint -- @param level (string) "none" | "basic" | "gb" | "strict" -- @return (boolean) function M.forbid_line_start(char, level) local e = TABLE[char] if not e or not e.forbid_start then return false end local rank = LEVEL_RANK[level] if not rank then return false end return rank >= LEVEL_RANK[e.forbid_start] end --- Whether a codepoint is forbidden at line end under a kinsoku level. -- @param char (number) Unicode codepoint -- @param level (string) "none" | "basic" | "gb" | "strict" -- @return (boolean) function M.forbid_line_end(char, level) local e = TABLE[char] if not e or not e.forbid_end then return false end local rank = LEVEL_RANK[level] if not rank then return false end return rank >= LEVEL_RANK[e.forbid_end] end --- Whether the codepoint carries the appendix "Unbreakable" flag -- (two-em dash/ellipsis members). -- @param char (number) Unicode codepoint -- @return (boolean) function M.is_unbreakable(char) local e = TABLE[char] return (e and e.unbreakable) or false end -- 叹问号叠加的序列形式(clreq 非典型标点:?? !! ?! !?)。 -- 与预组合码位 ‼(U+203C) ⁇(U+2047) 同义,但以两个全角符号连排出现, -- 构成一个两字宽的**刚性**整体:内部不挤压、不拆行、不被兜底均分撑开。 local STACKED_MARKS = { [0xFF01] = true, -- ! [0xFF1F] = true, -- ? } --- Whether two adjacent codepoints form a stacked exclamation/question -- sequence (?? !! ?! !?) — a rigid two-em unit per the clreq -- appendix note on stacked forms (占两个汉字宽度,宽度不可调整). -- @param a (number) previous codepoint -- @param b (number) next codepoint -- @return (boolean) function M.is_stacked_pair(a, b) return (STACKED_MARKS[a] and STACKED_MARKS[b]) or false end --- Whether two adjacent codepoints form an unbreakable two-em unit -- (——, ……, ⋯⋯: identical dash/ellipsis members; ?? !! ?! !?: -- stacked marks — the only case where the two members may differ). -- @param a (number) previous codepoint -- @param b (number) next codepoint -- @return (boolean) function M.is_unbreakable_pair(a, b) if M.is_stacked_pair(a, b) then return true end if a ~= b then return false end local e = TABLE[a] return (e and e.unbreakable and (e.class == "dash" or e.class == "ellipsis")) or false end --- Whether the codepoint is rotated 90° clockwise in vertical mode -- (appendix column). -- @param char (number) Unicode codepoint -- @return (boolean) function M.vert_rotate(char) local e = TABLE[char] return (e and e.vert_rotate) or false end -- ============================================================================ -- Quote style conversion (clreq 引号体例) -- ============================================================================ -- clreq: 横排简体中文用弯引号,嵌套体例先双后单(“…‘…’…”); -- 台湾用传统引号,先单后双(「…『…』…」)。两套引号按嵌套深度一一对应, -- 逐字映射即完成体例转换(外层对外层、内层对内层)。 local TO_CURLY = { [0x300C] = 0x201C, -- 「 → “ [0x300D] = 0x201D, -- 」 → ” [0x300E] = 0x2018, -- 『 → ‘ [0x300F] = 0x2019, -- 』 → ’ } local TO_CORNER = { [0x201C] = 0x300C, -- “ → 「 [0x201D] = 0x300D, -- ” → 」 [0x2018] = 0x300E, -- ‘ → 『 [0x2019] = 0x300F, -- ’ → 』 } --- Convert a quote codepoint to the target style, preserving nesting depth -- and open/close role. -- @param char (number) Unicode codepoint -- @param target (string) "curly" (简体横排) | "corner" (台湾) -- @return (number|nil) converted codepoint, or nil if no conversion applies function M.quote_convert(char, target) if target == "curly" then return TO_CURLY[char] elseif target == "corner" then return TO_CORNER[char] end return nil end -- Legacy six-type mapping used by the current vertical engine -- (open/close/fullstop/comma/middle/nobreak). For the P1 migration: -- luatex-cn-core-punct.lua derives its CL_* sets from this. local LEGACY_MAP = { open = "open", close = "close", fullstop = "fullstop", comma = "comma", colon = "middle", semicolon = "middle", exclamation = "middle", question = "middle", dash = "nobreak", ellipsis = "nobreak", -- P1 扩类:连接号/间隔号/分隔号并入 middle——竖排引擎对 middle 的处置 -- (行首禁则、满幅、legacy 不减半)与 clreq 对这三类的要求一致; -- 三类各自的宽度/可挤空白差异由 width_of / space_info 按码位另行表达。 connector = "middle", interpunct = "middle", solidus = "middle", } --- Legacy six-type name for a codepoint (P1 migration helper). -- @param char (number) Unicode codepoint -- @return (string|nil) "open"|"close"|"fullstop"|"comma"|"middle"|"nobreak" function M.legacy_type(char) local e = TABLE[char] if not e then return nil end -- The legacy engine only ever classified the stacked forms ‼⁇ as absent; -- keep them out of the legacy view to avoid behavior change before P1. if char == 0x203C or char == 0x2047 then return nil end return LEGACY_MAP[e.class] end --- Iterate all entries: for char, entry in punct_table.entries() do ... end -- @return (function) stateless iterator over (codepoint, entry) function M.entries() return pairs(TABLE) end return M