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.
This commit is contained in:
2026-07-24 21:37:58 +02:00
parent 8a2699a253
commit 4262fc6136
14 changed files with 657 additions and 146 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
*.o *.o
*.d *.d
*.so *.so
__pycache__/
# Editor / OS cruft # Editor / OS cruft
*~ *~

83
mac/.clang-tidy Normal file
View File

@@ -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

View File

@@ -6,6 +6,7 @@
#include "show.h" #include "show.h"
#include "katom.h" #include "katom.h"
#include "file.h" #include "file.h"
#include <algorithm>
#include <unistd.h> #include <unistd.h>
std::string shell(State state, std::string command, Locator loc) 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 {}; katom_list result {};
Machine M = m_machine; Machine M = m_machine;
size_t before = M.m_katoms.size();
M.read(eval_result); 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); result = trim(M.m_katoms);
return result; return result;
} }

View File

@@ -24,6 +24,18 @@
namespace fs = std::filesystem; 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<unsigned char>(c));
}
return ext;
}
std::string name_to_dirname(std::string name) std::string name_to_dirname(std::string name)
{ {
std::string result {}; std::string result {};
@@ -275,7 +287,10 @@ Font_file classify_font_file(const std::string& path)
{ {
Font_file file {}; Font_file file {};
file.path = path; 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); std::string d = read_binary_file(path);
if (d.size() < 12) { if (d.size() < 12) {
@@ -372,7 +387,7 @@ std::vector<Font_file> classify_font_files(const std::string& directory)
"The font directory \"" + directory + "\" does not exist"); "The font directory \"" + directory + "\" does not exist");
} }
for (auto& entry : fs::recursive_directory_iterator(directory)) { 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")) { if (entry.is_regular_file() && (ext == ".ttf" || ext == ".otf")) {
result.push_back(classify_font_file(entry.path())); result.push_back(classify_font_file(entry.path()));
} }

View File

@@ -214,6 +214,7 @@ void Klammer::copy_components(
} }
for (auto c : cs) { for (auto c : cs) {
m_body[c.target] = c.body; m_body[c.target] = c.body;
m_body_generic[c.target] = (c.target == Target_set::general_name);
m_varmap[c.target] = c.varmap; 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) { if (m_body.count(target_name) == 0 && target_name != Target_set::declare_name) {
// std::cout << " Copying to " << target_name << "\n"; // std::cout << " Copying to " << target_name << "\n";
m_body[target_name] = body; m_body[target_name] = body;
m_body_generic[target_name] = true; // general body -> writer content
m_defloc[target_name] = loc; m_defloc[target_name] = loc;
m_varmap[target_name] = varmap; m_varmap[target_name] = varmap;
} }

View File

@@ -86,6 +86,9 @@ public:
std::map<std::string, Locator> m_defloc {}; // target -> Locator std::map<std::string, Locator> m_defloc {}; // target -> Locator
std::map<std::string, defmode_t> m_defmode {}; // target -> defmode std::map<std::string, defmode_t> m_defmode {}; // target -> defmode
target_variable_map_t m_varmap {}; // target -> map: variable -> index 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<std::string, bool> m_body_generic {};
}; };
std::string klammer_name_from_katom(std::string s, Locator loc); std::string klammer_name_from_katom(std::string s, Locator loc);

View File

@@ -413,6 +413,66 @@ katom_list Machine::apply_klammer(
result[i].m_type = katom_t::text; 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<bool> 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); process_katoms(result, klammer.m_name);
apply(m_klammers, result, target); apply(m_klammers, result, target);
m_state.close_frame(); m_state.close_frame();

View File

@@ -1,11 +1,18 @@
@@date.k : Date formatted as "16 June 1910" @@ @@date.k :days.int 0 :
@@date.html :: @eval time.strftime("%d %B %Y").lstrip('0') eval@ @@ Date formatted as "16 June 1910", offset by ^:days days from today
@@date.tex :: @eval time.strftime("%d %B %Y").lstrip('0') eval@ @@ (^:days 1 is tomorrow, ^:days -1 is yesterday)
@@date.txt :: @eval time.strftime("%d %B %Y").lstrip('0') eval@ @@ @@
@@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.k :days.int 0 :
@@datetime.html :: @eval time.strftime("%d %B %Y, %H:%M").lstrip('0') eval@ @@ Date and time formatted as "16 June 1910, 13:10", offset by ^:days days
@@datetime.tex :: @eval time.strftime("%d %B %Y, %H:%M").lstrip('0') eval@ @@ 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.k : Date and time formatted as "1910.06.16-12:34" @@
@@timestamp :: @eval time.strftime("%Y.%m.%d-%H:%M") eval@ @@ @@timestamp :: @eval time.strftime("%Y.%m.%d-%H:%M") eval@ @@

17
sks/date/date.py Normal file
View File

@@ -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")

View File

@@ -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-" commas; each is an index "4", a closed range "1-4", or an open range "6-"
(to the end). All indices are zero-origin. (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: Examples:
3 index 3, full extent 3 index 3, full extent
2-5(0-2) indices 2 through 5, each restricted to 0 through 2 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 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 head(1-) with table hline names: boundary 1, from column 1 on
""".strip() """.strip()
@@ -38,8 +44,13 @@ class Range_error(Exception):
super().__init__(f"{message}\n\n{syntax_description}") super().__init__(f"{message}\n\n{syntax_description}")
item_rgx = re.compile(r"(?:(\d+)(-)?(\d*)|([A-Za-z]+))(?:\(([\d,\-]+)\))?$") # A numeric selector is a signed integer, optionally followed by a range
subset_rgx = re.compile(r"(\d+)(-)?(\d*)$") # 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): def hline_names(count):
@@ -49,7 +60,8 @@ def hline_names(count):
"head": [1], "head": [1],
"bottom": [last], "bottom": [last],
"inner": list(range(1, last)), "inner": list(range(1, last)),
"all": list(range(count))} "all": list(range(count)),
"none": []}
def vline_names(count): def vline_names(count):
@@ -57,7 +69,8 @@ def vline_names(count):
last = count - 1 last = count - 1
return {"outer": [0, last], return {"outer": [0, last],
"inner": list(range(1, last)), "inner": list(range(1, last)),
"all": list(range(count))} "all": list(range(count)),
"none": []}
class Indexed_range: class Indexed_range:
@@ -126,31 +139,36 @@ class Indexed_ranges:
argument = f"{self.argument} argument: " if self.argument else "" argument = f"{self.argument} argument: " if self.argument else ""
raise Range_error(f"{argument}{message}") 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): def parse(self, spec):
match = item_rgx.match(spec) match = item_rgx.match(spec)
if not match: if not match:
self.error(f'"{spec}" is not a valid indexed_range.') 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 is not None:
if name not in self.names: if name not in self.names:
known = " ".join(self.names) or "none" known = " ".join(self.names) or "none"
self.error(f'"{name}" is not a valid name here ' self.error(f'"{name}" is not a valid name here '
f"(valid names: {known}).") f"(valid names: {known}).")
indices = self.names[name] indices = self.names[name]
elif range_part is None:
indices = [self.normalize(int(number), spec, self.count)]
else: else:
start = int(number) first = self.normalize(int(number), spec, self.count)
if not hyphen: last = (self.normalize(int(end), spec, self.count)
indices = [start] if end else self.count - 1)
else: if first > last:
last = int(end) if end else self.count - 1 self.error(f'In "{spec}", the index range start {first} '
if start > last: f"is greater than its end {last}.")
self.error(f'In "{spec}", the index range start {start} ' indices = list(range(first, last + 1))
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 ranges = self.parse_subsets(spec, subsets) if subsets else None
for i in indices: for i in indices:
entry = self.by_index.setdefault(i, Indexed_range(i, self.maxval)) entry = self.by_index.setdefault(i, Indexed_range(i, self.maxval))
@@ -160,23 +178,24 @@ class Indexed_ranges:
entry.add_ranges(ranges) entry.add_ranges(ranges)
def parse_subsets(self, spec, subsets): 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 = [] ranges = []
for part in subsets.split(","): for part in subsets.split(","):
match = subset_rgx.match(part) match = subset_rgx.match(part)
if not match: if not match:
self.error(f'In "{spec}", "{part}" is not a valid subset.') self.error(f'In "{spec}", "{part}" is not a valid subset.')
number, hyphen, end = match.groups() number, range_part, end = match.groups()
start = int(number) if range_part is None:
if not hyphen: start = last = self.normalize(int(number), spec, count)
last = start
else: 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: if start > last:
self.error(f'In "{spec}", the subset start {start} ' self.error(f'In "{spec}", the subset start {start} '
f"is greater than its end {last}.") 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]) ranges.append([start, last])
return ranges return ranges

View File

@@ -3,9 +3,10 @@
@@@argtype index_subsets | @@@argtype index_subsets |
one or more subsets in parentheses, attached to an index. Each subset is a 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 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. 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) Example: (1-4,6-9)
:pattern \((?^:\d+(?^:-\d*)?)(?^:,\d+(?^:-\d*)?)*\) :pattern \((?^:-?\d+(?^:-(?^:-?\d+)?)?)(?^:,-?\d+(?^:-(?^:-?\d+)?)?)*\)
@@@ @@@
@@@argtype indexed_range | @@@argtype indexed_range |
@@ -13,14 +14,18 @@
single index <i>, a closed index range <i>-<j>, or an open index range <i>- 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 (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. (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 3 index 3, full extent
2-5 indices 2 through 5, 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 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 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 | @@@argtype column_width |
@@ -51,16 +56,19 @@
is either a boundary name or an indexed_range of boundary indices. The 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), names are 'top' (boundary 0), 'head' (boundary 1, under a header row),
'bottom' (boundary N), 'inner' (all boundaries between top and bottom), 'bottom' (boundary N), 'inner' (all boundaries between top and bottom),
and 'all' (every boundary). A name or index may be followed by 'all' (every boundary), and 'none' (no lines). A name or index may be
parenthesized subsets to draw only part of a line, given as zero-origin followed by parenthesized subsets to draw only part of a line, given as
column ranges. Examples: 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 top bottom lines above and below the table
head(1-) a line under the header, from column 1 to the last head(1-) a line under the header, from column 1 to the last
3(1-4,6-9) two partial lines at boundary 3 3(1-4,6-9) two partial lines at boundary 3
all every line 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()) :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 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 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' boundary indices. The names are 'outer' (boundaries 0 and M), 'inner'
(all boundaries between them), and 'all' (every boundary). A name or (all boundaries between them), 'all' (every boundary), and 'none' (no
index may be followed by parenthesized subsets to draw only part of a lines). A name or index may be followed by parenthesized subsets to
line, given as zero-origin row ranges. Examples: draw only part of a line, given as zero-origin row ranges. Examples:
outer lines at the left and right edges outer lines at the left and right edges
2(0-3) a line left of column 2, spanning rows 0 through 3 2(0-3) a line left of column 2, spanning rows 0 through 3
all every line 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()) :python_cast (lambda s : s.split())
@@@ @@@
@@ -105,22 +113,35 @@
<target> = <operator> <operand> <operand> ... <target> = <operator> <operand> <operand> ...
where the target is a single cell written <row>(<column>) with zero-origin where the operator is one of + - * / and each operand is a cell selection
indices, the operator is one of + - * /, and each operand is either a cell or a number. A cell selection is an indexed_range read as <rows>(<columns>);
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
<rows>(<columns>); a range expands to all of its cells in row order, so of rows 1 and 2. The SHAPE of the target chooses the operation: a single
"+ 1-2(3)" sums column 3 of rows 1 and 2. A plain number is a constant cell <row>(<column>) 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 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 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 and / gives the reciprocal. Calculations run in the order given, and each
reads the values earlier calculations have written, as displayed. 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) ; 1(3) = * 1(1-2) ;
2(3) = * 2(1-2) ; 2(3) = * 2(1-2) ;
3(3) = + 1-2(3) 3(3) = + 1-2(3)
:pattern \s*(\d+\(\d+\)\s*=\s*[-+*/](\s+(\d+(?^:-\d*)?'index_subsets'^|'float'))+\s*(;\s*^|\s*$))+ # Coarse structural check only -- "<target> = <op> <operand>..." 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 :default period
@@@ @@@
@@@argtype format_spec | @@@argtype table_hpos |
a Python format specification applied to calculated cell values, for cell position overrides, as one or more <cells> <position> pairs
example ",.2f" for two decimal places with grouped thousands. separated by semicolons (the same list style as ^:calc). <cells> is an
:pattern \S+ indexed_range selecting cells; <position> 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 ("<cells> <position>" 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 <cells> <function> pairs separated by
semicolons (the same list style as :calc). <cells> is an indexed_range
selecting the cells to format; <function> is a <module>.<function> 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 ("<cells> <function>" 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*} @@ @@rowcolor.tex s : \colorrow{*s*} @@
@@ -153,12 +197,13 @@
:vline.table_vline :vline.table_vline
:grid.bool false :grid.bool false
:cell_hpos.cell_hpos :cell_hpos.cell_hpos
:hpos.table_hpos
:header_font.font i :header_font.font i
:font.font_list :font.font_list
:colspan.table_span :colspan.table_span
:rowspan.table_span :rowspan.table_span
:calc.table_calc :calc.table_calc
:calc_format.format_spec :format.table_format
:decimal.decimal_mark :decimal.decimal_mark
:leading.float 1.3 :leading.float 1.3
:colsep 4pt :colsep 4pt

View File

@@ -1,5 +1,6 @@
import functools import functools
import collections import collections
import importlib
import re import re
import sys import sys
import traceback import traceback
@@ -14,6 +15,15 @@ from indexed_range import Indexed_ranges, hline_names, vline_names
import table_cell import table_cell
import font import font
# ---- :format functions ---------------------------------------------------
# A :format function takes (value, target) and returns the formatted cell
# text. It is named <module>.<function> in the :format list (like an eval
# reference), so it can live in ANY module: the SKS ships none by default, and
# a document defines its own (e.g. a euro() in the document's .py, named
# "<module>.euro" in :format) because a specific currency style is a property
# of that document, not of the SKS. A function should emit period-decimal
# numbers; apply_formats() applies the :decimal comma swap.
def extend(lst, count, fill=None): def extend(lst, count, fill=None):
if isinstance(lst, str): if isinstance(lst, str):
lst = lst.split() lst = lst.split()
@@ -44,18 +54,18 @@ class Table(klammer_base.Klammer_base):
id = 0 id = 0
def __init__(self, K): def __init__(self, K):
super().__init__(K) super().__init__(K)
self.row_count = len(self.rows)
if self.grid: if self.grid:
self.vline = ["all"] self.vline = ["all"]
self.hline = ["all"] self.hline = ["all"]
self.row_count = len(self.rows) elif not self.hline and self.header:
if self.header: # Default lines: under the header row and at the bottom. A
self.hline += ["1", str(self.row_count)] # 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]) self.row_size = max([len(e) for e in self.rows])
# Rows with fewer cells than the widest row are padded with empty # Rows with fewer cells than the widest row are padded with empty
# cells (last-value duplication is for argument lists, not content). # cells (last-value duplication is for argument lists, not content).
self.rows = [row + [""] * (self.row_size - len(row)) for row in self.rows] 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, self.s_vline = Indexed_ranges(self.row_size + 1, self.row_count - 1, self.vline,
vline_names(self.row_size + 1), ":vline") vline_names(self.row_size + 1), ":vline")
self.s_hline = Indexed_ranges(self.row_count + 1, self.row_size - 1, self.hline, 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") argument=":rowspan")
self.s_colspan = Indexed_ranges(self.row_count, self.row_size - 1, self.colspan, self.s_colspan = Indexed_ranges(self.row_count, self.row_size - 1, self.colspan,
argument=":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 = extend(parse_hpos(self.K_target, self.cell_hpos), self.row_size)
#self.cell_hpos = self.cell_hpos.split(";") #self.cell_hpos = self.cell_hpos.split(";")
self.font = extend(self.font, self.row_size) self.font = extend(self.font, self.row_size)
self.hpos_map = self.hpos_overrides() if self.hpos else {}
self.make_cells(self.rows) self.make_cells(self.rows)
# Calculated cell values (:calc). Calculations run in the order given; # Calculated cell values (:calc). A calculation is
# each reads cell values as displayed (display-precision semantics), so # <target> = <op> <operand> ... (prefix operator: + - * /)
# a printed total always equals the sum of the printed lines. Only # and the SHAPE of the target selects the operation:
# calculation targets are formatted (:calc_format); other cells keep # * single cell r(c) -> FOLD: the operands collapse to one value.
# their authored text. Future operators to consider: min, max, mean, # * row range R(c) -> horizontal MAP: run once per row in R.
# and a per-calculation format override. # * 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+)\)$") # A single-axis selector: an index or an inclusive range; negatives count
calc_cell_rgx = re.compile(r"\d+(-\d*)?\(") # from the end (-1 = last). A target/operand token is <rows>(<cols>),
# <rows>, or (<cols>) -- the last two are the relative operand forms.
selector_rgx = re.compile(r"^(-?\d+)(-(-?\d+)?)?$")
operand_rgx = re.compile(
r"^(?P<rows>-?\d+(?:-(?:-?\d+)?)?)?(?:\((?P<cols>[-\d,]+)\))?$")
def calc_error(self, calc, message): def calc_error(self, calc, message):
raise Exception(f'In the :calc calculation "{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() s = text.strip()
if self.decimal == "comma": if self.decimal == "comma":
s = s.translate(str.maketrans(",.", ".,")) s = s.translate(str.maketrans(",.", ".,"))
@@ -90,44 +133,129 @@ class Table(klammer_base.Klammer_base):
try: try:
return float(s) return float(s)
except ValueError: except ValueError:
self.calc_error( return None
calc, f'the cell {ref} contains "{text.strip()}", '
"which is not a number")
def format_number(self, value, calc): def parse_number(self, text, ref, calc):
if self.calc_format: v = self.to_number(text)
try: if v is None:
s = format(value, self.calc_format) # Unescape KTESC markers so the message shows the character the
except ValueError: # writer typed (e.g. "$5") rather than "KTESC0024KTESC5".
self.calc_error( self.calc_error(
calc, f'"{self.calc_format}" is not a valid ' calc, "the cell {} contains \"{}\", which is not a number"
"format specification") .format(ref, klammer_base.unescape_ktesc(text.strip())))
elif value.is_integer(): return v
s = str(int(value))
else: def format_number(self, value):
s = str(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": if self.decimal == "comma":
s = s.translate(str.maketrans(",.", ".,")) s = s.translate(str.maketrans(",.", ".,"))
return s return s
def operand_values(self, token, calc): def calc_norm(self, i, count, spec, ctx):
# A token with subsets is a cell selection; a bare number is a # Resolve a possibly-negative index; -1 is the last, like Python.
# constant (always period-decimal, independent of :decimal). j = i + count if i < 0 else i
if not self.calc_cell_rgx.match(token): if not 0 <= j < count:
return [float(token)] self.selector_error(
selection = Indexed_ranges(self.row_count, self.row_size - 1, ctx, f'in "{spec}", index {i} is out of range '
[token], argument=":calc") f"(0 through {count - 1})")
values = [] return j
for row_i in selection.by_index:
for _, col_i in selection.by_index[row_i].items(): def calc_selectors(self, spec, count, ctx):
values.append(self.parse_number( # "3", "0-2", "0--2", "-1", or a comma-separated list of those, to a
self.rows[row_i][col_i], f"{row_i}({col_i})", calc)) # list of indices in written order (order matters for a left fold).
return values 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): def apply_operator(self, op, values, calc):
if len(values) == 1: # Lisp-style unary - and / if len(values) == 1: # Lisp-style unary: - negates, / reciprocates
return {"+": values[0], "*": values[0], v = values[0] # + and * of one operand are the operand itself
"-": -values[0], "/": 1 / values[0]}[op] if op == "-":
return -v
if op == "/": # compute 1/v only for "/", so "+ <zero cell>"
return 1 / v # does not raise a spurious ZeroDivisionError
return v
result = values[0] result = values[0]
for v in values[1:]: # Fold from the left for v in values[1:]: # Fold from the left
if op == "+": if op == "+":
@@ -140,31 +268,118 @@ class Table(klammer_base.Klammer_base):
result /= v result /= v
return result 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): def calculate(self):
for calc in [c.strip() for c in self.calc.split(";") if c.strip()]: for calc in [c.strip() for c in self.calc.split(";") if c.strip()]:
target, eq, expression = calc.partition("=") self.run_calc(calc)
match = self.calc_target_rgx.match(target.strip())
if not eq or not match: def run_calc(self, calc):
self.calc_error(calc, "the target must be a single cell " target, eq, expression = calc.partition("=")
"written <row>(<column>), followed by \"=\"") target = target.strip()
row_i, col_i = int(match.group(1)), int(match.group(2)) m = self.operand_rgx.match(target)
if row_i >= self.row_count or col_i >= self.row_size: if not eq or not m or m.group('rows') is None or m.group('cols') is None:
self.calc_error( self.calc_error(
calc, f"the target {target.strip()} is outside the " calc, "the target must be a cell r(c) or a ranged cell such as "
f"table (rows 0-{self.row_count - 1}, " "0-(2) or -1(0-), followed by \"=\"")
f"columns 0-{self.row_size - 1})") ctx = f'In the :calc calculation "{calc}"'
tokens = expression.split() trows = self.calc_selectors(m.group('rows'), self.row_count, ctx)
if not tokens or tokens[0] not in "+-*/" or len(tokens) < 2: tcols = self.calc_selectors(m.group('cols'), self.row_size, ctx)
self.calc_error(calc, "the expression must be an operator " tokens = expression.split()
"(+ - * /) followed by at least one operand") if not tokens or tokens[0] not in ("+", "-", "*", "/") or len(tokens) < 2:
values = [] self.calc_error(
for token in tokens[1:]: calc, "the expression must be an operator (+ - * /) followed by "
values += self.operand_values(token, calc) "at least one operand")
try: op, operands = tokens[0], tokens[1:]
result = self.apply_operator(tokens[0], values, calc) row_range, col_range = len(trows) > 1, len(tcols) > 1
except ZeroDivisionError: if row_range and col_range:
self.calc_error(calc, "division by zero") self.calc_error(
self.rows[row_i][col_i] = self.format_number(result, calc) calc, "the target may range over rows or columns, but not both")
if not row_range and not col_range: # single cell: a fold
v = self.calc_fold(op, operands, calc, 'scalar', None, None)
self.calc_assign(trows[0], tcols[0], v, calc, target)
elif row_range: # horizontal map
for r in trows:
v = self.calc_fold(op, operands, calc, 'row', r, trows)
self.calc_assign(r, tcols[0], v, calc, target)
else: # vertical map
for c in tcols:
v = self.calc_fold(op, operands, calc, 'col', c, tcols)
self.calc_assign(trows[0], c, v, calc, target)
# ---- :format ----------------------------------------------------------
#
# ";"-separated <cells> <function> pairs (same list style as :calc).
# <cells> is an indexed_range; <function> is a "<module>.<function>"
# reference (like an @eval reference) to a Python function taking
# (value, target) and returning the formatted cell text. Each selected
# cell's value is parsed as a number (honoring :decimal); if numeric the
# function is called and its result -- with the :decimal comma swap
# applied -- replaces the cell (e.g. a writer's myformats.euro function
# turns 1234.56 into "1,234.56 €"). A
# non-numeric cell is left as-is with a warning. Runs AFTER :calc. The
# result is inserted verbatim (this pass is after the cell-processing pass),
# so a function may emit target markup directly.
def format_warn(self, message):
print(f"Warning: in :format: {message}", file=sys.stderr)
def format_function(self, spec, ctx):
# Resolve "<module>.<function>" to a callable, like an @eval reference.
if "." not in spec:
self.selector_error(
ctx, f'the format function "{spec}" must be written '
"<module>.<function>, e.g. table.euro")
mod_name, func_name = spec.rsplit(".", 1)
try:
return getattr(importlib.import_module(mod_name), func_name)
except (ImportError, AttributeError):
self.selector_error(
ctx, f'the format function "{spec}" was not found')
def apply_formats(self):
for stmt in [s.strip() for s in self.format.split(";") if s.strip()]:
ctx = f'In :format "{stmt}"'
parts = stmt.split()
if len(parts) != 2:
self.selector_error(
ctx, "each entry is <cells> <function>, e.g. 0-(5) myformats.euro")
rangespec, spec = parts
func = self.format_function(spec, ctx)
m = self.operand_rgx.match(rangespec)
if not m or m.group('rows') is None or m.group('cols') is None:
self.selector_error(
ctx, f'"{rangespec}" is not a cell range like 0-(5) '
"or 1--2(0-3)")
rows = self.calc_selectors(m.group('rows'), self.row_count, ctx)
cols = self.calc_selectors(m.group('cols'), self.row_size, ctx)
for r in rows:
for c in cols:
v = self.to_number(self.rows[r][c])
if v is None:
self.format_warn(
f'cell {r}({c}) contains "'
f'{klammer_base.unescape_ktesc(self.rows[r][c].strip())}'
'", which is not a number; left unformatted')
continue
result = func(v, self.K_target)
if self.decimal == "comma":
result = result.translate(str.maketrans(",.", ".,"))
self.rows[r][c] = result
def span_count(self, spans, index, cross_i): def span_count(self, spans, index, cross_i):
# The count of cells merged by a span anchored at (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.rowspan_covered.add((row_i, col_i))
self.covered = self.colspan_covered | self.rowspan_covered 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): def column_width_text(self):
# For each column, the text used to measure a 'fit' width in the # 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 # 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) longest = font.tex_fontify(longest, longest_font, 1.0)
self.column_widths.append(longest) self.column_widths.append(longest)
# ---- :hpos -------------------------------------------------------------
#
# ";"-separated <cells> <position> pairs (the same list style as :calc
# and :format). <cells> is an indexed_range; <position> is l, c, or r
# and overrides the column's :cell_hpos for the selected cells. A
# colspan anchor's override positions the whole merged cell; in the tex
# target an ordinary overridden cell is wrapped in \multicolumn{1}.
def 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 <cells> <position>, the position one "
"of l, c, or r -- e.g. -3--1(3) r")
rangespec, pos = parts
m = self.operand_rgx.match(rangespec)
if not m or m.group('rows') is None or m.group('cols') is None:
self.selector_error(
ctx, f'"{rangespec}" is not a cell range like 0-(5) '
"or 1--2(0-3)")
rows = self.calc_selectors(m.group('rows'), self.row_count, ctx)
cols = self.calc_selectors(m.group('cols'), self.row_size, ctx)
for r in rows:
for c in cols:
result[(r, c)] = pos
return result
def make_cells(self, rows): def make_cells(self, rows):
self.compute_coverage() self.compute_coverage()
result = [] result = []
@@ -241,10 +476,11 @@ class Table(klammer_base.Klammer_base):
# boundary at the END of the merged region. # boundary at the END of the merged region.
right_i = cell_i + max(cspan, 1) right_i = cell_i + max(cspan, 1)
bottom_i = row_i + max(rspan, 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( row_cells.append(
table_cell.Cell( table_cell.Cell(
cell, cell,
font, self.cell_hpos[cell_i], font, hpos,
self.s_hline.has(row_i, cell_i), self.s_hline.has(row_i, cell_i),
self.s_vline.has(right_i, row_i), self.s_vline.has(right_i, row_i),
self.s_hline.has(bottom_i, cell_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(cell_i),
self.s_vline.by_index.get(right_i), self.s_vline.by_index.get(right_i),
rspan, cspan, 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) cells.append(row_cells)
self.cells = cells self.cells = cells
self.column_width_text() self.column_width_text()

View File

@@ -12,10 +12,13 @@ class Cell:
def __init__(self, text, font, hpos, def __init__(self, text, font, hpos,
top, right, bottom, left, top, right, bottom, left,
left_all, right_all, left_all, right_all,
rowspan, colspan, first_column=False): rowspan, colspan, first_column=False, hpos_forced=False):
self.text = text self.text = text
self.font = font self.font = font
self.hpos = hpos 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.border = border.Border(top, right, bottom, left, left_all, right_all)
self.rowspan = rowspan self.rowspan = rowspan
self.colspan = colspan self.colspan = colspan
@@ -74,7 +77,7 @@ class Cell:
left_marker = "xL" if remove_left else "L" if self.border.left_all else "" 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 "" right_marker = "Rx" if remove_right else "R" if self.border.right_all else ""
result = f"{left_marker} {result} {right_marker}" 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 pos = self.hpos
if self.border.left_all and self.border.left: if self.border.left_all and self.border.left:
pos = "|" + pos pos = "|" + pos

View File

@@ -192,7 +192,10 @@ namespace latex {
ss << "\\newcommand{\\documenttwocolumn}{" << (two_column ? "true" : "false") << "}\n"; ss << "\\newcommand{\\documenttwocolumn}{" << (two_column ? "true" : "false") << "}\n";
ss << "\\newcommand{\\documentlandscape}{" << (landscape ? "true" : "false") << "}\n"; ss << "\\newcommand{\\documentlandscape}{" << (landscape ? "true" : "false") << "}\n";
ss << "\\newcommand{\\documentisbook}{" << (book_format ? "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"; ss << "\\newcommand{\\documentbottom}{" << bottom << "}\n";
} }
@@ -255,7 +258,7 @@ namespace latex {
ss << book_verso_page(copyright); ss << book_verso_page(copyright);
// Table of contents (starts on page iii, recto) // Table of contents (starts on page iii, recto)
ss << "\\pagestyle{noheader}\n" ss << "\\pagestyle{" << (no_footer ? "empty" : "noheader") << "}\n"
<< "\\tableofcontents\n"; << "\\tableofcontents\n";
// Advance to the next recto page for the body. // Advance to the next recto page for the body.
@@ -270,9 +273,13 @@ namespace latex {
ss << "\\clearpage\n" ss << "\\clearpage\n"
<< "\\thispagestyle{empty}\\mbox{}\\clearpage\n" << "\\thispagestyle{empty}\\mbox{}\\clearpage\n"
<< "\\pagenumbering{arabic}\n" << "\\pagenumbering{arabic}\n"
<< "\\pagestyle{sksbook}\n"; << "\\pagestyle{" << (no_footer ? "empty" : "sksbook") << "}\n";
} else { } 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 // Plain or article: title block with optional copyright footnote
if (!copyright.empty()) { if (!copyright.empty()) {
ss << "\\renewcommand{\\thefootnote}{}\n"; ss << "\\renewcommand{\\thefootnote}{}\n";