Verbatim-safe typography, ^-punctuation quoting, full-range ^UUUU^, polyglot html

Typographic transforms (---, quote pairs, ~) no longer touch verbatim
text: @c/@code/@source_listing content and ^'...'^ spans show exactly the
characters written. "^" before any punctuation character quotes it in
every target (the apostrophe excepted: ^' opens a literal span), with the
new :resolve option on @@@target declaring per-target renderings. The
^UUUU^ code-point form accepts 4-6 hex digits, the full Unicode range.
The html output and transform spellings are polyglot (XML-valid), in
preparation for an EPUB target. New suites: transform_test, character_test
(engine), typography_test (SKS).

(from dev 07ce5ea86a0a)
This commit is contained in:
2026-08-23 20:48:26 +02:00
parent d982c0d6cc
commit 37b6ba1c4f
77 changed files with 1665 additions and 1608 deletions

View File

@@ -61,10 +61,10 @@ public:
if (apply_depth >= max_apply_depth) {
std::stringstream ss {};
ss << "Klammer application nested more than " << max_apply_depth
<< " levels deep while applying " << q_(name) << ".\n"
<< " levels deep while applying " << q_(name) << "."
<< "A klammer that applies itself, directly or through a cycle "
<< "of klammers, does not terminate.";
throw Recursion_error(ss.str(), loc, false);
throw Recursion_error(ss.str(), loc);
}
++apply_depth;
}
@@ -132,7 +132,7 @@ void check_bar_count(katom_iter begin, std::size_t count)
ss << "Incorrectly formatted @cond klammer. There should only be one or two bar characters:\n"
<< " @cond <predicate> | <result-if-true @\nor:\n"
<< " @cond <predicate> | <result-if-true> | <result-if-false> @";
throw Argument_error(ss.str(), begin->m_loc, false);
throw Argument_error(ss.str(), begin->m_loc);
}
}
@@ -749,24 +749,23 @@ katom_list Machine::apply_klammer(
// remain in the klammer body substitution for final target-specific output.
katom_list result(klammer.m_body[target].begin(), klammer.m_body[target].end());
auto varmap = klammer.m_varmap[target];
m_state.open_frame("Arguments for klammer " + q_(klammer.m_name));
m_state.set(values, klammer.m_parameters);
for (const auto& [name, indices] : varmap) {
std::regex arg("\\*" + name + "\\*");
for (auto i : indices) {
result[i].m_text = std::regex_replace(result[i].m_text, arg, m_state.value(name));
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
// text of a GENERAL klammer's body. Runs BEFORE the *arg* substitution
// below: an argument value is writer text already escaped at the top
// level (KTESC markers, idempotent) plus final target markup from
// klammers the writer nested in the argument, and neither may be escaped
// here -- escaping after substitution swept both, so a general klammer
// with a klammer-bearing argument emitted \textbackslash{}emph{...}
// (fixed 2026-08-19; tst/escape_test.sh cases 26-29). A karg katom is
// skipped by the type filter; a variable inside a mixed text katom
// survives because no target declares '*' or identifier characters as
// escapes. Also runs BEFORE process_katoms/apply() 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. 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
@@ -818,6 +817,16 @@ katom_list Machine::apply_klammer(
}
}
}
m_state.open_frame("Arguments for klammer " + q_(klammer.m_name));
m_state.set(values, klammer.m_parameters);
for (const auto& [name, indices] : varmap) {
std::regex arg("\\*" + name + "\\*");
for (auto i : indices) {
result[i].m_text = std::regex_replace(
result[i].m_text, arg, m_state.value(name, true, result[i].m_loc));
result[i].m_type = katom_t::text;
}
}
process_katoms(result, klammer.m_name);
apply(m_klammers, result, target);
m_state.close_frame();
@@ -876,7 +885,18 @@ std::string Machine::run_phase_functions()
// phase sees its predecessor's result in K_result.
Eval E(*this, Locator());
if (!f.empty() && f[0] != ':') {
f += "(K_result)";
// K reaches a phase only as a parameter: the state's
// "class K" exists in the EVAL's globals (python_code()
// defines it), but a phase function's body resolves names in
// its own MODULE's globals. Every phase signature has
// carried "K=None" for this since the calling convention was
// created; the call never passed it, which surfaced when
// txt_justify_blocks needed Target_txt_width (2026-08-22).
// K_phase_call (defined in Eval_python's globals) passes K
// only to a function that DECLARES a K parameter, so a
// stdlib phase (string.capwords, whose second parameter is a
// separator) keeps working.
f = "K_phase_call(" + f + ", K_result, K)";
}
f = "@eval " + f + " @";
auto katoms = katomize(line_split(f), "phase");
@@ -916,7 +936,7 @@ std::string Machine::apply(const std::string& target_name, bool final_processing
// Characters produced later by klammer bodies will not be escaped.
// Skipped for sub-Machine apply() calls (e.g., from @eval), where the
// text is already in target-specific form.
auto target = m_targets.get(target_name, Locator());
auto target = m_targets.get(target_name, Locator::none());
if (escape_characters)
escape_target_characters(target, m_katoms);
@@ -935,18 +955,24 @@ std::string Machine::apply(const std::string& target_name, bool final_processing
if (++apply_count > apply_round_limit) {
std::stringstream ss {};
ss << "Klammer application did not reach a fixed point after "
<< apply_round_limit << " rounds.\n"
<< apply_round_limit << " rounds.\n\n"
<< "Each round applies every klammer present; a klammer whose "
<< "result contains further klammers starts another round.";
throw Recursion_error(ss.str(), Locator(), false);
throw Recursion_error(ss.str(), Locator());
}
}
// Typographic transforms run on the katoms, not the joined string, so
// that ^'...'^ literal content (katom_t::literal) stays verbatim --
// Target::transform() skips literal katoms. Verbatim text produced by
// an @eval renderer (@code, @c) is plain text by the time it is spliced
// back; the SKS protects it with KTESC markers (hide_typographic() in
// klammer_base.py), which are inert here and decode in resolve_escapes.
if (final_processing) {
target.transform(m_katoms);
}
m_result = to_string(m_katoms.begin(), m_katoms.end());
if (final_processing) {
for (const auto& [old_str, new_str] : target.m_transforms) {
m_result = string_replace(m_result, old_str, new_str);
}
m_result = target.resolve_escapes(m_result);
m_result = run_phase_functions();
}