Files
klammertext/sks/table/table.py
T
ack b80e823ab3 Tables in the plain-text target, with captions
A @table now renders to txt.  What it needed settled how plain text sets
a block off from the prose around it, which is by INDENTING it: that is
the only structural device the medium has, where html and LaTeX have
boxes, rules and space.  An indented block is one that has already been
laid out, and the target leaves its lines alone.

A caption is placed above a table or a listing and below a figure, in
every target rather than only in plain text.  A figure is taken in at a
glance, and the caption then names what the reader has already seen; a
table is read, and a reader who meets the rows before the subject has to
read them twice.

Also fixed, all three of them silent: an indented paragraph was dropped
from plain-text output rather than left as written; an @indent block was
re-flowed, so the indentation of its later lines appeared as gaps in the
middle of a line; and a table captioned with ":number false" lost the
rule under its caption, while an unnumbered table wrongly consumed a
number that a reader counting captions would then find missing.

(from dev 8173c371bba6)
2026-08-26 23:00:58 +02:00

904 lines
42 KiB
Python

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 <module>.<function> 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
# "<module>.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
# <target> = <op> <operand> ... (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 <rows>(<cols>),
# <rows>, or (<cols>) -- the last two are the relative operand forms.
selector_rgx = re.compile(r"^(-?\d+)(-(-?\d+)?)?$")
operand_rgx = re.compile(
r"^(?P<rows>-?\d+(?:-(?:-?\d+)?)?)?(?:\((?P<cols>[-\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 "+ <zero cell>"
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 <cells> <function> pairs (same list style as :calc).
# <cells> is an indexed_range; <function> is a "<module>.<function>"
# 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 "<module>.<function>" to a callable, like an @eval reference.
if "." not in spec:
self.selector_error(
ctx, f'the format function "{spec}" must be written '
"<module>.<function>, 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 <cells> <function>, 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_text(self):
# For each column, the text used to measure a 'fit' width in the
# tex target. The longest cell's font is applied, so a bold or
# italic cell is measured in the font it will be set in.
self.column_widths = []
for col_i in range(len(self.cells[0])):
longest = ""
longest_style = "r"
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 not None and cell.colspan <= 1:
lines = [e.strip() for e in cell.text.split("\\newline")]
lines = sorted(lines, key=len)
longest_in_line = lines[-1]
if len(longest_in_line) > len(longest):
longest = longest_in_line
longest_style = cell.font
if longest_style != "r":
longest = font.tex_fontify(longest, longest_style, 1.0)
self.column_widths.append(longest)
# ---- :hpos -------------------------------------------------------------
#
# ";"-separated <cells> <position> pairs (the same list style as :calc
# and :format). <cells> is an indexed_range; <position> 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 <cells> <position>, 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_text()
# 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
# <col> 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 </colgroup>: @document's insert_missing_ids
# would wrap it as a stray <p> inside the table.
return E("colgroup").body(cols.strip(), newline=False).str(), layout, table_width
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)
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)
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"]
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 = [f"\\widthof{{{self.column_widths[i]}}}"
for i, w in enumerate(widths) if w in ("fit", "f")]
remaining = "\\tablewidth" + "".join(f" - {e}" for e in fit)
widest = {i: f"\\widthof{{{self.column_widths[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', 'fill' via its precomputed register, 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(f"\\widthof{{{self.column_widths[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\\textwidth+2pt\\relax\n"
f"\\message{{^^JKT-WIDE-TABLE {name} overfull by "
"\\the\\dimexpr\\tableboxwidth-\\textwidth\\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"
if self.allow_break:
result += "\\vspace*{12pt}\n"
result += "\\begin{longtable}{"
result += self.tex_column_spec()
result += "}\n"
if self.allow_break:
result += self.make_caption()
result += self.tex_rows()
result += "\\end{longtable}\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}"
# The wrapper (add_caption/caption_wrapper) owns all vertical space
# around the table; longtable's own glue is zeroed.
result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n"
"\\setlength{\\LTpre}{0pt}\n"
"\\setlength{\\LTpost}{0pt}\n"
+ self.tex_position() +
# 2 \tabcolsep per column, minus the ones @{} removes at
# flush (lineless) edges.
f"\\setlength{{\\tablewidth}}{{\\textwidth - "
f"{2 * self.row_size - self.flush_l - self.flush_r}\\tabcolsep}}\n"
# Fill widths need \tablewidth and must precede the
# measuring \savebox, whose column spec reads them.
+ 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):
def ws(lst, ext=0):
result = [len(e) for e in lst]
if ext > 0:
result += ["~"] * (ext - (len(lst)))
return result
row_count = max(ws(self.rows))
widths = ws(self.rows[0], row_count)
for row in self.rows[1:]:
widths = list(max(widths, ws(row, row_count)))
return widths
def txt(self):
def hpos(p):
return {'l':'<', 'c':'^', 'r':'>'}[p]
def if_empty(char):
return "~" if len(char) == 0 else char
widths = self.txt_column_widths()
pos = [hpos(p) for p in self.cell_hpos]
result = ""
indent = "~" * int(self.Target_txt_indent)
colsep = " "
for row in self.rows:
result += indent + colsep.join(
[f"{if_empty(elt):{pos}{width}}" for elt, width, pos in
list(zip(row, widths, pos))])
result += "\n"
result = txt_util.add_caption(
result, "Table", self.number, self.caption, self.caption_side, \
int(self.Target_txt_indent))
return result