-- 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. -- ============================================================================ -- luatex-cn-banxin-layout.lua - Banxin Layout Calculation Module -- ============================================================================ -- Layer: Stage 2 - Layout Layer -- -- This module calculates banxin column layout data without rendering. -- It provides pure functions that return layout data structures, -- which can be used by the render stage to produce PDF output. -- -- Design: -- - Pure functions: no side effects, no node creation -- - Returns data structures describing positions and dimensions -- - Separates layout logic from rendering logic -- -- ============================================================================ local constants = package.loaded['core.luatex-cn-constants'] or require('core.luatex-cn-constants') local utils = package.loaded['util.luatex-cn-utils'] or require('util.luatex-cn-utils') local banxin_layout = {} -- ============================================================================ -- Helper Functions (Pure) -- ============================================================================ --- Count UTF-8 characters in a string -- @param text (string) UTF-8 string -- @return (number) Character count local function count_utf8_chars(text) if not text then return 0 end local count = 0 for _ in text:gmatch("[%z\1-\127\194-\244][\128-\191]*") do count = count + 1 end return count end --- Calculate fish tail (yuwei) dimensions -- @param width (number) Banxin column width (sp) -- @return (table) { edge_height, notch_height, gap } local function calculate_yuwei_dimensions(width) return { edge_height = width * 0.39, notch_height = width * 0.17, gap = 65536 * 3.7, -- 3.7pt gap from dividing lines } end --- Calculate total yuwei height including gap -- @param yuwei_dims (table) Yuwei dimensions -- @return (number) Total height (sp) local function calculate_yuwei_total_height(yuwei_dims) return yuwei_dims.gap + yuwei_dims.edge_height + yuwei_dims.notch_height end --- Parse middle section text into parts (supports \\\\ line breaks) -- @param text (string) Section text -- @return (table) Array of text parts local function parse_section_text(text) if not text then return {} end -- Handle both \\ and \\\\ (TeX vs Lua literal escaping) local raw_text = text:gsub("\\\\+", "\n") local parts = {} for s in raw_text:gmatch("[^\n]+") do table.insert(parts, s) end return parts end -- ============================================================================ -- Region Layout Calculations -- ============================================================================ --- Calculate the three region boundaries -- @param params (table) { height, upper_ratio, middle_ratio } -- @return (table) { upper, middle, lower } region data local function calculate_regions(params) local total_height = params.height or 0 local upper_ratio = params.upper_ratio or 0.28 local middle_ratio = params.middle_ratio or 0.56 local lower_ratio = 1 - upper_ratio - middle_ratio local upper_height = total_height * upper_ratio local middle_height = total_height * middle_ratio local lower_height = total_height * lower_ratio return { upper = { y_start = 0, height = upper_height, }, middle = { y_start = upper_height, height = middle_height, }, lower = { y_start = upper_height + middle_height, height = lower_height, }, } end --- Calculate decoration positions (dividers, fish tails) -- @param params (table) Layout params -- @param regions (table) Region data -- @return (table) Decoration layout local function calculate_decorations(params, regions) local yuwei_dims = calculate_yuwei_dimensions(params.width or 0) local y = params.y or 0 return { divider_1_y = y - regions.upper.height, divider_2_y = y - regions.upper.height - regions.middle.height, draw_dividers = params.banxin_divider ~= false, upper_yuwei = params.upper_yuwei ~= false, lower_yuwei = params.lower_yuwei ~= false, yuwei_dims = yuwei_dims, } end -- ============================================================================ -- Text Element Layout Calculations -- ============================================================================ --- Calculate upper section layout -- @param params (table) Layout params -- @param regions (table) Region data -- @return (table|nil) Upper section layout element local function calculate_upper_section_layout(params, regions) local text = params.upper_section_text or "" if text == "" then return nil end local upper_height = regions.upper.height local base_f_size = constants.resolve_dimen(params.font_size, 655360) -- Use section-specific font_size if specified, otherwise fall back to base font size local f_size = constants.resolve_dimen(params.upper_section_font_size, base_f_size) or base_f_size local c_padding_top = constants.resolve_dimen(params.c_padding_top, base_f_size) -- Use upper-section-specific bottom-padding if set, otherwise fall back to global column padding local c_padding_bottom = params.upper_section_bottom_padding or constants.resolve_dimen(params.c_padding_bottom, base_f_size) local effective_b = params.draw_border and constants.resolve_dimen(params.border_thickness, base_f_size) or 0 local adj_height = upper_height - effective_b - c_padding_top - c_padding_bottom local num_chars = count_utf8_chars(text) local grid_h = constants.resolve_dimen(constants.to_dimen(params.upper_section_grid_height), f_size) local total_text_height if grid_h and grid_h > 0 then total_text_height = grid_h * num_chars else if num_chars * f_size > adj_height then f_size = adj_height / num_chars end total_text_height = num_chars * f_size end local block_y_top = params.y - effective_b - c_padding_top local y_start if params.upper_section_align == "top" then y_start = block_y_top else y_start = block_y_top - (adj_height - total_text_height) / 2 end return { type = "upper_section", is_runtime = false, text = text, x = params.x, y_top = y_start, block_y_top = block_y_top, -- Top of content area (after border + padding) width = params.width, height = total_text_height, adj_height = adj_height, -- Available height (region - border - padding) num_cells = num_chars, v_align = "center", h_align = "center", font_size = f_size, bg_color = params.upper_section_bg_color, font_color = params.upper_section_font_color, } end --- Calculate middle section layout -- @param params (table) Layout params -- @param regions (table) Region data -- @param decorations (table) Decoration data -- @return (table|nil) Middle section layout (marked as runtime) local function calculate_middle_section_layout(params, regions, decorations) -- Middle section is runtime content (changes per page) -- We calculate the position/dimensions but mark text as runtime local text = params.middle_section_text or "" local yuwei_dims = decorations.yuwei_dims local upper_yuwei_total = decorations.upper_yuwei and calculate_yuwei_total_height(yuwei_dims) or 0 local lower_yuwei_total = decorations.lower_yuwei and calculate_yuwei_total_height(yuwei_dims) or 0 local middle_y_top = params.y - regions.upper.height local base_f_size = constants.resolve_dimen(params.font_size, 655360) local title_top_margin = constants.resolve_dimen(params.middle_section_top_margin, base_f_size) or 0 local section_y_top = middle_y_top - upper_yuwei_total - title_top_margin local available_height = regions.middle.height - upper_yuwei_total - lower_yuwei_total - title_top_margin if available_height <= 0 then available_height = regions.middle.height * 0.3 -- Fallback end local section_font_size = constants.resolve_dimen(params.middle_section_font_size, base_f_size) local font_scale = nil if not section_font_size then font_scale = 0.5 -- Default scale for banxin titles if not specified section_font_size = base_f_size * font_scale end local desired_grid_h = constants.resolve_dimen(params.middle_section_grid_height, section_font_size) if not desired_grid_h or desired_grid_h <= 0 then desired_grid_h = section_font_size * 1.1 end return { type = "middle_section", is_runtime = true, -- Content resolved at render time text = nil, -- Will be filled at render time x = params.x, y_top = section_y_top, block_y_top = section_y_top, width = params.width, available_height = available_height, font_size = section_font_size, font_scale = font_scale, grid_height = desired_grid_h, n_cols = params.middle_section_cols or 1, h_align = params.middle_section_align or "center", bg_color = params.middle_section_bg_color, font_color = params.middle_section_font_color, } end --- Calculate page number layout -- @param params (table) Layout params -- @param regions (table) Region data -- @param decorations (table) Decoration data -- @return (table|nil) Page number layout (marked as runtime) local function calculate_page_number_layout(params, regions, decorations) if params.page_number_show == false then return nil end -- Page number is runtime content (changes per page) local yuwei_dims = decorations.yuwei_dims local upper_yuwei_total = decorations.upper_yuwei and calculate_yuwei_total_height(yuwei_dims) or 0 local lower_yuwei_total = decorations.lower_yuwei and calculate_yuwei_total_height(yuwei_dims) or 0 local middle_y_bottom = params.y - regions.upper.height - regions.middle.height local page_right_margin = 65536 * 2 local page_bottom_margin = params.c_padding_bottom or (65536 * 15) local base_f_size = constants.resolve_dimen(params.font_size, 655360) local f_size = constants.resolve_dimen(params.page_number_font_size, base_f_size) or (65536 * 10) local grid_h = constants.resolve_dimen(params.page_number_grid_height, f_size) if not grid_h or grid_h <= 0 then grid_h = f_size * 1.2 end -- Estimate container height (actual depends on page number digits) -- We use a placeholder assuming 3-digit page numbers local estimated_chars = 3 local container_height = grid_h * estimated_chars local p_v_align = "bottom" local p_h_align = "right" local page_y_top = middle_y_bottom + lower_yuwei_total + page_bottom_margin + container_height if params.page_number_align == "center" then p_v_align = "center" p_h_align = "center" local available_middle_h = regions.middle.height - upper_yuwei_total - lower_yuwei_total local center_y = middle_y_bottom + lower_yuwei_total + available_middle_h / 2 page_y_top = center_y + container_height / 2 elseif params.page_number_align == "bottom-center" then p_v_align = "bottom" p_h_align = "center" end return { type = "page_number", is_runtime = true, -- Content resolved at render time text = nil, -- Will be filled at render time x = params.x, y_top = page_y_top, width = params.width - (params.page_number_align == "center" and 0 or page_right_margin), grid_height = grid_h, v_align = p_v_align, h_align = p_h_align, font_size = f_size, middle_y_bottom = middle_y_bottom, lower_yuwei_total = lower_yuwei_total, page_bottom_margin = page_bottom_margin, upper_yuwei_total = upper_yuwei_total, middle_height = regions.middle.height, } end --- Calculate lower section layout -- @param params (table) Layout params -- @param regions (table) Region data -- @return (table|nil) Lower section layout element local function calculate_lower_section_layout(params, regions) local text = params.lower_section_text or "" if text == "" then return nil end local base_f_size = constants.resolve_dimen(params.font_size, 655360) or 655360 local f_size = constants.resolve_dimen(params.lower_section_font_size, base_f_size) if not f_size or f_size <= 0 then f_size = 65536 * 10 -- Default 10pt end local grid_h = constants.resolve_dimen(params.lower_section_grid_height, f_size) if not grid_h or grid_h <= 0 then grid_h = math.floor(f_size * 1.2 + 0.5) -- Default 1.2 line height end local num_chars = count_utf8_chars(text) local container_height = grid_h * num_chars local bottom_margin = constants.resolve_dimen(params.lower_section_bottom_margin, f_size) or (65536 * 5) local banxin_bottom_y = params.y - params.height local y_top = banxin_bottom_y + bottom_margin + container_height -- block_y_top: top of the lower region (for pre-rendered box placement) local effective_b = params.draw_border and constants.resolve_dimen(params.border_thickness, base_f_size) or 0 local block_y_top = params.y - regions.upper.height - regions.middle.height - effective_b return { type = "lower_section", is_runtime = false, text = text, x = params.x, y_top = y_top, block_y_top = block_y_top, width = params.width, height = container_height, v_align = "bottom", h_align = params.lower_section_align == "center" and "center" or "right", font_size = f_size, bg_color = params.lower_section_bg_color, font_color = params.lower_section_font_color, } end -- ============================================================================ -- Main Layout Function -- ============================================================================ --- Calculate complete banxin column layout -- @param params (table) Configuration parameters -- @return (table) Complete layout specification function banxin_layout.calculate_column_layout(params) -- Calculate regions local regions = calculate_regions(params) -- Calculate decorations local decorations = calculate_decorations(params, regions) -- Calculate text elements local elements = {} local upper_elem = calculate_upper_section_layout(params, regions) if upper_elem then table.insert(elements, upper_elem) end local middle_elem = calculate_middle_section_layout(params, regions, decorations) if middle_elem then table.insert(elements, middle_elem) end local page_num_elem = calculate_page_number_layout(params, regions, decorations) if page_num_elem then table.insert(elements, page_num_elem) end local lower_elem = calculate_lower_section_layout(params, regions) if lower_elem then table.insert(elements, lower_elem) end return { -- Column geometry column = { x = params.x, y = params.y, width = params.width, height = params.height, draw_border = params.draw_border, border_thickness = params.border_thickness, color_str = params.color_str or "0 0 0", }, -- Region boundaries regions = regions, -- Decoration positions decorations = decorations, -- Text elements elements = elements, -- Original params for render-time calculations params = params, } end -- ============================================================================ -- Exported Helper Functions (for testing and render module) -- ============================================================================ --- Calculate middle section available height for TextBox pre-rendering -- @param params (table) { content_height, middle_ratio, banxin_width, upper_yuwei, lower_yuwei, top_margin } -- @return (number) Available height in sp function banxin_layout.calc_middle_available_height(params) local middle_h = (params.content_height or 0) * (params.middle_ratio or 0.56) local bw = params.banxin_width or 655360 local yuwei_dims = calculate_yuwei_dimensions(bw) local upper_yw = params.upper_yuwei and calculate_yuwei_total_height(yuwei_dims) or 0 local lower_yw = params.lower_yuwei and calculate_yuwei_total_height(yuwei_dims) or 0 local top_margin = params.top_margin or 0 local h = middle_h - upper_yw - lower_yw - top_margin if h < 0 then h = middle_h * 0.3 end return math.floor(h + 0.5) end banxin_layout.count_utf8_chars = count_utf8_chars banxin_layout.calculate_yuwei_dimensions = calculate_yuwei_dimensions banxin_layout.calculate_yuwei_total_height = calculate_yuwei_total_height banxin_layout.parse_section_text = parse_section_text banxin_layout.calculate_regions = calculate_regions -- Register in package.loaded package.loaded['banxin.luatex-cn-banxin-layout'] = banxin_layout return banxin_layout