A character written in the ^...^ mnemonic form -- ^oe^, ^zz^, ^hello^ --
used to kill ktext outright: no message, no output, and an exit status
that looked like a crash because it was one. Only the forms that happen
to spell hexadecimal digits escaped, and they were right by luck rather
than by reading. Any caret form that names no character is now a located
error that quotes what was written.
A klammer may now sit inside square brackets and braces as it already
could inside parentheses, and may be followed by the punctuation that
ends a sentence: [@i word@], {@b word@}, @i word@'s. The rule is a fixed
list of characters, because none of them can occur inside a klammer name
and so none of them is ambiguous next to a delimiter. A letter still
cannot: "x@" is the named closing delimiter, so a letter before "@" has
two readings and no rule can separate them.
The abbreviations an author writes -- the hyphen form @i-word, a klammer
glued to its neighbouring punctuation -- are now recognised as author
conveniences only. They are not applied to @@ definitions or @@@ system
commands, which are written by someone who needs no such shorthand and
whose forms are already whole.
Also fixed, each of them silent: an exported shell function in the
environment broke every @eval in the document; a line break written with
@nl was destroyed when it passed through a klammer that generates a
table, printing as \textbackslash{}newline in the finished PDF; and
"kdesc --coverage" shortened its file column by comparing the spelling of
a path rather than the path, so a klammer set reached by a symbolic link
showed a full path where its neighbours showed a short one.
New in the distribution: ref/, a small klammerset that describes the
Klammermachine's own tables in Klammertext. It currently provides the
character tables -- the diacritics and the extended Latin letters -- as
klammers that render to any target, so the documentation of what can be
written cannot drift from what the machine accepts. It is early, and
will grow.
The Klammertext taxonomy now has a section on targets, describes the
@@@klammerset system command alongside the other three, and states the
precedence of "^" -- what a caret means before a letter, a digit, a
punctuation character, and a quote, in one ordered list.
(from dev 6594c7ff8c1f)
925 lines
43 KiB
Python
925 lines
43 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):
|
|
"""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
|
|
widths = self.txt_column_widths()
|
|
pos = [hpos(p) for p in self.cell_hpos]
|
|
result = ""
|
|
indent = txt_util.NBSP * int(self.Target_txt_indent)
|
|
colsep = " "
|
|
for row in self.rows:
|
|
result += indent + colsep.join(
|
|
[pad(elt, width, align) for elt, width, align 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
|
|
|