import functools import collections import importlib import re import sys import traceback import pprint import kutil import klammer_base import html_util from html_util import E import latex_util import txt_util from indexed_range import Indexed_ranges, hline_names, vline_names import table_cell import font import phases # ---- :format functions --------------------------------------------------- # A :format function takes (value, target) and returns the formatted cell # text. It is named . in the :format list (like an eval # reference), so it can live in ANY module: the SKS ships none by default, and # a document defines its own (e.g. a euro() in the document's .py, named # ".euro" in :format) because a specific currency style is a property # of that document, not of the SKS. A function should emit period-decimal # numbers; apply_formats() applies the :decimal comma swap. def extend(lst, count, fill=None): if isinstance(lst, str): lst = lst.split() if fill is None: fill = lst[-1] if lst else "" return lst + ([fill] * (count - len(lst))) def parse_hpos(target, pos): if '"' in pos: pos_list = [] parts = pos.split('"') i = 0 while i < len(parts): pos_list += parts[i].split() i += 1 if i == len(parts): break #pos_list.append(f"{{{parts[i]}}}") pos_list.append(kutil.parse_length(target, f'"{parts[i]}"')[0]) i += 1 else: # pos_list = pos.split() pos_list = pos #print("pos_list:", pos_list) return pos_list class Table(klammer_base.Klammer_base): id = 0 def __init__(self, K): super().__init__(K) self.row_count = len(self.rows) if self.grid: self.vline = ["all"] self.hline = ["all"] elif not self.hline and self.header: # Default lines: under the header row and at the bottom. A # writer's own :hline replaces them (":hline none" = no lines). self.hline = ["1", str(self.row_count)] self.row_size = max([len(e) for e in self.rows]) # Rows with fewer cells than the widest row are padded with empty # cells (last-value duplication is for argument lists, not content). self.rows = [row + [""] * (self.row_size - len(row)) for row in self.rows] self.s_vline = Indexed_ranges(self.row_size + 1, self.row_count - 1, self.vline, vline_names(self.row_size + 1), ":vline") self.s_hline = Indexed_ranges(self.row_count + 1, self.row_size - 1, self.hline, hline_names(self.row_count + 1), ":hline") self.s_rowspan = Indexed_ranges(self.row_size, self.row_count - 1, self.rowspan, argument=":rowspan") self.s_colspan = Indexed_ranges(self.row_count, self.row_size - 1, self.colspan, argument=":colspan") # :calc runs after the span structures exist so calculate() can warn # when a target lands in a cell hidden by a colspan/rowspan merge; # :format runs after :calc so it formats the computed values. if self.calc: self.compute_coverage() self.calculate() if self.format: self.apply_formats() self.cell_hpos = extend(parse_hpos(self.K_target, self.cell_hpos), self.row_size) #self.cell_hpos = self.cell_hpos.split(";") self.style = extend(self.style, self.row_size) # In html, a 'fit' column mixed with sized columns must be clamped # to its widest entry (tex's \widthof semantics) or it absorbs the # window width: its cells get nowrap (class Wfit; see table.css). # In a full-width table (fractions/'*') they also need the classic # 1%-shrink width (class Wpct) to survive surplus distribution; in # a 'fill' table the table is content-sized (max-width) and a # percentage would blow it up to full width, so nowrap alone. # All-'fit' tables shrink to content anyway and keep wrapping. widths = extend(self.column_width, self.row_size) self.fill_columns = [i for i, w in enumerate(widths) if w == "fill"] if self.fill_columns: if any(w not in ("fit", "f", "fill") for w in widths): raise Exception( ':column_width: "fill" cannot be combined with a ' 'fraction or "*" -- fill computes the remaining width ' 'itself') if len(self.fill_columns) > 4: raise Exception( ':column_width: at most four "fill" columns are ' 'supported') mixed = not all(w in ("fit", "f") for w in widths) self.fit_columns = {i for i, w in enumerate(widths) if mixed and w in ("fit", "f")} self.fit_class = "Wfit" if self.fill_columns else "Wfit Wpct" # A table edge with no outer vertical line drops its outer cell # padding (html Fl/Fr classes, tex @{}) so the edge cells' text # aligns with the text margin; with an outer line the padding # stays -- text against a border looks worse than text inset # from a margin. self.flush_l = 0 not in self.s_vline.by_index self.flush_r = self.row_size not in self.s_vline.by_index self.justify_map = self.justify_overrides() if self.justify else {} self.make_cells(self.rows) # Calculated cell values (:calc). A calculation is # = ... (prefix operator: + - * /) # and the SHAPE of the target selects the operation: # * single cell r(c) -> FOLD: the operands collapse to one value. # * row range R(c) -> horizontal MAP: run once per row in R. # * column range r(C) -> vertical MAP: run once per column in C. # In a map the target's ranged axis iterates; an operand's aligned axis # iterates in lockstep and any other range in it folds. A relative # operand omits the iterated axis ("(col)" in a row map; bare "rows" in a # column map); a constant or a single fixed cell broadcasts. See # notes/calc_notation.md. Calculations run in order, each reading values # as displayed (display-precision); :format styles the results. # Future operators to consider: min, max, mean. # A single-axis selector: an index or an inclusive range; negatives count # from the end (-1 = last). A target/operand token is (), # , or () -- the last two are the relative operand forms. selector_rgx = re.compile(r"^(-?\d+)(-(-?\d+)?)?$") operand_rgx = re.compile( r"^(?P-?\d+(?:-(?:-?\d+)?)?)?(?:\((?P[-\d,]+)\))?$") def calc_error(self, calc, message): raise Exception(f'In the :calc calculation "{calc}": {message}') def calc_warn(self, calc, message): # Non-fatal: the value is still computed and stored (a covered target # may be read as an operand by a later calc), it just is not rendered. print(f'Warning: in the :calc calculation "{calc}": {message}', file=sys.stderr) def selector_error(self, ctx, message): # ctx is the caller's prefix, e.g. 'In the :calc calculation "..."' or # 'In :format "..."', so the same selector parser serves both. raise Exception(f"{ctx}: {message}") def to_number(self, text): # A displayed cell value as a float, honoring :decimal and thousands # separators; None (no error raised) when it is not a number. s = text.strip() if self.decimal == "comma": s = s.translate(str.maketrans(",.", ".,")) s = s.replace(",", "") # Remove thousands separators try: return float(s) except ValueError: return None def parse_number(self, text, ref, calc): v = self.to_number(text) if v is None: # Unescape KTESC markers so the message shows the character the # writer typed (e.g. "$5") rather than "KTESC0024KTESC5". self.calc_error( calc, "the cell {} contains \"{}\", which is not a number" .format(ref, klammer_base.unescape_ktesc(text.strip()))) return v def format_number(self, value): # Calc results are stored as plain numbers; :format does any styling. s = str(int(value)) if value.is_integer() else str(value) if self.decimal == "comma": s = s.translate(str.maketrans(",.", ".,")) return s def calc_norm(self, i, count, spec, ctx): # Resolve a possibly-negative index; -1 is the last, like Python. j = i + count if i < 0 else i if not 0 <= j < count: self.selector_error( ctx, f'in "{spec}", index {i} is out of range ' f"(0 through {count - 1})") return j def calc_selectors(self, spec, count, ctx): # "3", "0-2", "0--2", "-1", or a comma-separated list of those, to a # list of indices in written order (order matters for a left fold). result = [] for part in spec.split(","): m = self.selector_rgx.match(part) if not m: self.selector_error(ctx, f'"{part}" is not a valid selector') start = self.calc_norm(int(m.group(1)), count, part, ctx) if m.group(2) is None: result.append(start) continue end = (self.calc_norm(int(m.group(3)), count, part, ctx) if m.group(3) else count - 1) if start > end: self.selector_error( ctx, f'in "{part}", the start {start} is after the end {end}') result += list(range(start, end + 1)) return result def cell_number(self, r, c, calc): return self.parse_number(self.rows[r][c], f"{r}({c})", calc) def parse_operand(self, token, calc): # ('const', value) or ('cells', rows, cols) where each of rows/cols is # a list of indices, or None when that axis is not written (a relative # operand, resolved against the target's iterated axis by operand_cells). try: return ('const', float(token)) except ValueError: pass m = self.operand_rgx.match(token) if not m or (m.group('rows') is None and m.group('cols') is None): self.calc_error( calc, f'"{token}" is not a number or a cell selection') ctx = f'In the :calc calculation "{calc}"' rows = (self.calc_selectors(m.group('rows'), self.row_count, ctx) if m.group('rows') is not None else None) cols = (self.calc_selectors(m.group('cols'), self.row_size, ctx) if m.group('cols') is not None else None) return ('cells', rows, cols) def operand_cells(self, token, calc, mode, index, trange): # The operand's numbers for the current target cell. mode is 'scalar', # 'row' (horizontal map, rows iterate), or 'col' (vertical, cols # iterate); index is the current row/col; trange is the target's range. kind = self.parse_operand(token, calc) if kind[0] == 'const': return [kind[1]] _, rows, cols = kind if mode == 'scalar': if rows is None or cols is None: self.calc_error( calc, f'"{token}" is a relative operand; it needs a ranged ' "target (a row or column range) to resolve against") return [self.cell_number(r, c, calc) for r in rows for c in cols] if mode == 'row': # rows iterate; any columns fold if cols is None: self.calc_error( calc, f'"{token}" selects no column; a row-map operand ' "names a column, e.g. (0) or 0-(0)") if rows is None: # relative: this row use_rows = [index] elif len(rows) == 1: # a fixed row broadcasts use_rows = rows elif rows == trange: # explicit range in lockstep use_rows = [index] else: self.calc_error( calc, f'the rows of "{token}" must match the target rows') return [self.cell_number(r, c, calc) for r in use_rows for c in cols] # mode == 'col': columns iterate; any rows fold if rows is None: self.calc_error( calc, f'"{token}" selects no row; a column-map operand names ' "rows, e.g. 0--2 or 0--2(0-)") if cols is None: # relative: this column use_cols = [index] elif len(cols) == 1: # a fixed column broadcasts use_cols = cols elif cols == trange: # explicit range in lockstep use_cols = [index] else: self.calc_error( calc, f'the columns of "{token}" must match the target columns') return [self.cell_number(r, c, calc) for r in rows for c in use_cols] def apply_operator(self, op, values, calc): if len(values) == 1: # Lisp-style unary: - negates, / reciprocates v = values[0] # + and * of one operand are the operand itself if op == "-": return -v if op == "/": # compute 1/v only for "/", so "+ " return 1 / v # does not raise a spurious ZeroDivisionError return v result = values[0] for v in values[1:]: # Fold from the left if op == "+": result += v elif op == "-": result -= v elif op == "*": result *= v else: result /= v return result def calc_fold(self, op, operands, calc, mode, index, trange): values = [] for token in operands: values += self.operand_cells(token, calc, mode, index, trange) try: return self.apply_operator(op, values, calc) except ZeroDivisionError: self.calc_error(calc, "division by zero") def calc_assign(self, r, c, value, calc, target_text): if (r, c) in self.covered: self.calc_warn( calc, f"the target {target_text} is a cell hidden by a colspan " "or rowspan merge; its computed value will not be shown") self.rows[r][c] = self.format_number(value) def calculate(self): for calc in [c.strip() for c in self.calc.split(";") if c.strip()]: self.run_calc(calc) def run_calc(self, calc): target, eq, expression = calc.partition("=") target = target.strip() m = self.operand_rgx.match(target) if not eq or not m or m.group('rows') is None or m.group('cols') is None: self.calc_error( calc, "the target must be a cell r(c) or a ranged cell such as " "0-(2) or -1(0-), followed by \"=\"") ctx = f'In the :calc calculation "{calc}"' trows = self.calc_selectors(m.group('rows'), self.row_count, ctx) tcols = self.calc_selectors(m.group('cols'), self.row_size, ctx) tokens = expression.split() if not tokens or tokens[0] not in ("+", "-", "*", "/") or len(tokens) < 2: self.calc_error( calc, "the expression must be an operator (+ - * /) followed by " "at least one operand") op, operands = tokens[0], tokens[1:] row_range, col_range = len(trows) > 1, len(tcols) > 1 if row_range and col_range: self.calc_error( calc, "the target may range over rows or columns, but not both") if not row_range and not col_range: # single cell: a fold v = self.calc_fold(op, operands, calc, 'scalar', None, None) self.calc_assign(trows[0], tcols[0], v, calc, target) elif row_range: # horizontal map for r in trows: v = self.calc_fold(op, operands, calc, 'row', r, trows) self.calc_assign(r, tcols[0], v, calc, target) else: # vertical map for c in tcols: v = self.calc_fold(op, operands, calc, 'col', c, tcols) self.calc_assign(trows[0], c, v, calc, target) # ---- :format ---------------------------------------------------------- # # ";"-separated pairs (same list style as :calc). # is an indexed_range; is a "." # reference (like an @eval reference) to a Python function taking # (value, target) and returning the formatted cell text. Each selected # cell's value is parsed as a number (honoring :decimal); if numeric the # function is called and its result -- with the :decimal comma swap # applied -- replaces the cell (e.g. a writer's myformats.euro function # turns 1234.56 into "1,234.56 €"). A # non-numeric cell is left as-is with a warning. Runs AFTER :calc. The # result is inserted verbatim (this pass is after the cell-processing pass), # so a function may emit target markup directly. def format_warn(self, message): print(f"Warning: in :format: {message}", file=sys.stderr) def format_function(self, spec, ctx): # Resolve "." to a callable, like an @eval reference. if "." not in spec: self.selector_error( ctx, f'the format function "{spec}" must be written ' "., e.g. table.euro") mod_name, func_name = spec.rsplit(".", 1) try: return getattr(importlib.import_module(mod_name), func_name) except (ImportError, AttributeError): self.selector_error( ctx, f'the format function "{spec}" was not found') def apply_formats(self): for stmt in [s.strip() for s in self.format.split(";") if s.strip()]: ctx = f'In :format "{stmt}"' parts = stmt.split() if len(parts) != 2: self.selector_error( ctx, "each entry is , e.g. 0-(5) myformats.euro") rangespec, spec = parts func = self.format_function(spec, ctx) m = self.operand_rgx.match(rangespec) if not m or m.group('rows') is None or m.group('cols') is None: self.selector_error( ctx, f'"{rangespec}" is not a cell range like 0-(5) ' "or 1--2(0-3)") rows = self.calc_selectors(m.group('rows'), self.row_count, ctx) cols = self.calc_selectors(m.group('cols'), self.row_size, ctx) for r in rows: for c in cols: v = self.to_number(self.rows[r][c]) if v is None: self.format_warn( f'cell {r}({c}) contains "' f'{klammer_base.unescape_ktesc(self.rows[r][c].strip())}' '", which is not a number; left unformatted') continue result = func(v, self.K_target) if self.decimal == "comma": result = result.translate(str.maketrans(",.", ".,")) self.rows[r][c] = result def span_count(self, spans, index, cross_i): # The count of cells merged by a span anchored at (index, cross_i): # for colspan, index is the row and cross_i the column; for rowspan, # index is the column and cross_i the row. Non-anchor cells get 0. result = 0 entry = spans[index] if entry: for start, end in entry.ranges: if start == cross_i: result = end - start + 1 return result def compute_coverage(self): # Cells hidden by a span (every spanned cell except the anchor). self.colspan_covered = set() self.rowspan_covered = set() for row_i in self.s_colspan.by_index: for start, end in self.s_colspan.by_index[row_i].ranges: for col_i in range(start + 1, end + 1): self.colspan_covered.add((row_i, col_i)) for col_i in self.s_rowspan.by_index: for start, end in self.s_rowspan.by_index[col_i].ranges: for row_i in range(start + 1, end + 1): self.rowspan_covered.add((row_i, col_i)) self.covered = self.colspan_covered | self.rowspan_covered def column_width_candidates(self): # For each column, the texts whose widths a 'fit' or 'fill' column's # tex width is the maximum of: every cell of the column, each line # of a multi-line cell separately, set in its own cell's font. The # maximum is taken in TeX (tex_column_widths), not here: until # 2026-09-12 one "longest" entry was chosen by len() of the tex # MARKUP, so \texttt{\textasciicircum{}e'} (26 characters, 3 # visible) outranked a header "Written" and the header overflowed # into the next column (TODO #59). self.column_candidates = [] for col_i in range(len(self.cells[0])): candidates = [] for row_i in range(len(self.cells)): cell = self.cells[row_i][col_i] # A colspan anchor's text spans several columns and must # not set the width of its own column. if cell is None or cell.colspan > 1: continue for line in cell.text.split("\\newline"): line = line.strip() if not line: continue if cell.font != "r": line = font.tex_fontify(line, cell.font, 1.0) if line not in candidates: candidates.append(line) self.column_candidates.append(candidates) # ---- :hpos ------------------------------------------------------------- # # ";"-separated pairs (the same list style as :calc # and :format). is an indexed_range; is l, c, or r # and overrides the column's :cell_hpos for the selected cells. A # colspan anchor's override positions the whole merged cell; in the tex # target an ordinary overridden cell is wrapped in \multicolumn{1}. def justify_overrides(self): result = {} for stmt in [s.strip() for s in self.justify.split(";") if s.strip()]: ctx = f'In :justify "{stmt}"' parts = stmt.split() if len(parts) != 2 or parts[1] not in ("l", "c", "r"): self.selector_error( ctx, "each entry is , the position one " "of l, c, or r -- e.g. -3--1(3) r") rangespec, pos = parts m = self.operand_rgx.match(rangespec) if not m or m.group('rows') is None or m.group('cols') is None: self.selector_error( ctx, f'"{rangespec}" is not a cell range like 0-(5) ' "or 1--2(0-3)") rows = self.calc_selectors(m.group('rows'), self.row_count, ctx) cols = self.calc_selectors(m.group('cols'), self.row_size, ctx) for r in rows: for c in cols: result[(r, c)] = pos return result def make_cells(self, rows): self.compute_coverage() result = [] cells = [] for row_i, row in enumerate(rows): row_cells = [] for cell_i, cell in enumerate(row): rspan = self.span_count(self.s_rowspan, cell_i, row_i) cspan = self.span_count(self.s_colspan, row_i, cell_i) font = self.style[cell_i] if row_i == 0 and self.header: font = self.header_style # A span anchor's right and bottom borders come from the # boundary at the END of the merged region. right_i = cell_i + max(cspan, 1) bottom_i = row_i + max(rspan, 1) hpos = self.justify_map.get((row_i, cell_i), self.cell_hpos[cell_i]) row_cells.append( table_cell.Cell( cell, font, hpos, self.s_hline.has(row_i, cell_i), self.s_vline.has(right_i, row_i), self.s_hline.has(bottom_i, cell_i), self.s_vline.has(cell_i, row_i), self.s_vline.by_index.get(cell_i), self.s_vline.by_index.get(right_i), rspan, cspan, first_column=(cell_i == 0), hpos_forced=(row_i, cell_i) in self.justify_map, fit_class=(self.fit_class if cell_i in self.fit_columns else ""), flush_left=(self.flush_l and cell_i == 0), flush_right=(self.flush_r and cell_i + max(cspan, 1) == self.row_size))) cells.append(row_cells) self.cells = cells self.column_width_candidates() # HTML def html_colgroup(self): # The CSS counterpart of tex_hpos()'s width resolution: 'fit' is the # widest entry of the column and no larger (tex's \widthof — the # cells' Wfit class clamps it there), a # fraction is of the text column, and '*' shares the width the sized # columns leave over. All-'fit' (the default) needs no markup at # all. With no 'fit' column the fixed layout makes the fractions # exact (long content wraps, as LaTeX's p{} columns do); a 'fit' # column forces the auto layout, where the fraction widths are # honored approximately. widths = extend(self.column_width, self.row_size) if all(w in ("fit", "f") for w in widths): return "", "", None fractions = sum(float(w) for w in widths if w not in ("fit", "f", "*")) if any(w in ("fit", "f", "*") for w in widths): table_width = "100%" else: table_width = f"{min(fractions, 1) * 100:g}%" cols = "" for w in widths: if w in ("fit", "f"): # Clamped to the widest entry by the cells' Wfit class # (width 1% + nowrap; see table.css) -- a px width on the # does NOT survive surplus distribution when no '*' # column exists (seen in both Firefox and Chrome). cols += E("col").str() elif w == "*": cols += E("col").str() else: share = float(w) if table_width == "100%" else float(w) / fractions cols += E("col").attr("style", f"width: {share * 100:g}%").str() layout = "" if any(w in ("fit", "f") for w in widths) else "table-layout: fixed; " # No blank line before : @document's insert_missing_ids # would wrap it as a stray

inside the table. return E("colgroup").body(cols.strip(), newline=False).str(), layout, table_width def html_style(self): # :leading and :colsep under html (2026-09-18; both were tex-only). # \arraystretch multiplies the row strut, which is \baselineskip, # 1.2 times the font size -- so :leading L is a 1.2 L em row, and # the html equivalent is that line-height on the table, inherited # by every cell. The cells' vertical padding went with it # (table.css): an html-only extra with no tex counterpart, which # kept a :leading 1 table 0.3rem looser per row than its pdf. # :colsep is a custom property the td rule reads for its horizontal # padding (padding does not inherit); the flush-edge classes still # zero the outer ones. Measured 2026-09-18: rows of :leading 1 and # :leading 2 in the ratio 1:2 in both targets. line_height = f"{1.2 * float(self.leading):.4g}" # Hidden: the result is re-read and the html target's transforms # would turn the property's "--" into an en dash (font.py's trap). return klammer_base.hide_typographic( f"line-height: {line_height}; --colsep: {self.colsep}") def html(self): result = "" for row_i, row in enumerate(self.cells): row_html = "" for cell_i, cell in enumerate(row): if (row_i, cell_i) in self.covered: continue row_html += cell.html().strip() + "\n" result += E("tr").body(row_html).str() if self.fill_columns: # 'fill': the table sizes itself -- the browser's auto layout # computes min(available, widest entries) natively, so the fill # column grows only until nothing needs a line break. Several # fill columns share in proportion to their content (the auto # algorithm), matching the tex \ratio division. The max-width # cap goes on the caption wrapper when there is one (the table's # own percentage would be circular in a shrink-to-fit wrapper). result = E("table").body(result).attr("style", self.html_style()) if self.number or self.caption: result = html_util.add_caption( result, "Table", self.number, self.caption, self.caption_style, hpos=self.hpos, side=self.caption_side, font_size=self.caption_size, max_width="100%", offset=self.offset) else: result.attr("style", "max-width: 100%") result = html_util.hpos_container(result, self.hpos, self.offset).str() return result colgroup, layout, table_width = self.html_colgroup() result = E("table").body(colgroup + result).attr("style", self.html_style()) if self.number or self.caption: # The caption wrapper carries the width (a percentage on the # shrink-to-fit wrapper itself would be circular) and the table # fills it -- which also makes the caption track the table. if table_width: result.attr("style", f"{layout}width: 100%") result = html_util.add_caption( result, "Table", self.number, self.caption, self.caption_style, hpos=self.hpos, side=self.caption_side, font_size=self.caption_size, width=table_width, offset=self.offset) else: # An uncaptioned table still gets the position container, so # html and tex agree on where the table sits. if table_width: result.attr("style", f"{layout}width: {table_width}") result = html_util.hpos_container(result, self.hpos, self.offset).str() return result # LaTeX # The length registers holding computed 'fill' column widths, declared # in table.sty; one per fill column, in column order. fill_registers = ["\\klfilla", "\\klfillb", "\\klfillc", "\\klfilld"] @staticmethod def width_macro(col_i): # The macro holding a column's measured width: \klfitA ... \klfitZ, # \klfitAA, ... (a control-sequence name holds letters only). letters = "" i = col_i while i >= 0: letters = chr(ord("A") + i % 26) + letters i = i // 26 - 1 return "\\klfit" + letters def tex_column_widths(self): # Set each 'fit' or 'fill' column's width macro to the width of its # widest entry, measured in TeX: \settowidth of the first candidate, # then calc's \maxof against \widthof of each further one, in the # scratch register \klfitwidth (table.sty); the result is frozen # into \klfit by \edef, so the column spec -- which array's p{} # evaluates once per CELL -- reads a number instead of re-measuring # every candidate. Flat, one line per candidate: a nested \maxof # would recurse as deep as the column is long. result = "" for i, w in enumerate(extend(self.column_width, self.row_size)): if w not in ("fit", "f", "fill"): continue candidates = self.column_candidates[i] if not candidates: result += "\\setlength{\\klfitwidth}{0pt}\n" else: result += f"\\settowidth{{\\klfitwidth}}{{{candidates[0]}}}\n" for c in candidates[1:]: result += (f"\\setlength{{\\klfitwidth}}" f"{{\\maxof{{\\klfitwidth}}{{\\widthof{{{c}}}}}}}\n") result += f"\\edef{Table.width_macro(i)}{{\\the\\klfitwidth}}\n" return result def tex_fill_widths(self): # Set each 'fill' column's register to min(its share of the # remaining width, its widest entry) -- the same rule the html auto # layout applies. The shares divide the remaining width in # proportion to the columns' widest entries (calc's \ratio): either # the space covers them all and every column caps at its widest # entry, or no column caps and all the space is used -- no stranded # whitespace, and no iterative redistribution. if not self.fill_columns: return "" widths = extend(self.column_width, self.row_size) fit = [Table.width_macro(i) for i, w in enumerate(widths) if w in ("fit", "f")] remaining = "\\tablewidth" + "".join(f" - {e}" for e in fit) widest = {i: Table.width_macro(i) for i in self.fill_columns} total = " + ".join(widest[i] for i in self.fill_columns) result = "" for k, i in enumerate(self.fill_columns): reg = Table.fill_registers[k] if len(self.fill_columns) == 1: result += (f"\\setlength{{{reg}}}" f"{{\\minof{{{remaining}}}{{{widest[i]}}}}}\n") else: result += (f"\\setlength{{{reg}}}{{({remaining})" f"*\\ratio{{{widest[i]}}}{{{total}}}}}\n") result += (f"\\setlength{{{reg}}}" f"{{\\minof{{{reg}}}{{{widest[i]}}}}}\n") return result def tex_hpos(self): # One column specification per column: the width comes from # :column_width ('fit' and 'fill' via their precomputed macros, a # fraction of \tablewidth, or '*' for the remaining width), the # justification from :cell_hpos. def par_format(s, justification): command = {"l" : "raggedright", "c" : "centering", "r" : "raggedleft"}[justification] return f">{{\\{command}}}p{{{s}}}" widths = [] fill_ordinal = 0 for i, w in enumerate(extend(self.column_width, self.row_size)): if w in ("fit", "f"): widths.append(Table.width_macro(i)) elif w == "fill": widths.append(Table.fill_registers[fill_ordinal]) fill_ordinal += 1 elif w == "*": widths.append(None) else: widths.append(f"{w}\\tablewidth") fill_count = widths.count(None) if fill_count > 0: fixed = [e for e in widths if e is not None] if fixed: expr = f"(\\tablewidth - {' - '.join(fixed)}) / {fill_count}" else: expr = f"{1 / fill_count}\\tablewidth" widths = [e if e is not None else expr for e in widths] return [par_format(w, j) for w, j in zip(widths, self.cell_hpos)] def tex_column_spec(self): parts = [""] * (self.row_size * 2 + 1) for i in self.s_vline.by_index: parts[i * 2] = "|" # A lineless table edge drops its outer \tabcolsep (matched by the # \tablewidth arithmetic in tex()). if self.flush_l: parts[0] = "@{}" if self.flush_r: parts[-1] = "@{}" for i, hpos in enumerate(self.tex_hpos()): parts[i * 2 + 1] = hpos # print("tex_column_spec:", "".join(parts)) return "".join(parts) def tex_hline(self, index): # Contiguous cell borders coalesce into single \cline runs; a # full-width line becomes \hline. bottom = index == self.row_count if bottom: index -= 1 flags = [(cell.border.bottom if bottom else cell.border.top) for cell in self.cells[index]] if not bottom: # No line through the interior of a merged (rowspan) cell. flags = [flag and (index, col_i) not in self.rowspan_covered for col_i, flag in enumerate(flags)] if flags and all(flags): return "\\hline\n" hline = "" start = None for i, flag in enumerate(flags + [False]): if flag and start is None: start = i elif not flag and start is not None: hline += f"\\cline{{{start + 1}-{i}}} " start = None return hline.strip() + "\n" def tex_rows(self): result = "" for row_i, row in enumerate(self.cells): result += self.tex_hline(row_i) parts = [] for cell_i, cell in enumerate(row): if (row_i, cell_i) in self.colspan_covered: continue # Absorbed by the \multicolumn anchor if (row_i, cell_i) in self.rowspan_covered: parts.append("") # Occupied by the \multirow anchor else: parts.append(cell.tex()) result += " & ".join(parts) + " \\tabularnewline\n" result += self.tex_hline(self.row_count) return result def get_width(self): box = "\\savebox{\\tablebox}{%\n" box += "\\begin{tabular}{" box += self.tex_column_spec() box += "}\n" box += self.tex_rows() box += "\\end{tabular}}\n" result = box + "\\setlength{\\tableboxwidth}{\\wd\\tablebox}\n" # result += "\\the\\tableboxwidth\n\n" # print(result) return result def make_caption(self): column_count = str(len(self.rows[0])) caption = latex_util.make_caption_text( self.number, "Table", self.caption, self.caption_style, self.caption_size) result = "\\multicolumn{" + column_count + "}{c}{\\parbox{\\tableboxwidth}{" result += "\\vspace*{8pt}\\centering\\small\\em " result += caption result += "}} \\\\ \\endlastfoot\n" return result def tex_position(self): # Position a PAGE-BREAKING table with longtable's own glue (it # cannot be boxed). A boxed table (allow_break false) is positioned # by its :hpos wrapper instead; its glue is left neutral (\fill on # both sides collapses in the exactly-fitting box), because a fixed # length would overflow the box. A length is the left margin. # :offset insets the table from the margin :hpos names -- the same # rule the boxed path gets from latex_util.caption_wrapper, here # expressed as the fixed side of the glue pair. inset = latex_util.offset_length(self.offset) if not self.allow_break: left, right = "\\fill", "\\fill" elif self.hpos == "center": left, right = "\\fill", "\\fill" elif self.hpos == "left": left, right = inset, "\\fill" elif self.hpos == "right": left, right = "\\fill", inset elif self.hpos == "none": # No positioning container: the table starts at the text margin # like ordinary text, which is what "none" does in html (the # hpos_none class is inline-flex). A longtable cannot flow # inline, so flush left is as close as the target gets. The # offset names no margin here and is ignored, as when centered. left, right = "0pt", "\\fill" else: length, _, _ = kutil.parse_length("tex", self.hpos, 1) left, right = length, "\\fill" return (f"\\setlength{{\\LTleft}}{{{left}}}\n" f"\\setlength{{\\LTright}}{{{right}}}\n") def tex_width_check(self, name): # Emit a marker into the xelatex log when the measured table is # wider than the text column (2pt tolerance for exactly-full-width # tables). tex_to_pdf() in document.cpp scans the log for the # marker and prints the console warning with the :column_width # primer -- the widths are only known at LaTeX run time, and prose # kept out of TeX avoids the log's 79-column line wrapping. return ("\\ifdim\\tableboxwidth>\\dimexpr\\linewidth+2pt\\relax\n" f"\\message{{^^JKT-WIDE-TABLE {name} overfull by " "\\the\\dimexpr\\tableboxwidth-\\linewidth\\relax^^J}\n" "\\fi\n") def tex(self): name = f"Reference-Table-{Table.id}" Table.id += 1 # The measuring \savebox must stay OUTSIDE the \tableboxwidth # minipage below: it computes the width the minipage consumes. measure = self.get_width() + self.tex_width_check(name) result = f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n" # A page-breaking table is a longtable. A boxed one is a plain # tabular: inside its minipage a longtable could not break anyway, # and longtable refuses to run in a multicols or two-column page, # which is where a boxed table is wanted (until 2026-09-08 both # paths emitted longtable, so @table could not be used in # @multicolumn at all). env = "longtable" if self.allow_break else "tabular" result += f"\\begin{{{env}}}{{" result += self.tex_column_spec() result += "}\n" if self.allow_break and (self.number or self.caption): # The caption rides in longtable's last foot so it tracks the # table across page breaks. Without a caption there is no foot: # an empty one is still a full row (\vspace*{8pt} plus an empty # \small line), which put ~20pt of blank under every uncaptioned # breaking table until 2026-09-08. result += self.make_caption() result += self.tex_rows() result += f"\\end{{{env}}}\n" if not self.allow_break: # Box the table at its measured width so the caption tracks it # and the box can be positioned as one piece; the LT glue then # has no room and positioning falls to the :hpos wrapper. (The # page-breaking table cannot be boxed -- there the LT glue # positions and make_caption's \multicolumn tracks.) result = latex_util.minipage( result, "\\tableboxwidth", vertical="t", center=False) if self.number or self.caption: result = latex_util.add_caption( result, "Table", self.number, self.caption, "\\tableboxwidth", hpos=self.hpos, side=self.caption_side, font_symbol=self.caption_style, font_size=self.caption_size, offset=self.offset) else: result = latex_util.caption_wrapper(result, self.hpos, offset=self.offset) result = measure + result result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}" # A boxed table's wrapper (add_caption/caption_wrapper) owns all # vertical space around it, so longtable's own glue is zeroed. A # page-breaking table has no wrapper and is set by longtable itself, # so its glue is \parskip on both sides: it then sits in the text # with the spacing of a paragraph (it was a fixed \vspace*{12pt} # above and nothing below until 2026-09-08). lt_glue = "\\parskip" if self.allow_break else "0pt" result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n" f"\\setlength{{\\LTpre}}{{{lt_glue}}}\n" f"\\setlength{{\\LTpost}}{{{lt_glue}}}\n" + self.tex_position() + # 2 \tabcolsep per column, minus the ones @{} removes at # flush (lineless) edges. \linewidth, not \textwidth: in # a multicols column or a minipage the table must fit the # column it is set in (the two are equal in plain text). f"\\setlength{{\\tablewidth}}{{\\linewidth - " f"{2 * self.row_size - self.flush_l - self.flush_r}\\tabcolsep}}\n" # Column widths precede the fill widths, which read # them; both precede the measuring \savebox, whose # column spec reads them. + self.tex_column_widths() + self.tex_fill_widths() + result) result = re.sub(r"\newline", r"\\\\", result) # A boxed table is box material: it must sit in vertical mode or it # is typeset beside any text it follows. (A page-breaking longtable # breaks the paragraph itself, but \par on both sides is a no-op # there, so the rule stays unconditional.) return latex_util.block(result) # txt target def txt_column_widths(self): """The visible width of the widest cell in each column. Measured with txt_util.visible_length, not len: a cell holding a quoted character carries a KTESC marker, fourteen characters wide and one glyph, so len() sizes the column to a width the reader never sees -- and then the f-string below, padding the same unresolved text, adds nothing because it is already "wider" than the column. The cell came out unpadded and three columns wide in a column sized sixteen. """ widths = [0] * self.row_size for row in self.rows: # ELEMENT-WISE, which max() of two lists is not: it compares # them lexicographically and returns the whole larger list, so # a later row wider in a later column was discarded entirely # if it was narrower in an earlier one. widths = [max(w, txt_util.visible_length(cell)) for w, cell in zip(widths, row)] return widths def txt(self): def hpos(p): return {'l':'<', 'c':'^', 'r':'>'}[p] def if_empty(char): return txt_util.NBSP if len(char) == 0 else char # Padded by hand rather than by an f-string width: the format spec # counts characters, and a cell's markers are many characters and # few columns. visible_length is what the reader will see. def pad(cell, width, align): cell = if_empty(cell) space = max(0, width - txt_util.visible_length(cell)) if align == '>': return txt_util.NBSP * space + cell if align == '^': left = space // 2 return txt_util.NBSP * left + cell + txt_util.NBSP * (space - left) return cell + txt_util.NBSP * space # A cell's type style, as make_cells() assigns it for html and tex: # the column's :style code, overridden by :header_style in the # header row. Plain text has one face, so only the styles a # terminal can show are carried -- italic and bold, as the markers # the ansi target resolves and the txt target strips (phases.py); # the other codes (roman, sans, typewriter, code) style nothing. STYLE = {"i": "italic", "b": "bold"} def styled(cell, row_i, cell_i): code = self.header_style if row_i == 0 and self.header else self.style[cell_i] if cell and code in STYLE: return (phases.txt_style_marker(STYLE[code]) + cell + phases.txt_style_marker(STYLE[code], False)) return cell # A cell is one line of text: a newline written inside it is # whitespace, as it is in html and tex. rows = [[re.sub(r"\s*\n\s*", " ", cell) for cell in row] for row in self.rows] widest = self.txt_column_widths() pos = [hpos(p) for p in self.cell_hpos] # Placement under txt (2026-09-16): an explicit :hpos left takes its # indent from :offset (characters; 0 = flush), :hpos none flows with # no indent, and anything else -- including the default, center -- # keeps the block convention, Target_txt_indent. (Centering and # right-alignment are not attempted: a block that shifts with the # width of its widest row reads as an accident in plain text.) if self.hpos == "left": indent = txt_util.NBSP * int(kutil.parse_width("txt", self.offset)[0]) elif self.hpos == "none": indent = "" else: indent = txt_util.NBSP * int(self.Target_txt_indent) colsep = " " # Column widths, by the same rules as the other targets # (:column_width): 'fit' is the widest entry; a fraction is that part # of the text width; '*' takes the remaining width; 'fill' takes the # remaining width but no more than its widest entry -- and, unlike a # 'fit' column, its cells WRAP to that width (2026-09-13; until then # the txt table never wrapped a cell, so a description column ran off # the page). Several fill/'*' columns share the remainder in # proportion to their widest entries. text_width = (int(self.Target_txt_width) - int(self.Target_txt_indent) - len(colsep) * (self.row_size - 1)) specs = extend(self.column_width, self.row_size) widths = [0] * self.row_size for i, w in enumerate(specs): if w in ("fit", "f"): widths[i] = widest[i] elif w not in ("fill", "*"): widths[i] = max(1, int(float(w) * text_width)) flexible = [i for i, w in enumerate(specs) if w in ("fill", "*")] if flexible: remaining = max(len(flexible), text_width - sum(widths)) total = sum(max(1, widest[i]) for i in flexible) for i in flexible: share = max(1, remaining * max(1, widest[i]) // total) widths[i] = min(share, widest[i]) if specs[i] == "fill" else share wrapped = {i for i, w in enumerate(specs) if w not in ("fit", "f")} mark = phases.txt_verbatim_marker() # each line: already laid out rule = (mark + indent + klammer_base.hide_typographic( "-" * (sum(widths) + len(colsep) * (len(widths) - 1))) + "\n") result = "" for row_i, row in enumerate(rows): if row_i in self.s_hline.by_index: result += rule # Each cell becomes its lines: wrapped to the column's width in a # wrapping column, one line otherwise; the row is as tall as its # tallest cell, short cells padded with empty lines. cell_lines = [] for cell_i, (elt, width) in enumerate(zip(row, widths)): text = styled(elt, row_i, cell_i) lines = phases.txt_wrap(text, width) if cell_i in wrapped and text else [text] cell_lines.append(lines or [""]) height = max(len(lines) for lines in cell_lines) for n in range(height): result += mark + indent + colsep.join( [pad(lines[n] if n < len(lines) else "", width, align) for lines, width, align in zip(cell_lines, widths, pos)]) result += "\n" if len(rows) in self.s_hline.by_index: result += rule result = txt_util.add_caption( result, "Table", self.number, self.caption, self.caption_side, \ int(self.Target_txt_indent)) return result