From 4262fc61361d0132dba3686b3b39f931400cb371 Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Fri, 24 Jul 2026 21:37:58 +0200 Subject: [PATCH] Offer-motivated features: :hline defaults, ranged :hpos, @date :days, :bottom none - @table: a writer's :hline/:vline replaces the default lines; new boundary name 'none' removes all lines - @table: ranged :hpos argument overrides :cell_hpos per cell (\multicolumn{1} in tex; positions a colspan anchor's merged cell) - @date/@datetime: :days offset argument (sks/date/date.py) - @document: ":bottom none" suppresses the footer (\pagestyle{empty}) Also carries the escape-system generator/renderer fixes, per-cell-range :format, and uppercase .TTF/.OTF font recognition from klammertext-dev. --- .gitignore | 1 + mac/.clang-tidy | 83 ++++++++ mac/eval.cpp | 14 +- mac/font_store.cpp | 19 +- mac/klammer.cpp | 2 + mac/klammer.h | 3 + mac/machine.cpp | 60 ++++++ sks/date/date.k | 21 +- sks/date/date.py | 17 ++ sks/table/indexed_range.py | 71 ++++--- sks/table/table.k | 93 ++++++--- sks/table/table.py | 397 +++++++++++++++++++++++++++++-------- sks/table/table_cell.py | 7 +- sks/target/latex_util.cpp | 15 +- 14 files changed, 657 insertions(+), 146 deletions(-) create mode 100644 mac/.clang-tidy create mode 100644 sks/date/date.py diff --git a/.gitignore b/.gitignore index 384fdae..b2a1064 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.o *.d *.so +__pycache__/ # Editor / OS cruft *~ diff --git a/mac/.clang-tidy b/mac/.clang-tidy new file mode 100644 index 0000000..5c38c87 --- /dev/null +++ b/mac/.clang-tidy @@ -0,0 +1,83 @@ +Checks: > + *, + -boost-*, + -fuchsia-*, + -google-*, + -zircon-*, + -abseil-*, + -modernize-use-trailing-return-type, + -llvmlibc-*, + -altera-*, + -android-*, + -objc-*, + -fuchsia-*, + -hicpp-*, + -misc-*, + -performance-*, + -portability-*, + -readability-*, + -bugprone-*, + -cert-*, + -clang-analyzer-*, + -cppcoreguidelines-*, + -google-*, + -hicpp-*, + -llvm-*, + -llvmlibc-*, + -misc-*, + -modernize-*, + -performance-*, + -portability-*, + -readability-*, + -zircon-* + +WarningsAsErrors: '' +HeaderFilterRegex: '.*' +FormatStyle: none +CheckOptions: + - key: readability-identifier-naming.ClassCase + value: CamelCase + - key: readability-identifier-naming.ClassMemberCase + value: camelBack + - key: readability-identifier-naming.ConstexprVariableCase + value: CamelCase + - key: readability-identifier-naming.ConstexprVariablePrefix + value: k + - key: readability-identifier-naming.EnumCase + value: CamelCase + - key: readability-identifier-naming.EnumConstantCase + value: CamelCase + - key: readability-identifier-naming.FunctionCase + value: camelBack + - key: readability-identifier-naming.GlobalConstantCase + value: CamelCase + - key: readability-identifier-naming.GlobalConstantPrefix + value: k + - key: readability-identifier-naming.GlobalVariableCase + value: camelBack + - key: readability-identifier-naming.GlobalVariablePrefix + value: g_ + - key: readability-identifier-naming.LocalConstantCase + value: camelBack + - key: readability-identifier-naming.LocalVariableCase + value: camelBack + - key: readability-identifier-naming.MemberCase + value: camelBack + - key: readability-identifier-naming.MemberPrefix + value: m_ + - key: readability-identifier-naming.NamespaceCase + value: lower_case + - key: readability-identifier-naming.PrivateMemberPrefix + value: m_ + - key: readability-identifier-naming.StaticConstantCase + value: CamelCase + - key: readability-identifier-naming.StaticConstantPrefix + value: k + - key: readability-identifier-naming.StaticVariableCase + value: camelBack + - key: readability-identifier-naming.StaticVariablePrefix + value: s_ + - key: readability-identifier-naming.TemplateParameterCase + value: CamelCase + - key: readability-identifier-naming.VariableCase + value: camelBack \ No newline at end of file diff --git a/mac/eval.cpp b/mac/eval.cpp index f53736f..3c7763f 100644 --- a/mac/eval.cpp +++ b/mac/eval.cpp @@ -6,6 +6,7 @@ #include "show.h" #include "katom.h" #include "file.h" +#include #include std::string shell(State state, std::string command, Locator loc) @@ -151,8 +152,19 @@ katom_list Eval::eval(katom_iter begin, katom_iter end) } katom_list result {}; Machine M = m_machine; + size_t before = M.m_katoms.size(); M.read(eval_result); - M.apply(M.m_state.value("K_target"), false, false); + // If the @eval produced more Klammertext -- the read-back result still + // holds klammers to reduce (a "generator", e.g. a Python klammer returning + // a @table call with data) -- then its text is writer content: escape it + // for the target before applying, exactly as typed input is escaped, so a + // "%" in the data becomes "\%". If it produced final target markup (no + // klammers left, a "renderer" such as @table itself emitting \begin{tabular}) + // leave it untouched. ^'...'^ literal spans are skipped by the escape pass, + // so a generator can still carry raw target markup. + bool has_klammer = std::any_of( + M.m_katoms.begin() + before, M.m_katoms.end(), begin_apply); + M.apply(M.m_state.value("K_target"), false, has_klammer); result = trim(M.m_katoms); return result; } diff --git a/mac/font_store.cpp b/mac/font_store.cpp index e0150e5..3326aef 100644 --- a/mac/font_store.cpp +++ b/mac/font_store.cpp @@ -24,6 +24,18 @@ namespace fs = std::filesystem; +// A filesystem extension lowercased for case-insensitive matching, so a +// font named Foo.TTF or Foo.OTF is recognized like Foo.ttf / Foo.otf. +static std::string lower_extension(const fs::path& path) +{ + std::string ext = path.extension(); + for (char& c : ext) { + c = std::tolower(static_cast(c)); + } + return ext; +} + + std::string name_to_dirname(std::string name) { std::string result {}; @@ -275,7 +287,10 @@ Font_file classify_font_file(const std::string& path) { Font_file file {}; file.path = path; - file.extension = fs::path(path).extension(); + // Normalize to lowercase so the installed filename, its @font-face url, + // and the truetype/opentype format detection are uniform regardless of + // how the source file's extension was capitalized. + file.extension = lower_extension(path); std::string d = read_binary_file(path); if (d.size() < 12) { @@ -372,7 +387,7 @@ std::vector classify_font_files(const std::string& directory) "The font directory \"" + directory + "\" does not exist"); } for (auto& entry : fs::recursive_directory_iterator(directory)) { - std::string ext = entry.path().extension(); + std::string ext = lower_extension(entry.path()); if (entry.is_regular_file() && (ext == ".ttf" || ext == ".otf")) { result.push_back(classify_font_file(entry.path())); } diff --git a/mac/klammer.cpp b/mac/klammer.cpp index 136efa3..76352e5 100644 --- a/mac/klammer.cpp +++ b/mac/klammer.cpp @@ -214,6 +214,7 @@ void Klammer::copy_components( } for (auto c : cs) { m_body[c.target] = c.body; + m_body_generic[c.target] = (c.target == Target_set::general_name); m_varmap[c.target] = c.varmap; } } @@ -287,6 +288,7 @@ void Klammer::copy_general_klammer_to_undefined(Target_set targets) if (m_body.count(target_name) == 0 && target_name != Target_set::declare_name) { // std::cout << " Copying to " << target_name << "\n"; m_body[target_name] = body; + m_body_generic[target_name] = true; // general body -> writer content m_defloc[target_name] = loc; m_varmap[target_name] = varmap; } diff --git a/mac/klammer.h b/mac/klammer.h index 81e4caf..e6e4628 100644 --- a/mac/klammer.h +++ b/mac/klammer.h @@ -86,6 +86,9 @@ public: std::map m_defloc {}; // target -> Locator std::map m_defmode {}; // target -> defmode target_variable_map_t m_varmap {}; // target -> map: variable -> index + // target -> true if this target's body came from a general ("*") definition + // (writer content, subject to target escaping) vs a target-specific one. + std::map m_body_generic {}; }; std::string klammer_name_from_katom(std::string s, Locator loc); diff --git a/mac/machine.cpp b/mac/machine.cpp index 7d8ebb1..f6d7039 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -413,6 +413,66 @@ katom_list Machine::apply_klammer( result[i].m_type = katom_t::text; } } + // Escape target-specific characters (e.g. tex "&" -> "\&") in the writer + // text of a GENERAL klammer's body. Runs BEFORE process_katoms/apply() + // below expand the body, so that target-native markup pulled in by nested + // klammers (e.g. nl.tex -> "\newline") is left untouched -- only this + // klammer's own literal writer text is escaped here; nested klammers escape + // theirs when they are applied in turn. Bodies from target-specific + // definitions (m_body_generic[target] == false) are already in target form + // and skipped. KTESC markers are idempotent, so text already escaped at the + // top level passes through unchanged. Two kinds of body content are NOT + // writer text and must be skipped: + // * ^'...'^ literal spans -- raw target markup the writer typed directly. + // At this point they are typed literal_begin/literal_end with plain-text + // content (the literal phase runs in process_katoms, below), so track + // span depth rather than testing katom type. + // * @eval / @read / @cond argument spans -- code, filenames, and + // predicates consumed by the primitive, NOT emitted as target text. + // (Escaping an underscore in "offer.Price_list(K)" broke @eval.) The + // primitive's KLAMMERTEXT result, produced by process_katoms below, is + // klammer output and is likewise never escaped -- it is inserted after + // this pass and so is untouched, matching the top-level behavior where + // @eval is resolved before the escape pass runs. + auto gen = klammer.m_body_generic.find(target); + if (gen != klammer.m_body_generic.end() && gen->second) { + Target tgt = m_targets.get(target, Locator()); + if (!tgt.m_escapes.empty()) { + int literal_depth = 0; + int code_depth = 0; // inside an @eval/@read/@cond span + std::vector apply_is_code; // one entry per open application + for (auto& k : result) { + if (k.m_type == katom_t::literal_begin) { ++literal_depth; continue; } + if (k.m_type == katom_t::literal_end) { + if (literal_depth > 0) --literal_depth; + continue; + } + if (k.m_type == katom_t::eval_begin || + k.m_type == katom_t::read_begin || + k.m_type == katom_t::cond_begin) { + apply_is_code.push_back(true); + ++code_depth; + continue; + } + if (k.m_type == katom_t::apply_begin) { + apply_is_code.push_back(false); + continue; + } + if (k.m_type == katom_t::apply_end) { + if (!apply_is_code.empty()) { + if (apply_is_code.back()) --code_depth; + apply_is_code.pop_back(); + } + continue; + } + if (literal_depth == 0 && code_depth == 0 && + (k.m_type == katom_t::text || + k.m_type == katom_t::word || + k.m_type == katom_t::newline)) + k.m_text = tgt.escape_text(k.m_text); + } + } + } process_katoms(result, klammer.m_name); apply(m_klammers, result, target); m_state.close_frame(); diff --git a/sks/date/date.k b/sks/date/date.k index ebfb014..43c17b8 100755 --- a/sks/date/date.k +++ b/sks/date/date.k @@ -1,11 +1,18 @@ -@@date.k : Date formatted as "16 June 1910" @@ -@@date.html :: @eval time.strftime("%d %B %Y").lstrip('0') eval@ @@ -@@date.tex :: @eval time.strftime("%d %B %Y").lstrip('0') eval@ @@ -@@date.txt :: @eval time.strftime("%d %B %Y").lstrip('0') eval@ @@ +@@date.k :days.int 0 : + Date formatted as "16 June 1910", offset by ^:days days from today + (^:days 1 is tomorrow, ^:days -1 is yesterday) +@@ +@@date.html :: @eval date.date(K) eval@ @@ +@@date.tex :: @eval date.date(K) eval@ @@ +@@date.txt :: @eval date.date(K) eval@ @@ -@@datetime.k : Date formatted as "16 June 1910, 13:10 @@ -@@datetime.html :: @eval time.strftime("%d %B %Y, %H:%M").lstrip('0') eval@ @@ -@@datetime.tex :: @eval time.strftime("%d %B %Y, %H:%M").lstrip('0') eval@ @@ +@@datetime.k :days.int 0 : + Date and time formatted as "16 June 1910, 13:10", offset by ^:days days + from today (^:days 1 is tomorrow, ^:days -1 is yesterday) +@@ +@@datetime.html :: @eval date.datetime(K) eval@ @@ +@@datetime.tex :: @eval date.datetime(K) eval@ @@ +@@datetime.txt :: @eval date.datetime(K) eval@ @@ @@timestamp.k : Date and time formatted as "1910.06.16-12:34" @@ @@timestamp :: @eval time.strftime("%Y.%m.%d-%H:%M") eval@ @@ diff --git a/sks/date/date.py b/sks/date/date.py new file mode 100644 index 0000000..7b4586f --- /dev/null +++ b/sks/date/date.py @@ -0,0 +1,17 @@ +# Implementation of the @date and @datetime klammers (date.k). Each takes +# the current date and time, offset by the :days argument (positive is +# future, negative is past), and formats it. + +import datetime as dt + + +def offset(days): + return dt.datetime.now() + dt.timedelta(days=days or 0) + + +def date(K): + return offset(K.days).strftime("%d %B %Y").lstrip("0") + + +def datetime(K): + return offset(K.days).strftime("%d %B %Y, %H:%M").lstrip("0") diff --git a/sks/table/indexed_range.py b/sks/table/indexed_range.py index 2854f23..1c0cc55 100644 --- a/sks/table/indexed_range.py +++ b/sks/table/indexed_range.py @@ -25,10 +25,16 @@ index range "2-" (to the last index), or a name defined by the argument commas; each is an index "4", a closed range "1-4", or an open range "6-" (to the end). All indices are zero-origin. +A negative index counts from the end, as in Python: -1 is the last index, +-2 the second to last, and so on. Ranges are inclusive, so "1--2" is index +1 through the second-to-last index (the last index excluded). + Examples: 3 index 3, full extent 2-5(0-2) indices 2 through 5, each restricted to 0 through 2 3(1-4,6-9) index 3, restricted to 1-4 and 6-9 + -1 the last index + 1--2 index 1 through the second-to-last index head(1-) with table hline names: boundary 1, from column 1 on """.strip() @@ -38,8 +44,13 @@ class Range_error(Exception): super().__init__(f"{message}\n\n{syntax_description}") -item_rgx = re.compile(r"(?:(\d+)(-)?(\d*)|([A-Za-z]+))(?:\(([\d,\-]+)\))?$") -subset_rgx = re.compile(r"(\d+)(-)?(\d*)$") +# A numeric selector is a signed integer, optionally followed by a range +# part: a separating hyphen and an optional signed end index (empty end = +# open range). The leading sign lets an index count from the end (-1 is +# the last), matching Python list indexing. The separating hyphen never +# collides with a minus sign because \d+ never consumes it. +item_rgx = re.compile(r"(?:(-?\d+)(-(-?\d+)?)?|([A-Za-z]+))(?:\(([-\d,]+)\))?$") +subset_rgx = re.compile(r"(-?\d+)(-(-?\d+)?)?$") def hline_names(count): @@ -49,7 +60,8 @@ def hline_names(count): "head": [1], "bottom": [last], "inner": list(range(1, last)), - "all": list(range(count))} + "all": list(range(count)), + "none": []} def vline_names(count): @@ -57,7 +69,8 @@ def vline_names(count): last = count - 1 return {"outer": [0, last], "inner": list(range(1, last)), - "all": list(range(count))} + "all": list(range(count)), + "none": []} class Indexed_range: @@ -126,31 +139,36 @@ class Indexed_ranges: argument = f"{self.argument} argument: " if self.argument else "" raise Range_error(f"{argument}{message}") + def normalize(self, raw, spec, count): + """Resolve a possibly-negative index to 0..count-1 (Python-style): + a negative index counts from the end (-1 is the last).""" + i = raw + count if raw < 0 else raw + if not 0 <= i < count: + self.error(f'In "{spec}", index {raw} is out of range ' + f"(0 through {count - 1}, or -1 through -{count}).") + return i + def parse(self, spec): match = item_rgx.match(spec) if not match: self.error(f'"{spec}" is not a valid indexed_range.') - number, hyphen, end, name, subsets = match.groups() + number, range_part, end, name, subsets = match.groups() if name is not None: if name not in self.names: known = " ".join(self.names) or "none" self.error(f'"{name}" is not a valid name here ' f"(valid names: {known}).") indices = self.names[name] + elif range_part is None: + indices = [self.normalize(int(number), spec, self.count)] else: - start = int(number) - if not hyphen: - indices = [start] - else: - last = int(end) if end else self.count - 1 - if start > last: - self.error(f'In "{spec}", the index range start {start} ' - f"is greater than its end {last}.") - indices = list(range(start, last + 1)) - for i in indices: - if i >= self.count: - self.error(f'In "{spec}", index {i} is out of range ' - f"(0 through {self.count - 1}).") + first = self.normalize(int(number), spec, self.count) + last = (self.normalize(int(end), spec, self.count) + if end else self.count - 1) + if first > last: + self.error(f'In "{spec}", the index range start {first} ' + f"is greater than its end {last}.") + indices = list(range(first, last + 1)) ranges = self.parse_subsets(spec, subsets) if subsets else None for i in indices: entry = self.by_index.setdefault(i, Indexed_range(i, self.maxval)) @@ -160,23 +178,24 @@ class Indexed_ranges: entry.add_ranges(ranges) def parse_subsets(self, spec, subsets): + # Subset indices run 0..maxval inclusive, so their count is + # maxval + 1 and a negative subset index resolves against it. + count = self.maxval + 1 ranges = [] for part in subsets.split(","): match = subset_rgx.match(part) if not match: self.error(f'In "{spec}", "{part}" is not a valid subset.') - number, hyphen, end = match.groups() - start = int(number) - if not hyphen: - last = start + number, range_part, end = match.groups() + if range_part is None: + start = last = self.normalize(int(number), spec, count) else: - last = int(end) if end else self.maxval + start = self.normalize(int(number), spec, count) + last = (self.normalize(int(end), spec, count) + if end else self.maxval) if start > last: self.error(f'In "{spec}", the subset start {start} ' f"is greater than its end {last}.") - if last > self.maxval: - self.error(f'In "{spec}", {last} is out of range ' - f"(0 through {self.maxval}).") ranges.append([start, last]) return ranges diff --git a/sks/table/table.k b/sks/table/table.k index 8099c94..30f28ac 100644 --- a/sks/table/table.k +++ b/sks/table/table.k @@ -3,9 +3,10 @@ @@@argtype index_subsets | one or more subsets in parentheses, attached to an index. Each subset is a single index , a closed range -, or an open range - (from to - the end). Several subsets are separated by commas, with no spaces. + the end). A negative index counts from the end (-1 is the last). Several + subsets are separated by commas, with no spaces. Example: (1-4,6-9) - :pattern \((?^:\d+(?^:-\d*)?)(?^:,\d+(?^:-\d*)?)*\) + :pattern \((?^:-?\d+(?^:-(?^:-?\d+)?)?)(?^:,-?\d+(?^:-(?^:-?\d+)?)?)*\) @@@ @@@argtype indexed_range | @@ -13,14 +14,18 @@ single index , a closed index range -, or an open index range - (from to the last index). It may be followed by parenthesized subsets (see the index_subsets type) restricting the extent in the other dimension. - All indices are zero-origin. Examples: + All indices are zero-origin. A negative index counts from the end, as in + Python: -1 is the last index, -2 the second to last. Ranges are inclusive, + so "1--2" is index 1 through the second-to-last index. Examples: 3 index 3, full extent 2-5 indices 2 through 5, full extent 3(1-4,6-9) index 3, restricted to 1 through 4 and 6 through 9 2-5(0-2) indices 2 through 5, each restricted to 0 through 2 + -1 the last index + 1--2 index 1 through the second-to-last index - :pattern \d+(?^:-\d*)?(?^:'index_subsets')? + :pattern -?\d+(?^:-(?^:-?\d+)?)?(?^:'index_subsets')? @@@ @@@argtype column_width | @@ -51,16 +56,19 @@ is either a boundary name or an indexed_range of boundary indices. The names are 'top' (boundary 0), 'head' (boundary 1, under a header row), 'bottom' (boundary N), 'inner' (all boundaries between top and bottom), - and 'all' (every boundary). A name or index may be followed by - parenthesized subsets to draw only part of a line, given as zero-origin - column ranges. Examples: + 'all' (every boundary), and 'none' (no lines). A name or index may be + followed by parenthesized subsets to draw only part of a line, given as + zero-origin column ranges. When ^:hline is given it replaces the default + lines (with a header, 'head' and 'bottom'); ^:hline none removes them. + Examples: top bottom lines above and below the table head(1-) a line under the header, from column 1 to the last 3(1-4,6-9) two partial lines at boundary 3 all every line + none no lines - :pattern ((?^:top^|head^|inner^|bottom^|all)(?^:'index_subsets')?^|'indexed_range'^|\s+)+ + :pattern ((?^:top^|head^|inner^|bottom^|all^|none)(?^:'index_subsets')?^|'indexed_range'^|\s+)+ :python_cast (lambda s : s.split()) @@@ @@ -71,15 +79,15 @@ the left; boundary i lies to the left of column i, and boundary M is the right edge. An item is either a boundary name or an indexed_range of boundary indices. The names are 'outer' (boundaries 0 and M), 'inner' - (all boundaries between them), and 'all' (every boundary). A name or - index may be followed by parenthesized subsets to draw only part of a - line, given as zero-origin row ranges. Examples: + (all boundaries between them), 'all' (every boundary), and 'none' (no + lines). A name or index may be followed by parenthesized subsets to + draw only part of a line, given as zero-origin row ranges. Examples: outer lines at the left and right edges 2(0-3) a line left of column 2, spanning rows 0 through 3 all every line - :pattern ((?^:outer^|inner^|all)(?^:'index_subsets')?^|'indexed_range'^|\s+)+ + :pattern ((?^:outer^|inner^|all^|none)(?^:'index_subsets')?^|'indexed_range'^|\s+)+ :python_cast (lambda s : s.split()) @@@ @@ -105,22 +113,35 @@ = ... - where the target is a single cell written () 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 - (); a range expands to all of its cells in row order, so - "+ 1-2(3)" sums column 3 of rows 1 and 2. A plain number is a constant + where the operator is one of + - * / and each operand is a cell selection + or a number. A cell selection is an indexed_range read as (); + a range expands to all of its cells in row order, so "+ 1-2(3)" sums column 3 + of rows 1 and 2. The SHAPE of the target chooses the operation: a single + cell () folds the operands to one value, while a target that + ranges over rows (0-(2)) or columns (-1(0-)) runs the calculation once per + row or column (a "map"). See notes/calc_notation.md for the map forms, + relative operands, and broadcasting. A plain number is a constant and always uses a period as its decimal mark. Operators fold from the left ("- 1(0-2)" is a minus b minus c); with a single operand, - negates and / gives the reciprocal. Calculations run in the order given, and each reads the values earlier calculations have written, as displayed. - Example: + Negative indices count from the end (see indexed_range), which is handy + when a total sits in the last row: "-1(5) = + 1--2(5)" writes into the + last row of column 5 the sum of that column from row 1 through the row + above it. Example: 1(3) = * 1(1-2) ; 2(3) = * 2(1-2) ; 3(3) = + 1-2(3) - :pattern \s*(\d+\(\d+\)\s*=\s*[-+*/](\s+(\d+(?^:-\d*)?'index_subsets'^|'float'))+\s*(;\s*^|\s*$))+ + # Coarse structural check only -- " = ..." groups + # separated by ";" -- so that a malformed target or operand reaches + # run_calc() in table.py, whose per-token validation gives a precise message + # (e.g. an infix "* a * b" reports that "*" is not a number or a cell + # selection) instead of this whole description being dumped. A token is any + # run of characters other than space, ";", or "=" ("^^" escapes the regex + # class negation "[^...]" so the Klammertext "^" is not consumed). + :pattern \s*([^^\s;=]+\s*=\s*[-+*/](\s+[^^\s;=]+)+\s*(;\s*^|\s*$))+ @@@ @@ -133,10 +154,33 @@ :default period @@@ -@@@argtype format_spec | - a Python format specification applied to calculated cell values, for - example ",.2f" for two decimal places with grouped thousands. - :pattern \S+ +@@@argtype table_hpos | + cell position overrides, as one or more pairs + separated by semicolons (the same list style as ^:calc). is an + indexed_range selecting cells; is l, c, or r and overrides + the column position given by ^:cell_hpos for those cells. A colspan + anchor's override positions the whole merged cell. For example, + "-3--1(3) r" right-justifies the cells in column 3 of the last three + rows. + # Coarse check (" " pairs); hpos_overrides() in + # table.py validates the range and position. + :pattern \s*([^^\s;]+\s+[lcr]\s*(;\s*^|\s*$))+ +@@@ + +@@@argtype table_format | + cell formatting, as one or more pairs separated by + semicolons (the same list style as :calc). is an indexed_range + selecting the cells to format; is a . Python + reference (the same form the eval klammer uses) to a function that takes the + cell's value and the target and returns the formatted text. The function + lives in a module the writer supplies (a currency style is specific to a + document, so the SKS ships none): for example, with a euro() function in + your own module, "0-(5) myformats.euro" formats every cell of column 5 as a + Euro amount. Runs after :calc, so it formats computed values; a cell whose + value is not a number is left unchanged, with a warning. + # Coarse check (" " pairs); apply_formats() in table.py + # validates the range and calls the function. + :pattern \s*([^^\s;]+\s+[^^\s;]+\s*(;\s*^|\s*$))+ @@@ @@rowcolor.tex s : \colorrow{*s*} @@ @@ -153,12 +197,13 @@ :vline.table_vline :grid.bool false :cell_hpos.cell_hpos + :hpos.table_hpos :header_font.font i :font.font_list :colspan.table_span :rowspan.table_span :calc.table_calc - :calc_format.format_spec + :format.table_format :decimal.decimal_mark :leading.float 1.3 :colsep 4pt diff --git a/sks/table/table.py b/sks/table/table.py index af0a022..fe6007a 100644 --- a/sks/table/table.py +++ b/sks/table/table.py @@ -1,5 +1,6 @@ import functools import collections +import importlib import re import sys import traceback @@ -14,6 +15,15 @@ from indexed_range import Indexed_ranges, hline_names, vline_names import table_cell import font +# ---- :format functions --------------------------------------------------- +# A :format function takes (value, target) and returns the formatted cell +# text. It is named . 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 +# ".euro" in :format) because a specific currency style is a property +# of that document, not of the SKS. A function should emit period-decimal +# numbers; apply_formats() applies the :decimal comma swap. + def extend(lst, count, fill=None): if isinstance(lst, str): lst = lst.split() @@ -44,18 +54,18 @@ class Table(klammer_base.Klammer_base): id = 0 def __init__(self, K): super().__init__(K) + self.row_count = len(self.rows) if self.grid: self.vline = ["all"] self.hline = ["all"] - self.row_count = len(self.rows) - if self.header: - self.hline += ["1", str(self.row_count)] + elif not self.hline and self.header: + # Default lines: under the header row and at the bottom. A + # writer's own :hline replaces them (":hline none" = no lines). + self.hline = ["1", str(self.row_count)] self.row_size = max([len(e) for e in self.rows]) # Rows with fewer cells than the widest row are padded with empty # cells (last-value duplication is for argument lists, not content). self.rows = [row + [""] * (self.row_size - len(row)) for row in self.rows] - if self.calc: - self.calculate() self.s_vline = Indexed_ranges(self.row_size + 1, self.row_count - 1, self.vline, vline_names(self.row_size + 1), ":vline") self.s_hline = Indexed_ranges(self.row_count + 1, self.row_size - 1, self.hline, @@ -64,25 +74,58 @@ class Table(klammer_base.Klammer_base): argument=":rowspan") self.s_colspan = Indexed_ranges(self.row_count, self.row_size - 1, self.colspan, argument=":colspan") + # :calc runs after the span structures exist so calculate() can warn + # when a target lands in a cell hidden by a colspan/rowspan merge; + # :format runs after :calc so it formats the computed values. + if self.calc: + self.compute_coverage() + self.calculate() + if self.format: + self.apply_formats() self.cell_hpos = extend(parse_hpos(self.K_target, self.cell_hpos), self.row_size) #self.cell_hpos = self.cell_hpos.split(";") self.font = extend(self.font, self.row_size) + self.hpos_map = self.hpos_overrides() if self.hpos else {} self.make_cells(self.rows) - # Calculated cell values (:calc). Calculations run in the order given; - # each reads cell values as displayed (display-precision semantics), so - # a printed total always equals the sum of the printed lines. Only - # calculation targets are formatted (:calc_format); other cells keep - # their authored text. Future operators to consider: min, max, mean, - # and a per-calculation format override. + # Calculated cell values (:calc). A calculation is + # = ... (prefix operator: + - * /) + # and the SHAPE of the target selects the operation: + # * single cell r(c) -> FOLD: the operands collapse to one value. + # * row range R(c) -> horizontal MAP: run once per row in R. + # * column range r(C) -> vertical MAP: run once per column in C. + # In a map the target's ranged axis iterates; an operand's aligned axis + # iterates in lockstep and any other range in it folds. A relative + # operand omits the iterated axis ("(col)" in a row map; bare "rows" in a + # column map); a constant or a single fixed cell broadcasts. See + # notes/calc_notation.md. Calculations run in order, each reading values + # as displayed (display-precision); :format styles the results. + # Future operators to consider: min, max, mean. - calc_target_rgx = re.compile(r"(\d+)\((\d+)\)$") - calc_cell_rgx = re.compile(r"\d+(-\d*)?\(") + # A single-axis selector: an index or an inclusive range; negatives count + # from the end (-1 = last). A target/operand token is (), + # , or () -- the last two are the relative operand forms. + selector_rgx = re.compile(r"^(-?\d+)(-(-?\d+)?)?$") + operand_rgx = re.compile( + r"^(?P-?\d+(?:-(?:-?\d+)?)?)?(?:\((?P[-\d,]+)\))?$") def calc_error(self, calc, message): raise Exception(f'In the :calc calculation "{calc}": {message}') - def parse_number(self, text, ref, calc): + def calc_warn(self, calc, message): + # Non-fatal: the value is still computed and stored (a covered target + # may be read as an operand by a later calc), it just is not rendered. + print(f'Warning: in the :calc calculation "{calc}": {message}', + file=sys.stderr) + + def selector_error(self, ctx, message): + # ctx is the caller's prefix, e.g. 'In the :calc calculation "..."' or + # 'In :format "..."', so the same selector parser serves both. + raise Exception(f"{ctx}: {message}") + + def to_number(self, text): + # A displayed cell value as a float, honoring :decimal and thousands + # separators; None (no error raised) when it is not a number. s = text.strip() if self.decimal == "comma": s = s.translate(str.maketrans(",.", ".,")) @@ -90,44 +133,129 @@ class Table(klammer_base.Klammer_base): try: return float(s) except ValueError: - self.calc_error( - calc, f'the cell {ref} contains "{text.strip()}", ' - "which is not a number") + return None - def format_number(self, value, calc): - if self.calc_format: - try: - s = format(value, self.calc_format) - except ValueError: - self.calc_error( - calc, f'"{self.calc_format}" is not a valid ' - "format specification") - elif value.is_integer(): - s = str(int(value)) - else: - s = str(value) + def parse_number(self, text, ref, calc): + v = self.to_number(text) + if v is None: + # Unescape KTESC markers so the message shows the character the + # writer typed (e.g. "$5") rather than "KTESC0024KTESC5". + self.calc_error( + calc, "the cell {} contains \"{}\", which is not a number" + .format(ref, klammer_base.unescape_ktesc(text.strip()))) + return v + + def format_number(self, value): + # Calc results are stored as plain numbers; :format does any styling. + s = str(int(value)) if value.is_integer() else str(value) if self.decimal == "comma": s = s.translate(str.maketrans(",.", ".,")) return s - def operand_values(self, token, calc): - # A token with subsets is a cell selection; a bare number is a - # constant (always period-decimal, independent of :decimal). - if not self.calc_cell_rgx.match(token): - return [float(token)] - selection = Indexed_ranges(self.row_count, self.row_size - 1, - [token], argument=":calc") - values = [] - for row_i in selection.by_index: - for _, col_i in selection.by_index[row_i].items(): - values.append(self.parse_number( - self.rows[row_i][col_i], f"{row_i}({col_i})", calc)) - return values + def calc_norm(self, i, count, spec, ctx): + # Resolve a possibly-negative index; -1 is the last, like Python. + j = i + count if i < 0 else i + if not 0 <= j < count: + self.selector_error( + ctx, f'in "{spec}", index {i} is out of range ' + f"(0 through {count - 1})") + return j + + def calc_selectors(self, spec, count, ctx): + # "3", "0-2", "0--2", "-1", or a comma-separated list of those, to a + # list of indices in written order (order matters for a left fold). + result = [] + for part in spec.split(","): + m = self.selector_rgx.match(part) + if not m: + self.selector_error(ctx, f'"{part}" is not a valid selector') + start = self.calc_norm(int(m.group(1)), count, part, ctx) + if m.group(2) is None: + result.append(start) + continue + end = (self.calc_norm(int(m.group(3)), count, part, ctx) + if m.group(3) else count - 1) + if start > end: + self.selector_error( + ctx, f'in "{part}", the start {start} is after the end {end}') + result += list(range(start, end + 1)) + return result + + def cell_number(self, r, c, calc): + return self.parse_number(self.rows[r][c], f"{r}({c})", calc) + + def parse_operand(self, token, calc): + # ('const', value) or ('cells', rows, cols) where each of rows/cols is + # a list of indices, or None when that axis is not written (a relative + # operand, resolved against the target's iterated axis by operand_cells). + try: + return ('const', float(token)) + except ValueError: + pass + m = self.operand_rgx.match(token) + if not m or (m.group('rows') is None and m.group('cols') is None): + self.calc_error( + calc, f'"{token}" is not a number or a cell selection') + ctx = f'In the :calc calculation "{calc}"' + rows = (self.calc_selectors(m.group('rows'), self.row_count, ctx) + if m.group('rows') is not None else None) + cols = (self.calc_selectors(m.group('cols'), self.row_size, ctx) + if m.group('cols') is not None else None) + return ('cells', rows, cols) + + def operand_cells(self, token, calc, mode, index, trange): + # The operand's numbers for the current target cell. mode is 'scalar', + # 'row' (horizontal map, rows iterate), or 'col' (vertical, cols + # iterate); index is the current row/col; trange is the target's range. + kind = self.parse_operand(token, calc) + if kind[0] == 'const': + return [kind[1]] + _, rows, cols = kind + if mode == 'scalar': + if rows is None or cols is None: + self.calc_error( + calc, f'"{token}" is a relative operand; it needs a ranged ' + "target (a row or column range) to resolve against") + return [self.cell_number(r, c, calc) for r in rows for c in cols] + if mode == 'row': # rows iterate; any columns fold + if cols is None: + self.calc_error( + calc, f'"{token}" selects no column; a row-map operand ' + "names a column, e.g. (0) or 0-(0)") + if rows is None: # relative: this row + use_rows = [index] + elif len(rows) == 1: # a fixed row broadcasts + use_rows = rows + elif rows == trange: # explicit range in lockstep + use_rows = [index] + else: + self.calc_error( + calc, f'the rows of "{token}" must match the target rows') + return [self.cell_number(r, c, calc) for r in use_rows for c in cols] + # mode == 'col': columns iterate; any rows fold + if rows is None: + self.calc_error( + calc, f'"{token}" selects no row; a column-map operand names ' + "rows, e.g. 0--2 or 0--2(0-)") + if cols is None: # relative: this column + use_cols = [index] + elif len(cols) == 1: # a fixed column broadcasts + use_cols = cols + elif cols == trange: # explicit range in lockstep + use_cols = [index] + else: + self.calc_error( + calc, f'the columns of "{token}" must match the target columns') + return [self.cell_number(r, c, calc) for r in rows for c in use_cols] def apply_operator(self, op, values, calc): - if len(values) == 1: # Lisp-style unary - and / - return {"+": values[0], "*": values[0], - "-": -values[0], "/": 1 / values[0]}[op] + if len(values) == 1: # Lisp-style unary: - negates, / reciprocates + v = values[0] # + and * of one operand are the operand itself + if op == "-": + return -v + if op == "/": # compute 1/v only for "/", so "+ " + return 1 / v # does not raise a spurious ZeroDivisionError + return v result = values[0] for v in values[1:]: # Fold from the left if op == "+": @@ -140,31 +268,118 @@ class Table(klammer_base.Klammer_base): result /= v return result + def calc_fold(self, op, operands, calc, mode, index, trange): + values = [] + for token in operands: + values += self.operand_cells(token, calc, mode, index, trange) + try: + return self.apply_operator(op, values, calc) + except ZeroDivisionError: + self.calc_error(calc, "division by zero") + + def calc_assign(self, r, c, value, calc, target_text): + if (r, c) in self.covered: + self.calc_warn( + calc, f"the target {target_text} is a cell hidden by a colspan " + "or rowspan merge; its computed value will not be shown") + self.rows[r][c] = self.format_number(value) + def calculate(self): for calc in [c.strip() for c in self.calc.split(";") if c.strip()]: - target, eq, expression = calc.partition("=") - match = self.calc_target_rgx.match(target.strip()) - if not eq or not match: - self.calc_error(calc, "the target must be a single cell " - "written (), followed by \"=\"") - row_i, col_i = int(match.group(1)), int(match.group(2)) - if row_i >= self.row_count or col_i >= self.row_size: - self.calc_error( - calc, f"the target {target.strip()} is outside the " - f"table (rows 0-{self.row_count - 1}, " - f"columns 0-{self.row_size - 1})") - tokens = expression.split() - if not tokens or tokens[0] not in "+-*/" or len(tokens) < 2: - self.calc_error(calc, "the expression must be an operator " - "(+ - * /) followed by at least one operand") - values = [] - for token in tokens[1:]: - values += self.operand_values(token, calc) - try: - result = self.apply_operator(tokens[0], values, calc) - except ZeroDivisionError: - self.calc_error(calc, "division by zero") - self.rows[row_i][col_i] = self.format_number(result, calc) + self.run_calc(calc) + + def run_calc(self, calc): + target, eq, expression = calc.partition("=") + target = target.strip() + m = self.operand_rgx.match(target) + if not eq or not m or m.group('rows') is None or m.group('cols') is None: + self.calc_error( + calc, "the target must be a cell r(c) or a ranged cell such as " + "0-(2) or -1(0-), followed by \"=\"") + ctx = f'In the :calc calculation "{calc}"' + trows = self.calc_selectors(m.group('rows'), self.row_count, ctx) + tcols = self.calc_selectors(m.group('cols'), self.row_size, ctx) + tokens = expression.split() + if not tokens or tokens[0] not in ("+", "-", "*", "/") or len(tokens) < 2: + self.calc_error( + calc, "the expression must be an operator (+ - * /) followed by " + "at least one operand") + op, operands = tokens[0], tokens[1:] + row_range, col_range = len(trows) > 1, len(tcols) > 1 + if row_range and col_range: + self.calc_error( + calc, "the target may range over rows or columns, but not both") + if not row_range and not col_range: # single cell: a fold + v = self.calc_fold(op, operands, calc, 'scalar', None, None) + self.calc_assign(trows[0], tcols[0], v, calc, target) + elif row_range: # horizontal map + for r in trows: + v = self.calc_fold(op, operands, calc, 'row', r, trows) + self.calc_assign(r, tcols[0], v, calc, target) + else: # vertical map + for c in tcols: + v = self.calc_fold(op, operands, calc, 'col', c, tcols) + self.calc_assign(trows[0], c, v, calc, target) + + # ---- :format ---------------------------------------------------------- + # + # ";"-separated pairs (same list style as :calc). + # is an indexed_range; is a "." + # 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 "." to a callable, like an @eval reference. + if "." not in spec: + self.selector_error( + ctx, f'the format function "{spec}" must be written ' + "., 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 , e.g. 0-(5) myformats.euro") + rangespec, spec = parts + func = self.format_function(spec, ctx) + m = self.operand_rgx.match(rangespec) + if not m or m.group('rows') is None or m.group('cols') is None: + self.selector_error( + ctx, f'"{rangespec}" is not a cell range like 0-(5) ' + "or 1--2(0-3)") + rows = self.calc_selectors(m.group('rows'), self.row_count, ctx) + cols = self.calc_selectors(m.group('cols'), self.row_size, ctx) + for r in rows: + for c in cols: + v = self.to_number(self.rows[r][c]) + if v is None: + self.format_warn( + f'cell {r}({c}) contains "' + f'{klammer_base.unescape_ktesc(self.rows[r][c].strip())}' + '", which is not a number; left unformatted') + continue + result = func(v, self.K_target) + if self.decimal == "comma": + result = result.translate(str.maketrans(",.", ".,")) + self.rows[r][c] = result def span_count(self, spans, index, cross_i): # The count of cells merged by a span anchored at (index, cross_i): @@ -192,16 +407,6 @@ class Table(klammer_base.Klammer_base): self.rowspan_covered.add((row_i, col_i)) self.covered = self.colspan_covered | self.rowspan_covered - def remove_redundant_borders(self): - remove_right = [] - for row_i in range(self.row_count): - for cell_i in range(self.row_size): - a = self.cells[row_i][cell_i] - b = self.cells[row_i][cell_i+1] - if a.border.right and b.border.left: - a.border.right = False - a.border.right_all = False - def column_width_text(self): # For each column, the text used to measure a 'fit' width in the # tex target. The longest cell's font is applied, so a bold or @@ -225,6 +430,36 @@ class Table(klammer_base.Klammer_base): longest = font.tex_fontify(longest, longest_font, 1.0) self.column_widths.append(longest) + # ---- :hpos ------------------------------------------------------------- + # + # ";"-separated pairs (the same list style as :calc + # and :format). is an indexed_range; is l, c, or r + # and overrides the column's :cell_hpos for the selected cells. A + # colspan anchor's override positions the whole merged cell; in the tex + # target an ordinary overridden cell is wrapped in \multicolumn{1}. + + def hpos_overrides(self): + result = {} + for stmt in [s.strip() for s in self.hpos.split(";") if s.strip()]: + ctx = f'In :hpos "{stmt}"' + parts = stmt.split() + if len(parts) != 2 or parts[1] not in ("l", "c", "r"): + self.selector_error( + ctx, "each entry is , the position one " + "of l, c, or r -- e.g. -3--1(3) r") + rangespec, pos = parts + m = self.operand_rgx.match(rangespec) + if not m or m.group('rows') is None or m.group('cols') is None: + self.selector_error( + ctx, f'"{rangespec}" is not a cell range like 0-(5) ' + "or 1--2(0-3)") + rows = self.calc_selectors(m.group('rows'), self.row_count, ctx) + cols = self.calc_selectors(m.group('cols'), self.row_size, ctx) + for r in rows: + for c in cols: + result[(r, c)] = pos + return result + def make_cells(self, rows): self.compute_coverage() result = [] @@ -241,10 +476,11 @@ class Table(klammer_base.Klammer_base): # boundary at the END of the merged region. right_i = cell_i + max(cspan, 1) bottom_i = row_i + max(rspan, 1) + hpos = self.hpos_map.get((row_i, cell_i), self.cell_hpos[cell_i]) row_cells.append( table_cell.Cell( cell, - font, self.cell_hpos[cell_i], + font, hpos, self.s_hline.has(row_i, cell_i), self.s_vline.has(right_i, row_i), self.s_hline.has(bottom_i, cell_i), @@ -252,7 +488,8 @@ class Table(klammer_base.Klammer_base): self.s_vline.by_index.get(cell_i), self.s_vline.by_index.get(right_i), rspan, cspan, - first_column=(cell_i == 0))) + first_column=(cell_i == 0), + hpos_forced=(row_i, cell_i) in self.hpos_map)) cells.append(row_cells) self.cells = cells self.column_width_text() diff --git a/sks/table/table_cell.py b/sks/table/table_cell.py index 294ac3b..3b72bf4 100644 --- a/sks/table/table_cell.py +++ b/sks/table/table_cell.py @@ -12,10 +12,13 @@ class Cell: def __init__(self, text, font, hpos, top, right, bottom, left, left_all, right_all, - rowspan, colspan, first_column=False): + rowspan, colspan, first_column=False, hpos_forced=False): self.text = text self.font = font self.hpos = hpos + # An :hpos override on an ordinary cell must reach the tex target + # through a \multicolumn{1} (the column spec sets the default). + self.hpos_forced = hpos_forced self.border = border.Border(top, right, bottom, left, left_all, right_all) self.rowspan = rowspan self.colspan = colspan @@ -74,7 +77,7 @@ class Cell: left_marker = "xL" if remove_left else "L" if self.border.left_all else "" right_marker = "Rx" if remove_right else "R" if self.border.right_all else "" result = f"{left_marker} {result} {right_marker}" - if remove_left or remove_right: + if remove_left or remove_right or self.hpos_forced: pos = self.hpos if self.border.left_all and self.border.left: pos = "|" + pos diff --git a/sks/target/latex_util.cpp b/sks/target/latex_util.cpp index 823f5d7..42a0a54 100644 --- a/sks/target/latex_util.cpp +++ b/sks/target/latex_util.cpp @@ -192,7 +192,10 @@ namespace latex { ss << "\\newcommand{\\documenttwocolumn}{" << (two_column ? "true" : "false") << "}\n"; ss << "\\newcommand{\\documentlandscape}{" << (landscape ? "true" : "false") << "}\n"; ss << "\\newcommand{\\documentisbook}{" << (book_format ? "true" : "false") << "}\n"; - if (!bottom.empty()) { + // ":bottom none" suppresses the footer entirely: no \documentbottom, + // and \pagestyle{empty} replaces the sks page styles below. + bool no_footer = (bottom == "none"); + if (!bottom.empty() && !no_footer) { ss << "\\newcommand{\\documentbottom}{" << bottom << "}\n"; } @@ -255,7 +258,7 @@ namespace latex { ss << book_verso_page(copyright); // Table of contents (starts on page iii, recto) - ss << "\\pagestyle{noheader}\n" + ss << "\\pagestyle{" << (no_footer ? "empty" : "noheader") << "}\n" << "\\tableofcontents\n"; // Advance to the next recto page for the body. @@ -270,9 +273,13 @@ namespace latex { ss << "\\clearpage\n" << "\\thispagestyle{empty}\\mbox{}\\clearpage\n" << "\\pagenumbering{arabic}\n" - << "\\pagestyle{sksbook}\n"; + << "\\pagestyle{" << (no_footer ? "empty" : "sksbook") << "}\n"; } else { - ss << "\\pagestyle{sks" << structure << "}\n\n"; + if (no_footer) { + ss << "\\pagestyle{empty}\n\n"; + } else { + ss << "\\pagestyle{sks" << structure << "}\n\n"; + } // Plain or article: title block with optional copyright footnote if (!copyright.empty()) { ss << "\\renewcommand{\\thefootnote}{}\n";