Typed arguments, calculated tables, spans, closed-world fonts, top-level fnt/ and env/

Sync with klammertext-dev through b90b0e09:

- Argument types end to end: :python_cast values are applied (Python
  @eval receives real bools/numbers/lists), argument values are
  validated against their argtype patterns with the argtype's
  description as the error message, argtypes can declare :default
  (overridable per declaration), and parameterized type families are
  supported: rest(N) casts a rest argument to an N-dimensional list
  (bar-count = dimension).
- Unified indexed_range syntax (selector with parenthesized subsets,
  composable mnemonic names) for table lines and spans.
- Table klammer: caption fonts fixed in both targets, :column_width /
  :leading / :colsep wired, :colspan and :rowspan render (HTML
  attributes; \multicolumn / \multirow), calculated cell values (:calc)
  with prefix operators, display-precision semantics, :calc_format and
  :decimal period|comma.
- Fonts: closed-world resolution on the Klammertext font store
  (infrastructure in mac/font_store; no Google Fonts links or fetch).
  Default fonts live in the top-level fnt/; additional fonts install
  into KLAMMERTEXT_FONTS directories via kdesc --font (list, samples,
  preview, install — classification by font metadata).  CSS font family
  names are quoted (digit-initial families were silently lost).
- Environment files moved from mac/env/ to the top-level env/; shell
  profiles source env/runtime.env.  Dead per-host variants removed.
- Container: fnt/ ships in the image; curl removed (no network use).
This commit is contained in:
2026-07-22 18:17:43 +02:00
parent 6b75aa0c54
commit 8a2699a253
100 changed files with 1726 additions and 1152 deletions

197
sks/table/indexed_range.py Normal file
View File

@@ -0,0 +1,197 @@
"""Parsing for the indexed_range argument syntax.
An indexed_range selects positions in one dimension of a grid, with an
optional extent in the other dimension. The same syntax serves the table
klammer's :hline and :vline arguments (index = boundary, subsets = how far
along the line) and its :colspan and :rowspan arguments (index = row or
column, subsets = the cells to merge). Which dimension the index selects
is a property of the argument, not of the syntax.
This module replaces the former sequences.py and span.py (see debris).
"""
import re
syntax_description = """
An indexed_range is a selector, optionally followed by parenthesized
subsets, written with no spaces:
<selector> full extent
<selector>(<subsets>) restricted extent
The selector is a single index "3", a closed index range "2-5", an open
index range "2-" (to the last index), or a name defined by the argument
(for example "top" or "inner" for table lines). Subsets are separated by
commas; each is an index "4", a closed range "1-4", or an open range "6-"
(to the end). All indices are zero-origin.
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
head(1-) with table hline names: boundary 1, from column 1 on
""".strip()
class Range_error(Exception):
def __init__(self, message):
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*)$")
def hline_names(count):
"""Boundary-name map for horizontal lines; count = row_count + 1."""
last = count - 1
return {"top": [0],
"head": [1],
"bottom": [last],
"inner": list(range(1, last)),
"all": list(range(count))}
def vline_names(count):
"""Boundary-name map for vertical lines; count = column_count + 1."""
last = count - 1
return {"outer": [0, last],
"inner": list(range(1, last)),
"all": list(range(count))}
class Indexed_range:
"""The selected extent for one primary-dimension index."""
def __init__(self, index, maxval):
self.index = index
self.maxval = maxval
self.all = False # Full extent (no subsets given)
self.ranges = [] # [[start, end], ...], inclusive
def add_full(self):
self.all = True
self.ranges = [[0, self.maxval]]
def add_ranges(self, ranges):
if not self.all:
self.ranges += ranges
def has(self, i):
return any(start <= i <= end for start, end in self.ranges)
def items(self, invert=False):
result = []
for start, end in self.ranges:
for e in range(start, end + 1):
result.append((e, self.index) if invert else (self.index, e))
return result
def __str__(self):
subsets = ",".join([f"{s}-{e}" for s, e in self.ranges])
return f"{self.index}({subsets})"
def __repr__(self):
return self.__str__()
class Indexed_ranges:
"""A parsed indexed_range argument: Indexed_range entries by index.
count - number of valid primary indices (0 .. count-1)
maxval - largest valid subset value (the cross dimension)
specs - the argument value: a list of items (from the argtype's
python_cast), a whitespace-separated string, or None
names - map of selector names to index lists (hline_names, ...)
argument - argument name for error messages (":hline", ...)
Items targeting the same index merge: their subsets are unioned, and a
full-extent item absorbs any subsets.
"""
def __init__(self, count, maxval, specs, names=None, argument=""):
self.count = count
self.maxval = maxval
self.names = names or {}
self.argument = argument
self.by_index = {}
if specs is None:
specs = []
elif isinstance(specs, str):
specs = specs.split()
for spec in specs:
self.parse(spec)
def error(self, message):
argument = f"{self.argument} argument: " if self.argument else ""
raise Range_error(f"{argument}{message}")
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()
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]
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}).")
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))
if ranges is None:
entry.add_full()
else:
entry.add_ranges(ranges)
def parse_subsets(self, spec, subsets):
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
else:
last = int(end) 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
def __getitem__(self, index):
return self.by_index.get(index)
def __iter__(self):
return iter(self.by_index)
def has(self, index, i):
entry = self[index]
return entry.has(i) if entry else False
def __str__(self):
return " ".join([str(self.by_index[i]) for i in sorted(self.by_index)])
def __repr__(self):
return self.__str__()

View File

@@ -1,147 +0,0 @@
import sys
import re
syntax_description = """
A "sequence" is an integer (the "index") followed by an optional description of one
or more sequence subsets. A subset is defined by a series of subset
descriptions, separated by a comma. A subset description is either an integer,
two integers separated by a hypen to indicate a range, or an integer followed
only by a hyphen, which will include all the following elements of the sequence to
the end. No spaces are allowed in a sequence. All indices are zero-origin.
Sequence examples for an index of "3":
3
3(1)
3(0-4)
3(1-4,6-9)
3(5-)
Note that some sequence subsets must include two numbers, for example, border
lines in a table.
""".strip()
class Sequence:
def __init__(self, count, maxval, spec):
def parse_range(match):
start, hyphen, end = match.groups()
if hyphen is None and end is None:
end = start
elif end is None:
end = maxval
return [int(start), int(end)]
self.maxval = maxval
self.all = True
subset_pat = "[-\\d,]+"
sequence_rgx = re.compile(fr"(\d+)(\({subset_pat}\))*")
match = sequence_rgx.match(spec)
self.spec = spec
if match and match.group(0) == spec:
range_rgx = re.compile(r"(\d+)(-)?(\d+)?")
self.index = int(match.group(1))
if (match.group(2)):
self.subsets = match.group(2).strip("()").split(",")
matches = [range_rgx.match(e) for e in self.subsets]
self.ranges = [parse_range(e) if e else None for e in matches]
self.all = False
else:
self.ranges = [[0, self.maxval]]
else:
print(f'The sequence specification "{spec}" is incorrect.\n\n{syntax_description}\n')
sys.exit(1)
def __str__(self):
#subsets = "all" if self.all else ",".join([f"{e[0]}-{e[1]}" for e in self.ranges])
subsets = "all" if False else ",".join([f"{e[0]}-{e[1]}" for e in self.ranges])
return f"{self.index}[{subsets}]"
def __repr__(self):
return self.__str__()
def has(self, i):
for start, end in self.ranges:
if i >= start and i <= end:
return True
return False
def items(self, invert=False):
result = []
for start,end in self.ranges:
for e in range(start, end+1):
result.append((e, self.index) if invert else (self.index, e))
return result
class Sequences:
def __init__(self, count, maxval, sequence_specs):
self.count = count
self.maxval = maxval
self.sequences = {}
match_all = re.compile(r"\*(.*)").match(sequence_specs)
match_some = re.compile(r"\[(\d+)-(\d+)\](.*)").match(sequence_specs)
spec_list = []
if match_all:
subseq = match_all.group(1)
for i in range(count):
spec_list.append(f"{i}{subseq}")
elif match_some:
start = int(match_some.group(1))
end = int(match_some.group(2))
subseq = match_some.group(3)
for i in range(start, end+1):
spec_list.append(f"{i}{subseq}")
else:
spec_list = sequence_specs.split()
for specs in spec_list:
for spec in self.parse_spec(specs):
self.sequences[spec.index] = spec
def __getitem__(self, index):
return self.sequences.get(index)
def has(self, index, subseq_index):
return self[index].has(subseq_index) if self[index] else None
def parse_spec(self, spec):
named_spec = { "last" : [str(self.count-1)],
"outer" : ["0", str(self.count-1)],
"inner" : [str(e) for e in range(1, self.count - 1)],
"all" : [str(e) for e in range(0, self.count)]
}.get(spec)
if named_spec is None:
return [Sequence(self.count, self.maxval, spec)]
else:
return [Sequence(self.count, self.maxval, e) for e in named_spec]
def items(self, invert=False):
result = []
for seq in self.sequences:
result += self.sequences[seq].items(invert)
return set(result)
if __name__ == "__main__":
import pprint
for s in [
Sequence(10, "0"),
Sequence(10, "1"),
Sequence(10, "2(2)"),
Sequence(10, "3(2-)"),
Sequence(10, "4(2-4)"),
Sequence(10, "5(2-4)"),
Sequence(10, "6(2-4,6)"),
Sequence(10, "7(2,6-8)"),
Sequence(10, "8(2,7-8,9-11,13-14)")]:
print(s.spec, "->", s)
print("Sequences")
S = Sequences(10, "0")
for name in "top bottom head outer inner all 1(2-3)".split():
print(name, "->", S.parse_spec(name))
print("Instantiate:")
s = Sequences(10, "3(1-2,4-5) 4(8-9)")
#print(s.items(True))
print(s)
print(s.has(3,1))

View File

@@ -1,111 +0,0 @@
import sys
import re
syntax_description = """
A "span" is an integer (the "index") followed by an optional description of one
or more sequence subsets. A subset is defined by a series of subset
descriptions, separated by a comma. A subset description is either an integer,
two integers separated by a hypen to indicate a range, or an integer followed
only by a hyphen, which will include all the following elements of the span to
the end. No spaces are allowed in a span. All indices are zero-origin.
Span examples for an index of "3":
3
3(1)
3(0-4)
3(1-4,6-9)
3(5-)
Note that some sequence subsets must include two numbers, for example, border
lines in a table.
""".strip()
class Span:
def __init__(self, count, spec):
def parse_range(match):
start, hyphen, end = match.groups()
if hyphen is None and end is None:
end = start
elif end is None:
end = count - 1
return [int(start), int(end)]
self.all = True
subset_pat = "[-\\d,]+"
span_rgx = re.compile(f"(\d+)(\({subset_pat}\))*")
match = span_rgx.match(spec)
self.spec = spec
if match and match.group(0) == spec:
range_rgx = re.compile("(\d+)(-)?(\d+)?")
self.index = int(match.group(1))
if (match.group(2)):
self.subsets = match.group(2).strip("()").split(",")
matches = [range_rgx.match(e) for e in self.subsets]
self.ranges = [parse_range(e) if e else None for e in matches]
self.all = False
else:
self.ranges = [[0, count-1]]
else:
print(f'The span specification "{spec}" is incorrect.\n\n{syntax_description}\n')
sys.exit(1)
def __str__(self):
subsets = "all" if self.all else ",".join([f"{e[0]}-{e[1]}" for e in self.ranges])
return f"{self.index}[{subsets}]"
def __repr__(self):
return self.__str__()
class Spanset:
def __init__(self, count, span_specs):
self.count = count
self.spans = {}
for specs in span_specs.split():
for spec in self.parse_spec(specs):
print("Spanset spec:", spec)
self.spans[spec.index] = spec
def parse_spec(self, spec):
named_spec = {"top" : ["0"],
"bottom" : [str(self.count-1)],
"head" : ["1"],
"outer" : ["0", str(self.count-1)],
"inner" : [str(e) for e in range(1,self.count-1)],
"all" : [str(e) for e in range(0,self.count+1)]
}.get(spec)
if named_spec is None:
return [Span(self.count, spec)]
else:
return [Span(self.count, e) for e in named_spec]
if __name__ == "__main__":
import pprint
for s in [
Span(10, "0"),
Span(10, "1"),
Span(10, "2(2)"),
Span(10, "3(2-)"),
Span(10, "4(2-4)"),
Span(10, "5(2-4)"),
Span(10, "6(2-4,6)"),
Span(10, "7(2,6-8)"),
Span(10, "8(2,7-8,9-11,13-14)")]:
print(s.spec, "->", s)
print("Spanset")
S = Spanset(10, "1")
for name in "top bottom head outer inner all 1(2-3)".split():
print(name, "->", S.parse_spec(name))
print("Instantiate:")
s = Spanset(10, "3(1-2,4-5)")
print(s.tex_hline(3))

View File

@@ -1,63 +1,165 @@
#[ f: 0->1; n: 1.. (integer)
l, c, r
[<numeric-size>] <width> <justification>
width is widest line in a cell f [default]
width is fraction of table <f>t
width is specific length <n>pt|px|in|cm [...not portable?]
width width is remaining (evenly divided) *
]#
# Table argument types and klammer declaration
@@@argtype table_hpos |
@@@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.
Example: (1-4,6-9)
:pattern \((?^:\d+(?^:-\d*)?)(?^:,\d+(?^:-\d*)?)*\)
@@@
@@@argtype indexed_range |
an index with optional subsets, written with no spaces. The index part is a
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:
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
:pattern \d+(?^:-\d*)?(?^:'index_subsets')?
@@@
@@@argtype column_width |
width of the table columns. Each column is one of 'fit' (widest line of the
cells in that column), a fraction 0.0->1.0 (that fraction of the total table
width), or '*' (use the remaining width of the table; there can only be one
column with '*'). If there are fewer positions than columns in the table, the
last value is repeated. Extra positions generate a warning.
:pattern (fit^|f^|0?\.\d+^|\*^|\s+)+
:python_cast (lambda s : s.split())
:default fit
@@@
@@@argtype cell_hpos |
horizontal formatting in a table cell. One of 'l', 'c' or 'r' for each
cell in a row. If there are fewer positions than cells in a row, the
last value is repeated. Extra positions generate a warning. Default is 'l'
# :pattern ((f^|(0?\.'uint't^|\\*))[lcr]?^|\s)+
# :pattern ((f^|'float't^|\\*)[lcr]?^|\s)+
:pattern ([.\w]+^|\*^|\s+)+
#:pattern (l^|c^|r^|\s)+
last value is repeated. Extra positions generate a warning.
:pattern (l^|c^|r^|\s+)+
:python_cast (lambda s : s.split())
:default l
@@@
@@@argtype table_hline |
a table's horizontal line description; one or more of 'top',
'head', 'inner', 'bottom', or a row number for a line at the bottom
of that row
#:pattern (top^|head^|inner^|bottom^|\d+^|\d+:\(\d+\-\d+\)^|\s+)*
#:python_cast (lambda s : s.split())
a table's horizontal lines, as one or more whitespace-separated items.
With N rows there are N+1 horizontal boundaries, numbered 0 to N from the
top; boundary i lies above row i, and boundary N is the bottom. An item
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:
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
:pattern ((?^:top^|head^|inner^|bottom^|all)(?^:'index_subsets')?^|'indexed_range'^|\s+)+
:python_cast (lambda s : s.split())
@@@
@@@argtype table_vline |
a table's vertical line description; one or more of 'outer', 'inner,
or a column number for a line at the right of that column
# :pattern (outer^|inner^|\d+^|\d+^|\d+:\(\d+\-\d+\)^|\s+)*
# :python_cast (lambda s : s.split())
a table's vertical lines, as one or more whitespace-separated items.
With M columns there are M+1 vertical boundaries, numbered 0 to M from
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:
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+)+
:python_cast (lambda s : s.split())
@@@
@@@argtype table_span |
a list of spans in a table in the form (X,Y):N (no spaces), where
(X,Y) is the position in the table (zero origin in the top left
corner) and N is the number of columns or rows in the span
#:pattern (\(\d+(?:-\d+)?,\d+(?:-\d+)?\):\d+\s*)*
#:python_cast (lambda s : s.split())
a list of cell spans, each an indexed_range whose index selects the row
(for colspan) or the column (for rowspan), and whose parenthesized subset
gives the zero-origin range of cells to merge. An index range repeats
the same span; several subsets make several spans. Examples for colspan:
1(2-4) in row 1, merge columns 2 through 4
1(0-1,3-5) two merges in row 1
2-4(0-1) the same merge in rows 2 through 4
:pattern ('indexed_range'^|\s+)+
:python_cast (lambda s : s.split())
@@@
@@@argtype table_calc |
calculations that fill table cells with computed values, separated by
semicolons. Each calculation has the form
<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
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:
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*$))+
@@@
@@@argtype decimal_mark |
the character used as the decimal mark in numeric cell values, either
'period' (1,234.56) or 'comma' (1.234,56). Governs both the reading of
numbers from cells in table calculations and the formatting of
calculated values.
:pattern period^|comma
: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+
@@@
@@rowcolor.tex s : \colorrow{*s*} @@
@@table rows.rest
@@table rows.rest(2)
:id
@caption_arguments@
:center.bool true
:indent.length 1em
:header.bool true
:allow_break.bool false
:hline.table_hline
:column_width.column_width
:hline.table_hline
:vline.table_vline
:grid.bool false
:cell_hpos.table_hpos c
:cell_hpos.cell_hpos
:header_font.font i
:font.font_list r
:font.font_list
:colspan.table_span
:rowspan.table_span
:calc.table_calc
:calc_format.format_spec
:decimal.decimal_mark
:leading.float 1.3
:colsep 4pt
:

View File

@@ -10,13 +10,13 @@ import klammer_base
import html_util
from html_util import E
import latex_util
from sequences import Sequences
from indexed_range import Indexed_ranges, hline_names, vline_names
import table_cell
import font
def extend(lst, count, fill=None):
if isinstance(lst, str):
lst = lst.strip().split("\\s+")
lst = lst.split()
if fill is None:
fill = lst[-1] if lst else ""
return lst + ([fill] * (count - len(lst)))
@@ -44,34 +44,154 @@ class Table(klammer_base.Klammer_base):
id = 0
def __init__(self, K):
super().__init__(K)
# pprint.pprint(self.__dict__)
if self.grid:
self.vline = "all"
self.hline = "all"
self.number = self.number == "true"
self.rows = kutil.rest_args(self.rows, 2)
self.vline = ["all"]
self.hline = ["all"]
self.row_count = len(self.rows)
if self.header:
self.hline += f" 1 {self.row_count}"
self.hline += ["1", str(self.row_count)]
self.row_size = max([len(e) for e in self.rows])
self.s_vline = Sequences(self.row_size + 1, self.row_count - 1, self.vline)
self.s_hline = Sequences(self.row_count + 1, self.row_size - 1, self.hline)
self.s_rowspan = Sequences(self.row_size, self.row_count, self.rowspan)
self.s_colspan = Sequences(self.row_count, self.row_size, self.colspan)
self.cell_hpos = extend(parse_hpos(self.K_target, self.cell_hpos.split()), self.row_size)
# 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,
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")
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.make_cells(self.rows)
def span_count(self, span_seq, row_i, col_i):
result = 0
row_seq = span_seq[row_i]
if row_seq:
for range in row_seq.ranges:
if range[0] == col_i:
result = range[1] - range[0] + 1
# 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.
calc_target_rgx = re.compile(r"(\d+)\((\d+)\)$")
calc_cell_rgx = re.compile(r"\d+(-\d*)?\(")
def calc_error(self, calc, message):
raise Exception(f'In the :calc calculation "{calc}": {message}')
def parse_number(self, text, ref, calc):
s = text.strip()
if self.decimal == "comma":
s = s.translate(str.maketrans(",.", ".,"))
s = s.replace(",", "") # Remove thousands separators
try:
return float(s)
except ValueError:
self.calc_error(
calc, f'the cell {ref} contains "{text.strip()}", '
"which is not a number")
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)
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 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]
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 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)
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 remove_redundant_borders(self):
remove_right = []
for row_i in range(self.row_count):
@@ -83,66 +203,76 @@ class Table(klammer_base.Klammer_base):
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
# italic cell is measured in the font it will be set in.
self.column_widths = []
# print("column_width_text:", len(self.cells), len(self.cells[0]))
for col_i in range(len(self.cells[0])):
longest = ""
longest_font = "r"
for row_i in range(len(self.cells)):
cell = self.cells[row_i][col_i]
if cell is not None:
cell_text = cell.text
#lines = [e.strip() for e in cell_text.split("\\newline")]
lines = [e.strip() for e in cell_text.split("\newline")]
# 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_font = cell.font
if longest_font != "r":
longest = font.tex_fontify(longest, longest_font, 1.0)
self.column_widths.append(longest)
# kutil.msg("column_widths:")
# print(self.column_widths)
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, row_i, cell_i)
rspan = self.span_count(self.s_rowspan, cell_i, row_i)
cspan = self.span_count(self.s_colspan, row_i, cell_i)
font = self.font[cell_i]
if row_i == 0 and self.header:
font = self.header_font
# 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)
row_cells.append(
table_cell.Cell(
cell,
font, self.cell_hpos[cell_i],
self.s_hline.has(row_i, cell_i),
self.s_vline.has(cell_i+1, row_i),
self.s_hline.has(row_i+1, 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.sequences.get(cell_i),
self.s_vline.sequences.get(cell_i+1),
rspan, cspan))
self.s_vline.by_index.get(cell_i),
self.s_vline.by_index.get(right_i),
rspan, cspan,
first_column=(cell_i == 0)))
cells.append(row_cells)
widths = [len(e) for e in cells]
max_width = max(widths)
self.cells = [extend(row, max_width, None) for row in cells] \
if max_width != min(widths) else cells
self.cells = cells
self.column_width_text()
# HTML
def html(self):
result = ""
for row in self.cells:
for row_i, row in enumerate(self.cells):
row_html = ""
for cell in row:
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()
result = E("table").body(result)
if self.number or self.caption:
result = html_util.add_caption(
result, "Table", self.number, self.caption, self.caption_font)
result, "Table", self.number, self.caption, self.caption_font,
side=self.caption_side, font_size=self.caption_font_size)
else:
result = result.str()
return result
@@ -150,6 +280,9 @@ class Table(klammer_base.Klammer_base):
# LaTeX
def tex_hpos(self):
# One column specification per column: the width comes from
# :column_width ('fit', a fraction of \tablewidth, or '*' for the
# remaining width), the justification from :cell_hpos.
def par_format(s, justification):
command = {"l" : "raggedright",
"c" : "centering",
@@ -157,68 +290,26 @@ class Table(klammer_base.Klammer_base):
return f">{{\\{command}}}p{{{s}}}"
widths = []
hpos_pat = re.compile("(f|(?:0?(\\.\\d+)(t))|\\*)?([lcr]?)")
def parse(s, width_text):
match = hpos_pat.match(s)
# print("MATCH:", match, match.groups())
width, frac, table, just = match.groups()
width = width or "f"
just = just or "l"
if width and width[0] == "{":
width = f"\\widthof{{{s}}}"
elif table == "t":
width = f"{frac}\\tablewidth"
elif width == "f":
width = f"\\widthof{{{width_text}}}"
if width != "*":
widths.append(width)
result = par_format(width, just) if width != "*" else s
# print("PARSE:", result)
return result
# print("self.column_widths:", len(self.column_widths), self.column_widths)
# return extend([parse(e) for e in self.cell_hpos], self.row_size)
hpos_list = []
for i, hpos in enumerate(extend(self.cell_hpos, self.row_size)):
# print(f" Loop {i}:", hpos)
if i >= len(self.column_widths):
print(f"Warning: Ignoring table column width: {hpos}")
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 == "*":
widths.append(None)
else:
hpos_list.append(parse(hpos, self.column_widths[i]))
# print("hpos_list:", hpos_list)
fill_count = sum([1 if "*" in e else 0 for e in hpos_list])
# print("fill_count:", fill_count)
widths.append(f"{w}\\tablewidth")
fill_count = widths.count(None)
if fill_count > 0:
margins = f"(\\tabcolsep * {2 * len(hpos_list)})"
# print("MARGINS:", margins)
#expr = "\\linewidth - " + " - ".join(widths) + str("
if fill_count == len(hpos_list):
expr = f"{1/fill_count}\\tablewidth"
fixed = [e for e in widths if e is not None]
if fixed:
expr = f"(\\tablewidth - {' - '.join(fixed)}) / {fill_count}"
else:
#expr = f"(\\textwidth - {margins} - {' - '.join(widths)}) / {fill_count}"
expr = f"(\\tablewidth - {' - '.join(widths)}) / {fill_count}"
# print(expr)
result = []
for h in hpos_list:
if h[0] == "*":
just = h[1] if len(h) > 1 else "l"
result.append(par_format(expr, just))
else:
result.append(h)
else:
result = hpos_list
# print("tex_hpos:", result)
return result
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.sequences:
for i in self.s_vline.by_index:
parts[i * 2] = "|"
for i, hpos in enumerate(self.tex_hpos()):
parts[i * 2 + 1] = hpos
@@ -226,30 +317,42 @@ class Table(klammer_base.Klammer_base):
return "".join(parts)
def tex_hline(self, index):
hline = ""
# Contiguous cell borders coalesce into single \cline runs; a
# full-width line becomes \hline.
bottom = index == self.row_count
if bottom:
index -= 1
count = 0
for i, cell in enumerate(self.cells[index]):
has_border = cell.border.bottom if bottom else cell.border.top
if has_border:
hline += f"\\cline{{{i+1}-{i+1}}} "
count += 1
#if count == self.row_size:
# hline = "\\hline"
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)
tab = ""
parts = []
for cell_i, cell in enumerate(row):
result += tab + cell.tex()
tab = " & "
#result += " \\\\\n"
result += " \\tabularnewline\n"
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
@@ -277,9 +380,7 @@ class Table(klammer_base.Klammer_base):
def tex(self):
result = self.get_width()
result += "\\vspace*{-.75\\baselineskip}"
# result = ""
result += "\\renewcommand*{\\arraystretch}{1.3}\n"
result += f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n"
if self.allow_break:
result += "\\vspace*{12pt}\n"
result += "\\begin{longtable}{"
@@ -289,20 +390,29 @@ class Table(klammer_base.Klammer_base):
result += self.make_caption()
result += self.tex_rows()
result += "\\end{longtable}\n"
if not self.allow_break:
if self.number or self.caption:
result = latex_util.add_caption(result, "Table", self.number, self.caption, "\\tablewidth")
result = latex_util.add_caption(
result, "Table", self.number, self.caption, "\\tablewidth",
side=self.caption_side, font_symbol=self.caption_font,
font_size=self.caption_font_size)
else:
result = latex_util.caption_wrapper(result, "center")
name = f"Reference-Table-{Table.id}"
Table.id += 1
result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}"
result = f"\\setlength{{\\tablewidth}}{{\\textwidth - {2 * self.row_count}\\tabcolsep}}\n" + result
# result += "\\vspace*{-8pt}"
# 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"
f"\\setlength{{\\tablewidth}}{{\\textwidth - {2 * self.row_size}\\tabcolsep}}\n"
+ result)
result = re.sub(r"\newline", r"\\\\", result)
return result
def txt(self):
return "TXT"
return "Table in .txt format not implemented"

View File

@@ -1,19 +1,25 @@
import re
import border
import font
import html_util
from html_util import E
# A purely numeric cell value (either decimal-mark style, optional sign).
number_rgx = re.compile(r"[-+]?[\d.,]+$")
class Cell:
def __init__(self, text, font, hpos,
top, right, bottom, left,
left_all, right_all,
rowspan, colspan):
rowspan, colspan, first_column=False):
self.text = text
self.font = font
self.hpos = hpos
self.border = border.Border(top, right, bottom, left, left_all, right_all)
self.rowspan = rowspan
self.colspan = colspan
self.first_column = first_column
#print("Cell:", text, hpos)
def __str__(self):
@@ -30,6 +36,10 @@ class Cell:
def html(self):
result = self.text
result = E("td").body(font.html_fontify(result, self.font, 1.0))
if self.rowspan > 1:
result.attr("rowspan", self.rowspan)
if self.colspan > 1:
result.attr("colspan", self.colspan)
for pred, cls_name in zip(self.border.has(), "Bt Br Bb Bl".split()):
if pred:
result.cls(cls_name)
@@ -38,8 +48,26 @@ class Cell:
def tex(self, debug=False): # , left_line, right_line):
result = self.text
# A number must not line-break (LaTeX breaks after a minus sign
# read as a hyphen in narrow fit-width columns).
if number_rgx.match(result.strip()):
result = f"\\mbox{{{result.strip()}}}"
if self.font != "r":
result = font.tex_fontify(result, self.font, 1.0)
if self.rowspan > 1:
result = f"\\multirow{{{self.rowspan}}}{{*}}{{{result}}}"
if self.colspan > 1:
# \multicolumn carries the merged cell's own column spec. A
# left bar may only be given when the span starts at the
# table's first column: elsewhere the bar to the left belongs
# to the preceding column's preamble entry, and adding one
# here draws a doubled line.
pos = self.hpos
if self.border.left and self.first_column:
pos = "|" + pos
if self.border.right:
pos = pos + "|"
return f"\\multicolumn{{{self.colspan}}}{{{pos}}}{{{result}}}"
remove_left = self.border.left_all and not self.border.left
remove_right = self.border.right_all and not self.border.right
if debug: