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

View File

@@ -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<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);
apply(m_klammers, result, target);
m_state.close_frame();