-- 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.
-- render_page.lua - 坐标应用与视觉渲染（第三阶段主模块）
-- ============================================================================
-- 文件名: render_page.lua (原 render.lua)
-- 层级: 第三阶段 - 渲染层 (Stage 3: Render Layer)
--
-- 【模块功能 / Module Purpose】
-- 本模块负责排版流水线的第三阶段，将虚拟坐标应用到实际节点并绘制视觉元素：
--   1. 根据 layout_map 为每个节点设置 xoffset/yoffset（文字）或 kern/shift（块）
--   2. 插入负 kern 以抵消 TLT 方向盒子的水平推进
--   3. 调用子模块绘制边框、版心、背景
--   4. 文本框（Textbox）块由其内部逻辑渲染好后，在此模块仅作为整体块进行定位
--   5. 按页拆分节点流，生成多个独立的页面盒子
--   6. 可选绘制调试网格（蓝色框显示字符位置，红色框显示 textbox 块）
--
-- 【术语对照 / Terminology】
--   apply_positions   - 应用坐标位置（将虚拟坐标转为实际节点属性）
--   xoffset/yoffset   - 字形偏移（glyph 专用定位属性）
--   kern              - 字距调整（用于水平定位块级节点）
--   shift             - 盒子垂直偏移（box.shift 属性）
--   RTL               - 从右到左（Right-To-Left，竖排时列序）
--   page_nodes        - 页面节点分组（按页分组的节点列表）
--   p_head            - 页面头节点（当前页的节点链头部）
--   outer_shift       - 外边框偏移（外边框厚度+间距）
--
-- 【注意事项】
--   • Glyph 节点使用 xoffset/yoffset 定位，块级节点（HLIST/VLIST）使用 Kern+Shift
--   • RTL 列序转换：物理列号 = total_cols - 1 - 逻辑列号
--   • 绘制顺序严格控制：背景最底层 → 边框 → 版心 → 文字（通过 insert_before 实现）
--   • 所有 PDF 绘图指令使用 pdf_literal 节点（mode=0，用户坐标系）
--   • Kern 的 subtype=1 表示"显式 kern"，不会被后续清零（用于保护版心等特殊位置）
--   • 【重要】如果 xoffset/yoffset 计算错误（如 0 或 超出页面范围），文字将不可见
--   • 【重要】PDF literal 语法错误（如缺少 q/Q 对，或非法颜色值）会破坏整页渲染
--
-- 【整体架构 / Architecture】
--   输入: 节点流 + layout_map + 渲染参数（颜色、边框、页边距等）
--      ↓
--   apply_positions()
--      ├─ 按页分组节点（遍历 layout_map，根据 page 分组）
--      ├─ 对每一页：
--      │   ├─ 绘制背景色（render_background.draw_background）
--      │   ├─ 设置字体颜色（render_background.set_font_color）
--      │   ├─ 绘制外边框（render_border.draw_outer_border）
--      │   ├─ 绘制列边框（render_border.draw_column_borders，跳过版心列）
--      │   ├─ 绘制版心列（render_banxin.draw_banxin_column，含分隔线和文字）
--      │   ├─ 应用节点坐标
--      │   │   ├─ Glyph: 调用 render_position.calc_grid_position()
--      │   │   └─ Block: 使用 Kern 包裹 + Shift
--      │   └─ 可选：绘制调试网格
--      └─ 返回 result_pages[{head, cols}]
--      ↓
--   输出: 多个渲染好的页面（每页是一个 HLIST，dir=TLT）
--
-- ============================================================================

-- Load dependencies
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 content = package.loaded['core.luatex-cn-core-content'] or
    require('core.luatex-cn-core-content')
local debug = package.loaded['debug.luatex-cn-debug'] or
    require('debug.luatex-cn-debug')

local dbg = debug.get_debugger('render')
local page_mod = package.loaded['core.luatex-cn-core-page'] or
    require('core.luatex-cn-core-page')
local textbox_mod = package.loaded['core.luatex-cn-core-textbox'] or
    require('core.luatex-cn-core-textbox')
local render_border = package.loaded['core.luatex-cn-core-render-border'] or
    require('core.luatex-cn-core-render-border')
local linemark_mod = package.loaded['decorate.luatex-cn-linemark'] or
    require('decorate.luatex-cn-linemark')
local page_process = package.loaded['core.luatex-cn-core-render-page-process'] or
    require('core.luatex-cn-core-render-page-process')


-- Internal functions for unit testing (delegated to page_process module)
local _internal = {
    handle_glyph_node = page_process.handle_glyph_node,
    handle_block_node = page_process.handle_block_node,
    handle_debug_drawing = page_process.handle_debug_drawing,
    handle_decorate_node = page_process.handle_decorate_node,
    process_page_nodes = page_process.process_page_nodes,
}


-- 辅助函数：计算渲染上下文（尺寸、偏移、列数等）
-- All values come from ctx (populated by main.lua)
local function calculate_render_context(ctx)
    -- Unpack nested contexts
    local engine = ctx.engine
    local grid = ctx.grid
    local page = ctx.page
    local visual = ctx.visual

    -- All values from ctx (main.lua populates these from style stack)
    local border_thickness = engine.border_thickness
    local half_thickness = engine.half_thickness
    local ob_thickness_val = page.ob_thickness
    local ob_sep_val = page.ob_sep
    local c_padding_top = engine.c_padding_top
    local c_padding_bottom = engine.c_padding_bottom
    local grid_width = grid.width
    local grid_height = grid.height
    local banxin_width = grid.banxin_width or 0
    local body_font_size = grid.body_font_size or grid_width

    -- Dynamic values from ctx (calculated per-invocation in main.lua)
    local outer_shift = engine.outer_shift
    local shift_x = engine.shift_x
    local shift_y = engine.shift_y

    local interval = grid.n_column
    local p_cols = grid.cols
    local line_limit = grid.line_limit

    -- Visual params from ctx.visual (populated by main.lua)
    local vertical_align = visual.vertical_align or "center"
    local background_rgb_str = utils.normalize_rgb(visual.bg_rgb)
    -- Fallback to document-level font_color if current style has no font_color
    local text_rgb_str = utils.normalize_rgb(visual.font_rgb)
    if not text_rgb_str then
        _G.document = _G.document or {}
        text_rgb_str = utils.normalize_rgb(_G.document.font_color)
    end
    -- Border shape decoration parameters
    local border_shape = visual.border_shape or "none"
    local border_color_str = utils.normalize_rgb(visual.border_color) or "0 0 0"
    local border_width = constants.to_dimen(visual.border_width) or (65536 * 0.4)
    local border_margin = constants.resolve_dimen(visual.border_margin, grid_height) or (65536 * 1)
    -- Separate x/y margins: if border_margin_x/y set, use them; else fallback to border_margin
    local border_margin_x = (visual.border_margin_x and visual.border_margin_x ~= "")
        and constants.resolve_dimen(visual.border_margin_x, grid_width) or border_margin
    local border_margin_y = (visual.border_margin_y and visual.border_margin_y ~= "")
        and constants.resolve_dimen(visual.border_margin_y, grid_height) or border_margin
    local border_offset = (visual.border_offset and visual.border_offset ~= "")
        and constants.resolve_dimen(visual.border_offset, grid_width) or 0
    local outer_margin = constants.resolve_dimen(visual.outer_margin, grid_height) or 0
    -- Textbox outer border (drawn around decorative shape, not via body text mechanism)
    local textbox_outer_border = visual.textbox_outer_border or false
    local textbox_ob_thickness = constants.to_dimen(visual.textbox_ob_thickness) or (65536 * 1)
    local textbox_ob_sep = constants.to_dimen(visual.textbox_ob_sep) or (65536 * 2)

    -- Available width for glyph squeeze inside bordered TextBox
    -- When border_margin_x > 0, chars wider than (grid_width - 2*margin_x) get h-scaled
    local border_avail_width = 0
    if border_shape ~= "none" and border_margin_x > 0 and grid_width > 0 then
        local avail = grid_width - 2 * border_margin_x
        if avail > 0 and avail < grid_width then
            border_avail_width = avail
        end
    end

    -- Colors: border from engine (already normalized in main.lua)
    local b_rgb_str = engine.border_rgb_str

    -- Bundle column geometry triple for position functions
    local col_geom = {
        grid_width = grid_width,
        banxin_width = banxin_width,
        interval = interval,
        -- Phase 2.4: Free Mode column widths (page -> col -> width_sp)
        col_widths_sp = grid.col_widths_sp,
        -- Column spacing (right/left margins per column)
        col_spacing_top_sp = grid.col_spacing_top_sp,
        col_spacing_bottom_sp = grid.col_spacing_bottom_sp,
    }

    return {
        border_thickness = border_thickness,
        half_thickness = half_thickness,
        ob_thickness_val = ob_thickness_val,
        ob_sep_val = ob_sep_val,
        outer_shift = outer_shift,
        shift_x = shift_x,
        shift_y = shift_y,
        interval = interval,
        p_cols = p_cols,
        line_limit = line_limit,
        c_padding_top = c_padding_top,
        c_padding_bottom = c_padding_bottom,
        b_rgb_str = b_rgb_str,
        background_rgb_str = background_rgb_str,
        text_rgb_str = text_rgb_str,
        grid_height = grid_height,
        banxin_width = banxin_width,
        col_geom = col_geom,
        body_font_size = body_font_size,
        vertical_align = vertical_align,
        -- TextFlow align default (per-node align from style stack takes precedence)
        textflow_align = visual.textflow_align or "outward",
        -- Judou parameters from visual context (populated by core-main from judou plugin context)
        judou_pos = visual.judou_pos or "right-bottom",
        judou_size = visual.judou_size or "1em",
        judou_color = visual.judou_color or "red",
        -- Border shape decoration parameters (textbox only)
        border_shape = border_shape,
        border_color_str = border_color_str,
        border_width = border_width,
        border_margin = border_margin,
        border_margin_x = border_margin_x,
        border_margin_y = border_margin_y,
        border_offset = border_offset,
        outer_margin = outer_margin,
        border_avail_width = border_avail_width,
        -- Textbox outer border params
        textbox_outer_border = textbox_outer_border,
        textbox_ob_thickness = textbox_ob_thickness,
        textbox_ob_sep = textbox_ob_sep,
        -- Band (分栏) border params
        n_bands = grid.n_bands or 1,
        band_gap_sp = grid.band_gap_sp or 0,
        band_heights_sp = grid.band_heights_sp,
        band_y_offsets_sp = grid.band_y_offsets_sp,
        -- Content area width for RTL→LTR coordinate conversion
        content_width = grid.content_width or 0,
    }
end

_internal.calculate_render_context = calculate_render_context

-- 辅助函数：将节点按页分组
local function group_nodes_by_page(d_head, layout_map, total_pages)
    local page_nodes = {}
    for p = 0, total_pages - 1 do
        page_nodes[p] = { head = nil, tail = nil, max_col = 0, max_y_sp = 0 }
    end

    local t = d_head
    while t do
        local next_node = D.getnext(t)
        local pos = layout_map[t]
        D.setnext(t, nil)

        if pos and pos.mode ~= "floating" then
            local p = pos.page or 0
            if page_nodes[p] then
                if not page_nodes[p].head then
                    page_nodes[p].head = t
                else
                    D.setnext(page_nodes[p].tail, t)
                end
                page_nodes[p].tail = t
                if pos.col > page_nodes[p].max_col then page_nodes[p].max_col = pos.col end
                -- Track max_y_sp (bottom of furthest cell) for sp-based height
                if pos.y_sp then
                    local y_bottom = pos.y_sp + (pos.cell_height or 0)
                    if y_bottom > page_nodes[p].max_y_sp then page_nodes[p].max_y_sp = y_bottom end
                end
            else
                node.flush_node(D.tonode(t))
            end
        else
            node.flush_node(D.tonode(t))
        end
        t = next_node
    end
    return page_nodes
end

_internal.group_nodes_by_page = group_nodes_by_page

-- 辅助函数：定位浮动文本框
_internal.position_floating_box = textbox_mod.render_floating_box

-- Local reference to process_page_nodes from the process module
local process_page_nodes = page_process.process_page_nodes

-- 辅助函数：渲染单个页面
local function render_single_page(p_head, p_max_col, p, layout_map, params, ctx, p_max_y_sp)
    if not p_head then return nil, 0 end

    local p_total_cols = p_max_col + 1
    local p_cols = ctx.p_cols
    -- Always enforce full page width to ensure correct RTL/SplitPage absolute positioning
    if p_cols and p_cols > 0 and p_total_cols < p_cols then
        p_total_cols = p_cols
    end

    -- Actual content dimensions (for special border shapes)
    local actual_cols = p_max_col + 1
    local actual_height_sp = (p_max_y_sp and p_max_y_sp > 0) and p_max_y_sp or ctx.grid_height

    local grid_width = ctx.col_geom.grid_width
    local grid_height = ctx.grid_height
    local border_thickness = ctx.border_thickness
    local line_limit = ctx.line_limit
    local c_padding_top = ctx.c_padding_top
    local c_padding_bottom = ctx.c_padding_bottom
    local grid = params.grid
    local engine = params.engine
    local page = params.page

    -- For textbox with user-specified height, use that height for border rendering.
    -- user_height_sp is only set when user explicitly specifies height (e.g., height=24cm).
    -- Subtract 2*border_margin_y so that user height describes the outer border extent,
    -- since render_borders adds border_margin_y back: final = shape_height + 2*border_m_y.
    if page.is_textbox and engine.user_height_sp then
        local border_m_y = ctx.border_margin_y or ctx.border_margin or 0
        local target = engine.user_height_sp - 2 * border_m_y
        if target > actual_height_sp then
            actual_height_sp = target
        end
    end
    local plugins = params.plugins

    local draw_border = engine.draw_border
    local draw_column_border = engine.draw_column_border
    local draw_band_border = engine.draw_band_border
    local draw_outer_border = page.is_outer_border

    -- Per-column column border override from style stack (插图页 / IllustrationPage):
    -- Scan all nodes of this page; collect which columns have column_border=false.
    -- If ALL columns have it, suppress globally; otherwise build no_silk_cols set.
    local no_silk_cols = nil  -- set of column indices where 丝栏 should be suppressed
    if draw_column_border then
        local style_registry = package.loaded['util.luatex-cn-style-registry']
        if style_registry then
            -- Per-column silk classification:
            --   col_has_no_silk[col] = true  → first node carries column_border=false (\插图页 marker)
            --   col_has_silk[col]    = true  → any glyph in this column lacks the no-silk style
            -- A column is "silk" if it carries real text content not under \插图页 scope.
            -- Floating-textbox anchors (whatsit) are not real text and don't promote silk.
            local col_has_no_silk = {}
            local col_has_silk = {}
            local col_seen = {}
            local scan_t = p_head
            while scan_t do
                local pos = layout_map[scan_t]
                if pos then
                    local col = pos.col
                    -- First-node-per-column: check for no-silk marker
                    if not col_seen[col] then
                        col_seen[col] = true
                        local sid = D.get_attribute(scan_t, constants.ATTR_STYLE_REG_ID)
                        if sid then
                            local style = style_registry.get(sid)
                            if style and style.column_border == false then
                                col_has_no_silk[col] = true
                            end
                        end
                    end
                    -- Real glyph that isn't under \插图页: this column has actual text content.
                    if D.getid(scan_t) == constants.GLYPH then
                        local sid = D.get_attribute(scan_t, constants.ATTR_STYLE_REG_ID)
                        local under_no_silk = false
                        if sid then
                            local style = style_registry.get(sid)
                            if style and style.column_border == false then
                                under_no_silk = true
                            end
                        end
                        if not under_no_silk then
                            col_has_silk[col] = true
                        end
                    end
                end
                scan_t = D.getnext(scan_t)
            end
            -- Expand no-silk columns to cover entire half-pages.
            -- In butterfly binding, interval = n_column, so:
            --   left half  = cols 0..interval-1
            --   banxin     = col interval
            --   right half = cols interval+1..2*interval
            -- If any column in a half has column_border=false, the whole half is suppressed.
            -- Additionally: if no column on the page has real silk content (i.e. the page
            -- is purely \插图页 markers + floating textboxes + empty cells), promote
            -- suppression to the entire page so the other half (where the marker could
            -- not land due to layout collapsing) is also borderless. This covers the
            -- "every half has \插图页 but layout couldn't separate them" case without
            -- breaking the legitimate "half illustration + half text" layout.
            if next(col_has_no_silk) then
                local interval = ctx.interval or 0
                local left_no_silk = false
                local right_no_silk = false
                if interval > 0 then
                    for col in pairs(col_has_no_silk) do
                        if col < interval then
                            left_no_silk = true
                        elseif col > interval then
                            right_no_silk = true
                        end
                    end
                    -- If no real silk content exists anywhere on the page, both halves
                    -- are conceptually under \插图页 — extend suppression to both halves.
                    if not next(col_has_silk) then
                        left_no_silk = true
                        right_no_silk = true
                    end
                    -- Rebuild no_silk set with full half-page ranges
                    col_has_no_silk = {}
                    if left_no_silk then
                        for c = 0, interval - 1 do col_has_no_silk[c] = true end
                    end
                    if right_no_silk then
                        for c = interval + 1, 2 * interval do col_has_no_silk[c] = true end
                    end
                end
                -- Check if ALL non-banxin columns are suppressed
                if left_no_silk and right_no_silk then
                    draw_column_border = false
                else
                    no_silk_cols = col_has_no_silk
                end
            end
        end
    end

    local shift_x = ctx.shift_x
    local outer_shift = ctx.outer_shift
    local b_rgb_str = ctx.b_rgb_str

    -- Reserved columns (computed via engine_ctx helper function)
    local reserved_cols = grid.get_reserved_cols and grid.get_reserved_cols(p, p_total_cols) or {}

    -- Right-align columns within content area BEFORE drawing borders
    -- Skip TextBox (content_width = total_cols * grid_width, no gap to right-align)
    local page_col_widths_sp = (ctx.col_geom and ctx.col_geom.col_widths_sp and ctx.col_geom.col_widths_sp[p]) or nil

    -- Detect TitlePage: has legacy col_widths (from \行[width=...] in 书名页)
    -- but NOT from Free Mode (which exports to col_widths_sp instead)
    local has_free_mode_widths = page_col_widths_sp and next(page_col_widths_sp)
    local legacy_col_widths = not has_free_mode_widths and _G.content and _G.content.col_widths
    local is_titlepage = legacy_col_widths and #legacy_col_widths > 0

    if not page.is_textbox and not is_titlepage then
        local total_cols_width_sp
        if has_free_mode_widths then
            -- Free Mode: sum variable column widths
            total_cols_width_sp = 0
            for _, w in pairs(page_col_widths_sp) do
                total_cols_width_sp = total_cols_width_sp + w
            end
        else
            -- Fixed-width mode: calculate total width including banxin
            local col_gwidth = ctx.col_geom and ctx.col_geom.grid_width or 0
            local col_bwidth = ctx.col_geom and ctx.col_geom.banxin_width or 0
            local col_interval = ctx.col_geom and ctx.col_geom.interval or 0

            if col_interval > 0 and col_bwidth > 0 and col_bwidth ~= col_gwidth then
                -- With banxin columns
                local n_banxin = math.floor(p_total_cols / (col_interval + 1))
                local n_content = p_total_cols - n_banxin
                total_cols_width_sp = n_content * col_gwidth + n_banxin * col_bwidth
            else
                -- Uniform width
                total_cols_width_sp = p_total_cols * col_gwidth
            end
        end

        -- Right-align: add offset if columns are narrower than content area
        local content_width_sp = grid.content_width or 0
        if content_width_sp > total_cols_width_sp then
            shift_x = shift_x + (content_width_sp - total_cols_width_sp)
        end
    end

    -- Scan layout_map for taitou columns (negative y_sp = raised border)
    local col_min_y_sp = {}
    local scan_t = p_head
    while scan_t do
        local pos = layout_map[scan_t]
        if pos then
            local col = pos.col
            local y = pos.y_sp or 0
            if y < 0 and (not col_min_y_sp[col] or y < col_min_y_sp[col]) then
                col_min_y_sp[col] = y
            end
        end
        scan_t = D.getnext(scan_t)
    end

    -- Borders, background, and decorative frames (handled by render_border module)
    p_head = render_border.render_borders(p_head, {
        -- Grid and dimensions
        p_total_cols = p_total_cols,
        actual_cols = actual_cols,
        actual_height_sp = actual_height_sp,
        grid_width = grid_width,
        grid_height = grid_height,
        col_geom = ctx.col_geom,
        banxin_width = ctx.banxin_width,
        interval = ctx.interval,
        line_limit = line_limit,
        content_height_sp = engine.content_height_sp,
        -- Border params
        border_thickness = border_thickness,
        c_padding_top = c_padding_top,
        c_padding_bottom = c_padding_bottom,
        shift_x = shift_x,
        outer_shift = outer_shift,
        b_rgb_str = b_rgb_str,
        ob_thickness_val = ctx.ob_thickness_val,
        ob_sep_val = ctx.ob_sep_val,
        -- Flags
        draw_border = draw_border,
        draw_column_border = draw_column_border,
        draw_band_border = draw_band_border,
        draw_outer_border_flag = draw_outer_border,
        is_textbox = page.is_textbox,
        reserved_cols = reserved_cols,
        -- Visual params
        border_shape = ctx.border_shape,
        border_color_str = ctx.border_color_str,
        border_width = ctx.border_width,
        border_margin = ctx.border_margin,
        border_margin_x = ctx.border_margin_x,
        border_margin_y = ctx.border_margin_y,
        border_offset = ctx.border_offset,
        background_rgb_str = ctx.background_rgb_str,
        -- Taitou raised border
        col_min_y_sp = col_min_y_sp,
        -- Per-column silk suppression (插图页 partial page)
        no_silk_cols = no_silk_cols,
        -- Textbox outer border (drawn around decorative shape)
        textbox_outer_border = ctx.textbox_outer_border,
        textbox_ob_thickness = ctx.textbox_ob_thickness,
        textbox_ob_sep = ctx.textbox_ob_sep,
        -- Band (分栏) border params (per-page inline table overrides global)
        band_mode = (ctx.n_bands and ctx.n_bands > 1),
        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,
        -- Per-page inline table band info
        page_table_bands = _G.content and _G.content.page_table_bands and _G.content.page_table_bands[p] or nil,
    })

    -- Font color
    p_head = content.set_font_color(p_head, ctx.text_rgb_str)

    -- Node positions
    -- Update context with page-specific total_cols and col_widths_sp
    local ctx_node = {}
    for k, v in pairs(ctx) do ctx_node[k] = v end
    ctx_node.p_total_cols = p_total_cols
    -- Phase 2.4: Pass page-specific column widths for Free Mode
    ctx_node.page_num = p
    ctx_node.page_col_widths_sp = page_col_widths_sp
    -- Pass page-specific column spacing for glyph offset within column
    ctx_node.page_col_spacing_top_sp = (ctx.col_geom and ctx.col_geom.col_spacing_top_sp and ctx.col_geom.col_spacing_top_sp[p]) or nil
    ctx_node.page_col_spacing_bottom_sp = (ctx.col_geom and ctx.col_geom.col_spacing_bottom_sp and ctx.col_geom.col_spacing_bottom_sp[p]) or nil
    -- Apply the computed right-alignment shift_x (for legacy calc_grid_position)
    ctx_node.shift_x = shift_x
    -- Original shift_x (without right-align correction) for RTL pos.x conversion
    ctx_node.shift_x_base = ctx.shift_x
    -- Per-page content_width for RTL→LTR coordinate conversion.
    -- TextBox: total_cols * grid_width
    -- Main content: grid.content_width from page geometry
    -- Note: pages with variable-width columns (TitlePage, Free Mode) get content_width
    -- from pos.content_width in layout_map (set during layout re-compute step).
    if page.is_textbox then
        ctx_node.content_width = p_total_cols * grid_width
    else
        ctx_node.content_width = grid.content_width or ctx.content_width or 0
    end
    -- Pass is_textbox flag so process_page_nodes can skip outer col_widths fallback
    ctx_node.is_textbox = page.is_textbox

    p_head = process_page_nodes(p_head, layout_map, params, ctx_node)

    -- Render Line Marks (专名号/书名号) - batch PDF drawing after all glyphs are positioned
    if ctx_node.line_mark_entries and #ctx_node.line_mark_entries > 0 then
        p_head = linemark_mod.render_line_marks(p_head, ctx_node.line_mark_entries, ctx_node)
    end

    -- Render Floating TextBoxes (mode="floating" entries in layout_map)
    for _, pos in pairs(layout_map) do
        if pos.mode == "floating" and pos.page == p then
            p_head = textbox_mod.render_floating_box(p_head, pos, params)
        end
    end

    -- Debug: draw table cell coordinates if debug is enabled
    -- page_table_bands[p] is an array of per-table band info (one entry per table on this page)
    local ptbs = _G.content and _G.content.page_table_bands and _G.content.page_table_bands[p]
    if ptbs and debug.is_enabled("table") then
        for _, ptb in ipairs(ptbs) do
            p_head = debug.draw_table_cells_debug(p_head, ptb, {
                total_cols = p_total_cols,
                shift_x = shift_x,
                shift_y = ctx.shift_y,
                half_thickness = ctx.half_thickness,
                col_geom = ctx.col_geom,
            })
        end
    end

    -- Debug: draw layout debug overlay (TextBox blocks, floating boxes, sidenotes)
    if debug.is_enabled("layout") then
        local plugin_ctxs = params.plugins and params.plugins.plugin_contexts
        local sidenote_map = plugin_ctxs and plugin_ctxs["sidenote"]
            and plugin_ctxs["sidenote"].map or nil
        p_head = debug.draw_layout_debug(p_head, layout_map, p, {
            content_width = ctx_node.content_width,
            shift_x = ctx.shift_x,
            shift_y = ctx.shift_y,
            half_thickness = ctx.half_thickness,
            grid_width = grid_width,
            grid_height = grid_height,
            col_geom = ctx.col_geom,
            sidenote_map = sidenote_map,
            p_total_cols = p_total_cols,
        })
    end

    -- For TextBox: return actual content dimensions, not expanded page dimensions
    -- This ensures TextBox output box has correct dimensions in main document flow
    local return_cols = page.is_textbox and actual_cols or p_total_cols
    local return_height_sp = page.is_textbox and actual_height_sp or engine.content_height_sp
    -- Include border_margin in TextBox height only in natural layout mode
    -- In grid mode, height is quantized to integer rows so margin is absorbed by ceil()
    local is_natural = _G.content and _G.content.layout_mode == "natural"
    if page.is_textbox and is_natural and ctx.border_shape ~= "none" and (ctx.border_margin or 0) > 0 then
        return_height_sp = return_height_sp + 2 * ctx.border_margin
    end
    -- Add outer_margin for extra spacing after TextBox (independent of layout mode)
    if page.is_textbox and (ctx.outer_margin or 0) > 0 then
        return_height_sp = return_height_sp + ctx.outer_margin
    end
    return D.tonode(p_head), return_cols, return_height_sp
end

_internal.render_single_page = render_single_page

-- @param head (node) 节点列表头部
-- @param layout_map (table) 从节点指针到 {col, row} 的映射
-- @param params (table) 渲染参数
-- @return (table) 页面信息数组 {head, cols}
local function apply_positions(head, layout_map, params)
    local d_head = D.todirect(head)

    local ctx = calculate_render_context(params)

    dbg.log(string.format("[render] apply_positions: border=%s, font=%s",
        tostring(params.border_rgb), tostring(params.font_rgb)))

    -- Group nodes by page
    local page_nodes = group_nodes_by_page(d_head, layout_map, params.total_pages)

    local result_pages = {}

    -- Process each page
    for p = 0, params.total_pages - 1 do
        local p_head = page_nodes[p].head
        local p_max_col = page_nodes[p].max_col
        local p_max_y_sp = page_nodes[p].max_y_sp
        local rendered_head, cols, height_sp = render_single_page(p_head, p_max_col, p, layout_map, params, ctx, p_max_y_sp)
        if rendered_head then
            result_pages[p + 1] = { head = rendered_head, cols = cols, height_sp = height_sp }
        end
    end

    return result_pages
end

-- Create module table
local render = {
    apply_positions = apply_positions,
    _internal = _internal
}

-- Register module
package.loaded['core.luatex-cn-core-render-page'] = render
return render
