Offer-motivated features: :hline defaults, ranged :hpos, @date :days, :bottom none

- @table: a writer's :hline/:vline replaces the default lines; new
  boundary name 'none' removes all lines
- @table: ranged :hpos argument overrides :cell_hpos per cell
  (\multicolumn{1} in tex; positions a colspan anchor's merged cell)
- @date/@datetime: :days offset argument (sks/date/date.py)
- @document: ":bottom none" suppresses the footer (\pagestyle{empty})

Also carries the escape-system generator/renderer fixes, per-cell-range
:format, and uppercase .TTF/.OTF font recognition from klammertext-dev.
This commit is contained in:
2026-07-24 21:37:58 +02:00
parent 8a2699a253
commit 4262fc6136
14 changed files with 657 additions and 146 deletions

View File

@@ -1,5 +1,6 @@
import functools
import collections
import importlib
import re
import sys
import traceback
@@ -14,6 +15,15 @@ from indexed_range import Indexed_ranges, hline_names, vline_names
import table_cell
import font
# ---- :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()
@@ -44,18 +54,18 @@ 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"]
self.row_count = len(self.rows)
if self.header:
self.hline += ["1", str(self.row_count)]
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]
if self.calc:
self.calculate()
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,
@@ -64,25 +74,58 @@ class Table(klammer_base.Klammer_base):
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.font = extend(self.font, self.row_size)
self.hpos_map = self.hpos_overrides() if self.hpos else {}
self.make_cells(self.rows)
# Calculated cell values (:calc). Calculations run in the order given;
# each reads cell values as displayed (display-precision semantics), so
# a printed total always equals the sum of the printed lines. Only
# calculation targets are formatted (:calc_format); other cells keep
# their authored text. Future operators to consider: min, max, mean,
# and a per-calculation format override.
# 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.
calc_target_rgx = re.compile(r"(\d+)\((\d+)\)$")
calc_cell_rgx = re.compile(r"\d+(-\d*)?\(")
# 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 parse_number(self, text, ref, calc):
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(",.", ".,"))
@@ -90,44 +133,129 @@ class Table(klammer_base.Klammer_base):
try:
return float(s)
except ValueError:
self.calc_error(
calc, f'the cell {ref} contains "{text.strip()}", '
"which is not a number")
return None
def format_number(self, value, calc):
if self.calc_format:
try:
s = format(value, self.calc_format)
except ValueError:
self.calc_error(
calc, f'"{self.calc_format}" is not a valid '
"format specification")
elif value.is_integer():
s = str(int(value))
else:
s = str(value)
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 operand_values(self, token, calc):
# A token with subsets is a cell selection; a bare number is a
# constant (always period-decimal, independent of :decimal).
if not self.calc_cell_rgx.match(token):
return [float(token)]
selection = Indexed_ranges(self.row_count, self.row_size - 1,
[token], argument=":calc")
values = []
for row_i in selection.by_index:
for _, col_i in selection.by_index[row_i].items():
values.append(self.parse_number(
self.rows[row_i][col_i], f"{row_i}({col_i})", calc))
return values
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 - and /
return {"+": values[0], "*": values[0],
"-": -values[0], "/": 1 / values[0]}[op]
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 == "+":
@@ -140,31 +268,118 @@ class Table(klammer_base.Klammer_base):
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()]:
target, eq, expression = calc.partition("=")
match = self.calc_target_rgx.match(target.strip())
if not eq or not match:
self.calc_error(calc, "the target must be a single cell "
"written <row>(<column>), followed by \"=\"")
row_i, col_i = int(match.group(1)), int(match.group(2))
if row_i >= self.row_count or col_i >= self.row_size:
self.calc_error(
calc, f"the target {target.strip()} is outside the "
f"table (rows 0-{self.row_count - 1}, "
f"columns 0-{self.row_size - 1})")
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")
values = []
for token in tokens[1:]:
values += self.operand_values(token, calc)
try:
result = self.apply_operator(tokens[0], values, calc)
except ZeroDivisionError:
self.calc_error(calc, "division by zero")
self.rows[row_i][col_i] = self.format_number(result, calc)
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):
@@ -192,16 +407,6 @@ class Table(klammer_base.Klammer_base):
self.rowspan_covered.add((row_i, col_i))
self.covered = self.colspan_covered | self.rowspan_covered
def remove_redundant_borders(self):
remove_right = []
for row_i in range(self.row_count):
for cell_i in range(self.row_size):
a = self.cells[row_i][cell_i]
b = self.cells[row_i][cell_i+1]
if a.border.right and b.border.left:
a.border.right = False
a.border.right_all = False
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
@@ -225,6 +430,36 @@ class Table(klammer_base.Klammer_base):
longest = font.tex_fontify(longest, longest_font, 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 hpos_overrides(self):
result = {}
for stmt in [s.strip() for s in self.hpos.split(";") if s.strip()]:
ctx = f'In :hpos "{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 = []
@@ -241,10 +476,11 @@ class Table(klammer_base.Klammer_base):
# boundary at the END of the merged region.
right_i = cell_i + max(cspan, 1)
bottom_i = row_i + max(rspan, 1)
hpos = self.hpos_map.get((row_i, cell_i), self.cell_hpos[cell_i])
row_cells.append(
table_cell.Cell(
cell,
font, self.cell_hpos[cell_i],
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),
@@ -252,7 +488,8 @@ class Table(klammer_base.Klammer_base):
self.s_vline.by_index.get(cell_i),
self.s_vline.by_index.get(right_i),
rspan, cspan,
first_column=(cell_i == 0)))
first_column=(cell_i == 0),
hpos_forced=(row_i, cell_i) in self.hpos_map))
cells.append(row_cells)
self.cells = cells
self.column_width_text()