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).
419 lines
17 KiB
Python
419 lines
17 KiB
Python
import functools
|
|
import collections
|
|
import re
|
|
import sys
|
|
import traceback
|
|
import pprint
|
|
|
|
import kutil
|
|
import klammer_base
|
|
import html_util
|
|
from html_util import E
|
|
import latex_util
|
|
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.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)
|
|
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)]
|
|
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,
|
|
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)
|
|
|
|
# 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):
|
|
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
|
|
# 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_font = "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_font = cell.font
|
|
if longest_font != "r":
|
|
longest = font.tex_fontify(longest, longest_font, 1.0)
|
|
self.column_widths.append(longest)
|
|
|
|
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.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(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)))
|
|
cells.append(row_cells)
|
|
self.cells = cells
|
|
self.column_width_text()
|
|
|
|
# HTML
|
|
|
|
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()
|
|
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,
|
|
side=self.caption_side, font_size=self.caption_font_size)
|
|
else:
|
|
result = result.str()
|
|
return result
|
|
|
|
# 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",
|
|
"r" : "raggedleft"}[justification]
|
|
return f">{{\\{command}}}p{{{s}}}"
|
|
|
|
widths = []
|
|
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:
|
|
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] = "|"
|
|
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_font, self.caption_font_size)
|
|
result = "\\multicolumn{" + column_count + "}{c}{\\parbox{\\tableboxwidth}{"
|
|
result += "\\vspace*{8pt}\\centering\\small\\em "
|
|
result += caption
|
|
result += "}} \\\\ \\endlastfoot\n"
|
|
return result
|
|
|
|
def tex(self):
|
|
result = self.get_width()
|
|
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:
|
|
if self.number or self.caption:
|
|
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}"
|
|
# 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 "Table in .txt format not implemented"
|
|
|