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

@@ -25,10 +25,16 @@ index range "2-" (to the last index), or a name defined by the argument
commas; each is an index "4", a closed range "1-4", or an open range "6-"
(to the end). All indices are zero-origin.
A negative index counts from the end, as in Python: -1 is the last index,
-2 the second to last, and so on. Ranges are inclusive, so "1--2" is index
1 through the second-to-last index (the last index excluded).
Examples:
3 index 3, full extent
2-5(0-2) indices 2 through 5, each restricted to 0 through 2
3(1-4,6-9) index 3, restricted to 1-4 and 6-9
-1 the last index
1--2 index 1 through the second-to-last index
head(1-) with table hline names: boundary 1, from column 1 on
""".strip()
@@ -38,8 +44,13 @@ class Range_error(Exception):
super().__init__(f"{message}\n\n{syntax_description}")
item_rgx = re.compile(r"(?:(\d+)(-)?(\d*)|([A-Za-z]+))(?:\(([\d,\-]+)\))?$")
subset_rgx = re.compile(r"(\d+)(-)?(\d*)$")
# A numeric selector is a signed integer, optionally followed by a range
# part: a separating hyphen and an optional signed end index (empty end =
# open range). The leading sign lets an index count from the end (-1 is
# the last), matching Python list indexing. The separating hyphen never
# collides with a minus sign because \d+ never consumes it.
item_rgx = re.compile(r"(?:(-?\d+)(-(-?\d+)?)?|([A-Za-z]+))(?:\(([-\d,]+)\))?$")
subset_rgx = re.compile(r"(-?\d+)(-(-?\d+)?)?$")
def hline_names(count):
@@ -49,7 +60,8 @@ def hline_names(count):
"head": [1],
"bottom": [last],
"inner": list(range(1, last)),
"all": list(range(count))}
"all": list(range(count)),
"none": []}
def vline_names(count):
@@ -57,7 +69,8 @@ def vline_names(count):
last = count - 1
return {"outer": [0, last],
"inner": list(range(1, last)),
"all": list(range(count))}
"all": list(range(count)),
"none": []}
class Indexed_range:
@@ -126,31 +139,36 @@ class Indexed_ranges:
argument = f"{self.argument} argument: " if self.argument else ""
raise Range_error(f"{argument}{message}")
def normalize(self, raw, spec, count):
"""Resolve a possibly-negative index to 0..count-1 (Python-style):
a negative index counts from the end (-1 is the last)."""
i = raw + count if raw < 0 else raw
if not 0 <= i < count:
self.error(f'In "{spec}", index {raw} is out of range '
f"(0 through {count - 1}, or -1 through -{count}).")
return i
def parse(self, spec):
match = item_rgx.match(spec)
if not match:
self.error(f'"{spec}" is not a valid indexed_range.')
number, hyphen, end, name, subsets = match.groups()
number, range_part, end, name, subsets = match.groups()
if name is not None:
if name not in self.names:
known = " ".join(self.names) or "none"
self.error(f'"{name}" is not a valid name here '
f"(valid names: {known}).")
indices = self.names[name]
elif range_part is None:
indices = [self.normalize(int(number), spec, self.count)]
else:
start = int(number)
if not hyphen:
indices = [start]
else:
last = int(end) if end else self.count - 1
if start > last:
self.error(f'In "{spec}", the index range start {start} '
f"is greater than its end {last}.")
indices = list(range(start, last + 1))
for i in indices:
if i >= self.count:
self.error(f'In "{spec}", index {i} is out of range '
f"(0 through {self.count - 1}).")
first = self.normalize(int(number), spec, self.count)
last = (self.normalize(int(end), spec, self.count)
if end else self.count - 1)
if first > last:
self.error(f'In "{spec}", the index range start {first} '
f"is greater than its end {last}.")
indices = list(range(first, last + 1))
ranges = self.parse_subsets(spec, subsets) if subsets else None
for i in indices:
entry = self.by_index.setdefault(i, Indexed_range(i, self.maxval))
@@ -160,23 +178,24 @@ class Indexed_ranges:
entry.add_ranges(ranges)
def parse_subsets(self, spec, subsets):
# Subset indices run 0..maxval inclusive, so their count is
# maxval + 1 and a negative subset index resolves against it.
count = self.maxval + 1
ranges = []
for part in subsets.split(","):
match = subset_rgx.match(part)
if not match:
self.error(f'In "{spec}", "{part}" is not a valid subset.')
number, hyphen, end = match.groups()
start = int(number)
if not hyphen:
last = start
number, range_part, end = match.groups()
if range_part is None:
start = last = self.normalize(int(number), spec, count)
else:
last = int(end) if end else self.maxval
start = self.normalize(int(number), spec, count)
last = (self.normalize(int(end), spec, count)
if end else self.maxval)
if start > last:
self.error(f'In "{spec}", the subset start {start} '
f"is greater than its end {last}.")
if last > self.maxval:
self.error(f'In "{spec}", {last} is out of range '
f"(0 through {self.maxval}).")
ranges.append([start, last])
return ranges

View File

@@ -3,9 +3,10 @@
@@@argtype index_subsets |
one or more subsets in parentheses, attached to an index. Each subset is a
single index <n>, a closed range <n>-<m>, or an open range <n>- (from <n> to
the end). Several subsets are separated by commas, with no spaces.
the end). A negative index counts from the end (-1 is the last). Several
subsets are separated by commas, with no spaces.
Example: (1-4,6-9)
:pattern \((?^:\d+(?^:-\d*)?)(?^:,\d+(?^:-\d*)?)*\)
:pattern \((?^:-?\d+(?^:-(?^:-?\d+)?)?)(?^:,-?\d+(?^:-(?^:-?\d+)?)?)*\)
@@@
@@@argtype indexed_range |
@@ -13,14 +14,18 @@
single index <i>, a closed index range <i>-<j>, or an open index range <i>-
(from <i> to the last index). It may be followed by parenthesized subsets
(see the index_subsets type) restricting the extent in the other dimension.
All indices are zero-origin. Examples:
All indices are zero-origin. A negative index counts from the end, as in
Python: -1 is the last index, -2 the second to last. Ranges are inclusive,
so "1--2" is index 1 through the second-to-last index. Examples:
3 index 3, full extent
2-5 indices 2 through 5, full extent
3(1-4,6-9) index 3, restricted to 1 through 4 and 6 through 9
2-5(0-2) indices 2 through 5, each restricted to 0 through 2
-1 the last index
1--2 index 1 through the second-to-last index
:pattern \d+(?^:-\d*)?(?^:'index_subsets')?
:pattern -?\d+(?^:-(?^:-?\d+)?)?(?^:'index_subsets')?
@@@
@@@argtype column_width |
@@ -51,16 +56,19 @@
is either a boundary name or an indexed_range of boundary indices. The
names are 'top' (boundary 0), 'head' (boundary 1, under a header row),
'bottom' (boundary N), 'inner' (all boundaries between top and bottom),
and 'all' (every boundary). A name or index may be followed by
parenthesized subsets to draw only part of a line, given as zero-origin
column ranges. Examples:
'all' (every boundary), and 'none' (no lines). A name or index may be
followed by parenthesized subsets to draw only part of a line, given as
zero-origin column ranges. When ^:hline is given it replaces the default
lines (with a header, 'head' and 'bottom'); ^:hline none removes them.
Examples:
top bottom lines above and below the table
head(1-) a line under the header, from column 1 to the last
3(1-4,6-9) two partial lines at boundary 3
all every line
none no lines
:pattern ((?^:top^|head^|inner^|bottom^|all)(?^:'index_subsets')?^|'indexed_range'^|\s+)+
:pattern ((?^:top^|head^|inner^|bottom^|all^|none)(?^:'index_subsets')?^|'indexed_range'^|\s+)+
:python_cast (lambda s : s.split())
@@@
@@ -71,15 +79,15 @@
the left; boundary i lies to the left of column i, and boundary M is the
right edge. An item is either a boundary name or an indexed_range of
boundary indices. The names are 'outer' (boundaries 0 and M), 'inner'
(all boundaries between them), and 'all' (every boundary). A name or
index may be followed by parenthesized subsets to draw only part of a
line, given as zero-origin row ranges. Examples:
(all boundaries between them), 'all' (every boundary), and 'none' (no
lines). A name or index may be followed by parenthesized subsets to
draw only part of a line, given as zero-origin row ranges. Examples:
outer lines at the left and right edges
2(0-3) a line left of column 2, spanning rows 0 through 3
all every line
:pattern ((?^:outer^|inner^|all)(?^:'index_subsets')?^|'indexed_range'^|\s+)+
:pattern ((?^:outer^|inner^|all^|none)(?^:'index_subsets')?^|'indexed_range'^|\s+)+
:python_cast (lambda s : s.split())
@@@
@@ -105,22 +113,35 @@
<target> = <operator> <operand> <operand> ...
where the target is a single cell written <row>(<column>) with zero-origin
indices, the operator is one of + - * /, and each operand is either a cell
selection or a number. A cell selection is an indexed_range read as
<rows>(<columns>); a range expands to all of its cells in row order, so
"+ 1-2(3)" sums column 3 of rows 1 and 2. A plain number is a constant
where the operator is one of + - * / and each operand is a cell selection
or a number. A cell selection is an indexed_range read as <rows>(<columns>);
a range expands to all of its cells in row order, so "+ 1-2(3)" sums column 3
of rows 1 and 2. The SHAPE of the target chooses the operation: a single
cell <row>(<column>) folds the operands to one value, while a target that
ranges over rows (0-(2)) or columns (-1(0-)) runs the calculation once per
row or column (a "map"). See notes/calc_notation.md for the map forms,
relative operands, and broadcasting. A plain number is a constant
and always uses a period as its decimal mark. Operators fold from the
left ("- 1(0-2)" is a minus b minus c); with a single operand, - negates
and / gives the reciprocal. Calculations run in the order given, and each
reads the values earlier calculations have written, as displayed.
Example:
Negative indices count from the end (see indexed_range), which is handy
when a total sits in the last row: "-1(5) = + 1--2(5)" writes into the
last row of column 5 the sum of that column from row 1 through the row
above it. Example:
1(3) = * 1(1-2) ;
2(3) = * 2(1-2) ;
3(3) = + 1-2(3)
:pattern \s*(\d+\(\d+\)\s*=\s*[-+*/](\s+(\d+(?^:-\d*)?'index_subsets'^|'float'))+\s*(;\s*^|\s*$))+
# Coarse structural check only -- "<target> = <op> <operand>..." groups
# separated by ";" -- so that a malformed target or operand reaches
# run_calc() in table.py, whose per-token validation gives a precise message
# (e.g. an infix "* a * b" reports that "*" is not a number or a cell
# selection) instead of this whole description being dumped. A token is any
# run of characters other than space, ";", or "=" ("^^" escapes the regex
# class negation "[^...]" so the Klammertext "^" is not consumed).
:pattern \s*([^^\s;=]+\s*=\s*[-+*/](\s+[^^\s;=]+)+\s*(;\s*^|\s*$))+
@@@
@@ -133,10 +154,33 @@
:default period
@@@
@@@argtype format_spec |
a Python format specification applied to calculated cell values, for
example ",.2f" for two decimal places with grouped thousands.
:pattern \S+
@@@argtype table_hpos |
cell position overrides, as one or more <cells> <position> pairs
separated by semicolons (the same list style as ^:calc). <cells> is an
indexed_range selecting cells; <position> is l, c, or r and overrides
the column position given by ^:cell_hpos for those cells. A colspan
anchor's override positions the whole merged cell. For example,
"-3--1(3) r" right-justifies the cells in column 3 of the last three
rows.
# Coarse check ("<cells> <position>" pairs); hpos_overrides() in
# table.py validates the range and position.
:pattern \s*([^^\s;]+\s+[lcr]\s*(;\s*^|\s*$))+
@@@
@@@argtype table_format |
cell formatting, as one or more <cells> <function> pairs separated by
semicolons (the same list style as :calc). <cells> is an indexed_range
selecting the cells to format; <function> is a <module>.<function> Python
reference (the same form the eval klammer uses) to a function that takes the
cell's value and the target and returns the formatted text. The function
lives in a module the writer supplies (a currency style is specific to a
document, so the SKS ships none): for example, with a euro() function in
your own module, "0-(5) myformats.euro" formats every cell of column 5 as a
Euro amount. Runs after :calc, so it formats computed values; a cell whose
value is not a number is left unchanged, with a warning.
# Coarse check ("<cells> <function>" pairs); apply_formats() in table.py
# validates the range and calls the function.
:pattern \s*([^^\s;]+\s+[^^\s;]+\s*(;\s*^|\s*$))+
@@@
@@rowcolor.tex s : \colorrow{*s*} @@
@@ -153,12 +197,13 @@
:vline.table_vline
:grid.bool false
:cell_hpos.cell_hpos
:hpos.table_hpos
:header_font.font i
:font.font_list
:colspan.table_span
:rowspan.table_span
:calc.table_calc
:calc_format.format_spec
:format.table_format
:decimal.decimal_mark
:leading.float 1.3
:colsep 4pt

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()

View File

@@ -12,10 +12,13 @@ class Cell:
def __init__(self, text, font, hpos,
top, right, bottom, left,
left_all, right_all,
rowspan, colspan, first_column=False):
rowspan, colspan, first_column=False, hpos_forced=False):
self.text = text
self.font = font
self.hpos = hpos
# An :hpos override on an ordinary cell must reach the tex target
# through a \multicolumn{1} (the column spec sets the default).
self.hpos_forced = hpos_forced
self.border = border.Border(top, right, bottom, left, left_all, right_all)
self.rowspan = rowspan
self.colspan = colspan
@@ -74,7 +77,7 @@ class Cell:
left_marker = "xL" if remove_left else "L" if self.border.left_all else ""
right_marker = "Rx" if remove_right else "R" if self.border.right_all else ""
result = f"{left_marker} {result} {right_marker}"
if remove_left or remove_right:
if remove_left or remove_right or self.hpos_forced:
pos = self.hpos
if self.border.left_all and self.border.left:
pos = "|" + pos