-- Copyright 2026 Open-Guji (https://github.com/open-guji) -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. -- You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. --- Modern Punctuation Plugin for luatex-cn -- Provides punctuation squeeze, kinsoku (line-breaking rules), -- vertical quote replacement, and punctuation hanging. -- Active when punct-mode = "normal" (default for ltc-book). -- Mutually exclusive with the judou plugin. local punct = {} local D = node.direct local constants = require('core.luatex-cn-constants') local debug_mod = require('debug.luatex-cn-debug') local shared_punct = require('shared.luatex-cn-punct-table') local punct_squeeze = require('shared.luatex-cn-punct-squeeze') local punct_anchors = require('shared.luatex-cn-punct-anchors') local adjust = require('shared.luatex-cn-adjust') local kinsoku = require('shared.luatex-cn-kinsoku') local cjk_western = require('shared.luatex-cn-cjk-western') local dbg = debug_mod.get_debugger('punct') -- clreq 挤压优先顺序的类别 → 序号(写进 ATTR_PUNCT_SHRINK_CLASS, -- layout 阶段再还原成类别名交给求解器;顺序表本身只有共享层持有) local SHRINK_CLASS_INDEX = {} for i, cls in ipairs(adjust.SHRINK_ORDER) do SHRINK_CLASS_INDEX[cls] = i end -- ============================================================================ -- Font ink-center cache for punctuation auto-centering -- ============================================================================ -- Some fonts (e.g. FZShuSong-Z01) have punctuation glyphs whose ink is not -- centered in the advance width. This cache stores the ink center ratio -- (ink_center_x / advance_width) so we can compensate at render time. -- Key: font_id, Value: { [charcode] = ratio (0..1), ... } local font_ink_center_cache = {} -- Characters whose ink center we need to measure local INK_CENTER_CHARS = { [0xFF0C] = true, -- , [0x3001] = true, -- 、 [0x3002] = true, -- 。 [0xFF0E] = true, -- . [0xFF1A] = true, -- : [0xFF1B] = true, -- ; [0xFF01] = true, -- ! [0xFF1F] = true, -- ? } --- Parse a tounicode string (hex) into a numeric codepoint. -- Only handles single codepoints (4 hex digits). Multi-codepoint tounicode -- (surrogate pairs, ligatures) returns nil. -- @param tu_str (string) Hex string like "FF0C" or "3001" -- @return (number|nil) Codepoint, or nil if not a single codepoint local function parse_tounicode(tu_str) if not tu_str or #tu_str ~= 4 then return nil end return tonumber(tu_str, 16) end --- Get ink center ratios for a glyph in a given font. -- Returns the center of the glyph ink as ratios of advance width (x and y). -- For a perfectly centered glyph both are 0.5. -- @param fid (number) Font ID -- @param charcode (number) Unicode codepoint -- @return (number, number) Ink center ratio x, y (0..1), or 0.5, 0.5 if unknown local function get_ink_center_ratio(fid, charcode) -- Check cache first local font_cache = font_ink_center_cache[fid] if font_cache then local entry = font_cache[charcode] if entry then return entry.x, entry.y end -- Already analyzed this font but char not found if font_cache._analyzed then return 0.5, 0.5 end end -- Analyze font using fontloader font_ink_center_cache[fid] = font_ink_center_cache[fid] or {} font_cache = font_ink_center_cache[fid] font_cache._analyzed = true local f = font.getfont(fid) if not f or not f.filename then return 0.5, 0.5 end local ok, raw = pcall(fontloader.open, f.filename) if not ok or not raw then return 0.5, 0.5 end local ok2, info = pcall(fontloader.to_table, raw) fontloader.close(raw) if not ok2 or not info or not info.glyphs then return 0.5, 0.5 end -- Scan all glyphs for target characters (by Unicode) for i = 0, (info.glyphcnt or 0) - 1 do local g = info.glyphs[i] if g and g.unicode and INK_CENTER_CHARS[g.unicode] and g.width and g.width > 0 then if g.boundingbox then local cx = (g.boundingbox[1] + g.boundingbox[3]) / 2 local cy = (g.boundingbox[2] + g.boundingbox[4]) / 2 font_cache[g.unicode] = { x = cx / g.width, y = cy / g.width } end end end -- Also cache ink centers for PUA chars whose tounicode points to -- INK_CENTER_CHARS (e.g. vert GSUB substituted punctuation). -- Use glyph index from font.getfont() to look up fontloader bounding box. if f.characters then for code, ch_data in pairs(f.characters) do if ((code >= 0xE000 and code <= 0xF8FF) or (code >= 0xF0000 and code <= 0xFFFFF) or code >= 0x100000) and ch_data.tounicode and ch_data.index then local orig = parse_tounicode(ch_data.tounicode) if orig and INK_CENTER_CHARS[orig] then local g = info.glyphs[ch_data.index] if g and g.width and g.width > 0 and g.boundingbox then local cx = (g.boundingbox[1] + g.boundingbox[3]) / 2 local cy = (g.boundingbox[2] + g.boundingbox[4]) / 2 font_cache[code] = { x = cx / g.width, y = cy / g.width } end end end end end dbg.log(string.format("punct: analyzed font %d ink centers: %s", fid, f.filename)) local entry = font_cache[charcode] if entry then return entry.x, entry.y end return 0.5, 0.5 end -- ============================================================================ -- Punctuation Character Classification (derived from the shared clreq table) -- ============================================================================ -- The six legacy classes (open/close/fullstop/comma/middle/nobreak) are no -- longer hand-maintained here: they derive from shared.luatex-cn-punct-table -- via legacy_type() — single data source per the clreq shared-core contract -- (ai_must_read/clreq-shared-core.md). Vertical presentation forms (the -- replacement targets in VERT_FORM_MAP below) inherit the class of their -- horizontal source character. The derived map is built after VERT_FORM_MAP. -- Vertical form replacement map (horizontal → vertical presentation forms) -- Replaces CJK brackets/parentheses with their Unicode Vertical Presentation -- Forms (U+FE30–U+FE4F) for correct display in vertical text. -- Also replaces curly quotes with corner bracket vertical forms. local VERT_FORM_MAP = { -- CJK corner brackets → vertical forms [0x300C] = 0xFE41, -- 「 → ﹁ (left corner bracket) [0x300D] = 0xFE42, -- 」 → ﹂ (right corner bracket) [0x300E] = 0xFE43, -- 『 → ﹃ (left white corner bracket) [0x300F] = 0xFE44, -- 』 → ﹄ (right white corner bracket) -- Fullwidth parentheses → vertical forms [0xFF08] = 0xFE35, -- ( → ︵ (left parenthesis) [0xFF09] = 0xFE36, -- ) → ︶ (right parenthesis) -- Angle brackets → vertical forms [0x3008] = 0xFE3F, -- 〈 → ︿ (left angle bracket) [0x3009] = 0xFE40, -- 〉 → ﹀ (right angle bracket) -- Double angle brackets → vertical forms [0x300A] = 0xFE3D, -- 《 → ︽ (left double angle bracket) [0x300B] = 0xFE3E, -- 》 → ︾ (right double angle bracket) -- Lenticular brackets → vertical forms [0x3010] = 0xFE3B, -- 【 → ︻ (left black lenticular bracket) [0x3011] = 0xFE3C, -- 】 → ︼ (right black lenticular bracket) -- Tortoise shell brackets → vertical forms [0x3014] = 0xFE39, -- 〔 → ︹ (left tortoise shell bracket) [0x3015] = 0xFE3A, -- 〕 → ︺ (right tortoise shell bracket) -- Em dash and ellipsis → vertical forms [0x2014] = 0xFE31, -- — → ︱ (em dash → vertical em dash) [0x2026] = 0xFE19, -- … → ︙ (horizontal ellipsis → vertical ellipsis) -- Curly quotes → corner bracket vertical forms (mainland convention) -- Mainland: "" = first level → 「」, '' = second level → 『』 [0x201C] = 0xFE41, -- " → ﹁ (left double → vertical left corner bracket) [0x201D] = 0xFE42, -- " → ﹂ (right double → vertical right corner bracket) [0x2018] = 0xFE43, -- ' → ﹃ (left single → vertical left white corner bracket) [0x2019] = 0xFE44, -- ' → ﹄ (right single → vertical right white corner bracket) } -- Derived legacy classification map: codepoint → "open"|"close"|"fullstop"| -- "comma"|"middle"|"nobreak". Built from the shared clreq table, then -- extended with the vertical presentation forms, which inherit the class of -- their horizontal source (e.g. ﹁ inherits "open" from 「). local LEGACY_CLASS = {} for char in shared_punct.entries() do LEGACY_CLASS[char] = shared_punct.legacy_type(char) end for h_char, v_char in pairs(VERT_FORM_MAP) do LEGACY_CLASS[v_char] = shared_punct.legacy_type(h_char) end -- ============================================================================ -- tounicode-based reverse mapping for GSUB-substituted punctuation -- ============================================================================ -- Some fonts (e.g. KingHwa_OldSong) have vert GSUB entries that map punctuation -- to glyphs without a standard Unicode codepoint. luaotfload's node mode then -- assigns these glyphs Private Use Area (PUA) codepoints (U+F0000+), causing -- punct.classify() to fail. -- -- This cache maps (font_id, substituted_char) → original_codepoint by reading -- the glyph's tounicode field from font.getfont().characters. -- Key: font_id, Value: { [pua_char] = original_codepoint, ... } local font_tounicode_cache = {} --- Resolve the original Unicode codepoint for a possibly-substituted glyph. -- If the glyph's char is in the PUA range and has a tounicode pointing to -- a known punctuation codepoint, return that original codepoint. -- Results are cached per font. -- @param fid (number) Font ID -- @param char (number) Current char code (possibly PUA) -- @return (number|nil) Original codepoint if resolved, nil otherwise local function resolve_original_codepoint(fid, char) -- Only attempt resolution for PUA-range chars -- Supplementary PUA-A: U+F0000..U+FFFFF -- Supplementary PUA-B: U+100000..U+10FFFD -- BMP PUA: U+E000..U+F8FF if not (char >= 0xE000 and char <= 0xF8FF) and not (char >= 0xF0000 and char <= 0xFFFFF) and not (char >= 0x100000) then return nil end -- Check cache local fc = font_tounicode_cache[fid] if fc then return fc[char] -- may be nil (already checked, not a punct) end -- Build cache for this font: scan characters for PUA entries with -- tounicode pointing to known punctuation fc = {} font_tounicode_cache[fid] = fc local fdata = font.getfont(fid) if not fdata or not fdata.characters then return nil end for code, ch_data in pairs(fdata.characters) do -- Only cache PUA-range chars if ((code >= 0xE000 and code <= 0xF8FF) or (code >= 0xF0000 and code <= 0xFFFFF) or code >= 0x100000) and ch_data.tounicode then local orig = parse_tounicode(ch_data.tounicode) if orig and punct.classify(orig) then fc[code] = orig end end end return fc[char] end -- Punctuation type numeric codes (for ATTR_PUNCT_TYPE attribute) local PUNCT_CODES = { open = 1, close = 2, fullstop = 3, comma = 4, middle = 5, nobreak = 6, } -- Reverse mapping: code → type name local PUNCT_NAMES = {} for name, code in pairs(PUNCT_CODES) do PUNCT_NAMES[code] = name end -- ============================================================================ -- Classification Functions -- ============================================================================ --- Classify a character code into punctuation type -- @param char_code (number) Unicode code point -- @return (string|nil) "open", "close", "fullstop", "comma", "middle", "nobreak", or nil function punct.classify(char_code) return LEGACY_CLASS[char_code] end --- 识别破折号合字,返回它代表几个 em dash。 -- -- 带 ccmp 的字体(思源宋体 / Noto CJK 等)在 shaping 阶段就把 —— 合成一个 -- 字形,早于 flatten。合字只有横排形(无 ︱),宽度约 1.7 em,塞进一字一格 -- 的竖排网格里就成了「两个字只占一格的横杠」。做法是把它拆回 N 个 em dash, -- 每个各自走标准流程(一格一个、竖排形/旋转、连排规则)。 -- 两种形态: -- * U+2E3A(⸺ 两字破折号)/ U+2E3B(⸻ 三字):思源走这一路,有正式码位 -- * 无码位的合字:luaotfload 给它派 PUA 码位,靠 tounicode 是 2–3 个 -- 破折号码位连排来识别 -- @param fid (number) Font ID -- @param char (number) Current char code -- @return (number|nil) 组成它的破折号个数(2 或 3),不是合字则为 nil function punct.dash_ligature_count(fid, char) if char == 0x2E3A then return 2 end if char == 0x2E3B then return 3 end if (char >= 0xE000 and char <= 0xF8FF) or (char >= 0xF0000 and char <= 0xFFFFF) or char >= 0x100000 then local fdata = font.getfont(fid) local ch = fdata and fdata.characters and fdata.characters[char] local tu = ch and ch.tounicode if type(tu) == "string" and (#tu == 8 or #tu == 12) then local n = 0 for i = 1, #tu, 4 do local cp = tonumber(tu:sub(i, i + 3), 16) -- U+2015 横杠:部分字体的破折号合字以它为构件 if cp ~= 0x2014 and cp ~= 0x2015 then return nil end n = n + 1 end return n end end return nil end --- Check if a punctuation type is forbidden at line start (column top) -- @param ptype (string) Punctuation type -- @return (boolean) function punct.is_line_start_forbidden(ptype) return ptype == "close" or ptype == "fullstop" or ptype == "comma" or ptype == "middle" end --- Check if a punctuation type is forbidden at line end (column bottom) -- @param ptype (string) Punctuation type -- @return (boolean) function punct.is_line_end_forbidden(ptype) return ptype == "open" end --- Get punctuation type name from ATTR_PUNCT_TYPE attribute value -- @param code (number) Attribute value -- @return (string|nil) Type name function punct.type_from_code(code) return PUNCT_NAMES[code] end --- Get ATTR_PUNCT_TYPE attribute value from type name -- @param name (string) Type name -- @return (number|nil) Attribute value function punct.code_from_type(name) return PUNCT_CODES[name] end -- ============================================================================ -- Kinsoku (Line-breaking Rules) Implementation -- ============================================================================ --- Find the next visible GLYPH node after the current one, skipping glue/kern/penalty/whatsit -- @param current_node (direct node) Current node in the direct node list -- @return (direct node|nil) Next visible glyph, or nil if none local function find_next_glyph(current_node) local n = D.getnext(current_node) while n do local nid = D.getid(n) if nid == constants.GLYPH then return n elseif nid == constants.GLUE or nid == constants.KERN or nid == constants.PENALTY or nid == constants.WHATSIT then n = D.getnext(n) else return nil -- Unknown node type, stop looking end end return nil end --- Create the kinsoku check hook callback for layout-grid.lua -- This function is called after each GLYPH is placed in col_buffer. -- When the column is full (ctx.cur_row >= effective_limit), it looks ahead -- to see if the next character is forbidden at line start. If so, it -- pulls the current character out and wraps them together to the new column. -- -- @param punct_ctx (table) Punctuation plugin context -- @return (function) The hook callback function punct.make_kinsoku_hook(punct_ctx) if not punct_ctx or not punct_ctx.kinsoku then return nil end return function(t, ctx, effective_limit, col_buffer, flush_buffer, wrap_to_next_column, p_cols, interval, grid_height, indent) -- Only act when the column is full or nearly full if ctx.cur_row < effective_limit then return end -- Column is full (ctx.cur_row >= effective_limit) -- The character at col_buffer[#col_buffer] was just placed at the last row --- 把列尾的 n 个字连同换列一起挪到下一列 local function pull_tail(n) local pulled = {} for _ = 1, n do local e = table.remove(col_buffer) if not e then break end table.insert(pulled, 1, e) end if #pulled == 0 then return false end flush_buffer() wrap_to_next_column(ctx, p_cols, interval, grid_height, indent, false, false) -- Apply indent: wrap_to_next_column resets cur_row to 0 but -- does not apply paragraph indentation for the new column if indent and indent > 0 and ctx.cur_row < indent then ctx.cur_row = indent ctx.cur_column_indent = indent ctx.cur_y_sp = ctx.cur_row * grid_height end for _, e in ipairs(pulled) do e.page = ctx.cur_page e.col = ctx.cur_col e.relative_row = ctx.cur_y_sp / grid_height e.y_sp = ctx.cur_y_sp table.insert(col_buffer, e) ctx.cur_row = ctx.cur_row + 1 ctx.cur_y_sp = ctx.cur_row * grid_height end ctx.page_has_content = true return true end -- Strategy 0: 下一个字与列尾字同属两字幅标点单元(—— …… ?!)。 -- clreq 符号分离禁则:这类符号占两个汉字宽度,是一个整体,断在中间 -- 就把一个符号劈成了两半。把列尾这半(若是 ——— 这样的长串则是整 -- 个尾串)一起带到下一列。 local next_glyph = find_next_glyph(t) if next_glyph and #col_buffer > 0 and D.get_attribute(next_glyph, constants.ATTR_RIGID_PREV) == 2 then -- 至少留一个字在本列,否则空列 → 死循环 local n = 0 while n < #col_buffer - 1 do n = n + 1 local e = col_buffer[#col_buffer - n + 1] if D.get_attribute(e.node, constants.ATTR_RIGID_PREV) ~= 2 then break end end if n > 0 and pull_tail(n) then dbg.log(string.format( "kinsoku: 两字幅单元不拆开,尾部 %d 字随下一字移入新列 [p:%d c:%d]", n, ctx.cur_page, ctx.cur_col)) return end end -- Strategy 1: Check if next visible glyph is line-start-forbidden if next_glyph then local next_char = D.getfield(next_glyph, "char") local next_ptype = punct.classify(next_char) if next_ptype and punct.is_line_start_forbidden(next_ptype) then -- Next character cannot start a new column. -- Pull the last character from col_buffer and move both to new column. if pull_tail(1) then dbg.log(string.format( "kinsoku: pulled char to new col (next=0x%04X type=%s) [p:%d c:%d]", next_char, next_ptype, ctx.cur_page, ctx.cur_col)) end return end end -- Strategy 2: Check if current character (last in buffer) is line-end-forbidden if #col_buffer > 0 then local last_entry = col_buffer[#col_buffer] local last_char = D.getfield(last_entry.node, "char") local last_ptype = punct.classify(last_char) if last_ptype and punct.is_line_end_forbidden(last_ptype) then -- Current character (opening bracket) cannot end a column. pull_tail(1) dbg.log(string.format( "kinsoku: moved line-end-forbidden char to new col (0x%04X type=%s) [p:%d c:%d]", last_char, last_ptype, ctx.cur_page, ctx.cur_col)) return end end end end -- ============================================================================ -- Configuration -- ============================================================================ --- Setup function called from TeX layer to sync configuration -- @param cfg (table) Configuration table function punct.setup(cfg) _G.punct = _G.punct or {} if cfg.style then _G.punct.style = cfg.style end if cfg.squeeze ~= nil then _G.punct.squeeze = cfg.squeeze end if cfg.hanging ~= nil then _G.punct.hanging = cfg.hanging end if cfg.kinsoku ~= nil then _G.punct.kinsoku = cfg.kinsoku end -- clreq 上下文相关宽度调整(squeeze_mode = "legacy" | "context") if cfg.squeeze_mode then _G.punct.squeeze_mode = cfg.squeeze_mode end if cfg.adjacent_punct then _G.punct.adjacent_punct = cfg.adjacent_punct end if cfg.line_start_bracket then _G.punct.line_start_bracket = cfg.line_start_bracket end if cfg.line_end_punct then _G.punct.line_end_punct = cfg.line_end_punct end if cfg.western_space ~= nil then _G.punct.western_space = cfg.western_space end end -- ============================================================================ -- Plugin Standard API -- ============================================================================ --- Initialize Punctuation Plugin -- @param params (table) Parameters from TeX -- @param engine_ctx (table) Shared engine context -- @param plugin_contexts (table) Already-initialized plugin contexts (judou must init before punct) -- @return (table|nil) Plugin context, or nil to disable function punct.initialize(params, engine_ctx, plugin_contexts) -- Read punct mode from judou plugin context (judou initializes before punct) local judou_ctx = plugin_contexts and plugin_contexts["judou"] local mode = judou_ctx and judou_ctx.punct_mode or "normal" if mode ~= "normal" then dbg.log("punct plugin: disabled (punct_mode=" .. tostring(mode) .. ")") return nil end local style = (_G.punct and _G.punct.style) or "mainland" local ctx = { style = style, -- style=none 是 clreq「不调整」预设:既不挤压也不偏靠 squeeze = (style ~= "none") and not (_G.punct and _G.punct.squeeze == false), -- default true hanging = (_G.punct and _G.punct.hanging) or false, -- default false kinsoku = not (_G.punct and _G.punct.kinsoku == false), -- default true -- clreq 上下文相关宽度调整。默认 legacy = 现行无条件挤压, -- 保证 ltc-guji 版面零变化(CLREQ-GAP-ANALYSIS R5 分档); -- vbook 两个类在配置里切到 context。 squeeze_mode = (_G.punct and _G.punct.squeeze_mode) or "legacy", adjacent_punct = (_G.punct and _G.punct.adjacent_punct) or "1.5", line_start_bracket = (_G.punct and _G.punct.line_start_bracket) or "trim", line_end_punct = (_G.punct and _G.punct.line_end_punct) or "compress", -- clreq 中西间距(1/4em)。默认开;只在 context 挡位实际生效 western_space = not (_G.punct and _G.punct.western_space == false), } dbg.log(string.format( "punct plugin: enabled (style=%s, squeeze=%s/%s, kinsoku=%s, hanging=%s)", ctx.style, tostring(ctx.squeeze), ctx.squeeze_mode, tostring(ctx.kinsoku), tostring(ctx.hanging))) return ctx end -- ============================================================================ -- Context-sensitive squeeze annotation (clreq 标点符号的宽度调整) -- ============================================================================ --- Build the shared-layer options table from the plugin context. -- @param ctx (table) Plugin context -- @return (table) opts for shared.luatex-cn-punct-squeeze local function squeeze_opts(ctx) return { style = ctx.style, mode = "vertical", adjacent_punct = ctx.adjacent_punct, line_start_bracket = ctx.line_start_bracket, line_end_punct = ctx.line_end_punct, } end --- Annotate every punctuation glyph with the blank it may reclaim, judged -- from its neighbours (clreq: 只有相邻标点连排才无条件缩减;夹在汉字之间 -- 的标点占满一字幅). 行首/行尾的收回量要等断列结果才知道,留给 P2 的 -- flush_buffer 接线(见 docs/CLREQ-VERTICAL-ADJUST-DESIGN.md)。 -- @param seq (table) {{node, char, punct}, ...} in list order -- @param ctx (table) Plugin context -- @return (number) how many glyphs were annotated local function annotate_context_squeeze(seq, ctx) if ctx.squeeze_mode ~= "context" or not ctx.squeeze then return 0 end local opts = squeeze_opts(ctx) local count = 0 -- 脚注标号组(︻一︼)整组不参与标点宽度调整:组内字幅由 flush_buffer 的 -- marker 预处理按组高分配,不是正文字幅,再叠加收回量会把组内的括号挤歪; -- 组外的标点也不该把标号的括号当作行文中的夹注符号来做连续标点缩减 -- (clreq 的连续标点缩减针对夹注符号,注释记号是另一回事)。 -- 因此把整组视作不透明:组内不标注,组外判定时它既不是标点也不是汉字。 local is_marker = {} for i, item in ipairs(seq) do local marker = D.get_attribute(item.node, constants.ATTR_FOOTNOTE_MARKER) is_marker[i] = (marker and marker > 0) or false end for i, item in ipairs(seq) do if item.punct and not is_marker[i] then local prev_c = (not is_marker[i - 1]) and seq[i - 1] and seq[i - 1].char or nil local next_c = (not is_marker[i + 1]) and seq[i + 1] and seq[i + 1].char or nil local plan = punct_squeeze.plan(prev_c, item.char, next_c, nil, opts) -- 固定宽度扩类(clreq:中国大陆式间隔号占半个字宽):不是可挤 -- 空白而是无条件的窄字幅,两侧各收一半保持墨迹居中,也不参与 -- 求解器(无 ATTR_PUNCT_BLANK,宽度不可再调)。只对全角形式 -- 生效——interpunct 类中仅 · 的 mainland 宽度 < 1;connector/ -- solidus 的半宽成员(- – /)是比例西文字形,字幅语义不同, -- 分类归 middle 但宽度不动。 if shared_punct.class_of(item.char) == "interpunct" and plan.total == 0 then local w = shared_punct.width_of(item.char, opts.style) if w and w < 1 then plan.total = 1 - w plan.head = (1 - w) / 2 end end D.set_attribute(item.node, constants.ATTR_PUNCT_SQUEEZE, 1 + math.floor(plan.total * 1000 + 0.5)) D.set_attribute(item.node, constants.ATTR_PUNCT_SQUEEZE_HEAD, 1 + math.floor(plan.head * 1000 + 0.5)) -- 潜在空白与挤压类别:相邻规则强制收回的只是其中一部分,余量留给 -- flush_buffer 的求解器按 clreq 优先顺序处置(P2 接线)。 local blank_head, blank_tail = punct_squeeze.blanks(item.char, opts) local blank_total = blank_head + blank_tail if blank_total > 0 then D.set_attribute(item.node, constants.ATTR_PUNCT_BLANK, 1 + math.floor(blank_total * 1000 + 0.5)) D.set_attribute(item.node, constants.ATTR_PUNCT_BLANK_HEAD, 1 + math.floor(blank_head * 1000 + 0.5)) local cls = shared_punct.shrink_class_of(item.char, opts.style, "vertical") local cls_idx = cls and SHRINK_CLASS_INDEX[cls] if cls_idx then D.set_attribute(item.node, constants.ATTR_PUNCT_SHRINK_CLASS, 1 + cls_idx) end -- 落在列首 / 列末时的额外收回量(设计 §2.3):断列结果这里还 -- 不知道,先把两种情形各算一遍,差额留给 flush_buffer 取用。 local at_start = punct_squeeze.plan(prev_c, item.char, next_c, { at_line_start = true }, opts) local at_end = punct_squeeze.plan(prev_c, item.char, next_c, { at_line_end = true }, opts) local d_start = at_start.head - plan.head local d_end = at_end.tail - plan.tail if d_start > 0 then D.set_attribute(item.node, constants.ATTR_PUNCT_TRIM_START, 1 + math.floor(d_start * 1000 + 0.5)) end if d_end > 0 then D.set_attribute(item.node, constants.ATTR_PUNCT_TRIM_END, 1 + math.floor(d_end * 1000 + 0.5)) end end -- clreq 行尾点号悬挂(可选,默认关):点号落在列末时整个字幅 -- 移出列内。标记与 TRIM_END 同理由预算——「在不在列末」要等 -- 断列结果,flush_buffer 按位置取用。条件不含 blank_total: -- 悬挂让墨迹也出列,与该标点有无可挤空白无关。 if ctx.hanging and shared_punct.is_point(item.char) then D.set_attribute(item.node, constants.ATTR_PUNCT_HANG, 1) end count = count + 1 end end return count end -- 刚性单元(clreq 符号分离禁则)的原因标签:这些边界不只是「不能断」, -- 而是「内部不能有任何伸缩」——两字幅标点、数字串、数字+单位、正负号+数字、 -- 货币符号+数字、西文单词。行首/行尾禁则不在此列(「一。」不能断开,但 -- 「一」与「。」之间的间距照常参与调整)。与横排 hori-spacing 的 -- RIGID_REASONS 同一份口径。 local RIGID_REASONS = { unbreakable_pair = true, digit_run = true, digit_suffix = true, sign_prefix = true, currency = true, western_word = true, } --- 标注刚性单元边界:字符 i 与 i−1 同属一个不可分单元时,在 i 上打标记。 -- layout 阶段据此把该边界上的字距与两侧标点空白全部锁死(横排的教训: -- 叠加符号自带可挤空白,只清 stretch 不清 shrink 会让两字幅单元被压扁)。 -- -- 属性取值区分两档,因为竖排对两者的处置不同: -- 1 = 一般刚性单元(数字串、西文词……):锁死既有字距即可 -- 2 = 两字幅标点单元(—— …… ?!):字距还要**归零**。clreq 说它「占两个 -- 汉字宽度」,中间夹 0.1em 竖排字距就成了 2.1em,破折号还会现出断口。 -- 横排 hori-spacing 的 CJK 间边界本来就是 width=0,这里对齐同一口径。 -- @param seq (table) {{node, char, punct}, ...} -- @return (number) 标注的边界数 local function annotate_rigid_units(seq) local count = 0 for i = 2, #seq do local prev_c, cur_c = seq[i - 1].char, seq[i].char local _, reason = kinsoku.no_break_between(prev_c, cur_c) if reason and RIGID_REASONS[reason] then local two_em = shared_punct.is_unbreakable_pair(prev_c, cur_c) D.set_attribute(seq[i].node, constants.ATTR_RIGID_PREV, two_em and 2 or 1) -- 连排破折号的两端都要标记:render 把每一段的墨迹拉满字幅, -- 串起来才是一条不断的线(省略号不在此列——…… 的墨迹是圆点, -- 拉伸只会把点扯成椭圆) if two_em and shared_punct.class_of(cur_c) == "dash" then D.set_attribute(seq[i - 1].node, constants.ATTR_DASH_RUN, 1) D.set_attribute(seq[i].node, constants.ATTR_DASH_RUN, 1) end count = count + 1 end end return count end --- 中西间距标注(clreq:汉字与西文字母/数字间加不多于 1/4 汉字宽的间距, -- 点号旁与夹注号内侧不加——规则本体在 shared/luatex-cn-cjk-western.lua, -- 与横排 hori-spacing 同一份)。仅 context 挡位(vbook 两类)生效, -- `western-space=false` 可关;ltc-guji(legacy)版面零变化。 -- 脚注标号组不参与:标号与被注文字的间距由 marker 预处理管。 -- @param seq (table) {{node, char, punct}, ...} -- @return (number) 标注的边界数 local function annotate_cjk_western(seq, ctx) if ctx.squeeze_mode ~= "context" or ctx.western_space == false then return 0 end local count = 0 for i = 2, #seq do local a, b = seq[i - 1], seq[i] local am = D.get_attribute(a.node, constants.ATTR_FOOTNOTE_MARKER) local bm = D.get_attribute(b.node, constants.ATTR_FOOTNOTE_MARKER) if not (am and am > 0) and not (bm and bm > 0) and cjk_western.takes_spacing(a.char, b.char) then D.set_attribute(b.node, constants.ATTR_CJK_WESTERN_PREV, 1) count = count + 1 end end return count end --- Flatten stage: classify punctuation and replace vertical quotes -- @param head (node) The node list head -- @param params (table) Parameters -- @param ctx (table) Plugin context -- @return (node) The modified head function punct.flatten(head, params, ctx) if not ctx then return head end local d_head = D.todirect(head) local t = d_head local count_classified = 0 local count_replaced = 0 -- 字符序列(逻辑码位,直排字形替换与 PUA 还原之前的那一个), -- 供 flatten 之后的上下文判定使用。非字形节点视为透明。 local seq = {} while t do local id = D.getid(t) local next_node = D.getnext(t) if id == constants.GLYPH then -- Skip decoration characters (e.g. 。、used as decorate markers) local dec_id = D.get_attribute(t, constants.ATTR_DECORATE_ID) if not (dec_id and dec_id > 0) then local char = D.getfield(t, "char") local logical_char = char -- 0. 拆解破折号合字(⸺/⸻ 或 ccmp 派生的 PUA 字形),还原成 -- N 个 em dash,各自走下面的标准流程 local n_dash = punct.dash_ligature_count(D.getfont(t), char) if n_dash then D.setfield(t, "char", 0x2014) char = 0x2014 logical_char = 0x2014 for _ = 2, n_dash do D.insert_after(d_head, t, D.copy(t)) end -- 复制品排在 t 之后,下一轮循环依次处理 next_node = D.getnext(t) count_replaced = count_replaced + 1 dbg.log(string.format( "punct: 破折号合字拆解为 %d 个 em dash", n_dash)) end -- 1. Vertical form replacement: brackets/quotes → vertical presentation forms local vert_char = VERT_FORM_MAP[char] if vert_char then -- Check font has the target glyph before replacing local fid = D.getfont(t) local fdata = font.getfont(fid) if fdata and fdata.characters and fdata.characters[vert_char] then D.setfield(t, "char", vert_char) char = vert_char count_replaced = count_replaced + 1 else -- Font lacks vertical glyph - mark for rotation fallback -- Only rotate horizontally-oriented glyphs (ellipsis, em dash) if char == 0x2026 or char == 0x2014 then D.set_attribute(t, constants.ATTR_VERT_ROTATE, 1) dbg.log(string.format("marked for rotation: char=0x%04X", char)) end end end -- 2. Classify punctuation and set attribute local ptype = punct.classify(char) -- 2b. If classification failed, the char may have been GSUB-substituted -- by the font's vert/vrt2 feature to a PUA codepoint. Try to resolve -- the original Unicode codepoint via the glyph's tounicode field. if not ptype then local fid = D.getfont(t) local orig = resolve_original_codepoint(fid, char) if orig then ptype = punct.classify(orig) if ptype then logical_char = orig dbg.log(string.format( "punct: resolved PUA 0x%05X -> 0x%04X (%s) via tounicode", char, orig, ptype)) end end end if ptype then -- P1 扩类(连接号/间隔号/分隔号)只在 context 挡位写 -- ATTR_PUNCT_TYPE:该属性会把渲染切到「标点按字面居中」 -- 路径,legacy 挡(ltc-guji 默认)须维持这些字符未分类 -- 时代的落点(R5 版面零变化,judou 基线实测有差异)。 -- 行首禁则不受此门控——kinsoku 走 punct.classify(), -- 不读该属性,扩类的禁则改进对所有挡位生效。 local skip_attr = false if ctx.squeeze_mode ~= "context" then local cls = shared_punct.class_of(logical_char) skip_attr = cls == "connector" or cls == "interpunct" or cls == "solidus" end if not skip_attr then local code = PUNCT_CODES[ptype] D.set_attribute(t, constants.ATTR_PUNCT_TYPE, code) count_classified = count_classified + 1 end end seq[#seq + 1] = { node = t, char = logical_char, punct = ptype ~= nil } end end t = next_node end local count_context = annotate_context_squeeze(seq, ctx) local count_rigid = annotate_rigid_units(seq) local count_mixed = annotate_cjk_western(seq, ctx) if count_classified > 0 or count_replaced > 0 or count_mixed > 0 then dbg.log(string.format( "punct flatten: classified=%d, quotes_replaced=%d, context_squeeze=%d, rigid=%d, cjk_western=%d", count_classified, count_replaced, count_context, count_rigid, count_mixed)) end return D.tonode(d_head) end -- ============================================================================ -- Punctuation Squeeze (CLREQ Standard) -- ============================================================================ --- Check if a punctuation type has trailing half-width space -- (close brackets, fullstop, comma all have glyph in first half, space in second) local function has_trailing_space(ptype) return ptype == "close" or ptype == "fullstop" or ptype == "comma" end --- Check if a punctuation type has leading half-width space -- (open brackets have space in first half, glyph in second) local function has_leading_space(ptype) return ptype == "open" end --- Default squeeze amount (0.5 = half grid cell) local DEFAULT_SQUEEZE = 0.5 --- Per-character squeeze overrides (default is 0.5 = occupy half grid cell) local CHAR_SQUEEZE = { [0x3002] = 0, -- 。fullstop: full cell (no squeeze) [0xFF0E] = 0, -- .fullstop: full cell (no squeeze) } --- Get the punctuation type for a node from its attribute -- @param node_d (direct node) The node -- @return (string|nil) Punctuation type name local function get_node_punct_type(node_d) local code = D.get_attribute(node_d, constants.ATTR_PUNCT_TYPE) if code and code > 0 then return PUNCT_NAMES[code] end return nil end --- Layout stage: post-process layout_map for squeeze adjustments -- Scans each column for consecutive punctuation and adjusts row positions. -- @param list (node) The node list -- @param layout_map (table) Layout map (node → position) -- @param engine_ctx (table) Engine context -- @param ctx (table) Plugin context function punct.layout(list, layout_map, engine_ctx, ctx) if not ctx then return end if not ctx.squeeze then return end local context_mode = (ctx.squeeze_mode == "context") -- Taiwan style (legacy mode): no squeeze — all punctuation occupies a full -- grid cell. In context mode 台湾式仍适用 clreq 的连续标点缩减 -- (「无论文本整体采用何种风格」),故不再整体跳过。 if ctx.style == "taiwan" and not context_mode then return end -- Natural mode (no default_cell_height) handles punctuation sizing via -- get_cell_height() in layout-grid; squeeze post-processing would overwrite -- those carefully computed values. if not engine_ctx.default_cell_height then return end -- Note: punct-hanging requires deeper integration with layout-grid.lua -- to allow dot-class punctuation to overflow beyond effective_limit. -- This will be implemented in a future version. -- Collect all layout entries with their node references, grouped by (page, col) local columns = {} -- key: "page:col" → sorted list of {node, pos, ptype} for node_d, pos in pairs(layout_map) do -- Only process nodes that have y_sp (actual positioned content) if pos.y_sp then local ptype = get_node_punct_type(node_d) local key = string.format("%d:%d", pos.page, pos.col) if not columns[key] then columns[key] = {} end table.insert(columns[key], { node = node_d, pos = pos, ptype = ptype, -- may be nil for non-punct }) end end local total_squeezed = 0 local grid_height = engine_ctx.g_height -- Process each column for _, col_entries in pairs(columns) do -- Sort by y_sp position table.sort(col_entries, function(a, b) return a.pos.y_sp < b.pos.y_sp end) -- Sequential cell placement: each character occupies a cell of a certain -- height. Punctuation cells are shorter (squeezed), but cells NEVER overlap. -- accumulated_squeeze_sp tracks total sp saved so far, so subsequent -- characters shift up by that amount. local accumulated_squeeze_sp = 0 local prev_orig_y_sp = nil local prev_ptype = nil for _, entry in ipairs(col_entries) do local curr_ptype = entry.ptype local squeeze_amount = 0 -- Close gaps caused by invisible spacing nodes (e.g. TeX newlines -- between sentences). Only close gaps that follow punctuation marks, -- as these are almost always interword spaces rather than intentional -- paragraph spacing. local orig_y_sp = entry.pos.y_sp if prev_orig_y_sp and prev_ptype then local gap_sp = orig_y_sp - prev_orig_y_sp - grid_height if gap_sp > 0 then accumulated_squeeze_sp = accumulated_squeeze_sp + gap_sp end end prev_orig_y_sp = orig_y_sp prev_ptype = curr_ptype -- Determine squeeze for this entry if context_mode then -- clreq 上下文相关:收回量已由 flatten 阶段按相邻上下文算好 local attr = D.get_attribute(entry.node, constants.ATTR_PUNCT_SQUEEZE) if attr and attr > 1 then squeeze_amount = (attr - 1) / 1000 total_squeezed = total_squeezed + 1 end elseif curr_ptype then if has_leading_space(curr_ptype) or has_trailing_space(curr_ptype) then local char = D.getfield(entry.node, "char") squeeze_amount = (char and CHAR_SQUEEZE[char]) or DEFAULT_SQUEEZE total_squeezed = total_squeezed + 1 end end -- Cell height for this entry (in sp) local cell_height_sp = math.floor((1.0 - squeeze_amount) * grid_height + 0.5) -- Shift up by accumulated squeeze (cells are sequential, no overlap) entry.pos.y_sp = entry.pos.y_sp - accumulated_squeeze_sp -- Store cell height (sp) for render stage to use for centering entry.pos.cell_height = cell_height_sp -- Accumulate squeeze for subsequent entries (in sp) accumulated_squeeze_sp = accumulated_squeeze_sp + math.floor(squeeze_amount * grid_height + 0.5) end end if total_squeezed > 0 then dbg.log(string.format("punct layout: squeezed %d punctuation marks", total_squeezed)) end end -- ============================================================================ -- Punctuation Style Positioning (Mainland vs Taiwan) -- ============================================================================ -- Mainland style: dot-class punctuation (fullstop, comma) offset toward -- the upper-right corner of the grid cell (when viewed in vertical layout). -- In the coordinate system: -- x offset > 0 = rightward (toward the column's outer edge) -- y offset > 0 = upward -- The offset is expressed as a fraction of grid dimensions. local MAINLAND_OFFSETS = { fullstop = { x = 0.20, y = 0.25 }, -- 。full cell, upper-right comma = { x = 0.20, y = 0 }, -- ,、 half cell, right-shifted middle = { x = 0.20, y = 0 }, -- :;!? right-shifted open = { x = 0, y = 0 }, -- 「【( centered in cell by calc_grid_position close = { x = 0, y = 0 }, -- 」】)centered in cell by calc_grid_position } -- 度量驱动的字面分布(差距分析 4.1 第 5 条):不再「经验偏移 + 部分字体 -- 补偿」,而是直接把字形**墨迹**锚到字幅内的规范位置。锚点表与位移计算 -- 在共享层 shared/luatex-cn-punct-anchors.lua(HR5:clreq 规则只写在 -- tex/shared/,横排 hori-pipeline 用同一模块): -- 中国大陆式直排:点号偏靠右上(x=0.857,贴前字); -- 台湾式:字面居中——**字体自己居不居中无所谓**:中国大陆惯例设计的字体 -- (思源宋体、京華老宋体)横排形在左下、vert 形在右上,不锚定的话 -- 台湾式版面会随字体漂移。 -- 教训:锚点不能从 Tm 相对坐标量——xoffset 会写进 Tm,量出来的只是 -- 字形自身的 bbox 中心,偏靠会整个丢失、退化为居中。 -- 无 boundingbox 可查时,中国大陆式退回上面的经验偏移路径,台湾式不动。 --- Render stage: apply punctuation style offsets -- Anchors the ink of dot/middle punctuation per style (mainland 偏靠 / -- taiwan 居中). style=none is the clreq 不调整 preset — no offsets. -- @param head (node) The page node list head -- @param layout_map (table) Layout map -- @param render_ctx (table) Render context -- @param ctx (table) Plugin context -- @param engine_ctx (table) Engine context -- @param page_idx (number) Current page index -- @param p_total_cols (number) Total columns on this page -- @return (node) The modified head function punct.render(head, layout_map, render_ctx, ctx, engine_ctx, page_idx, p_total_cols) if not ctx then return head end -- style=none:clreq 不调整预设,字面不挪动。 -- 台湾式的度量锚定只在 context 挡位(vbook 类)启用:ltc-guji 默认 -- taiwan + legacy,字面位置维持字体原样(R5:古籍版面不动)。 -- 中国大陆式沿 #138 的口径不加挡位(现存中国大陆式文档类均为 context)。 local do_taiwan = (ctx.style == "taiwan" and ctx.squeeze_mode == "context") if ctx.style ~= "mainland" and not do_taiwan then return head end -- Mainland style: offset dot-class punctuation local grid_width = engine_ctx.g_width local grid_height = engine_ctx.g_height local d_head = D.todirect(head) local t = d_head local count = 0 while t do local id = D.getid(t) if id == constants.GLYPH then local pos = layout_map[t] if pos and pos.page == page_idx then local ptype = get_node_punct_type(t) local style_offset = ptype and MAINLAND_OFFSETS[ptype] if style_offset then -- Read current offsets and add style adjustment local cur_x = D.getfield(t, "xoffset") or 0 local cur_y = D.getfield(t, "yoffset") or 0 local char = D.getfield(t, "char") local fid = D.getfield(t, "font") -- 度量驱动路径:有墨迹 bbox 就直接锚定,跳过下面的 -- 「经验偏移 + 字体补偿」。锚点表按**原始码位**查 -- (vert GSUB 落到 PUA 的字形先解析回来),bbox 用 -- 实际绘制的字形查。 local metric_done = false if fid and char then local fi = font.getfont(fid) local desc = fi and fi.descriptions and fi.descriptions[char] local bb = desc and desc.boundingbox local upem = fi and fi.units_per_em or 1000 local em_sp = fi and fi.size local orig = char if char >= 0xE000 then orig = resolve_original_codepoint(fid, char) or char end local dx, dy = punct_anchors.offsets( orig, ctx.style, "vertical", bb, upem, em_sp) if dx then D.setfield(t, "xoffset", cur_x + dx) D.setfield(t, "yoffset", cur_y + dy) count = count + 1 metric_done = true end -- 夹注号:字面归到上/下半格(clreq 图 30,两风格同值)。 -- 位移相对引擎落点算,见 punct_anchors.vert_bracket_dy。 -- 字体缺竖排形时引擎自己旋转字形(ATTR_VERT_ROTATE), -- 位置写在 pdf_literal 的变换矩阵里、xoffset/yoffset 被清零, -- 这里挪不动它——那条退路仍是字面居中。 -- 脚注标号组(︻一︼)同样不参与:组内字幅由 flush_buffer -- 按组高分配、括号已按真实墨迹缩放居中(#134),再挪半格 -- 会把一对括号推出组外。 local is_marker = (D.get_attribute(t, constants.ATTR_FOOTNOTE_MARKER) or 0) > 0 if not metric_done and em_sp and em_sp > 0 and not is_marker and D.get_attribute(t, constants.ATTR_VERT_ROTATE) ~= 1 then local h_em = (D.getfield(t, "height") or 0) / em_sp local d_em = (D.getfield(t, "depth") or 0) / em_sp local dy_em = punct_anchors.vert_bracket_dy( ptype, bb, upem, h_em, d_em) if dy_em then D.setfield(t, "yoffset", cur_y + math.floor(dy_em * em_sp + 0.5)) count = count + 1 metric_done = true end end end -- Font ink-center compensation: some fonts have punctuation -- glyphs whose ink is not centered in the advance width. -- Compensate so the glyph visually centers before applying -- the mainland style offset. -- 经验偏移是中国大陆式的退路;台湾式无 bbox 时不动(字体原样)。 local glyph_width = grid_width -- fallback if not metric_done and ctx.style == "mainland" and fid and char then local fi = font.getfont(fid) local ci = fi and fi.characters and fi.characters[char] if ci and ci.width then glyph_width = ci.width end local needs_ink_comp = INK_CENTER_CHARS[char] local is_pua_char = false if not needs_ink_comp then local orig = resolve_original_codepoint(fid, char) if orig and INK_CENTER_CHARS[orig] then needs_ink_comp = true is_pua_char = true -- This is a PUA char (vert GSUB substituted) end end if needs_ink_comp then local ratio_x, ratio_y = get_ink_center_ratio(fid, char) -- X-axis compensation: ink left of center → shift right (positive offset) -- ratio_x < 0.5 means ink is left → (0.5 - ratio_x) > 0 → shift right local comp_x = math.floor((0.5 - ratio_x) * glyph_width + 0.5) cur_x = cur_x + comp_x -- Y-axis compensation: ONLY for PUA chars (vert GSUB substituted glyphs) -- Normal fonts have well-centered punctuation, but vert forms from -- fonts like KingHwa_OldSong may have significant y-axis deviation. -- ratio_y > 0.5 means ink is high → negative offset shifts down if is_pua_char then local y_deviation = math.abs(ratio_y - 0.5) if y_deviation > 0.03 then -- Use 1.5x multiplier for stronger downward compensation -- Note: positive yoffset moves UP, so we need (0.5 - ratio_y) for downward local comp_y = math.floor((0.5 - ratio_y) * glyph_width * 1.5 + 0.5) cur_y = cur_y + comp_y end end end end -- 偏靠量按**该字自身的字幅**计,不是版面网格:夹注、 -- 批注、脚注等小字的字幅小于正文,用版面网格算会让小字 -- 的标点偏出所在列(偏移量固定而字幅变小)。 if not metric_done and ctx.style == "mainland" then local own_w, own_h = grid_width, grid_height if fid then local fi = font.getfont(fid) local fs = fi and fi.size if fs and fs > 0 and grid_height > 0 then local scale = fs / grid_height own_w = grid_width * scale own_h = fs end end local dx = math.floor(own_w * style_offset.x + 0.5) local dy = math.floor(own_h * style_offset.y + 0.5) D.setfield(t, "xoffset", cur_x + dx) D.setfield(t, "yoffset", cur_y + dy) count = count + 1 end end end end t = D.getnext(t) end if count > 0 then dbg.log(string.format("punct render: applied mainland offsets to %d marks (page %d)", count, page_idx)) end return D.tonode(d_head) end -- Export internal functions for unit testing punct._internal = { parse_tounicode = parse_tounicode, annotate_rigid_units = annotate_rigid_units, annotate_context_squeeze = annotate_context_squeeze, resolve_original_codepoint = resolve_original_codepoint, get_ink_center_ratio = get_ink_center_ratio, INK_CENTER_CHARS = INK_CENTER_CHARS, font_tounicode_cache = font_tounicode_cache, font_ink_center_cache = font_ink_center_cache, } return punct