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