-- 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.lua - 统一布局引擎（第二阶段）
-- ============================================================================
-- 文件名: layout_grid.lua (原 layout.lua)
-- 层级: 第二阶段 - 布局层 (Stage 2: Layout Layer)
--
-- 【模块功能 / Module Purpose】
-- 本模块负责排版流水线的第二阶段，在不修改节点的情况下进行"虚拟布局模拟"：
--   1. 遍历节点流，计算每个节点应该出现在哪一页、哪一列、第几行
--   2. 处理自动换列、分页逻辑（当行数超过 line_limit 时）
--   3. 避让版心（banxin）列位置，确保不在版心列放置正文内容
--   4. 支持"分布模式"（distribute），在列内均匀分布字符（用于 textbox）
--   5. 维护占用地图（occupancy map），防止 textbox 块与其他内容重叠
--
-- 【术语对照 / Terminology】
--   layout_map        - 布局映射（节点指针 → 坐标位置）
--   cur_page/col/row  - 当前光标位置（页/列/行）
--   banxin            - 版心（古籍中间的分隔列）
--   occupancy         - 占用地图（记录已被使用的网格位置）
--   line_limit        - 每列最大行数
--   page_columns      - 每页最大列数
--   effective_limit   - 有效行数限制（考虑右缩进后）
--   col_buffer        - 列缓冲区（用于分布模式）
--   distribute        - 分布模式（均匀分布字符）
--
-- 【注意事项】
--   • 本模块只计算位置（layout_map），不修改节点本身
--   • 版心列由 n_column 参数控制：每 (n_column + 1) 列就是一个版心列
--   • 右缩进（r_indent）会缩短列的有效高度（effective_limit）
--   • Textbox 块（由 core_textbox.lua 处理生成）占用多个网格单元（width × height）
--   • Textbox 在外部布局中始终表现为一个 width=1 的块，高度由其内容决定
--   • Penalty≤-10000 会触发强制换列（由 flatten_nodes.lua 插入）
--
-- 【整体架构 / Architecture】
--   输入: 一维节点流 + grid_height + line_limit + n_column + page_columns
--      ↓
--   calculate_grid_positions()
--      ├─ 维护光标状态 (cur_page, cur_col, cur_row)
--      ├─ 遍历每个节点
--      │   ├─ 应用缩进逻辑（hanging indent）
--      │   ├─ 检查是否需要换列/分页
--      │   ├─ 跳过版心列和已占用位置
--      │   └─ 记录位置到 layout_map[node] = {page, col, row}
--      └─ Textbox 块额外标记 occupancy 地图
--      ↓
--   输出: layout_map (节点指针 → 坐标) + total_pages
--
-- ============================================================================

-- Load dependencies
-- Check if already loaded via dofile (package.loaded set manually)
local constants = package.loaded['core.luatex-cn-constants'] or
    require('core.luatex-cn-constants')
local D = constants.D
local utils = package.loaded['util.luatex-cn-utils'] or
    require('util.luatex-cn-utils')
local hooks = package.loaded['core.luatex-cn-hooks'] or
    require('core.luatex-cn-hooks')
local debug = package.loaded['debug.luatex-cn-debug'] or
    require('debug.luatex-cn-debug')
local style_registry = package.loaded['util.luatex-cn-style-registry'] or
    require('util.luatex-cn-style-registry')

local dbg = debug.get_debugger('layout')

-- Load helpers (parameter getters, style attributes, column validation, occupancy)
local h = package.loaded['core.luatex-cn-layout-grid-helpers'] or
    require('core.luatex-cn-layout-grid-helpers')

local get_banxin_on = h.get_banxin_on
local get_grid_width = h.get_grid_width
local get_margin_right = h.get_margin_right
local get_chapter_title = h.get_chapter_title
local get_node_font_color = h.get_node_font_color
local get_node_font_size = h.get_node_font_size
local apply_style_attrs = h.apply_style_attrs
local is_reserved_col = h.is_reserved_col
local is_center_gap_col = h.is_center_gap_col
local is_occupied = h.is_occupied
local mark_occupied = h.mark_occupied
local get_cell_height = h.get_cell_height
local resolve_cell_height = h.resolve_cell_height
local resolve_cell_width = h.resolve_cell_width

-- Gap between characters in natural mode = 10% of cell height (0.1em)
local GAP_RATIO = 0.1

-- Export _internal for testing
local _internal = {}
_internal.get_banxin_on = get_banxin_on
_internal.get_grid_width = get_grid_width
_internal.get_margin_right = get_margin_right
_internal.get_chapter_title = get_chapter_title
_internal.is_reserved_col = is_reserved_col
_internal.is_center_gap_col = is_center_gap_col

local function create_grid_context(params, line_limit, p_cols)
    -- Use helper for chapter_title (from params only, no _G fallback)
    local initial_chapter = get_chapter_title(params)
    -- Unified layout: grid mode IS natural mode with fixed cell height and zero gap.
    -- Grid mode: default_cell_height = grid_height, default_cell_gap = 0
    -- Natural mode: default_cell_height = nil (font-size-based), default_cell_gap > 0
    local default_cell_height = params.default_cell_height  -- nil = natural mode
    local default_cell_gap = params.default_cell_gap       -- 0 set at layout_params definition
    local col_height_sp = params.col_height_sp             -- text area height (padding deducted)
    -- Full content height for band allocation (no padding deduction)
    local band_alloc_height = params.content_height_sp or col_height_sp
    local ctx = {
        cur_page = 0,
        cur_col = 0,
        cur_row = 0,
        occupancy = {},
        params = params,
        line_limit = line_limit,
        p_cols = p_cols,
        cur_column_indent = 0,
        page_has_content = false,
        -- Global column-padding (for per-column padding delta calculation)
        c_padding_top = params.c_padding_top or 0,
        c_padding_bottom = params.c_padding_bottom or 0,
        -- P2: page geometry offsets for computing absolute coordinates
        shift_x = params.shift_x or 0,
        shift_y = params.shift_y or 0,
        half_thickness = params.half_thickness or 0,
        -- Flag: whether padding has been applied for the current column
        col_padding_applied = true,
        chapter_title = initial_chapter,
        page_chapter_titles = {}, -- To store chapter title for each page
        page_resets = {}, -- page_resets[page] = true when a \chapter marker resets page number
        last_glyph_row = -1, -- Track last glyph row for detecting line changes
        -- Unified layout: grid = fixed cell_height + zero gap; natural = font-size + configurable gap
        default_cell_height = default_cell_height,
        default_cell_gap = default_cell_gap,
        cur_y_sp = 0,
        col_height_sp = col_height_sp,
        band_alloc_height = band_alloc_height,
        -- Explicit punct config (nil = no squeeze; set at layout_params definition)
        punct_config = params.punct_config,
        -- Free mode tracking
        is_free_mode = false,
        content_width = 0,
        accumulated_width_sp = 0,
        -- Phase 2.3: Column width tracking for Free Mode
        col_widths_sp = {}, -- col_widths_sp[page][col] = width_sp
        col_spacing_top_sp = {}, -- col_spacing_top_sp[page][col] = spacing_sp
        col_spacing_bottom_sp = {}, -- col_spacing_bottom_sp[page][col] = spacing_sp
        -- Auto column wrap: when true (default), columns auto-wrap on overflow.
        -- Set to false to disable auto-wrap (only explicit penalties cause column breaks).
        auto_column_wrap = true,
        -- Band (分栏) mode: divide page into horizontal bands
        n_bands = 1,
        cur_band = 0,
        band_heights_sp = {},     -- band_heights_sp[band_idx] = height_sp
        band_y_offsets_sp = {},   -- band_y_offsets_sp[band_idx] = y_offset_sp
        band_line_limits = {},    -- band_line_limits[band_idx] = max_rows
        band_cols_per_band = p_cols,
        band_mode = "auto",
        band_gap_sp = 0,
    }
    -- Initialize band parameters
    local n_bands = params.n_bands or 1
    if n_bands > 1 then
        local band_gap_sp = params.band_gap_sp or 0
        local total_gap = band_gap_sp * (n_bands - 1)
        -- Band allocation uses full content height (no padding deduction)
        local available_height = band_alloc_height - total_gap
        local band_heights = params.band_heights  -- user-specified per-band heights

        -- Pre-compute default height: unspecified bands share remaining space equally.
        -- The last unspecified band absorbs any sp rounding remainder.
        local specified_total = 0
        local unspecified_count = 0
        local last_unspecified = -1
        for i = 0, n_bands - 1 do
            if band_heights and band_heights[i + 1] then
                specified_total = specified_total + band_heights[i + 1]
            else
                unspecified_count = unspecified_count + 1
                last_unspecified = i
            end
        end
        local default_h = unspecified_count > 0
            and math.floor((available_height - specified_total) / unspecified_count)
            or 0

        local offset = 0
        for i = 0, n_bands - 1 do
            local h
            if band_heights and band_heights[i + 1] then
                h = band_heights[i + 1]
            elseif i == last_unspecified then
                -- Last unspecified band absorbs rounding remainder
                h = band_alloc_height - offset
                if h < 0 then h = 0 end
            else
                h = default_h
            end
            ctx.band_heights_sp[i] = h
            ctx.band_y_offsets_sp[i] = offset
            ctx.band_line_limits[i] = default_cell_height and math.floor(h / default_cell_height)
                or line_limit
            offset = offset + h + band_gap_sp
        end

        ctx.n_bands = n_bands
        ctx.band_cols_per_band = (params.band_columns and params.band_columns > 0)
            and params.band_columns or p_cols
        ctx.band_mode = params.band_mode or "auto"
        ctx.band_gap_sp = band_gap_sp
        -- Free mode (p_cols >= 10000): compute actual column count from content width
        if ctx.band_cols_per_band >= 10000 then
            local grid_w = params.grid_width or params.grid_height
            local cw = params.content_width or 0
            if cw > 0 and grid_w > 0 then
                ctx.band_cols_per_band = math.floor(cw / grid_w)
            end
        end
        -- Set initial line_limit to band 0's limit
        ctx.line_limit = ctx.band_line_limits[0]
        ctx.col_height_sp = ctx.band_heights_sp[0]
    else
        ctx.band_heights_sp[0] = band_alloc_height
        ctx.band_y_offsets_sp[0] = 0
        ctx.band_line_limits[0] = line_limit
    end

    ctx.page_chapter_titles[0] = ctx.chapter_title -- Initialize page 0 with the initial chapter title
    return ctx
end


local function apply_indentation(ctx, indent)
    if not indent or indent == 0 then return end
    local old_row = ctx.cur_row
    if indent < 0 then
        -- 负缩进（抬头）：列起始时设置 cur_row 为负值
        -- 用 cur_column_indent 追踪是否已应用，防止每个字符都重置
        -- （因为 -1+1=0，若用 cur_row<=0 判断会反复触发）
        if (ctx.cur_column_indent or 0) == 0 then
            ctx.cur_row = indent
            ctx.cur_column_indent = indent
        end
    else
        -- 正缩进：保持原有逻辑
        if ctx.cur_row < indent then ctx.cur_row = indent end
        if indent > (ctx.cur_column_indent or 0) then ctx.cur_column_indent = indent end
        if ctx.cur_row < (ctx.cur_column_indent or 0) then ctx.cur_row = ctx.cur_column_indent end
    end
    -- Sync sp accumulator when cur_row changed (unified layout)
    -- In natural mode, each cell occupies (grid_height + gap) where gap = 0.1em (GAP_RATIO).
    -- Grid mode (default_cell_height set): no gap between characters.
    if ctx.cur_row ~= old_row then
        local gh = (ctx.params and ctx.params.grid_height) or 655360
        local cell_gap = ctx.default_cell_height and 0 or math.floor(gh * GAP_RATIO)
        ctx.cur_y_sp = ctx.cur_row * (gh + cell_gap)
    end
end


local function move_to_next_valid_position(ctx, interval, grid_height, indent)
    local changed = true
    local banxin_on = get_banxin_on(ctx.params)
    while changed do
        changed = false
        -- Skip Banxin and register it
        while is_reserved_col(ctx.cur_col, interval, banxin_on) do
            -- Register this Banxin column for the current page
            if not ctx.banxin_registry[ctx.cur_page] then
                ctx.banxin_registry[ctx.cur_page] = {}
            end
            ctx.banxin_registry[ctx.cur_page][ctx.cur_col] = true

            ctx.cur_col = ctx.cur_col + 1
            if ctx.auto_column_wrap ~= false and ctx.cur_col >= ctx.p_cols then
                ctx.cur_col = 0
                ctx.cur_page = ctx.cur_page + 1
            end
            changed = true
            -- When wrapping column/page, row reset must honor indent
            ctx.cur_row = 0
            ctx.cur_y_sp = 0
            ctx.cur_column_indent = 0
            if indent then apply_indentation(ctx, indent) end
        end
        -- Skip Center Gap
        while is_center_gap_col(ctx.cur_col, ctx.params, grid_height) do
            ctx.cur_col = ctx.cur_col + 1
            if ctx.auto_column_wrap ~= false and ctx.cur_col >= ctx.p_cols then
                ctx.cur_col = 0
                ctx.cur_page = ctx.cur_page + 1
            end
            changed = true
            -- When wrapping column/page, row reset must honor indent
            ctx.cur_row = 0
            ctx.cur_y_sp = 0
            ctx.cur_column_indent = 0
            if indent then apply_indentation(ctx, indent) end
        end
        -- Skip Occupied
        if is_occupied(ctx.occupancy, ctx.cur_page, ctx.cur_band, ctx.cur_col, ctx.cur_row) then
            ctx.cur_row = ctx.cur_row + 1
            ctx.cur_y_sp = ctx.cur_row * grid_height
            if ctx.cur_row >= ctx.line_limit then
                ctx.cur_row = 0
                ctx.cur_y_sp = 0
                ctx.cur_col = ctx.cur_col + 1
                changed = true
                -- Reset column-specific indent tracker
                ctx.cur_column_indent = 0
                if indent then apply_indentation(ctx, indent) end
            else
                changed = true
                -- Row increment might need to re-check indent if we passed it then hit something?
                -- Actually skip indent is handled before placement usually.
            end
        end
    end
end

_internal.move_to_next_valid_position = move_to_next_valid_position

--- Reset per-column/cell transient state.
-- Must be called whenever the cursor moves to a new column, cell, or band
-- to prevent stale state from leaking across boundaries.
-- @param ctx (table) Grid context
local function reset_column_transient_state(ctx)
    ctx.cur_row = 0
    ctx.cur_y_sp = 0
    -- NOTE: cur_column_indent is NOT reset here because wrap_to_next_column
    -- has its own conditional logic (reset_indent / negative-indent check).
    -- Callers that need indent reset should do ctx.cur_column_indent = 0 explicitly.
    ctx.textflow_pending_sub_col = nil
    ctx.textflow_pending_row_used = nil
end

--- Wrap cursor to next column (and page if needed)
-- @param ctx (table) Grid context
--- Apply band format padding at column/band start.
-- Called eagerly when cur_y_sp is reset to 0, so all content types (glyphs,
-- textflow, textbox) benefit from per-band padding without lazy glyph check.
-- @param ctx (table) Grid context
local function apply_band_padding(ctx)
    ctx.col_padding_applied = false
    ctx.col_band_padding_applied = false
    ctx.col_band_padding_value = nil
    if ctx.table_band_formats then
        local bf = ctx.table_band_formats[ctx.cur_band]
        if bf and bf.padding_top then
            local pt = constants.to_dimen(bf.padding_top)
            if pt and pt ~= 0 then
                -- Band padding is an absolute offset from band top, not relative to column_padding
                ctx.cur_y_sp = ctx.cur_y_sp + pt
                ctx.col_band_padding_applied = true
                ctx.col_band_padding_value = pt
            end
        end
    end
end

-- @param p_cols (number) Total columns per page
-- @param interval (number) Banxin interval
-- @param grid_height (number) Grid height in sp
-- @param indent (number|nil) Current indent
-- @param reset_indent (boolean) Whether to reset column indent
-- @param reset_content (boolean) Whether to reset page_has_content flag
local function wrap_to_next_column(ctx, p_cols, interval, grid_height, indent, reset_indent, reset_content)
    -- Pop temporary indents when changing column
    style_registry.pop_temporary()

    ctx.cur_col = ctx.cur_col + 1
    ctx.just_wrapped_column = true -- Flag for issue #54 fix
    reset_column_transient_state(ctx)
    apply_band_padding(ctx)

    local should_wrap_page = false

    -- In digital mode (auto_column_wrap==false), never auto-wrap pages.
    -- Only explicit \换页 (PENALTY_FORCE_PAGE) triggers page breaks.
    -- Content overflowing beyond p_cols will render outside the border,
    -- making errors visible without cascading to subsequent pages.
    if ctx.auto_column_wrap ~= false then
        if ctx.n_bands > 1 then
            local band_start = ctx.table_start_col or 0
            local cols_in_band = ctx.band_cols_per_band
            if ctx.cur_col >= band_start + cols_in_band then
                if ctx.band_mode == "parallel" then
                    -- Parallel mode (table default): column overflow wraps to
                    -- next page at the same band position, not to the next band.
                    ctx.cur_col = band_start
                    should_wrap_page = true
                else
                    -- Auto mode: columns fill current band, then next band,
                    -- then wrap to next page from band 0.
                    ctx.cur_col = band_start
                    ctx.cur_band = ctx.cur_band + 1
                    if ctx.cur_band >= ctx.n_bands then
                        ctx.cur_band = 0
                        should_wrap_page = true
                    end
                end
                -- Update line_limit and col_height for current band
                ctx.line_limit = ctx.band_line_limits[ctx.cur_band]
                ctx.col_height_sp = ctx.band_heights_sp[ctx.cur_band]
                ctx.cur_y_sp = 0
                apply_band_padding(ctx)
            end
        elseif ctx.is_free_mode and ctx.content_width > 0 then
            -- Free mode: check if accumulated width exceeds available width
            if ctx.accumulated_width_sp >= ctx.content_width then
                should_wrap_page = true
                ctx.accumulated_width_sp = 0 -- Reset for new page
            end
        else
            -- Grid mode: use fixed column count
            should_wrap_page = (ctx.cur_col >= p_cols)
        end
    end

    if should_wrap_page then
        -- In table mode, save cum offset BEFORE wrap so the subsequent
        -- target_col in PENALTY_CELL_BREAK is rebased to the new page.
        -- Counts cells already finished (cell_idx); the cell currently
        -- rendering will continue at col 0 of the new page.
        if ctx.table_render_cell_idx ~= nil and ctx.band_mode == "parallel" then
            local all_col_groups_w = (_G.content and _G.content.table_col_groups) or {}
            local col_groups_w = all_col_groups_w[ctx.cur_band] or {}
            local cum_so_far = 0
            for i = 1, ctx.table_render_cell_idx do
                cum_so_far = cum_so_far + (col_groups_w[i] or 0)
            end
            ctx.table_render_cum_offset = cum_so_far
        end
        ctx.cur_page = ctx.cur_page + 1
        -- Always reset page_has_content on page turn:
        -- new page has no content yet regardless of reset_content flag.
        -- (reset_content only controls same-page column wraps)
        ctx.page_has_content = false
        if ctx.n_bands > 1 and ctx.band_mode == "parallel" then
            -- Parallel mode: track the maximum page any band reached,
            -- so TABLE_END can resume after all bands.
            ctx.table_max_page = math.max(ctx.table_max_page or ctx.cur_page, ctx.cur_page)
            -- Track per-band max page for border rendering
            if ctx.table_band_max_page then
                local band = ctx.cur_band
                ctx.table_band_max_page[band] = math.max(
                    ctx.table_band_max_page[band] or (ctx.table_start_page or 0),
                    ctx.cur_page)
            end
            -- Parallel mode: new page starts at col 0, keep same band.
            -- Update table_start_col since on the new page the table
            -- occupies the full width (no preceding content).
            ctx.cur_col = 0
            ctx.table_start_col = 0
            ctx.band_cols_per_band = p_cols
        elseif ctx.n_bands > 1 then
            -- Auto mode: reset to column 0, band 0
            ctx.cur_col = 0
            ctx.cur_band = 0
            ctx.line_limit = ctx.band_line_limits[0]
            ctx.col_height_sp = ctx.band_heights_sp[0]
        else
            ctx.cur_col = 0
        end
    end
    -- Always reset negative cur_column_indent on column wrap:
    -- taitou (negative indent) only applies to its own column.
    if reset_indent or (ctx.cur_column_indent or 0) < 0 then
        ctx.cur_column_indent = 0
    end
    -- Negative indent (taitou) should only apply in the taitou column itself.
    -- After column change, check if we're outside the taitou column scope.
    local skip_indent = indent
    if indent and indent < 0 then
        if ctx.taitou_col == nil or ctx.cur_col ~= ctx.taitou_col or ctx.cur_page ~= ctx.taitou_page then
            skip_indent = 0
        end
    end
    move_to_next_valid_position(ctx, interval, grid_height, skip_indent)
end

_internal.wrap_to_next_column = wrap_to_next_column

--- Accumulate consecutive glue/kern nodes and return total width
-- @param start_node (direct node) Starting node
-- @return (number, direct node) net_width in sp, and next non-spacing node
local function accumulate_spacing(start_node)
    local net_width = 0
    local lookahead = start_node

    while lookahead do
        local lid = D.getid(lookahead)
        if lid == constants.GLUE then
            net_width = net_width + (D.getfield(lookahead, "width") or 0)
        elseif lid == constants.KERN then
            net_width = net_width + (D.getfield(lookahead, "kern") or 0)
        elseif lid == constants.PENALTY then
            if D.getfield(lookahead, "penalty") <= -10000 then break end
        elseif lid == constants.WHATSIT then
            -- Skip whatsit nodes
        else
            break
        end
        lookahead = D.getnext(lookahead)
    end

    return net_width, lookahead
end

_internal.accumulate_spacing = accumulate_spacing

--- Flush pending textflow state (advance cursor past textflow rows)
-- Called before processing regular glyphs or penalty breaks that follow textflow
local function flush_textflow_pending(ctx, grid_height)
    if ctx.textflow_pending_sub_col and ctx.textflow_pending_row_used then
        ctx.cur_row = ctx.cur_row + ctx.textflow_pending_row_used
        ctx.cur_y_sp = ctx.cur_row * grid_height
        ctx.textflow_pending_sub_col = nil
        ctx.textflow_pending_row_used = nil
    end
end

--- Apply cell vertical-align post-processing
-- After a cell's content has been laid out, shift all its glyph y_sp
-- positions to center the content within the band height.
-- @param ctx (table) Grid context (reads cell_valign_nodes, cell_cur_valign, col_height_sp)
-- @param layout_map (table) The layout map (node -> map_entry)
local function apply_cell_valign_impl(valign, nodes, band_height, layout_map)
    if not nodes or #nodes == 0 then return false end
    if not valign or (valign ~= "center" and valign ~= "bottom") then return false end

    -- Find content bottom: max(y_sp + effective_height) across all nodes.
    -- For textbox blocks, use ATTR_TEXTBOX_HEIGHT_SP (precise sp height) when available,
    -- because cell_height (= tb_rows * outer_grid_height) is rounded up and may exceed
    -- the band height, preventing centering.
    local max_y = 0
    for _, n in ipairs(nodes) do
        local entry = layout_map[n]
        if entry then
            local h = entry.cell_height or 0
            if entry.is_block then
                local precise_h = D.get_attribute(n, constants.ATTR_TEXTBOX_HEIGHT_SP)
                if precise_h and precise_h > 0 then
                    h = precise_h
                end
            end
            local bottom = entry.y_sp + h
            if bottom > max_y then max_y = bottom end
        end
    end

    if band_height <= 0 or max_y <= 0 then return false end

    -- Offset: center = half gap, bottom = full gap
    local gap = band_height - max_y
    local offset = 0
    if valign == "center" then
        offset = math.floor(gap / 2)
    elseif valign == "bottom" then
        offset = gap
    end
    if offset > 0 then
        for _, n in ipairs(nodes) do
            local entry = layout_map[n]
            if entry then
                entry.y_sp = entry.y_sp + offset
            end
        end
    end
    return true
end

-- Get the effective band height for valign, deducting all padding.
-- Global c_padding_top/bottom shift content start/end within the band;
-- band-format padding_top/bottom further reduce usable space.
-- Both must be deducted so centering/bottom-aligning uses only the usable area.
local function get_valign_band_height(ctx)
    local bh = ctx.col_height_sp or 0
    -- Deduct global column padding
    bh = bh - (ctx.c_padding_top or 0) - (ctx.c_padding_bottom or 0)
    -- Deduct band-level padding
    if ctx.table_band_formats then
        local bf = ctx.table_band_formats[ctx.cur_band]
        if bf then
            if bf.padding_top then
                local pt = constants.to_dimen(bf.padding_top)
                if pt and pt > 0 then bh = bh - pt end
            end
            if bf.padding_bottom then
                local pb = constants.to_dimen(bf.padding_bottom)
                if pb and pb > 0 then bh = bh - pb end
            end
        end
    end
    return bh
end

local function apply_cell_valign(ctx, layout_map)
    local nodes = ctx.cell_valign_nodes
    local valign = ctx.cell_cur_valign

    if not nodes or #nodes == 0 then
        -- Nodes not yet available (textbox may appear after BAND_BREAK in node stream).
        -- Save pending valign so flush_buffer can apply it when nodes arrive.
        if valign and (valign == "center" or valign == "bottom") and nodes then
            ctx.pending_cell_valign = valign
            ctx.pending_cell_valign_band_height = get_valign_band_height(ctx)
            ctx.pending_cell_valign_nodes = {}
        end
        ctx.cell_valign_nodes = nil
        ctx.cell_cur_valign = nil
        return
    end

    local bh = get_valign_band_height(ctx)
    apply_cell_valign_impl(valign, nodes, bh, layout_map)

    ctx.cell_valign_nodes = nil
    ctx.cell_cur_valign = nil
end

_internal.apply_cell_valign = apply_cell_valign

--- Handle penalty node for column/page breaks
-- @param p_val (number) Penalty value
-- @param ctx (table) Grid context
-- @param flush_buffer (function) Buffer flush function
-- @param p_cols (number) Columns per page
-- @param interval (number) Banxin interval
-- @param grid_height (number) Grid height
-- @param indent (number) Current indent
-- @return (boolean) true if handled, false otherwise
local function handle_penalty_breaks(p_val, ctx, flush_buffer_fn, p_cols, interval, grid_height, indent, penalty_node)
    if p_val == constants.PENALTY_DIGITAL_NEWLINE then
        -- DigitalContent newline: always force column break, even on empty column.
        -- Every ^^M in DigitalContent source = one column in PDF output,
        -- so consecutive newlines (empty lines) must produce empty columns.
        -- Also disable auto column wrap: only explicit newlines cause column breaks.
        ctx.auto_column_wrap = false

        -- Skip this newline if it immediately follows a page break (\换页).
        -- After PENALTY_FORCE_PAGE, the page resets to col=0, row=0, page_has_content=false.
        -- The ^^M after \换页 is just the TeX source line ending, not a meaningful column break.
        if ctx.cur_col == 0 and ctx.cur_row == 0 and not ctx.page_has_content then
            return true
        end

        flush_buffer_fn()
        if ctx.is_free_mode then
            local g_w = get_grid_width(ctx.params, grid_height)
            ctx.accumulated_width_sp = ctx.accumulated_width_sp + g_w
        end
        wrap_to_next_column(ctx, p_cols, interval, grid_height, indent, false, true)
        ctx.cur_column_indent = 0
        return true
    elseif p_val == constants.PENALTY_FORCE_COLUMN or p_val == constants.PENALTY_TAITOU then
        -- Forced column break (\换行 command) or taitou column break (\抬头 command)
        flush_buffer_fn()
        -- Use actual cur_column_indent (not clamped to 0) so that columns with
        -- taitou (negative indent) are correctly detected as non-empty.
        -- E.g., \抬头[2]壹贰\\ → cur_column_indent=-2, cur_row=0 → 0 > -2 → wrap.
        -- Without this, cur_row(0) > max(-2,0)=0 is false → wrap skipped (bug #78).
        if ctx.cur_row > ctx.cur_column_indent then
            -- Free mode: accumulate column width for page wrap
            if ctx.is_free_mode then
                local g_w = get_grid_width(ctx.params, grid_height)
                ctx.accumulated_width_sp = ctx.accumulated_width_sp + g_w
            end
            wrap_to_next_column(ctx, p_cols, interval, grid_height, indent, false, true)
        end
        ctx.cur_column_indent = 0
        -- Only taitou penalties record the target column for scope tracking.
        -- Regular PENALTY_FORCE_COLUMN (paragraph breaks, \换行) must NOT
        -- update taitou scope, otherwise stale forced negative indent from a
        -- previous \抬头 would leak into subsequent paragraphs.
        if p_val == constants.PENALTY_TAITOU then
            ctx.taitou_col = ctx.cur_col
            ctx.taitou_page = ctx.cur_page
        end
        return true
    elseif p_val == constants.PENALTY_HALF_PAGE then
        -- Half-page break (\换半页): skip to next half-page boundary.
        -- In butterfly-binding mode, each page = left half + banxin + right half.
        -- interval = n_column = columns per half-page.
        -- Left half: cols 0..interval-1, banxin: col interval, right half: cols interval+1..2*interval.
        if interval > 0 then
            flush_buffer_fn()
            -- When at the start of a half-page with no content in this half yet,
            -- just record the penalty node in place (don't jump).
            -- This handles \插图页 at page/half-page start: no-silk applies to current half.
            -- cur_row==0 at a half-page boundary means the current half is empty,
            -- regardless of whether the other half had content (page_has_content).
            local at_half_start = (ctx.cur_col == 0 or ctx.cur_col == interval + 1)
                                  and ctx.cur_row == 0
            if at_half_start then
                if penalty_node then
                    ctx.layout_map[penalty_node] = {
                        page = ctx.cur_page,
                        col = ctx.cur_col,
                        row = 0,
                        y_sp = 0,
                    }
                end
            else
                local target_col
                if ctx.cur_col <= interval then
                    -- Currently in left half (or at banxin): jump to right half start
                    target_col = interval + 1
                else
                    -- Currently in right half: jump to next page
                    target_col = 2 * interval + 1
                end
                -- Advance to target column
                ctx.cur_col = target_col
                ctx.cur_row = 0
                ctx.cur_y_sp = 0
                ctx.cur_column_indent = 0
                -- Check if we've passed page boundary
                if ctx.cur_col >= p_cols then
                    ctx.cur_col = 0
                    ctx.cur_page = ctx.cur_page + 1
                    ctx.page_has_content = false
                end
                -- Record penalty node in layout_map at final position
                -- so the no-silk scan covers the correct half-page.
                if penalty_node then
                    ctx.layout_map[penalty_node] = {
                        page = ctx.cur_page,
                        col = ctx.cur_col,
                        row = 0,
                        y_sp = 0,
                    }
                end
                move_to_next_valid_position(ctx, interval, grid_height, indent)
            end
        end
        return true
    elseif p_val == constants.PENALTY_FORCE_PAGE then
        -- Forced page break (\newpage, \clearpage)
        if not ctx.page_has_content and ctx.cur_col == 0 and ctx.cur_row == 0 then
            if ctx.n_bands > 1 then
                -- Inside a table: place a placeholder so render stage still
                -- produces the empty page (needed for parallel mode cross-page tables).
                if penalty_node then
                    ctx.layout_map[penalty_node] = {
                        page = ctx.cur_page,
                        col = 0,
                        row = 0,
                        y_sp = 0,
                        mode = "placeholder",
                    }
                end
            else
                -- Normal text: skip redundant page break to avoid empty pages
                -- (e.g., column overflow already wrapped to a new page).
                return true
            end
        end
        flush_buffer_fn()
        ctx.cur_page = ctx.cur_page + 1
        ctx.cur_col = 0
        ctx.cur_row = 0
        ctx.cur_y_sp = 0
        ctx.cur_column_indent = 0
        ctx.page_has_content = false
        -- Reset band to 0 on explicit page break
        if ctx.n_bands > 1 then
            ctx.cur_band = 0
            ctx.line_limit = ctx.band_line_limits[0]
            ctx.col_height_sp = ctx.band_heights_sp[0]
        end
        move_to_next_valid_position(ctx, interval, grid_height, indent)
        return true
    elseif p_val == constants.PENALTY_BAND_BREAK then
        -- Forced band break (\换栏 command)
        -- Skip to the next horizontal band (or next page if on last band)
        if ctx.n_bands > 1 then
            flush_buffer_fn()
            apply_cell_valign(ctx, ctx.layout_map)
            ctx.cur_col = ctx.table_start_col or 0
            reset_column_transient_state(ctx)
            ctx.cur_column_indent = 0
            -- Reset table cell index for new band (row)
            if ctx.table_start_col ~= nil then
                ctx.table_render_cell_idx = 0
                ctx.table_render_cum_offset = 0
            end
            ctx.cur_band = ctx.cur_band + 1
            if ctx.cur_band >= ctx.n_bands then
                -- Both auto and parallel: wrap to next page, band 0
                ctx.cur_band = 0
                ctx.cur_page = ctx.cur_page + 1
                ctx.page_has_content = false
                -- On new page, table starts at col 0 with full page width
                -- (unless column_fill=half-page, which keeps the original width)
                ctx.cur_col = 0
                ctx.table_start_col = 0
                ctx.band_cols_per_band = ctx.table_orig_band_cols or p_cols
            elseif ctx.band_mode == "parallel" and ctx.table_start_page ~= nil then
                -- Parallel mode: each band starts independently from the
                -- table's start page/col, so a long band that overflowed to
                -- later pages does not push subsequent bands forward.
                ctx.cur_page = ctx.table_start_page
                local orig_col = ctx.table_orig_start_col or 0
                ctx.cur_col = orig_col
                ctx.table_start_col = orig_col
                ctx.band_cols_per_band = ctx.table_orig_band_cols or (p_cols - orig_col)
                ctx.page_has_content = true
            end
            ctx.line_limit = ctx.band_line_limits[ctx.cur_band]
            ctx.col_height_sp = ctx.band_heights_sp[ctx.cur_band]
            apply_band_padding(ctx)

            -- Initialize valign tracking for the first cell of the new band
            local all_cell_valigns = _G.content and _G.content.table_cell_valigns or {}
            local cell_valigns = all_cell_valigns[ctx.cur_band] or {}
            if cell_valigns[1] then
                ctx.cell_valign_nodes = {}
                ctx.cell_cur_valign = cell_valigns[1]
            end

            move_to_next_valid_position(ctx, interval, grid_height, indent)
        end
        return true
    elseif p_val == constants.PENALTY_CELL_BREAK then
        -- Cell break in table mode: jump to next column group
        -- Use ctx.table_start_col to detect table mode (not _G.content.table_mode
        -- which is already cleared by cleanup() before layout runs)
        if ctx.table_start_col ~= nil then
            flush_buffer_fn()
            apply_cell_valign(ctx, ctx.layout_map)

            local all_col_groups = (_G.content and _G.content.table_col_groups) or {}
            local col_groups = all_col_groups[ctx.cur_band] or {}
            local cell_idx = ctx.table_render_cell_idx or 0
            local cell_width = col_groups[cell_idx + 1] or 0

            if cell_width > 0 then
                -- Fixed-width cell: jump to start of next group.
                -- cum_offset is set when a mid-band page-wrap occurred,
                -- to rebase target_col relative to the new page (otherwise
                -- target_col would still reflect pre-wrap position, leaving
                -- large gaps between cells across pages).
                local band_start = ctx.table_start_col
                local cum = 0
                for i = 1, cell_idx + 1 do
                    cum = cum + (col_groups[i] or 0)
                end
                local cum_offset = ctx.table_render_cum_offset or 0
                local target_col = band_start + (cum - cum_offset)
                -- If the previous cell's content overflowed past the target column
                -- (e.g. auto-width cells contributed 0 to cum but consumed real columns),
                -- use current position + cell_width, ensuring at least 1 column advance.
                if target_col <= ctx.cur_col then
                    local advance = math.max(cell_width, ctx.cur_row > 0 and 1 or 0)
                    target_col = ctx.cur_col + advance
                end
                ctx.cur_col = target_col
            else
                -- Auto-width cell (行宽=nil): advance past current content
                -- Always advance at least 1 column to avoid overlap with next cell
                ctx.cur_col = ctx.cur_col + 1
            end

            -- Parallel mode: if cell pushed cur_col past band boundary, wrap to next page
            if ctx.band_mode == "parallel" and ctx.n_bands > 1 then
                local band_start = ctx.table_start_col or 0
                local cols_in_band = ctx.band_cols_per_band
                if ctx.cur_col >= band_start + cols_in_band then
                    -- Save cum offset BEFORE wrap so subsequent target_col is
                    -- rebased to the new page.
                    local cum_so_far = 0
                    for i = 1, cell_idx + 1 do
                        cum_so_far = cum_so_far + (col_groups[i] or 0)
                    end
                    ctx.table_render_cum_offset = cum_so_far
                    ctx.cur_page = ctx.cur_page + 1
                    ctx.table_max_page = math.max(ctx.table_max_page or ctx.cur_page, ctx.cur_page)
                    if ctx.table_band_max_page then
                        local band = ctx.cur_band
                        ctx.table_band_max_page[band] = math.max(
                            ctx.table_band_max_page[band] or (ctx.table_start_page or 0),
                            ctx.cur_page)
                    end
                    ctx.page_has_content = false
                    ctx.cur_col = 0
                    ctx.table_start_col = 0
                    ctx.band_cols_per_band = p_cols
                end
            end

            reset_column_transient_state(ctx)
            ctx.cur_column_indent = 0
            apply_band_padding(ctx)
            ctx.table_render_cell_idx = cell_idx + 1

            -- Initialize valign tracking for the next cell
            local all_cell_valigns = _G.content and _G.content.table_cell_valigns or {}
            local cell_valigns = all_cell_valigns[ctx.cur_band] or {}
            local next_valign = cell_valigns[cell_idx + 2]
            if next_valign then
                ctx.cell_valign_nodes = {}
                ctx.cell_cur_valign = next_valign
            end

            move_to_next_valid_position(ctx, interval, grid_height, indent)
        end
        return true
    elseif p_val == constants.PENALTY_TABLE_START then
        -- Begin inline table section: switch to band mode dynamically
        flush_buffer_fn()
        -- Start table on a new column
        if ctx.cur_row > 0 or ctx.cur_col > 0 then
            ctx.cur_col = ctx.cur_col + (ctx.cur_row > 0 and 1 or 0)
            ctx.cur_row = 0
            ctx.cur_y_sp = 0
            ctx.cur_column_indent = 0
        end

        -- Save pre-table state
        ctx.saved_band_state = {
            n_bands = ctx.n_bands,
            band_heights_sp = ctx.band_heights_sp,
            band_y_offsets_sp = ctx.band_y_offsets_sp,
            band_line_limits = ctx.band_line_limits,
            band_cols_per_band = ctx.band_cols_per_band,
            band_mode = ctx.band_mode,
            band_gap_sp = ctx.band_gap_sp,
            line_limit = ctx.line_limit,
            col_height_sp = ctx.col_height_sp,
        }

        -- Dequeue table instance from the FIFO queue
        local table_mod = require('core.luatex-cn-core-table')
        local table_instance = table_mod.dequeue_instance()
        local tp = (table_instance and table_instance.params) or {}
        -- Install instance data so TABLE_END handler can read col_groups etc.
        if _G.content and table_instance then
            _G.content.table_params = table_instance.params
            _G.content.table_col_groups = table_instance.col_groups
            _G.content.table_band_formats = table_instance.band_formats
            _G.content.table_cell_valigns = table_instance.cell_valigns
            _G.content.table_cell_column_borders = table_instance.cell_column_borders
        end
        local n_bands = tp.n_bands or 2
        local band_gap_sp = tp.band_gap_sp or 0
        -- Use full content height for band allocation (no padding deduction)
        local alloc_height = ctx.band_alloc_height or ctx.saved_band_state.col_height_sp
        local default_cell_height = ctx.default_cell_height
        local orig_line_limit = ctx.saved_band_state.line_limit

        -- Calculate band layout
        local total_gap = band_gap_sp * (n_bands - 1)
        local available_height = alloc_height - total_gap
        local band_heights_sp = {}
        local band_y_offsets_sp = {}
        local band_line_limits = {}
        local band_heights = tp.band_heights

        -- Pre-compute default height: unspecified bands share remaining space equally.
        -- The last unspecified band absorbs any sp rounding remainder.
        local specified_total = 0
        local unspecified_count = 0
        local last_unspecified = -1
        for i = 0, n_bands - 1 do
            if band_heights and band_heights[i + 1] then
                specified_total = specified_total + band_heights[i + 1]
            else
                unspecified_count = unspecified_count + 1
                last_unspecified = i
            end
        end
        local default_h = unspecified_count > 0
            and math.floor((available_height - specified_total) / unspecified_count)
            or 0

        local offset = 0
        for i = 0, n_bands - 1 do
            local h
            if band_heights and band_heights[i + 1] then
                h = band_heights[i + 1]
            elseif i == last_unspecified then
                -- Last unspecified band absorbs rounding remainder
                h = alloc_height - offset
                if h < 0 then h = 0 end
            else
                h = default_h
            end
            band_heights_sp[i] = h
            band_y_offsets_sp[i] = offset
            band_line_limits[i] = default_cell_height
                and math.floor(h / default_cell_height) or orig_line_limit
            offset = offset + h + band_gap_sp
        end

        ctx.n_bands = n_bands
        ctx.band_heights_sp = band_heights_sp
        ctx.band_y_offsets_sp = band_y_offsets_sp
        ctx.band_line_limits = band_line_limits
        local cf = tp.column_fill
        if cf == "page" then
            ctx.band_cols_per_band = p_cols - ctx.cur_col
        elseif cf == "half-page" and interval > 0 then
            -- Fill current half-page only
            local half_end
            if ctx.cur_col <= interval then
                -- In left half (or at banxin): fill up to banxin boundary
                half_end = interval
            else
                -- In right half: fill up to page end
                half_end = p_cols
            end
            ctx.band_cols_per_band = half_end - ctx.cur_col
        elseif tp.n_columns and tp.n_columns > 0 then
            ctx.band_cols_per_band = tp.n_columns
        else
            ctx.band_cols_per_band = p_cols
        end
        ctx.band_mode = "parallel"
        ctx.band_gap_sp = band_gap_sp
        ctx.cur_band = 0
        ctx.line_limit = band_line_limits[0]
        ctx.col_height_sp = band_heights_sp[0]

        -- Record table start column and init cell tracking
        ctx.table_start_col = ctx.cur_col
        ctx.table_start_page = ctx.cur_page
        ctx.table_orig_start_col = ctx.cur_col  -- immutable copy for parallel reset
        ctx.table_orig_band_cols = ctx.band_cols_per_band  -- immutable copy for parallel reset
        -- Track max page each band reaches (for per-page border rendering)
        ctx.table_band_max_page = {}
        ctx.table_render_cell_idx = 0
        -- Cum offset for target_col rebase after page-wrap mid-band
        ctx.table_render_cum_offset = 0
        -- Save band formats for per-band padding lookup
        ctx.table_band_formats = _G.content and _G.content.table_band_formats or nil
        -- Apply band 0 padding
        apply_band_padding(ctx)

        -- Initialize valign tracking for the first cell (band 0)
        local all_cell_valigns = _G.content and _G.content.table_cell_valigns or {}
        local cell_valigns = all_cell_valigns[0] or {}
        if cell_valigns[1] then
            ctx.cell_valign_nodes = {}
            ctx.cell_cur_valign = cell_valigns[1]
        end

        move_to_next_valid_position(ctx, interval, grid_height, indent)
        return true
    elseif p_val == constants.PENALTY_TABLE_END then
        -- End inline table section: restore pre-table band state
        flush_buffer_fn()
        apply_cell_valign(ctx, ctx.layout_map)

        -- Parallel mode: restore cur_page to the maximum page any band reached,
        -- so the table end position accounts for all bands' overflow.
        if ctx.table_max_page and ctx.table_max_page > ctx.cur_page then
            ctx.cur_page = ctx.table_max_page
            ctx.cur_col = 0
            ctx.page_has_content = false
        end

        -- Record table end info for border rendering
        ctx.table_end_col = ctx.cur_col
        ctx.table_end_page = ctx.cur_page

        -- Read column_fill early (needed for both border width and page break)
        local tparams_cf = (_G.content and _G.content.table_params)
            and _G.content.table_params.column_fill or nil
        local is_fill_page = (tparams_cf == "page" or tparams_cf == "half-page")

        -- Calculate actual table width from col_groups (max across all bands).
        -- For column_fill=page, the start-page width differs from continuation
        -- pages (start spans from table_orig_start_col to page right edge;
        -- continuations start at col 0 and span the full page), so we record
        -- both and pick per-page below.
        local all_col_groups = (_G.content and _G.content.table_col_groups) or {}
        local actual_band_cols = 0
        local actual_band_cols_overflow = nil
        if is_fill_page then
            -- column_fill=page: start-page width is the original (saved before
            -- any mid-band page wrap reset ctx.band_cols_per_band to p_cols).
            actual_band_cols = ctx.table_orig_band_cols or ctx.band_cols_per_band
            actual_band_cols_overflow = p_cols
        else
            for _, band_groups in pairs(all_col_groups) do
                local band_cols = 0
                for i = 1, #band_groups do
                    local w = band_groups[i] or 0
                    band_cols = band_cols + (w > 0 and w or 1)
                end
                if band_cols > actual_band_cols then
                    actual_band_cols = band_cols
                end
            end
        end
        if actual_band_cols == 0 then
            actual_band_cols = ctx.band_cols_per_band
        end

        -- Save per-page inline table band info for border rendering
        ctx.page_table_bands = ctx.page_table_bands or {}
        local start_page = ctx.table_start_page or ctx.cur_page
        local end_page = ctx.cur_page
        local band_max_page = ctx.table_band_max_page or {}
        for pg = start_page, end_page do
            do
            local tparams = _G.content and _G.content.table_params or {}
            -- Build per-band column_border map from band_formats
            local band_formats = _G.content and _G.content.table_band_formats
            local band_column_borders = nil
            if band_formats and next(band_formats) then
                band_column_borders = {}
                for band_idx, fmt in pairs(band_formats) do
                    if fmt.column_border ~= nil then
                        band_column_borders[band_idx] = fmt.column_border
                    end
                end
                if not next(band_column_borders) then band_column_borders = nil end
            end

            -- Build per-cell column_border map
            local cell_column_borders = _G.content and _G.content.table_cell_column_borders or nil
            local cell_col_borders_map = nil
            if cell_column_borders and next(cell_column_borders) then
                cell_col_borders_map = cell_column_borders
            end

            -- Per-cell column borders are keyed by absolute cell_idx, which
            -- corresponds to specific cells on the start page only. On overflow
            -- pages, those cell positions are different (or empty) so applying
            -- the same map would draw spurious vertical lines at wrong places.
            local pg_cell_borders = (pg == start_page) and cell_col_borders_map or nil
            -- Multi-table per page: store as array, not single object,
            -- so multiple tables on the same page each retain their band info.
            local pg_band_cols = (pg == start_page) and actual_band_cols
                or (actual_band_cols_overflow or actual_band_cols)
            ctx.page_table_bands[pg] = ctx.page_table_bands[pg] or {}
            table.insert(ctx.page_table_bands[pg], {
                n_bands = ctx.n_bands,
                band_heights_sp = ctx.band_heights_sp,
                band_y_offsets_sp = ctx.band_y_offsets_sp,
                band_gap_sp = ctx.band_gap_sp or 0,
                table_start_col = (pg == start_page) and (ctx.table_orig_start_col or 0) or 0,
                actual_band_cols = pg_band_cols,
                column_border = tparams.column_border,
                band_border = tparams.band_border,
                band_column_borders = band_column_borders,
                cell_column_borders = pg_cell_borders,
                column_fill = tparams_cf,
                -- Debug: save cell column groups for cell coordinate debug
                col_groups = all_col_groups,
                n_columns = ctx.band_cols_per_band,
            })
            end
        end

        -- Move to next column after the table
        ctx.cur_col = ctx.cur_col + (ctx.cur_row > 0 and 1 or 0)
        reset_column_transient_state(ctx)
        ctx.cur_column_indent = 0

        -- column_fill=page/half-page: force break after table
        -- Skip if already at the start of a new page (e.g. parallel mode wrapped here)
        if is_fill_page and (ctx.cur_col > 0 or ctx.page_has_content) then
            if tparams_cf == "half-page" and interval > 0 then
                -- half-page: advance to next half-page boundary
                if ctx.cur_col <= interval then
                    -- In left half: jump to right half start
                    ctx.cur_col = interval + 1
                else
                    -- In right half: jump to next page
                    ctx.cur_col = 0
                    ctx.cur_page = ctx.cur_page + 1
                    ctx.page_has_content = false
                end
            else
                -- page: advance to next page
                ctx.cur_col = 0
                ctx.cur_page = ctx.cur_page + 1
                ctx.page_has_content = false
            end
        end

        -- Restore saved band state
        if ctx.saved_band_state then
            local s = ctx.saved_band_state
            ctx.n_bands = s.n_bands
            ctx.band_heights_sp = s.band_heights_sp
            ctx.band_y_offsets_sp = s.band_y_offsets_sp
            ctx.band_line_limits = s.band_line_limits
            ctx.band_cols_per_band = s.band_cols_per_band
            ctx.band_mode = s.band_mode
            ctx.band_gap_sp = s.band_gap_sp
            ctx.line_limit = s.line_limit
            ctx.col_height_sp = s.col_height_sp
            ctx.cur_band = 0
            ctx.saved_band_state = nil
        end

        -- Clean up table-specific ctx state
        ctx.table_start_col = nil
        ctx.table_start_page = nil
        ctx.table_orig_start_col = nil
        ctx.table_max_page = nil
        ctx.table_band_max_page = nil
        ctx.table_render_cell_idx = nil
        -- Clean up table data that were preserved for layout
        if _G.content then
            _G.content.table_col_groups = nil
            _G.content.table_params = nil
            _G.content.table_band_formats = nil
            _G.content.table_cur_band = nil
            _G.content.table_cell_valigns = nil
            _G.content.table_cell_column_borders = nil
        end

        move_to_next_valid_position(ctx, interval, grid_height, indent)
        return true
    end
    return false
end

_internal.handle_penalty_breaks = handle_penalty_breaks

--- Handle all penalty nodes: smart break, force column, force page
-- Combines smart break logic (previously inline in main loop) with
-- handle_penalty_breaks for a unified penalty dispatch.
local function handle_penalty_node(t, ctx, grid_height, indent, interval, p_cols, flush_fn)
    local p_val = D.getfield(t, "penalty")
    if p_val == constants.PENALTY_SMART_BREAK then
        -- Smart column break: only break if next node is NOT textflow
        local next_node = D.getnext(t)
        if next_node then
            local next_is_textflow = D.get_attribute(next_node, constants.ATTR_JIAZHU) == 1
            if not next_is_textflow then
                -- If column is empty (cur_row==0, e.g. after FORCE_COLUMN wrapped),
                -- clear stale textflow pending state without advancing cur_row.
                -- Otherwise a normal flush would re-add pending rows from the
                -- previous column and cause a double-wrap (empty column bug).
                if ctx.cur_row == 0 and ctx.textflow_pending_row_used then
                    ctx.textflow_pending_sub_col = nil
                    ctx.textflow_pending_row_used = nil
                else
                    flush_textflow_pending(ctx, grid_height)
                end
                flush_fn()
                -- Use max(cur_column_indent, 0) to prevent negative indent (抬头)
                -- from causing false-positive wraps (same logic as FORCE_COLUMN).
                local sb_effective_indent = math.max(ctx.cur_column_indent, 0)
                if ctx.cur_row > sb_effective_indent then
                    wrap_to_next_column(ctx, p_cols, interval, grid_height, indent, false, true)
                end
                ctx.cur_column_indent = 0
            end
            -- If next is textflow, don't break - let textflow continue naturally
        end
    else
        handle_penalty_breaks(p_val, ctx, flush_fn, p_cols, interval, grid_height, indent, t)
    end
end

--- Get grid width for free mode column tracking
local function get_free_mode_grid_width(params)
    return params.grid_width or 0
end

--- Accumulate column width and record for free mode page-wrap detection
local function accumulate_free_mode_col_width(ctx, params)
    if not ctx.is_free_mode then return end
    local g_w = get_free_mode_grid_width(params)
    ctx.accumulated_width_sp = ctx.accumulated_width_sp + g_w
    ctx.col_widths_sp[ctx.cur_page] = ctx.col_widths_sp[ctx.cur_page] or {}
    if not ctx.col_widths_sp[ctx.cur_page][ctx.cur_col + 1] then
        ctx.col_widths_sp[ctx.cur_page][ctx.cur_col + 1] = g_w
    end
end

--- Determine indent for current cursor position (first-indent vs base-indent)
-- @param ctx (table) Grid context (reads cur_page, cur_col)
-- @param block_start_cols (table) Map of block_id -> {page, col} tracking first column
-- @param block_id (number|nil) Current block ID
-- @param base_indent (number) Base indent value
-- @param first_indent (number) First-column indent value (-1 means not set)
-- @return (number) The indent to use
local function get_indent_for_current_pos(ctx, block_start_cols, block_id, base_indent, first_indent)
    if block_id and block_id > 0 and first_indent >= 0 then
        if not block_start_cols[block_id] then
            block_start_cols[block_id] = { page = ctx.cur_page, col = ctx.cur_col }
        end
        local start_info = block_start_cols[block_id]
        if ctx.cur_page == start_info.page and ctx.cur_col == start_info.col then
            return first_indent
        end
    end
    return base_indent
end

--- Resolve indentation and layout constraints for a node
-- Three-tier priority: forced indent > explicit attribute > style stack inheritance
-- @param t (direct node) Current node
-- @param id (number) Node type ID
-- @param ctx (table) Grid context
-- @param block_start_cols (table) Block tracking map
-- @param grid_height (number) Grid height in sp
-- @param line_limit (number) Max rows per column
-- @return indent, r_indent, effective_limit, effective_col_height_sp, tb_w, tb_h
local function resolve_node_indent(t, id, ctx, block_start_cols, grid_height, line_limit)
    -- Penalty nodes don't occupy layout space — they should never carry indent.
    -- TeX's attribute inheritance causes penalty nodes inside \Column to inherit
    -- forced indent from \Indent, leading to empty columns in layout grid.
    if id == constants.PENALTY then
        local effective_limit = ctx.line_limit or line_limit
        local effective_col_height_sp = math.min(effective_limit * grid_height,
            ctx.col_height_sp or (effective_limit * grid_height))
        return 0, 0, effective_limit, effective_col_height_sp, 0, 0, 0, -1, nil
    end

    local block_id = D.get_attribute(t, constants.ATTR_BLOCK_ID)
    local node_indent = D.get_attribute(t, constants.ATTR_INDENT)
    local node_first_indent = D.get_attribute(t, constants.ATTR_FIRST_INDENT)

    -- Decode indent: two categories with different scope rules
    --   Taitou indent (\抬头/\平抬/\相对抬头): scoped to taitou column
    --   Suojin indent (\缩进[N]): not affected by taitou scope
    local indent_is_taitou, taitou_indent_value = constants.is_taitou_indent(node_indent)
    local indent_is_suojin, suojin_indent_value = constants.is_suojin_indent(node_indent)
    local first_indent_is_taitou, first_taitou_value = constants.is_taitou_indent(node_first_indent)
    local first_indent_is_suojin, first_suojin_value = constants.is_suojin_indent(node_first_indent)

    local indent_is_forced = indent_is_taitou or indent_is_suojin
    local first_indent_is_forced = first_indent_is_taitou or first_indent_is_suojin

    -- Taitou scope check: taitou indent only applies in the taitou column.
    -- When taitou_col is set (by PENALTY_TAITOU handler), nodes outside that
    -- column have their taitou values cleared and fall back to style stack.
    -- When taitou_col is nil (no active scope), taitou values pass through
    -- because wrap_to_next_column already cleared the scope — any remaining
    -- taitou-encoded nodes are in the correct (target) column.
    local outside_taitou = ctx.taitou_col ~= nil
        and (ctx.cur_col ~= ctx.taitou_col or ctx.cur_page ~= ctx.taitou_page)
    if indent_is_taitou and outside_taitou then
        indent_is_taitou = false
        indent_is_forced = false
        node_indent = nil
    end
    if first_indent_is_taitou and outside_taitou then
        first_indent_is_taitou = false
        first_indent_is_forced = false
        node_first_indent = nil
    end
    -- Note: suojin indent is NOT affected by taitou scope.

    -- Resolve base_indent: forced (taitou or suojin) > explicit > style stack
    local forced_indent_value = indent_is_taitou and taitou_indent_value
        or indent_is_suojin and suojin_indent_value or nil
    local base_indent = forced_indent_value or (node_indent or 0)

    local forced_first_value = first_indent_is_taitou and first_taitou_value
        or first_indent_is_suojin and first_suojin_value or nil
    local first_indent = forced_first_value or (node_first_indent or -1)

    -- If not forced and no explicit value, inherit from style stack
    if not indent_is_forced and (node_indent == nil or node_indent == 0) then
        local style_id = D.get_attribute(t, constants.ATTR_STYLE_REG_ID)
        if style_id then
            local stack_indent = style_registry.get_indent(style_id)
            if stack_indent and stack_indent > 0 then
                base_indent = stack_indent
            end
        end
    end

    -- Same logic for first_indent
    if not first_indent_is_forced and (node_first_indent == nil or node_first_indent == -1) then
        local style_id = D.get_attribute(t, constants.ATTR_STYLE_REG_ID)
        if style_id then
            local stack_first_indent = style_registry.get_first_indent(style_id)
            if stack_first_indent and stack_first_indent ~= -1 then
                first_indent = stack_first_indent
            end
        end
    end

    local indent = get_indent_for_current_pos(ctx, block_start_cols, block_id, base_indent, first_indent)
    local r_indent = D.get_attribute(t, constants.ATTR_RIGHT_INDENT) or 0

    -- Textbox attributes; ONLY treat HLIST/VLIST as blocks
    local tb_w = 0
    local tb_h = 0
    if id == constants.HLIST or id == constants.VLIST then
        tb_w = D.get_attribute(t, constants.ATTR_TEXTBOX_WIDTH) or 0
        tb_h = D.get_attribute(t, constants.ATTR_TEXTBOX_HEIGHT) or 0
    end

    local effective_limit = (ctx.line_limit or line_limit) - r_indent
    if effective_limit < indent + 1 then effective_limit = indent + 1 end
    local effective_col_height_sp = math.min(effective_limit * grid_height,
        ctx.col_height_sp or (effective_limit * grid_height))

    return indent, r_indent, effective_limit, effective_col_height_sp, tb_w, tb_h, base_indent, first_indent, block_id
end

--- Export free mode layout data to _G.content
-- Fills missing column widths and exports accumulated data for render-position
local function export_free_mode_data(ctx, layout_map, params)
    if not ctx.is_free_mode then return end

    -- Fill in missing column widths from layout_map
    -- \行 columns record their own widths, but regular text columns
    -- only trigger overflow recording when full. This fills gaps with grid_width.
    local g_w = get_free_mode_grid_width(params)
    local max_col_per_page = {}
    for _, pos in pairs(layout_map) do
        local pg = pos.page
        local col = pos.col
        if pg and col then
            if not max_col_per_page[pg] or col > max_col_per_page[pg] then
                max_col_per_page[pg] = col
            end
        end
    end
    for pg, max_col in pairs(max_col_per_page) do
        ctx.col_widths_sp[pg] = ctx.col_widths_sp[pg] or {}
        for c = 0, max_col do
            if not ctx.col_widths_sp[pg][c + 1] then
                ctx.col_widths_sp[pg][c + 1] = g_w
            end
        end
    end

    _G.content = _G.content or {}
    _G.content.is_free_mode_layout = true
    _G.content.col_widths_sp = ctx.col_widths_sp
    _G.content.col_spacing_top_sp = ctx.col_spacing_top_sp
    _G.content.col_spacing_bottom_sp = ctx.col_spacing_bottom_sp

    -- Debug: log recorded widths
    local total_cols = 0
    for _, cols in pairs(ctx.col_widths_sp) do
        for _ in pairs(cols) do
            total_cols = total_cols + 1
        end
    end
    dbg.log(string.format("[Phase 2.3] Free Mode: recorded %d column widths", total_cols))
end

--- Handle spacing node (glue/kern): accumulate and quantize to grid
-- @param t (direct node) Current spacing node
-- @param ctx (table) Grid context
-- @param grid_height (number) Grid height in sp
-- @param effective_col_height_sp (number) Effective column height in sp
-- @param indent (number) Current indent
-- @param interval (number) Banxin interval
-- @param p_cols (number) Columns per page
-- @param flush_fn (function) Buffer flush callback
-- @return (direct node|nil) Next node to process (nil = end of list)
local function handle_spacing_node(t, ctx, grid_height, effective_col_height_sp,
                                    indent, interval, p_cols, flush_fn)
    local net_width, lookahead = accumulate_spacing(t)

    -- In table mode, don't auto-wrap on spacing overflow
    local in_table = ctx.table_start_col ~= nil

    -- Unified guard: skip spacing at column start (before any content)
    if net_width > 0 and ctx.cur_y_sp > 0 then
        if ctx.default_cell_height then
            -- Grid mode: quantize spacing to discrete grid cells
            local cell_h = ctx.default_cell_height
            local threshold = cell_h * 0.25
            if net_width > threshold then
                local num_cells = math.floor(net_width / cell_h + 0.5)
                if num_cells < 1 then num_cells = 1 end

                dbg.log(string.format("  SPACING: val=%.2fpt, grid_h=%.2fpt, num_cells=%d",
                    net_width / 65536, grid_height / 65536, num_cells))

                for i = 1, num_cells do
                    ctx.cur_y_sp = ctx.cur_y_sp + cell_h
                    ctx.cur_row = math.floor(ctx.cur_y_sp / grid_height + 0.5)
                    if not in_table and ctx.cur_y_sp >= effective_col_height_sp then
                        flush_fn()
                        wrap_to_next_column(ctx, p_cols, interval, grid_height, indent, false, false)
                    else
                        move_to_next_valid_position(ctx, interval, grid_height, indent)
                    end
                end
            end
        else
            -- Natural mode: accumulate sp directly, no quantization
            ctx.cur_y_sp = ctx.cur_y_sp + net_width
            ctx.cur_row = math.floor(ctx.cur_y_sp / grid_height + 0.5)
            if not in_table and ctx.cur_y_sp > effective_col_height_sp then
                flush_fn()
                wrap_to_next_column(ctx, p_cols, interval, grid_height, indent, false, false)
            end
        end
    end

    return lookahead
end

--- Helper: Calculate actual accumulated height in column buffer
-- Returns total height of all characters + gaps between them
-- @param col_buffer (table) Buffer of character entries
-- @return (number) Total accumulated height in sp

local function calculate_buffer_height(col_buffer)
    if #col_buffer == 0 then return 0 end
    local total_height = 0
    for i, entry in ipairs(col_buffer) do
        local ch = entry.cell_height or 0
        total_height = total_height + ch
        if i < #col_buffer then
            total_height = total_height + math.floor(ch * GAP_RATIO)
        end
    end
    return total_height
end

_internal.calculate_buffer_height = calculate_buffer_height

-- ============================================================================
-- Natural Mode Kinsoku (Line-breaking Rules)
-- ============================================================================
-- Punctuation type codes (from ATTR_PUNCT_TYPE):
-- 1=open, 2=close, 3=fullstop, 4=comma, 5=middle, 6=nobreak

local function is_line_start_forbidden_code(code)
    return code == 2 or code == 3 or code == 4 or code == 5
end

local function is_line_end_forbidden_code(code)
    return code == 1
end

--- Calculate whether to squeeze or stretch for kinsoku resolution.
-- Compares per-gap cost of each option and returns the cheaper one.
-- @param col_buffer (table) Current column buffer (N chars)
-- @param t_node (direct node) The character about to be placed
-- @param ctx (table) Grid context
-- @param grid_height (number) Grid height in sp
-- @return (string) "squeeze" or "stretch"
local function calculate_kinsoku_action(col_buffer, t_node, ctx, grid_height)
    local N = #col_buffer
    if N < 2 then return "stretch" end

    local col_start_y = col_buffer[1].y_sp or 0
    local available = ctx.col_height_sp - col_start_y
    local base_gap = math.floor(grid_height * GAP_RATIO)

    local total_cells = 0
    for _, e in ipairs(col_buffer) do
        total_cells = total_cells + (e.cell_height or grid_height)
    end

    -- Squeeze: fit N+1 chars in column (N gaps)
    local t_cell_h = resolve_cell_height(t_node, grid_height, nil, ctx.punct_config)
    local total_squeeze = total_cells + t_cell_h
    local n_gaps_squeeze = N
    local squeeze_cost = math.huge
    if total_squeeze < available and n_gaps_squeeze > 0 then
        local new_gap = (available - total_squeeze) / n_gaps_squeeze
        squeeze_cost = math.abs(new_gap - base_gap)
    end

    -- Stretch: keep N-1 chars in column (N-2 gaps)
    local last_cell_h = col_buffer[N].cell_height or grid_height
    local total_stretch = total_cells - last_cell_h
    local n_gaps_stretch = N - 2
    local stretch_cost = math.huge
    if n_gaps_stretch > 0 then
        local new_gap = (available - total_stretch) / n_gaps_stretch
        stretch_cost = math.abs(new_gap - base_gap)
    elseif N == 2 then
        stretch_cost = 0
    end

    dbg.log(string.format(
        "kinsoku cost: squeeze=%.2f stretch=%.2f (base_gap=%d, N=%d) [p:%d c:%d]",
        squeeze_cost, stretch_cost, base_gap, N, ctx.cur_page, ctx.cur_col))

    return squeeze_cost <= stretch_cost and "squeeze" or "stretch"
end

--- Check if a kinsoku violation would occur at column wrap point.
-- Called when should_wrap=true in natural mode.
-- @param t (direct node) Character about to be placed (would go to next column)
-- @param ctx (table) Grid context
-- @param col_buffer (table) Current column buffer
-- @param grid_height (number) Grid height in sp
-- @return (string|nil) "squeeze", "stretch", or nil (no violation)
local function check_natural_kinsoku(t, ctx, col_buffer, grid_height)
    if #col_buffer < 1 then return nil end
    if not ctx.punct_config or not ctx.punct_config.kinsoku then return nil end

    -- Case 1: t is line-start-forbidden (would start new column)
    local t_code = D.get_attribute(t, constants.ATTR_PUNCT_TYPE)
    if t_code and t_code > 0 and is_line_start_forbidden_code(t_code) then
        return calculate_kinsoku_action(col_buffer, t, ctx, grid_height)
    end

    -- Case 2: last buffer char is line-end-forbidden (would end current column)
    local last = col_buffer[#col_buffer]
    local last_code = D.get_attribute(last.node, constants.ATTR_PUNCT_TYPE)
    if last_code and last_code > 0 and is_line_end_forbidden_code(last_code) then
        return calculate_kinsoku_action(col_buffer, t, ctx, grid_height)
    end

    return nil
end

_internal.is_line_start_forbidden_code = is_line_start_forbidden_code
_internal.is_line_end_forbidden_code = is_line_end_forbidden_code
_internal.calculate_kinsoku_action = calculate_kinsoku_action
_internal.check_natural_kinsoku = check_natural_kinsoku

--- Handle glyph node: decoration markers and regular characters
-- @param t (direct node) Current glyph node
-- @param ctx (table) Grid context
-- @param col_buffer (table) Column buffer
-- @param layout_map (table) Output layout map
-- @param grid_height (number) Grid height in sp
-- @param indent (number) Current indent
-- @param effective_limit (number) Effective line limit
-- @param distribute (boolean) Distribution mode flag
-- @param interval (number) Banxin interval
-- @param p_cols (number) Columns per page
-- @param params (table) Layout parameters (for hooks, free mode)
-- @param flush_fn (function) Buffer flush callback
local function handle_glyph_node(t, ctx, col_buffer, layout_map, grid_height,
                                  indent, effective_limit, distribute,
                                  interval, p_cols, params, flush_fn, base_indent)
    flush_textflow_pending(ctx, grid_height)
    local dec_id = D.get_attribute(t, constants.ATTR_DECORATE_ID)
    if dec_id and dec_id > 0 then
        -- Decorate Marker: position for the PREVIOUS character
        -- ISSUE #54 FIX: When column just wrapped (just_wrapped_column flag),
        -- use the previous character's position (last column's last row)
        local dec_page = ctx.cur_page
        local dec_col = ctx.cur_col
        local dec_y_sp = ctx.cur_y_sp
        local dec_band = ctx.cur_band

        if ctx.just_wrapped_column and ctx.last_char_row then
            -- Column just wrapped - use previous character's position
            dec_page = ctx.last_char_page or ctx.cur_page
            dec_col = ctx.last_char_col or ctx.cur_col
            dec_y_sp = (ctx.last_char_y_sp or 0) + (ctx.last_char_cell_height or grid_height)
            dec_band = ctx.last_char_band or ctx.cur_band
        end

        local dec_band_y_off = ctx.band_y_offsets_sp[dec_band] or 0
        local map_entry = {
            page = dec_page,
            col = dec_col,
            band = dec_band,
            y_sp = dec_y_sp,
            band_y_offset_sp = dec_band_y_off,
            cell_height = ctx.last_char_cell_height or grid_height,
            -- P2: absolute coordinates
            x = h.compute_x(dec_col, dec_page, ctx),
            y = h.compute_y(dec_y_sp, dec_band_y_off, ctx),
        }

        apply_style_attrs(map_entry, t)

        layout_map[t] = map_entry
        -- DO NOT increment cur_row - marker is zero-width overlay
    else
        -- Detect line change and clear temporary indents
        if ctx.cur_row ~= ctx.last_glyph_row then
            style_registry.pop_temporary()
            ctx.last_glyph_row = ctx.cur_row
        end

        -- Unified layout: resolve cell height and gap
        local cell_h = resolve_cell_height(t, grid_height, ctx.default_cell_height, ctx.punct_config)
        local cell_w = resolve_cell_width(t, ctx.default_cell_width)
        -- Natural mode: 0.1em gap (proportional to font size); grid mode: 0
        local gap = ctx.default_cell_height and 0 or math.floor(cell_h * GAP_RATIO)

        -- Column overflow check (sp-based)
        -- Natural mode: use actual accumulated height from buffer instead of cur_y_sp
        -- Grid mode: use cur_y_sp (which is synchronized with cur_row * grid_height)
        -- In table mode, column transitions are controlled by CELL_BREAK/BAND_BREAK only
        local should_wrap = false
        if not distribute and ctx.auto_column_wrap and ctx.cur_y_sp > 0
                and ctx.table_start_col == nil then
            if ctx.default_cell_height then
                -- Grid mode: use cur_y_sp (synchronized with grid)
                should_wrap = (ctx.cur_y_sp + cell_h > ctx.col_height_sp)
            else
                -- Natural mode: calculate actual height from buffer
                local col_start_y = #col_buffer > 0 and col_buffer[1].y_sp or 0
                local buffer_height = calculate_buffer_height(col_buffer)
                local next_y = col_start_y + buffer_height + cell_h
                -- Add 0.1em gap for the character we're about to add
                if #col_buffer > 0 then
                    local prev_ch = col_buffer[#col_buffer].cell_height or 0
                    next_y = next_y + math.floor(prev_ch * GAP_RATIO)
                end
                should_wrap = (next_y > ctx.col_height_sp)
            end
        end

        -- Natural mode kinsoku check (before flush)
        if should_wrap and not ctx.default_cell_height then
            local kinsoku_action = check_natural_kinsoku(t, ctx, col_buffer, grid_height)
            if kinsoku_action == "squeeze" then
                should_wrap = false
                dbg.log(string.format(
                    "natural kinsoku: SQUEEZE (char 0x%04X) [p:%d c:%d]",
                    D.getfield(t, "char") or 0, ctx.cur_page, ctx.cur_col))
            elseif kinsoku_action == "stretch" then
                local pulled = table.remove(col_buffer)
                flush_fn()
                accumulate_free_mode_col_width(ctx, params)
                wrap_to_next_column(ctx, p_cols, interval, grid_height, base_indent or indent, false, false)
                apply_indentation(ctx, base_indent or indent)
                table.insert(col_buffer, {
                    node = pulled.node,
                    page = ctx.cur_page,
                    col = ctx.cur_col,
                    band = ctx.cur_band,
                    y_sp = ctx.cur_y_sp,
                    height = pulled.height,
                    cell_height = pulled.cell_height,
                    cell_width = pulled.cell_width,
                })
                local ph = pulled.cell_height or grid_height
                ctx.cur_y_sp = ctx.cur_y_sp + ph + math.floor(ph * GAP_RATIO)
                ctx.cur_row = math.floor(ctx.cur_y_sp / grid_height + 0.5)
                ctx.page_has_content = true
                should_wrap = false
                dbg.log(string.format(
                    "natural kinsoku: STRETCH (char 0x%04X) [p:%d c:%d]",
                    D.getfield(t, "char") or 0, ctx.cur_page, ctx.cur_col))
            end
        end

        if should_wrap then
            flush_fn()
            accumulate_free_mode_col_width(ctx, params)
            wrap_to_next_column(ctx, p_cols, interval, grid_height, base_indent or indent, false, false)
            apply_indentation(ctx, base_indent or indent)
        end

        -- Apply per-column style padding override on first glyph of a new column.
        -- Band format padding is applied eagerly at column/band start (see apply_band_padding).
        -- Style-level padding overrides band padding when set on the first glyph.
        -- Both band padding and style padding are absolute offsets from column/band top.
        if not ctx.col_padding_applied then
            ctx.col_padding_applied = true
            local style_id = D.get_attribute(t, constants.ATTR_STYLE_REG_ID)
            local style_pt = style_id and style_registry.get_padding_top(style_id)
            if style_pt then
                if ctx.col_band_padding_applied then
                    -- Band padding was already applied; replace with style padding
                    local band_val = ctx.col_band_padding_value or 0
                    local adjust = style_pt - band_val
                    if adjust ~= 0 then
                        ctx.cur_y_sp = ctx.cur_y_sp + adjust
                    end
                else
                    -- No band padding; apply style padding directly
                    if style_pt ~= 0 then
                        ctx.cur_y_sp = ctx.cur_y_sp + style_pt
                    end
                end
            end
        end

        table.insert(col_buffer, {
            node = t,
            page = ctx.cur_page,
            col = ctx.cur_col,
            band = ctx.cur_band,
            y_sp = ctx.cur_y_sp,
            height = (D.getfield(t, "height") or 0) + (D.getfield(t, "depth") or 0),
            cell_height = cell_h,
            cell_width = cell_w,
        })

        -- Track last character position for decoration markers.
        -- MUST be done AFTER column wrap decision so last_char_* reflects
        -- the character's actual position (which may be in a new column).
        ctx.last_char_page = ctx.cur_page
        ctx.last_char_col = ctx.cur_col
        ctx.last_char_band = ctx.cur_band
        ctx.last_char_row = ctx.cur_row
        ctx.last_char_y_sp = ctx.cur_y_sp
        ctx.last_char_cell_height = cell_h
        -- Clear wrapped flag: character has been placed in its final column.
        ctx.just_wrapped_column = false

        ctx.cur_y_sp = ctx.cur_y_sp + cell_h + gap
        ctx.cur_row = math.floor(ctx.cur_y_sp / grid_height + 0.5)
        ctx.page_has_content = true

        -- Kinsoku (line-breaking rules) hook:
        -- gap==0 implies grid mode (natural mode has gap>0), so no mode guard needed
        if gap == 0 and not distribute and params and params.hooks
            and params.hooks.check_kinsoku then
            params.hooks.check_kinsoku(
                t, ctx, effective_limit, col_buffer,
                flush_fn, wrap_to_next_column,
                p_cols, interval, grid_height, indent)
        end

        -- Skip banxin columns, center gap, and occupied cells
        -- In natural mode (n_column=0): all checks are no-op
        move_to_next_valid_position(ctx, interval, grid_height, indent)
    end
end

--- Flush column buffer: finalize positions and write to layout_map
-- @param col_buffer (table) Buffer of entries for current column
-- @param ctx (table) Grid context
-- @param grid_height (number) Grid height in sp
-- @param distribute (boolean) Whether to distribute nodes evenly
-- @param layout_map (table) Output layout map (node → position)
local function flush_buffer(col_buffer, ctx, grid_height, distribute, layout_map)
    if #col_buffer == 0 then return end

    local N = #col_buffer
    local H = ctx.line_limit -- Default to integer grid cells

    -- Column start offset: first entry's y_sp may include indent from
    -- apply_indentation (e.g., footnote indent=1). All recalculation
    -- paths must preserve this offset instead of starting from 0.
    local col_start_y = col_buffer[1].y_sp or 0

    -- If absolute height is provided and we are in distribution mode,
    -- use the actual dimension to calculate distribution.
    -- Deduct padding_top/bottom (box-level inset, not inherited).
    local fill_pad_top = 0
    local fill_pad_bottom = 0
    if distribute and ctx.params.absolute_height and ctx.params.absolute_height > 0 then
        local constants_mod = package.loaded['core.luatex-cn-constants'] or
            require('core.luatex-cn-constants')
        if ctx.params.padding_top then
            fill_pad_top = constants_mod.to_dimen(ctx.params.padding_top) or 0
        end
        if ctx.params.padding_bottom then
            fill_pad_bottom = constants_mod.to_dimen(ctx.params.padding_bottom) or 0
        end
        local dist_height = ctx.params.absolute_height - fill_pad_top - fill_pad_bottom
        H = dist_height / grid_height
    end

    local v_scale_all = 1.0
    local distribute_y_sp = {}

    if distribute and N > 1 then
        local total_char_height = 0
        for _, entry in ipairs(col_buffer) do
            local ch = entry.cell_height or entry.height or grid_height
            if ch <= 0 then ch = grid_height end
            total_char_height = total_char_height + ch
        end

        local H_sp = H * grid_height
        -- Start distributing from col_start_y (not fill_pad_top).
        -- shift_y already includes c_padding_top, so adding fill_pad_top here
        -- would double-apply the top padding.
        local dist_start_y = col_start_y
        local available_sp = H_sp - dist_start_y
        if total_char_height > available_sp then
            -- Squeeze mode
            v_scale_all = available_sp / total_char_height
            local current_y = dist_start_y
            for i = 1, N do
                local entry = col_buffer[i]
                local ch = entry.cell_height or entry.height or grid_height
                if ch <= 0 then ch = grid_height end
                ch = ch * v_scale_all
                local y_center = current_y + ch / 2
                distribute_y_sp[i] = y_center - grid_height * 0.5
                current_y = current_y + ch
            end
        else
            -- Distribute mode (No enlargement)
            v_scale_all = 1.0
            local gap = (available_sp - total_char_height) / (N - 1)
            local current_y = dist_start_y
            for i = 1, N do
                local entry = col_buffer[i]
                local ch = entry.cell_height or entry.height or grid_height
                if ch <= 0 then ch = grid_height end
                local y_center = current_y + ch / 2
                distribute_y_sp[i] = y_center - grid_height * 0.5
                current_y = current_y + ch + gap
            end
        end
    end

    -- Natural mode (no default_cell_height): recalculate positions.
    -- Natural mode gap strategy (0.1em proportional gap):
    --   1. Base gap = 0.1 * cell_height per character (proportional to font size)
    --   2. If column is nearly full (remaining < 1 char), stretch stretchable gaps
    --      so total height = col_height_sp (bottom-aligned with full columns)
    --   Stretchable gaps: only between regular text glyphs (not marker groups, not blocks)
    if not ctx.default_cell_height and N > 0 and not distribute then
        -- Pre-pass: set marker group cell_heights so gap calculation uses correct sizes.
        -- Without this, each marker char counts as grid_height in total_cells,
        -- but the actual marker group height is much smaller (marker_height * fn_size).
        do
            local i = 1
            while i <= N do
                local mv = D.get_attribute(col_buffer[i].node, constants.ATTR_FOOTNOTE_MARKER)
                if mv and mv > 0 then
                    local gs, ge = i, i
                    while ge + 1 <= N do
                        local nv = D.get_attribute(col_buffer[ge + 1].node, constants.ATTR_FOOTNOTE_MARKER)
                        if nv == mv then ge = ge + 1 else break end
                    end
                    local glen = ge - gs + 1
                    if glen >= 3 then
                        local total_h = mv
                        local bracket_h = math.floor(total_h * 0.125)
                        local middle_total = total_h - 2 * bracket_h
                        local n_middle = glen - 2
                        local middle_h = math.floor(middle_total / n_middle)
                        col_buffer[gs].cell_height = bracket_h
                        for j = gs + 1, ge - 1 do
                            col_buffer[j].cell_height = middle_h
                        end
                        col_buffer[ge].cell_height = bracket_h
                    end
                    i = ge + 1
                else
                    i = i + 1
                end
            end
        end

        -- Classify each gap: stretchable, fixed, or none
        -- gap_fixed[i] = fixed gap size in sp (0 for markers, 0.1em for blocks)
        -- is_stretchable[i] = true only for text-text gaps
        local is_stretchable = {}
        local gap_fixed = {}
        local total_cells = 0
        local total_fixed_gaps = 0
        local total_base_gaps = 0
        local n_stretchable = 0
        for i, e in ipairs(col_buffer) do
            local ch = e.cell_height or grid_height
            total_cells = total_cells + ch
            if i < N then
                local cur_is_marker = D.get_attribute(e.node, constants.ATTR_FOOTNOTE_MARKER)
                local nxt = col_buffer[i + 1]
                local nxt_is_marker = D.get_attribute(nxt.node, constants.ATTR_FOOTNOTE_MARKER)
                local cur_is_block = e.is_block
                if (cur_is_marker and cur_is_marker > 0)
                    and (nxt_is_marker and nxt_is_marker > 0) then
                    -- Inside marker group: no gap
                    is_stretchable[i] = false
                    gap_fixed[i] = 0
                elseif (cur_is_marker and cur_is_marker > 0)
                    and not (nxt_is_marker and nxt_is_marker > 0) then
                    -- Marker end → next element: fixed small gap
                    local fg = math.floor(grid_height * GAP_RATIO)
                    is_stretchable[i] = false
                    gap_fixed[i] = fg
                    total_fixed_gaps = total_fixed_gaps + fg
                elseif (nxt_is_marker and nxt_is_marker > 0) then
                    -- Before marker group start: same fixed small gap
                    local fg = math.floor(grid_height * GAP_RATIO)
                    is_stretchable[i] = false
                    gap_fixed[i] = fg
                    total_fixed_gaps = total_fixed_gaps + fg
                elseif cur_is_block then
                    -- Block (墨围 etc.): fixed 0.1em gap, not stretchable
                    local fg = math.floor(ch * GAP_RATIO)
                    is_stretchable[i] = false
                    gap_fixed[i] = fg
                    total_fixed_gaps = total_fixed_gaps + fg
                else
                    -- Regular text: stretchable 0.1em gap
                    local bg = math.floor(ch * GAP_RATIO)
                    is_stretchable[i] = true
                    gap_fixed[i] = bg
                    n_stretchable = n_stretchable + 1
                    total_base_gaps = total_base_gaps + bg
                end
            end
        end
        local occupied = total_cells + total_base_gaps + total_fixed_gaps + col_start_y
        local remaining = ctx.col_height_sp - occupied

        if N == 1 then
            -- Keep original y_sp (preserves indent offset)
        elseif remaining < 0 and N > 1 and n_stretchable > 0 then
            -- Overfull (kinsoku squeeze): compress stretchable gaps to fit
            local total_available = ctx.col_height_sp - total_cells - col_start_y - total_fixed_gaps
            if total_available > 0 then
                local squeeze_gap = total_available / n_stretchable
                local y = col_start_y
                for i, e in ipairs(col_buffer) do
                    e.y_sp = y
                    local ch = e.cell_height or grid_height
                    if i < N then
                        y = y + ch + (is_stretchable[i] and squeeze_gap or gap_fixed[i])
                    else
                        y = y + ch
                    end
                end
            else
                -- Even with zero gaps, chars don't fit. Stack tight.
                local y = col_start_y
                for i, e in ipairs(col_buffer) do
                    e.y_sp = y
                    local ch = e.cell_height or grid_height
                    if i < N then
                        y = y + ch + (is_stretchable[i] and 0 or (gap_fixed[i] or 0))
                    end
                end
            end
        elseif remaining >= 0 and remaining < grid_height + math.floor(grid_height * GAP_RATIO) and N > 1 and n_stretchable > 0 then
            -- Nearly full: stretch only stretchable gaps; fixed gaps keep their size
            local total_available = ctx.col_height_sp - total_cells - col_start_y - total_fixed_gaps
            local stretch_gap = total_available / n_stretchable
            local y = col_start_y
            for i, e in ipairs(col_buffer) do
                e.y_sp = y
                local ch = e.cell_height or grid_height
                if i < N then
                    y = y + ch + (is_stretchable[i] and stretch_gap or gap_fixed[i])
                else
                    y = y + ch
                end
            end
        else
            -- Not full: use 0.1em for stretchable and block gaps; 0 for markers
            local y = col_start_y
            for i, e in ipairs(col_buffer) do
                local ch = e.cell_height or grid_height
                e.y_sp = y
                if i < N then
                    y = y + ch + gap_fixed[i]
                else
                    y = y + ch
                end
            end
        end
    end

    -- Redistribute footnote marker y_sp and v_scale.
    -- cell_height was already set in the pre-pass above; here we only fix y_sp
    -- (which was computed using the correct cell_heights) and set v_scale for
    -- multi-char middle glyphs that need to be squeezed.
    do
        local i = 1
        while i <= N do
            local mv = D.get_attribute(col_buffer[i].node, constants.ATTR_FOOTNOTE_MARKER)
            if mv and mv > 0 then
                local gs, ge = i, i
                while ge + 1 <= N do
                    local nv = D.get_attribute(col_buffer[ge + 1].node, constants.ATTR_FOOTNOTE_MARKER)
                    if nv == mv then ge = ge + 1 else break end
                end
                local glen = ge - gs + 1
                if glen >= 3 then
                    local n_middle = glen - 2
                    local middle_h = col_buffer[gs + 1].cell_height
                    -- Scale middle chars when multi-char
                    if n_middle > 1 and middle_h < grid_height then
                        local mid_v_scale = middle_h / grid_height
                        for j = gs + 1, ge - 1 do
                            col_buffer[j].v_scale = mid_v_scale
                        end
                    end
                    -- Fix y_sp: redistribute within group using pre-set cell_heights
                    local sy = col_buffer[gs].y_sp
                    local y = sy
                    for j = gs, ge do
                        col_buffer[j].y_sp = y
                        y = y + col_buffer[j].cell_height
                    end
                end
                i = ge + 1
            else
                i = i + 1
            end
        end
    end

    for i, entry in ipairs(col_buffer) do
        local y_sp = distribute_y_sp[i] or entry.y_sp
        local v_scale = entry.v_scale or ((distribute and N > 1) and v_scale_all or 1.0)

        local fl_band = entry.band or 0
        local fl_band_y_off = ctx.band_y_offsets_sp[fl_band] or 0
        local map_entry = {
            page = entry.page,
            col = entry.col,
            band = fl_band,
            y_sp = y_sp,
            band_y_offset_sp = fl_band_y_off,
            is_block = entry.is_block,
            width = entry.width,
            height = entry.height,
            v_scale = v_scale,
            cell_height = entry.cell_height,
            cell_width = entry.cell_width,
            -- P2: absolute coordinates
            x = h.compute_x(entry.col, entry.page, ctx),
            y = h.compute_y(y_sp, fl_band_y_off, ctx),
        }
        apply_style_attrs(map_entry, entry.node)

        -- Check for line mark attribute (专名号/书名号)
        local lm_id = D.get_attribute(entry.node, constants.ATTR_LINE_MARK_ID)
        if lm_id and lm_id > 0 then
            map_entry.line_mark_id = lm_id
        end

        layout_map[entry.node] = map_entry

        -- Track nodes for cell vertical-align post-processing
        if ctx.cell_valign_nodes then
            ctx.cell_valign_nodes[#ctx.cell_valign_nodes + 1] = entry.node
        end
        -- Track nodes for deferred (pending) cell valign
        if ctx.pending_cell_valign_nodes then
            ctx.pending_cell_valign_nodes[#ctx.pending_cell_valign_nodes + 1] = entry.node
        end
    end

    -- Apply deferred cell valign if pending nodes were collected
    if ctx.pending_cell_valign and ctx.pending_cell_valign_nodes and #ctx.pending_cell_valign_nodes > 0 then
        apply_cell_valign_impl(
            ctx.pending_cell_valign,
            ctx.pending_cell_valign_nodes,
            ctx.pending_cell_valign_band_height,
            layout_map)
        ctx.pending_cell_valign = nil
        ctx.pending_cell_valign_nodes = nil
        ctx.pending_cell_valign_band_height = nil
    end

    -- Clear buffer in-place to preserve reference for external hooks (kinsoku)
    for i = #col_buffer, 1, -1 do
        col_buffer[i] = nil
    end
end

-- @param page_columns (number) Total columns before a page break
-- @param params (table) Optional parameters:
--   - distribute (boolean) If true, distribute nodes evenly in columns
-- @return (table, number, table, table) layout_map, total_pages, page_chapter_titles, banxin_registry
local function calculate_grid_positions(head, grid_height, line_limit, n_column, page_columns, params)
    local d_head = D.todirect(head)
    params = params or {}
    params.grid_height = params.grid_height or grid_height -- Store for ctx.params access
    local distribute = params.distribute

    if line_limit < 1 then line_limit = 20 end

    local interval = tonumber(n_column) or 0
    local p_cols
    local is_free_mode = (interval == 0 and not page_columns)

    if page_columns and tonumber(page_columns) then
        -- Explicit page-columns specified
        p_cols = tonumber(page_columns)
    elseif interval > 0 then
        -- Grid mode: use interval-based calculation
        p_cols = 2 * interval + 1
    else
        -- Free mode (n-column = 0, no page-columns):
        -- Don't pre-calculate columns, use large value and check space dynamically
        p_cols = 10000 -- Large value, actual wrap decided by available width
    end

    if p_cols <= 0 then p_cols = 10000 end -- Safety

    -- Debug: Log floating textbox parameters
    if params.floating then
        dbg.log(string.format("Floating textbox detected: floating_x=%.1fpt, paper_width=%.1fpt",
            (params.floating_x or 0) / 65536, (params.paper_width or 0) / 65536))
    end

    -- Stateful cursor layout
    local ctx = create_grid_context(params, line_limit, p_cols)
    ctx.banxin_registry = {} -- Track Banxin columns per page
    ctx.is_free_mode = is_free_mode
    -- P2: Store column geometry in ctx for compute_x (RTL coordinate calculation)
    ctx.col_interval = interval
    ctx.col_banxin_width = _G.content and _G.content.banxin_width or (params.grid_width or 0)

    -- In free mode, use content_width from three-layer architecture for page wrap
    if is_free_mode then
        ctx.content_width = params.content_width  -- 0 set at layout_params definition
        dbg.log(string.format("[Free Mode] Enabled, p_cols=%d (virtual), content_width=%.1fpt", p_cols, ctx.content_width / 65536))
    else
        ctx.content_width = 0
    end

    local layout_map = {}
    ctx.layout_map = layout_map

    -- Buffer for distribution mode
    local col_buffer = {}

    -- Initial skip
    move_to_next_valid_position(ctx, interval, grid_height, 0)

    -- Block tracking for First Indent
    local block_start_cols = {} -- map[block_id] -> {page=p, col=c}

    local function do_flush()
        flush_buffer(col_buffer, ctx, grid_height, distribute, layout_map)
    end

    local t = d_head
    move_to_next_valid_position(ctx, interval, grid_height, 0)

    local node_count = 0
    while t do
        -- Check for dynamic chapter title marker via attribute
        local reg_id = D.get_attribute(t, constants.ATTR_CHAPTER_REG_ID)
        if reg_id and reg_id > 0 then
            local new_title = _G.chapter_registry and _G.chapter_registry[reg_id]
            if new_title then
                ctx.chapter_title = new_title
                ctx.page_chapter_titles[ctx.cur_page] = new_title
                ctx.page_resets[ctx.cur_page] = true
            end
        end

        ::start_of_loop::
        local id = D.getid(t)


        if id == constants.WHATSIT then
            -- Position transparently at current cursor
            local wh_band_y_off = ctx.band_y_offsets_sp[ctx.cur_band] or 0
            local map_entry = {
                page = ctx.cur_page,
                col = ctx.cur_col,
                band = ctx.cur_band,
                y_sp = ctx.cur_y_sp,
                band_y_offset_sp = wh_band_y_off,
                -- P2: absolute coordinates
                x = h.compute_x(ctx.cur_col, ctx.cur_page, ctx),
                y = h.compute_y(ctx.cur_y_sp, wh_band_y_off, ctx),
            }
            apply_style_attrs(map_entry, t)

            -- Floating textbox anchors carry visible content for the page;
            -- mark page_has_content so a following \newpage isn't dropped as redundant.
            if D.getfield(t, "user_id") == constants.FLOATING_TEXTBOX_USER_ID then
                ctx.page_has_content = true
            end

            layout_map[t] = map_entry
            t = D.getnext(t)
            if not t then break end
            goto start_of_loop
        end

        if node_count < 200 then
            dbg.log(string.format("  Node=%s ID=%d [p:%d, c:%d, r:%.1f]", tostring(t), id, ctx.cur_page,
                ctx.cur_col, ctx.cur_row))
        end
        node_count = node_count + 1

        -- Resolve indent, textbox dimensions, and effective column height
        local indent, r_indent, effective_limit, effective_col_height_sp, tb_w, tb_h,
              base_indent, first_indent, block_id =
            resolve_node_indent(t, id, ctx, block_start_cols, grid_height, line_limit)


        -- Check wrapping BEFORE placing (unified: sp-based)
        -- In distribution mode, we allow overflow so we can squeeze characters later
        -- When auto_column_wrap is false, only explicit penalties cause column breaks
        -- Skip auto-wrap for jiazhu (textflow) nodes: they manage column transitions
        -- via their own callbacks.wrap(). Without this check, body text filling
        -- exactly one column would trigger a premature wrap here, creating an
        -- empty column before jiazhu content.
        -- Also skip when the current non-content node (GLUE/KERN/PENALTY) is immediately
        -- followed by a textflow node — the intermediate spacing should not cause a wrap.
        local is_textflow_node = D.get_attribute(t, constants.ATTR_JIAZHU) == 1
            and not (tb_w > 0 and tb_h > 0)
        local next_is_textflow = false
        if not is_textflow_node and id ~= constants.GLYPH then
            -- Lookahead: skip non-content nodes to find the next content node
            local lookahead = D.getnext(t)
            while lookahead do
                local la_id = D.getid(lookahead)
                if la_id == constants.GLYPH or la_id == constants.HLIST or la_id == constants.VLIST then
                    -- Found a content node — check if it's textflow
                    if D.get_attribute(lookahead, constants.ATTR_JIAZHU) == 1 then
                        next_is_textflow = true
                    end
                    break
                elseif la_id == constants.GLUE or la_id == constants.KERN
                        or la_id == constants.PENALTY or la_id == constants.WHATSIT then
                    lookahead = D.getnext(lookahead)
                else
                    break
                end
            end
        end
        -- Pre-node column wrap check: use actual buffer height in natural mode
        -- In table mode, column transitions are controlled by CELL_BREAK/BAND_BREAK only
        local in_table = ctx.table_start_col ~= nil
        local should_wrap_before_node = false
        if ctx.auto_column_wrap and not distribute and not is_textflow_node and not next_is_textflow and not in_table then
            if ctx.default_cell_height then
                -- Grid mode: use cur_y_sp
                should_wrap_before_node = (ctx.cur_y_sp >= effective_col_height_sp)
            else
                -- Natural mode: use actual buffer height
                if #col_buffer > 0 then
                    local col_start_y = col_buffer[1].y_sp or 0
                    local buffer_height = calculate_buffer_height(col_buffer)
                    local total_height = col_start_y + buffer_height
                    should_wrap_before_node = (total_height >= effective_col_height_sp)
                end
            end
        end

        -- Natural mode kinsoku at pre-node wrap: if the upcoming glyph would
        -- cause a kinsoku violation when placed at the start of a new column,
        -- suppress the pre-node wrap and let handle_glyph_node's kinsoku logic
        -- resolve it (squeeze the current column or stretch by pulling a char).
        if should_wrap_before_node and not ctx.default_cell_height
                and id == constants.GLYPH then
            local kinsoku_action = check_natural_kinsoku(t, ctx, col_buffer, grid_height)
            if kinsoku_action then
                should_wrap_before_node = false
            end
        end

        if should_wrap_before_node then
            do_flush()
            accumulate_free_mode_col_width(ctx, params)
            wrap_to_next_column(ctx, p_cols, interval, grid_height, base_indent, true, false)
            -- After column wrap, fully re-resolve indent: the column change may
            -- invalidate taitou scope (\平抬/\抬头 only affects one column),
            -- and first_indent only applies to the block's starting column.
            indent, r_indent, effective_limit, effective_col_height_sp, tb_w, tb_h,
                base_indent, first_indent, block_id =
                resolve_node_indent(t, id, ctx, block_start_cols, grid_height, line_limit)
            indent = get_indent_for_current_pos(ctx, block_start_cols, block_id, base_indent, first_indent)
        end

        -- Skip apply_indentation for textflow nodes with forced indent:
        -- textflow handles forced indent (from \缩进[N]) internally via
        -- base_y_sp = ni_indent_val * gh. Applying it here would advance
        -- ctx.cur_row, causing the left sub-column to inherit the right's indent.
        -- Normal indent (from style stack, e.g., \段落[indent=2]) must still
        -- be applied so ctx.cur_row is set correctly for textflow layout.
        --
        -- However, when \缩进[N] is at the line level (before regular text + textflow),
        -- the suojin forced indent attribute leaks to textflow nodes. If cur_column_indent
        -- already equals the forced indent value, it means apply_indentation was
        -- already called for preceding regular text — clear the forced indent so
        -- textflow continues from ctx.cur_row instead of jumping back.
        -- IMPORTANT: Only clear suojin-encoded indents (\缩进[N]).
        -- Taitou-encoded indents (\平抬/\单抬/\相对抬头) must be passed through
        -- to textflow intact, as they represent intentional forced positioning.
        if is_textflow_node then
            local tf_indent_attr = D.get_attribute(t, constants.ATTR_INDENT)
            local tf_is_suojin, tf_indent_val = constants.is_suojin_indent(tf_indent_attr)
            if tf_is_suojin then
                if (ctx.cur_column_indent or 0) == tf_indent_val then
                    -- Suojin forced indent already consumed by preceding regular text;
                    -- clear it so textflow uses ctx.cur_row as base position.
                    D.set_attribute(t, constants.ATTR_INDENT, 0)
                end
            else
                apply_indentation(ctx, indent)
            end
        else
            apply_indentation(ctx, indent)
        end

        -- Check for Column (单列排版) first
        local is_column = D.get_attribute(t, constants.ATTR_COLUMN) == 1
        if is_column then
            local column_mod = package.loaded['core.luatex-cn-core-column'] or
                require('core.luatex-cn-core-column')

            -- Get align mode to check for LastColumn (align >= 4)
            local align_mode = D.get_attribute(t, constants.ATTR_COLUMN_ALIGN) or 0
            local is_last_column = align_mode >= 4

            -- Column always starts on a new column
            do_flush()
            -- In grid mode or if current column has content, wrap to next column
            -- In free mode with space available, allow columns side-by-side
            -- Use cur_column_indent as threshold: if cur_row only advanced due to
            -- apply_indentation (not actual content), don't wrap.
            local should_wrap_before_column = false
            local col_indent_threshold = math.max(ctx.cur_column_indent or 0, 0)
            if ctx.cur_row > col_indent_threshold then
                if not ctx.is_free_mode then
                    -- Grid mode: always wrap
                    should_wrap_before_column = true
                else
                    -- Free mode: accumulate current column width, then use
                    -- wrap_to_next_column for proper page wrap checking
                    local g_w = get_grid_width(params, grid_height)
                    ctx.accumulated_width_sp = ctx.accumulated_width_sp + g_w
                    should_wrap_before_column = true
                end
            end
            if should_wrap_before_column then
                wrap_to_next_column(ctx, p_cols, interval, grid_height, 0, true, false)
            end

            -- For LastColumn, jump to the last column of current half-page
            if is_last_column then
                if ctx.is_free_mode then
                    texio.write_nl("luatex-cn warning: \\末行 (LastColumn) is not supported in Free Mode (n-column=0). Ignored.")
                else
                    -- Calculate last column before banxin (or page end)
                    local last_col = column_mod.find_last_column_in_half_page(
                        ctx.cur_col, p_cols, interval, get_banxin_on(params))
                    if last_col > ctx.cur_col then
                        ctx.cur_col = last_col
                        ctx.cur_row = 0
                        ctx.cur_y_sp = 0
                    end
                end
            end

            local column_params = {
                line_limit = line_limit,
                grid_height = grid_height,
                p_cols = p_cols,
                interval = interval,
                indent = indent,
            }
            local column_callbacks = {
                flush = do_flush,
                wrap = function()
                    if ctx.is_free_mode then
                        -- Free mode: don't wrap, just move to next column position
                        ctx.cur_col = ctx.cur_col + 1
                        ctx.cur_row = 0
                        ctx.cur_y_sp = 0
                    else
                        -- Grid mode: normal wrap (may trigger page break)
                        wrap_to_next_column(ctx, p_cols, interval, grid_height, 0, true, false)
                    end
                end,
                debug = function(msg) dbg.log(msg) end
            }

            -- In free mode, track column width for page wrap decision
            local col_start_col = ctx.cur_col
            local col_start_page = ctx.cur_page
            local col_start_node = t  -- Save Column's first node for style lookup

            t = column_mod.place_nodes(ctx, t, layout_map, column_params, column_callbacks)

            if not t then break end

            -- Record column width in col_widths_sp for compute_x.
            -- Free Mode: always record (explicit width or estimated from grid).
            -- Non-Free Mode: only record when explicit column_width is set (\行[width=...]).
            do
                local style_reg = require('util.luatex-cn-style-registry')
                local style_id = D.get_attribute(col_start_node, constants.ATTR_STYLE_REG_ID)
                local col_width_val = style_id and style_reg.get_attr(style_id, "column_width")
                local col_width_sp = (type(col_width_val) == "number" and col_width_val > 0) and col_width_val or nil

                -- cols_used may be negative when place_nodes triggers page wrap
                -- (last column fills the page, advancing cur_col to 0 on next page).
                -- For explicit column_width, always record regardless of cols_used.
                local cols_used = ctx.cur_col - col_start_col
                if cols_used <= 0 and ctx.cur_page > col_start_page then
                    -- Column placed on col_start_page, but cursor wrapped to next page.
                    -- The column was valid (1 column used on start page).
                    cols_used = 1
                end

                if cols_used > 0 and (ctx.is_free_mode or col_width_sp) then
                    local g_width = get_grid_width(params, 0)
                    local actual_width_sp = col_width_sp or (cols_used * g_width)

                    local sp_top = style_id and style_reg.get_spacing_top(style_id) or 0
                    local sp_bot = style_id and style_reg.get_spacing_bottom(style_id) or 0
                    local total_width_sp = actual_width_sp + (sp_top or 0) + (sp_bot or 0)

                    ctx.col_widths_sp[col_start_page] = ctx.col_widths_sp[col_start_page] or {}
                    ctx.col_widths_sp[col_start_page][col_start_col + 1] = total_width_sp

                    ctx.col_spacing_top_sp[col_start_page] = ctx.col_spacing_top_sp[col_start_page] or {}
                    ctx.col_spacing_top_sp[col_start_page][col_start_col + 1] = sp_top or 0
                    ctx.col_spacing_bottom_sp[col_start_page] = ctx.col_spacing_bottom_sp[col_start_page] or {}
                    ctx.col_spacing_bottom_sp[col_start_page][col_start_col + 1] = sp_bot or 0

                    if ctx.is_free_mode then
                        ctx.accumulated_width_sp = ctx.accumulated_width_sp + total_width_sp
                    end
                end
            end
            goto start_of_loop
        end

        local is_textflow = D.get_attribute(t, constants.ATTR_JIAZHU) == 1
        -- Textboxes inside jiazhu enter textflow as inline blocks so they
        -- get sub-column placement (#96). collect_nodes recognises HLIST/
        -- VLIST with TEXTBOX_WIDTH > 0 as inline-block content.
        if is_textflow then
            local textflow = package.loaded['core.luatex-cn-core-textflow'] or
                require('core.luatex-cn-core-textflow')

            local textflow_mode = D.get_attribute(t, constants.ATTR_JIAZHU_MODE) or 0
            local place_params = {
                effective_limit = effective_limit,
                line_limit = line_limit,
                base_indent = base_indent,
                r_indent = r_indent,
                block_id = block_id,
                first_indent = first_indent,
                textflow_mode = textflow_mode,
                grid_height = grid_height, -- For cur_y_sp sync
            }
            local callbacks = {
                flush = do_flush,
                wrap = function()
                    wrap_to_next_column(ctx, p_cols, interval, grid_height, indent, false, false)
                end,
                get_indent = function(bid, bi, fi)
                    return get_indent_for_current_pos(ctx, block_start_cols, bid, bi, fi)
                end,
                debug = function(msg) dbg.log(msg) end
            }

            t = textflow.place_nodes(ctx, t, layout_map, place_params, callbacks)
            ctx.page_has_content = true

            if not t then break end
            goto start_of_loop
        end

        if tb_w > 0 and tb_h > 0 then
            -- Handle Textbox Block
            local textbox = package.loaded['core.luatex-cn-core-textbox'] or
                require('core.luatex-cn-core-textbox')

            local tb_params = {
                effective_limit = effective_limit,
                p_cols = p_cols,
                indent = 0, -- Textbox should not inherit paragraph indent
                grid_height = grid_height, -- For cur_y_sp sync
            }
            local tb_callbacks = {
                flush = do_flush,
                wrap = function(ri, rc)
                    wrap_to_next_column(ctx, p_cols, interval, grid_height, indent, ri, rc)
                end,
                is_reserved = function(c)
                    return is_reserved_col(c, interval, ctx.params.banxin_on)
                end,
                mark_occupied = mark_occupied,
                push_buffer = function(e) table.insert(col_buffer, e) end,
                move_next = function()
                    move_to_next_valid_position(ctx, interval, grid_height, indent)
                end
            }

            textbox.place_textbox_node(ctx, t, tb_w, tb_h, tb_params, tb_callbacks)
        elseif id == constants.GLYPH then
            handle_glyph_node(t, ctx, col_buffer, layout_map, grid_height,
                indent, effective_limit, distribute, interval, p_cols, params, do_flush, base_indent)
        elseif id == constants.GLUE or id == constants.KERN then
            t = handle_spacing_node(t, ctx, grid_height, effective_col_height_sp,
                indent, interval, p_cols, do_flush)
            if not t then break end
            goto start_of_loop
        elseif id == constants.PENALTY then
            handle_penalty_node(t, ctx, grid_height, indent, interval, p_cols, do_flush)
        end

        t = D.getnext(t)
    end

    do_flush()

    local map_count = 0
    for _ in pairs(layout_map) do map_count = map_count + 1 end
    dbg.log(string.format("Layout map built. Total entries: %d, Total pages: %d", map_count,
        ctx.cur_page + 1))

    export_free_mode_data(ctx, layout_map, params)

    -- Re-compute pos.x for all layout_map entries now that col_widths_sp is complete.
    -- During layout, compute_x() may see incomplete col_widths_sp (missing entries for columns
    -- that were broken via penalty rather than natural overflow).
    -- Also applies to non-Free Mode pages with \行[width=...] (e.g. TitlePage).
    do
        local has_widths = ctx.is_free_mode
        if not has_widths then
            for _ in pairs(ctx.col_widths_sp) do has_widths = true; break end
        end
        if has_widths then
            -- Pre-compute per-page content_width (sum of col_widths_sp)
            -- and count how many columns each page has in col_widths_sp
            local page_cw_sum = {}
            local page_cw_count = {}
            for pg, cols in pairs(ctx.col_widths_sp) do
                local sum_w = 0
                local count = 0
                for _, w in pairs(cols) do sum_w = sum_w + w; count = count + 1 end
                page_cw_sum[pg] = sum_w
                page_cw_count[pg] = count
            end
            -- Count actual columns per page from layout_map (max col + 1)
            local page_max_col = {}
            for _, pos in pairs(layout_map) do
                if pos.page ~= nil and pos.col ~= nil then
                    local prev = page_max_col[pos.page]
                    if not prev or pos.col > prev then
                        page_max_col[pos.page] = pos.col
                    end
                end
            end
            -- Determine which pages have ALL columns covered by col_widths_sp
            -- (e.g. TitlePage). Pages with only partial coverage use uniform grid_width.
            local page_all_covered = {}
            if not ctx.is_free_mode then
                for pg, count in pairs(page_cw_count) do
                    local max_col = page_max_col[pg] or 0
                    if count >= max_col + 1 then
                        page_all_covered[pg] = true
                    end
                end
            end
            for _, pos in pairs(layout_map) do
                if pos.page ~= nil and pos.col ~= nil then
                    -- Re-compute pos.x / col_width / content_width only for:
                    -- 1) Free Mode pages (all cols computed from content width)
                    -- 2) Pages where ALL columns have col_widths_sp entries (TitlePage)
                    -- Skip pages with only partial col_widths_sp (e.g. \行[column-width=50pt])
                    -- — those use uniform grid_width and don't need pos.x re-computation.
                    if ctx.is_free_mode or page_all_covered[pos.page] then
                        pos.x = h.compute_x(pos.col, pos.page, ctx)
                        local cws = ctx.col_widths_sp[pos.page]
                        local cw = cws and cws[pos.col + 1]
                        if cw and cw > 0 then
                            pos.col_width = cw
                        end
                        -- Store page content_width for non-Free Mode (TitlePage)
                        if not ctx.is_free_mode and page_cw_sum[pos.page] then
                            pos.content_width = page_cw_sum[pos.page]
                        end
                    end
                end
            end
        end
    end

    -- Export band layout info for render layer (border drawing)
    if ctx.n_bands > 1 and _G.content then
        _G.content.band_heights_sp = ctx.band_heights_sp
        _G.content.band_y_offsets_sp = ctx.band_y_offsets_sp
        _G.content.band_cols_per_band = ctx.band_cols_per_band
    end

    -- Export per-page inline table band info for render layer
    if ctx.page_table_bands and _G.content then
        _G.content.page_table_bands = ctx.page_table_bands
    end

    return layout_map, ctx.cur_page + 1, ctx.page_chapter_titles, ctx.banxin_registry, ctx.page_resets
end

-- Create module table
local layout = {
    calculate_grid_positions = calculate_grid_positions,
    _internal = _internal,
}

-- Register module in package.loaded for require() compatibility
-- 注册模块到 package.loaded
package.loaded['core.luatex-cn-layout-grid'] = layout

-- Return module exports
return layout
