-- 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. -- ============================================================================ -- layout_grid_helpers.lua - 布局网格辅助函数 -- ============================================================================ -- 从 layout_grid.lua 提取的参数获取、样式属性、列验证、占用地图等辅助函数。 -- 这些函数不依赖布局上下文 (ctx),是纯粹的工具函数。 -- ============================================================================ local constants = package.loaded['core.luatex-cn-constants'] or require('core.luatex-cn-constants') local D = constants.D local style_registry = package.loaded['util.luatex-cn-style-registry'] or require('util.luatex-cn-style-registry') local helpers = {} -- ============================================================================= -- Parameter getters (all values must be provided via params) -- ============================================================================= local function get_banxin_on(params) if params.banxin_on ~= nil then return params.banxin_on end return false end local function get_grid_width(params, fallback) if params.grid_width and type(params.grid_width) == "number" and params.grid_width > 0 then return params.grid_width end return fallback end local function get_margin_right(params) if params.margin_right then if type(params.margin_right) == "number" then return params.margin_right else return constants.to_dimen(params.margin_right) or 0 end end return 0 end local function get_chapter_title(params) if params.chapter_title and params.chapter_title ~= "" then return params.chapter_title end return "" end -- ============================================================================= -- Style attribute helpers -- ============================================================================= local function get_node_font_color(node) local style_id = D.get_attribute(node, constants.ATTR_STYLE_REG_ID) if style_id and style_id > 0 then return style_registry.get_font_color(style_id) end return nil end local function get_node_font_size(node) local style_id = D.get_attribute(node, constants.ATTR_STYLE_REG_ID) if style_id and style_id > 0 then return style_registry.get_font_size(style_id) end return nil end local function get_node_font(node) local style_id = D.get_attribute(node, constants.ATTR_STYLE_REG_ID) if style_id and style_id > 0 then return style_registry.get_font(style_id) end return nil end -- P1: style fields (font_color, font_size, font, textflow_align, xshift, yshift) -- are no longer copied into layout_map entries. Renderers read directly from -- style_registry via ATTR_STYLE_REG_ID. This function is kept as a no-op for -- backward compatibility with existing call sites. local function apply_style_attrs(map_entry, node_ptr) -- no-op: style fields now read directly from style_registry at render time end -- ============================================================================= -- P2: Absolute coordinate computation -- Mirrors render-position.lua's get_column_x / calculate_rtl_position logic, -- but callable from the layout stage (Stage 2) without requiring Stage 3 modules. -- ============================================================================= --- Compute the grid X offset for a given rtl_col in uniform-column mode. -- Mirrors render-position.lua:get_column_x. local function get_column_x_uniform(rtl_col, params) local grid_width = get_grid_width(params, params.grid_height or 0) local banxin_width = params.banxin_width or 0 local interval = params.n_column or 0 if interval <= 0 or banxin_width <= 0 or banxin_width == grid_width then return rtl_col * grid_width end local group_size = interval + 1 local full_groups = math.floor(rtl_col / group_size) local remainder = rtl_col % group_size local x = full_groups * (interval * grid_width + banxin_width) if remainder < interval then x = x + remainder * grid_width else x = x + interval * grid_width end return x end --- Compute the X coordinate (sp) for a layout_map entry. -- Coordinate system: origin at content area right edge, X increases from right to left. -- col=0 (rightmost) → x=0, col=1 → x=grid_width, etc. -- Does NOT depend on total_cols — each node only needs its own col. -- Pure grid coordinate: no half_thickness, no shift_x. -- @param col (number) Logical column index (0-indexed, 0 = rightmost) -- @param page (number) Page index (0-indexed) -- @param ctx (table) Layout context (has params, col_widths_sp, col_interval, col_banxin_width) -- @return (number) X coordinate from right edge (sp) local function compute_x(col, page, ctx) local col_widths = ctx.col_widths_sp and ctx.col_widths_sp[page] local grid_width = get_grid_width(ctx.params, ctx.params.grid_height or 0) local interval = ctx.col_interval or 0 local banxin_width = ctx.col_banxin_width or 0 -- Accumulate widths for col 0..col-1. -- Each column uses col_widths_sp if available, else uniform grid_width (with banxin). if col_widths and next(col_widths) then local x = 0 for c = 0, col - 1 do local cw = col_widths[c + 1] if cw then x = x + cw elseif interval <= 0 or banxin_width <= 0 or banxin_width == grid_width then x = x + grid_width else -- Banxin: check if column c is a reserved (banxin) column local group_size = interval + 1 if (c % group_size) == interval then x = x + banxin_width else x = x + grid_width end end end return x end -- Pure uniform-width column mode with banxin support if interval <= 0 or banxin_width <= 0 or banxin_width == grid_width then return col * grid_width end local group_size = interval + 1 local full_groups = math.floor(col / group_size) local remainder = col % group_size local x = full_groups * (interval * grid_width + banxin_width) if remainder < interval then x = x + remainder * grid_width else x = x + interval * grid_width end return x end --- Compute the absolute Y coordinate (sp) for a layout_map entry. -- Origin: page top-right corner. Positive direction: top-to-bottom. -- Includes shift_y (page geometry offset). -- @param y_sp (number) Row Y position (sp, from grid top) -- @param band_y_offset_sp (number) Band Y offset (sp) -- @param ctx (table) Layout context (has shift_y) -- @return (number) Y coordinate (sp) local function compute_y(y_sp, band_y_offset_sp, ctx) return (y_sp or 0) + (band_y_offset_sp or 0) + (ctx.shift_y or 0) end -- ============================================================================= -- Column validation functions -- ============================================================================= local function is_reserved_col(col, interval, banxin_on) if not banxin_on then return false end if interval <= 0 then return false end return _G.core.hooks.is_reserved_column(col, interval) end local function is_center_gap_col(col, params, grid_height) local banxin_on = get_banxin_on(params) if not banxin_on then return false end local g_width = get_grid_width(params, grid_height) local paper_w = params.paper_width if paper_w <= 0 then return false end local center = paper_w / 2 local gap_half_width = 15 * 65536 -- 15pt in sp local floating_x = params.floating_x or 0 -- Determine physical column position based on context: -- TextBox with floating_x > 0 (e.g. meipi): col is physical (left-to-right) -- Main text (floating_x = 0): col is logical (right-to-left), need RTL conversion local phys_col if floating_x > 0 then -- TextBox: col is physical column index, floating_x is absolute x position phys_col = col else -- Main text: convert logical RTL col to physical LTR col local total_cols = params.page_columns or 1 if total_cols <= 0 then total_cols = 1 end phys_col = total_cols - 1 - col floating_x = get_margin_right(params) end local col_right_x = floating_x + phys_col * g_width local col_left_x = col_right_x + g_width local gap_left = center - gap_half_width local gap_right = center + gap_half_width local overlaps = (col_right_x < gap_right) and (col_left_x > gap_left) return overlaps end -- ============================================================================= -- Occupancy map functions -- ============================================================================= local function is_occupied(occupancy, p, b, c, r) if not occupancy[p] then return false end if not occupancy[p][b] then return false end if not occupancy[p][b][c] then return false end return occupancy[p][b][c][r] == true end local function mark_occupied(occupancy, p, b, c, r) if not occupancy[p] then occupancy[p] = {} end if not occupancy[p][b] then occupancy[p][b] = {} end if not occupancy[p][b][c] then occupancy[p][b][c] = {} end occupancy[p][b][c][r] = true end -- ============================================================================= -- Glyph ink metrics -- ============================================================================= --- 字形的真实墨迹跨度(以字号为 1 的 em 比值,基线以上为正)。 -- -- 不能拿 height/depth 当墨迹:它们把基线到墨迹之间的空白也算进去。直排 -- 结束夹注符号 ︼ 的墨迹在 [0.300, 0.668],depth 为 0 而 height 是 0.668, -- 比真实墨迹(0.368)虚高 1.8 倍——按它缩放会让 ︻ ︼ 一对括号一大一小。 -- descriptions 的 boundingbox 才是墨迹盒;取不到时退回 height/depth。 -- -- @param n (direct node) 字形节点 -- @return (number, number) top_em, bottom_em(bottom 为负表示墨迹低于基线) local function glyph_ink_span(n) local fid = D.getfont(n) local f = fid and font.getfont(fid) local char = D.getfield(n, "char") local desc = f and f.descriptions and char and f.descriptions[char] local bb = desc and desc.boundingbox local upem = (f and f.units_per_em) or 1000 if bb and bb[2] and bb[4] and upem > 0 then return bb[4] / upem, bb[2] / upem end local size = f and f.size if size and size > 0 then return (D.getfield(n, "height") or 0) / size, -((D.getfield(n, "depth") or 0) / size) end return 0, 0 end --- 字形墨迹的**横向**跨度(以字号为 1 的 em 比值,字形原点为 0)。 -- 与 glyph_ink_span 同一份 boundingbox,取的是另一对分量;旋转 90° 摆放的 -- 字形(字体缺竖排形时的破折号)沿列方向的长度就是这个横向跨度。 -- 取不到 boundingbox 时退回字形宽度(含左右边距,偏大)。 -- @param n (direct node) 字形节点 -- @return (number, number) left_em, right_em local function glyph_ink_hspan(n) local fid = D.getfont(n) local f = fid and font.getfont(fid) local char = D.getfield(n, "char") local desc = f and f.descriptions and char and f.descriptions[char] local bb = desc and desc.boundingbox local upem = (f and f.units_per_em) or 1000 if bb and bb[1] and bb[3] and upem > 0 then return bb[1] / upem, bb[3] / upem end local size = f and f.size if size and size > 0 then return 0, (D.getfield(n, "width") or 0) / size end return 0, 0 end -- ============================================================================= -- Cell height calculation (natural layout mode) -- ============================================================================= --- Get cell height for a node in natural layout mode -- Returns font_size from style registry, or actual font size, -- or grid_height as fallback. -- Punctuation nodes (ATTR_PUNCT_TYPE > 0) get half height in mainland+squeeze mode. -- @param node (direct node) The glyph node -- @param grid_height (number) Base grid height in sp -- @param punct_config (table) Punctuation config {style, squeeze}. -- Callers must provide this; defaults are set at the parameter source (core-main.lua). local function get_cell_height(node, grid_height, punct_config) -- 横置西文(\横置):旋转 90° 后沿列方向的长度就是字形的 advance -- 宽度,字幅按它计,字母才能连排成词(串内字距由 layout 归零)。 if D.get_attribute(node, constants.ATTR_SIDEWAYS) == 1 then local w = D.getfield(node, "width") if w and w > 0 then return w end end local base local fs = get_node_font_size(node) if fs and fs > 0 then base = fs else local fid = D.getfield(node, "font") if fid then local f = font.getfont(fid) if f and f.size then base = f.size end end end base = base or grid_height -- Punctuation occupies half cell in mainland mode (squeeze enabled) -- Taiwan style: all punctuation occupies full cell (no squeeze) local punct_type = D.get_attribute(node, constants.ATTR_PUNCT_TYPE) if punct_type and punct_type > 0 and punct_config then local style = punct_config.style local squeeze = punct_config.squeeze -- clreq 上下文相关模式:收回量由 punct.flatten 按相邻上下文写在 -- ATTR_PUNCT_SQUEEZE 上(1 + 千分比),夹在汉字之间的标点为满幅。 if squeeze and punct_config.squeeze_mode == "context" then local attr = D.get_attribute(node, constants.ATTR_PUNCT_SQUEEZE) if attr and attr > 1 then return math.floor(base * (1 - (attr - 1) / 1000) + 0.5) end return base end if style ~= "taiwan" and squeeze then -- Only comma(4)/open(1)/close(2) get half height -- fullstop(3), middle(5), nobreak(6) stay full height if punct_type == 4 or punct_type == 1 or punct_type == 2 then return math.floor(base * 0.5) end end end return base end -- ============================================================================= -- Unified cell size resolution -- ============================================================================= --- Resolve cell height for a node in the unified layout engine -- Priority: 1) node style cell_height → 2) column default_cell_height → 3) font-size based -- @param node (direct node) The glyph node -- @param grid_height (number) Base grid height in sp -- @param default_cell_height (number|nil) Fixed cell height (grid mode) or nil (natural mode) -- @param punct_config (table|nil) Punctuation config for natural mode fallback -- @return (number) Cell height in sp local function resolve_cell_height(node, grid_height, default_cell_height, punct_config) -- 0. 横置西文(\横置)优先于固定格高:旋转 90° 后沿列方向的长度是 -- 字形 advance 宽度,grid 模式若仍按整格分配,字母就无法连排成词 -- (render 只旋转不改字幅,布局必须同步用 advance 计)。 if D.get_attribute(node, constants.ATTR_SIDEWAYS) == 1 then local w = D.getfield(node, "width") if w and w > 0 then return w end end -- 1. Check node style for per-character/paragraph grid_height override local sid = D.get_attribute(node, constants.ATTR_STYLE_REG_ID) if sid and sid > 0 then local style_ch = style_registry.get_grid_height(sid) if style_ch and style_ch > 0 then return style_ch end end -- 2. Column-level default (grid mode) if default_cell_height and default_cell_height > 0 then return default_cell_height end -- 3. Natural mode: font-size based return get_cell_height(node, grid_height, punct_config) end --- Resolve cell width for a node in the unified layout engine -- Priority: 1) node style cell_width → 2) column default_cell_width -- @param node (direct node) The glyph node -- @param default_cell_width (number|nil) Column-level default cell width in sp -- @return (number|nil) Cell width in sp, or nil (use grid_width) local function resolve_cell_width(node, default_cell_width) local sid = D.get_attribute(node, constants.ATTR_STYLE_REG_ID) if sid and sid > 0 then local style_cw = style_registry.get_cell_width(sid) if style_cw and style_cw > 0 then return style_cw end end return default_cell_width end --- Resolve cell gap for a node in the unified layout engine -- Priority: 1) node style cell_gap → 2) column default_cell_gap -- @param node (direct node) The glyph node -- @param default_cell_gap (number) Column-level default cell gap in sp -- @return (number) Cell gap in sp local function resolve_cell_gap(node, default_cell_gap) local sid = D.get_attribute(node, constants.ATTR_STYLE_REG_ID) if sid and sid > 0 then local style_gap = style_registry.get_cell_gap(sid) if style_gap then return style_gap end end return default_cell_gap or 0 end -- ============================================================================= -- Module exports -- ============================================================================= helpers.get_banxin_on = get_banxin_on helpers.get_grid_width = get_grid_width helpers.get_margin_right = get_margin_right helpers.get_chapter_title = get_chapter_title helpers.get_node_font_color = get_node_font_color helpers.get_node_font_size = get_node_font_size helpers.get_node_font = get_node_font helpers.apply_style_attrs = apply_style_attrs helpers.compute_x = compute_x helpers.compute_y = compute_y helpers.is_reserved_col = is_reserved_col helpers.is_center_gap_col = is_center_gap_col helpers.is_occupied = is_occupied helpers.mark_occupied = mark_occupied helpers.get_cell_height = get_cell_height helpers.resolve_cell_height = resolve_cell_height helpers.resolve_cell_width = resolve_cell_width helpers.resolve_cell_gap = resolve_cell_gap --- Create a linemark entry with standard fields -- @param opts (table) { group_id, col, y_sp, cell_height, font_size, sub_col, x_center_sp } -- @return (table) Linemark entry local function create_linemark_entry(opts) return { group_id = opts.group_id, col = opts.col, y_sp = opts.y_sp, cell_height = opts.cell_height, font_size = opts.font_size, sub_col = opts.sub_col, x_center_sp = opts.x_center_sp, } end helpers.create_linemark_entry = create_linemark_entry helpers.glyph_ink_span = glyph_ink_span helpers.glyph_ink_hspan = glyph_ink_hspan package.loaded['core.luatex-cn-layout-grid-helpers'] = helpers return helpers