From 37b6ba1c4f42fcc9f41186d28d9ed07227a385cb Mon Sep 17 00:00:00 2001
From: Andy Kopra
Date: Sun, 23 Aug 2026 20:48:26 +0200
Subject: [PATCH] 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)
---
README.md | 2 +-
com/ktext.cpp | 2 +-
doc/edit/emacs/klammertext-indent.el | 3 +-
doc/edit/emacs/klammertext-mode.el | 42 +-
doc/edit/shared/klammertext_edit.py | 41 +-
doc/edit/sublime/Klammertext.sublime-syntax | 17 +-
doc/edit/vim/syntax/klammertext.vim | 10 +-
.../syntaxes/klammertext.tmLanguage.json | 14 +-
mac/argtype_registry.cpp | 22 +-
mac/argument_set.cpp | 19 +-
mac/argv.cpp | 36 +-
mac/character.cpp | 20 +-
mac/character.h | 6 +-
mac/command.cpp | 15 +-
mac/error.cpp | 3 +-
mac/error.h | 40 +-
mac/eval.cpp | 31 +-
mac/eval_cpp.cpp | 2 +-
mac/eval_python.cpp | 17 +-
mac/file.cpp | 32 +-
mac/katom.cpp | 2 +-
mac/katom_list.cpp | 28 +-
mac/klammer.cpp | 28 +-
mac/klammerset_registry.cpp | 6 +-
mac/ktype.h | 2 +-
mac/locator.h | 8 +
mac/machine.cpp | 80 ++--
mac/option_set_registry.cpp | 34 +-
mac/target.cpp | 70 ++-
mac/target.h | 19 +
mac/target_registry.cpp | 17 +-
mac/target_registry.h | 2 +-
mac/util.cpp | 62 ++-
sks/block/block.k | 156 +++++--
sks/block/block.py | 104 ++---
sks/block/css/block.css | 13 +-
sks/code/code_block.py | 56 ++-
sks/code/code_format.py | 403 ------------------
sks/color/color.py | 10 -
sks/document/document.cpp | 4 +-
sks/document/document.k | 17 +
sks/document/document_class.cpp | 69 +--
sks/document/document_html.cpp | 50 +--
sks/document/heading.cpp | 98 -----
sks/font/css/font.css | 23 +-
sks/font/font.py | 2 +-
sks/form/page.k | 2 +-
sks/kutil/klammer_base.py | 28 +-
sks/kutil/kutil.py | 5 +-
sks/link/link.py | 4 +-
sks/list/list.py | 331 +-------------
sks/section/css/section.css | 59 ++-
sks/section/section.k | 3 +
sks/target/css/target.css | 22 +-
sks/target/html_util.py | 24 +-
sks/target/latex_util.py | 34 +-
sks/target/phases.py | 392 ++++-------------
sks/target/target.k | 45 +-
sks/tns/md_to_pdf.py | 4 +-
tst/Makefile | 4 +
tst/alone_test.sh | 10 +-
tst/character_test.sh | 81 ++++
tst/command_option_test.sh | 18 +-
tst/cond_test.sh | 4 +-
tst/deftype_test.sh | 2 +-
tst/editor/indent_literal_c.kt | 11 +
tst/editor/indent_literal_c_expected.kt | 11 +
tst/editor/vim_feature_test.vim | 16 +
tst/editor_test.sh | 4 +-
tst/escape_test.sh | 27 ++
tst/eval_test.sh | 22 +
tst/option_set_test.sh | 6 +-
tst/recursion_test.sh | 2 +-
tst/state_test.sh | 121 ++++++
tst/target_list_test.sh | 2 +-
tst/target_test.sh | 133 ++++++
tst/transform_test.sh | 109 +++++
77 files changed, 1665 insertions(+), 1608 deletions(-)
delete mode 100644 sks/code/code_format.py
create mode 100755 tst/character_test.sh
create mode 100644 tst/editor/indent_literal_c.kt
create mode 100644 tst/editor/indent_literal_c_expected.kt
create mode 100755 tst/state_test.sh
create mode 100755 tst/target_test.sh
create mode 100755 tst/transform_test.sh
diff --git a/README.md b/README.md
index f1a96e7..ed93487 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ are regenerated on each release — patches cannot be merged directly.
Report problems (or send patches) to the author; accepted changes are
applied to the development tree and appear in a following snapshot.
-This snapshot was assembled from development commit `f34e66a4dbfb`.
+This snapshot was assembled from development commit `07ce5ea86a0a`.
## License
diff --git a/com/ktext.cpp b/com/ktext.cpp
index 5e7a16d..92987dc 100644
--- a/com/ktext.cpp
+++ b/com/ktext.cpp
@@ -49,7 +49,7 @@ int main(int argc, char* argv[])
if (input_text.empty() && input_filenames.empty()) {
throw Argument_error(
- "You must specify input filenames and/or text", Locator());
+ "You must specify input filenames and/or text", Locator::none());
}
auto [target, output_dir, output_basename, output_filename,
diff --git a/doc/edit/emacs/klammertext-indent.el b/doc/edit/emacs/klammertext-indent.el
index 3debd17..1f6641b 100644
--- a/doc/edit/emacs/klammertext-indent.el
+++ b/doc/edit/emacs/klammertext-indent.el
@@ -133,8 +133,7 @@ same way the font-lock scanner steps over them."
((and (= len 1)
(member name klammertext-literal-klammers))
;; Verbatim interior: find the closing NAME@ by name.
- (if (re-search-forward
- (concat (regexp-quote name) "@") nil t)
+ (if (klammertext--search-literal-close name)
(when (< pos (point))
(setq opaque t done t))
(setq opaque t done t))) ; never closed
diff --git a/doc/edit/emacs/klammertext-mode.el b/doc/edit/emacs/klammertext-mode.el
index c393990..3de0dea 100644
--- a/doc/edit/emacs/klammertext-mode.el
+++ b/doc/edit/emacs/klammertext-mode.el
@@ -131,15 +131,32 @@ Register one with `klammertext-add-literal-klammer', e.g. in your init file:
;; doc/edit/sublime/Klammertext.sublime-syntax
;; * the @NAME verbatim region in doc/edit/vim/syntax/klammertext.vim
;; * the @NAME rule in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
-;; All are currently seeded with just "code".
+;; All are currently seeded with "code" and "c" (@c is the inline form of
+;; @code and took a literal argument 2026-08-16; the miscounted stack from an
+;; unrecognized "@c ... c@" shifted a whole document's indentation by one).
(defun klammertext-add-literal-klammer (name)
"Register NAME as a klammer whose literal content must not be interpreted.
NAME is the klammer name without the leading @ (e.g. \"code\")."
(add-to-list 'klammertext-literal-klammers name))
+(defun klammertext--search-literal-close (name &optional bound)
+ "Move point past the exact close token NAME@ at or after point.
+Return the position after the close, or nil if there is none before BOUND.
+NAME@ preceded by a name character is verbatim content, not a close --
+\"basic@\" does not close @c, and \"barcode@\" does not close @code
+\(the engine's close is a whole katom). Mirrors the shared core's
+find_literal_close."
+ (let ((close (concat (regexp-quote name) "@")) (found nil))
+ (while (and (setq found (re-search-forward close bound t))
+ (let ((b (match-beginning 0)))
+ (and (> b (point-min))
+ (klammertext--name-char-p (char-before b))))))
+ found))
+
;; Seed the list through the same entry point future users will use.
(klammertext-add-literal-klammer "code")
+(klammertext-add-literal-klammer "c")
;; --- Helpers -----------------------------------------------------------
@@ -275,13 +292,12 @@ BEFORE is the character before POS."
;; BEFORE `klammertext--set-match', because `re-search-forward'
;; clobbers the match data.
(if (member name klammertext-literal-klammers)
- (let ((close (concat (regexp-quote name) "@")))
- (if (re-search-forward close nil t)
- (let ((close-end (point)))
- (put-text-property pos close-end 'font-lock-multiline t)
- (goto-char (- close-end (length name) 1)))
- (put-text-property pos (point-max) 'font-lock-multiline t)
- (goto-char (point-max))))
+ (if (klammertext--search-literal-close name)
+ (let ((close-end (point)))
+ (put-text-property pos close-end 'font-lock-multiline t)
+ (goto-char (- close-end (length name) 1)))
+ (put-text-property pos (point-max) 'font-lock-multiline t)
+ (goto-char (point-max)))
(goto-char name-end))
;; Set the match data for the opening LAST, so it survives to the
;; highlight step.
@@ -478,8 +494,8 @@ delimiter (or skipped region) and return (POS . KIND) with KIND `open or
(let ((name (buffer-substring-no-properties (1+ hit) (point))))
(cond
((member name klammertext-literal-klammers) ; literal span: skip
- (let ((close (concat (regexp-quote name) "@")))
- (unless (re-search-forward close nil t) (goto-char (point-max)))))
+ (unless (klammertext--search-literal-close name)
+ (goto-char (point-max))))
((eq (char-after) ?-)) ; @name-arg : no span
(t (throw 'found (cons hit 'open))))))
(t ; name@ / bare @ : closing
@@ -574,7 +590,7 @@ name, not by depth counting."
The verbatim content is opaque, so we search for the literal close string."
(save-excursion
(goto-char (+ open-pos 1 (length name)))
- (when (search-forward (concat name "@") nil t)
+ (when (klammertext--search-literal-close name)
(1- (point)))))
(defun klammertext--literal-match-backward (close-pos name)
@@ -586,7 +602,11 @@ CLOSE-POS, or nil. Literal spans do not nest, so the nearest preceding real
(let ((open-str (concat "@" name)) (result nil))
(while (and (not result) (search-backward open-str nil t))
(let ((op (point)))
+ ;; The name must end where the token ends: "@c" found inside
+ ;; "@caption" is not an opener of @c.
(unless (or (eq (char-before op) ?@) ; @@NAME = definition
+ (klammertext--name-char-p
+ (char-after (+ op 1 (length name))))
(klammertext--escaped-p op))
(setq result op))))
result)))
diff --git a/doc/edit/shared/klammertext_edit.py b/doc/edit/shared/klammertext_edit.py
index 6eae53c..f246ff4 100644
--- a/doc/edit/shared/klammertext_edit.py
+++ b/doc/edit/shared/klammertext_edit.py
@@ -36,10 +36,10 @@
# * the Emacs defcustoms (klammertext-literal-klammers, -transparent-,
# -code-, -align-klammers, -indent-offset, -align-cell-max, -align-row-max)
# in doc/edit/emacs/klammertext-mode.el / -indent.el / -align.el
-# * the '@code' rule + literal_code context in
+# * the '@code'/'@c' rules + literal_code/literal_c contexts in
# doc/edit/sublime/Klammertext.sublime-syntax
-# * the '@code' verbatim region in doc/edit/vim/syntax/klammertext.vim
-# * the '@code' rule in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
+# * the '@code'/'@c' verbatim regions in doc/edit/vim/syntax/klammertext.vim
+# * the '@code'/'@c' rules in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
#
# Installation note: editors locate this file either next to their own plugin
# files (a vendored copy, placed there by doc/make_editing_zip.sh), as
@@ -57,7 +57,7 @@ import sys
# Klammer names whose content is a literal argument (verbatim interior,
# closed by a named NAME@ delimiter).
-LITERAL_KLAMMERS = set(["code"])
+LITERAL_KLAMMERS = set(["code", "c"])
# Klammers that contribute no indentation level (a @document's paragraphs
# stay at the left margin).
@@ -91,6 +91,19 @@ def name_char_p(ch):
or ('0' <= ch <= '9') or ch == '_')
+def find_literal_close(s, name, start):
+ """Index of the exact close token NAME@ at or after START, or -1.
+ The engine's close is a whole katom, so NAME@ preceded by a name
+ character is content, not a close -- "basic@" does not close @c, and
+ "barcode@" does not close @code. Single-letter literal names (@c) make
+ this guard essential rather than theoretical."""
+ close = name + '@'
+ idx = s.find(close, start)
+ while idx > 0 and name_char_p(s[idx - 1]):
+ idx = s.find(close, idx + 1)
+ return idx
+
+
def escaped_p(s, pos):
"""True if the char at POS is escaped by an odd run of ^ before it.
In Klammertext ^# and ^@ are literal, so such a char is not a delimiter."""
@@ -188,9 +201,8 @@ def next_app_delim(s, i, limit):
name = s[hit + 1:k]
after = s[k] if k < n else None
if name in LITERAL_KLAMMERS: # literal span: skip to its close
- close = name + '@'
- idx = s.find(close, k)
- i = n if idx == -1 else idx + len(close)
+ idx = find_literal_close(s, name, k)
+ i = n if idx == -1 else idx + len(name) + 1
continue
elif after == '-': # @name-arg : opens no span
i = k
@@ -318,7 +330,7 @@ def literal_match_forward(s, open_pos, name):
"""Index of the @ of the NAME@ that closes the literal @NAME at OPEN_POS, or
None. The content is opaque, so search for the literal close string."""
start = open_pos + 1 + len(name)
- idx = s.find(name + '@', start)
+ idx = find_literal_close(s, name, start)
return idx + len(name) if idx != -1 else None
@@ -333,7 +345,10 @@ def literal_match_backward(s, close_pos, name):
if idx == -1:
return None
before = s[idx - 1] if idx > 0 else None
- if before != '@' and not escaped_p(s, idx):
+ # The name must end where the token ends: "@c" found inside
+ # "@caption" is not an opener of @c.
+ follower = s[idx + len(open_str)] if idx + len(open_str) < len(s) else None
+ if before != '@' and not name_char_p(follower) and not escaped_p(s, idx):
return idx
end = idx
@@ -446,7 +461,7 @@ def state_at(s, pos):
i = k
if run_len == 1 and name in LITERAL_KLAMMERS:
# Verbatim interior: find the closing NAME@ by name.
- idx = s.find(name + '@', k)
+ idx = find_literal_close(s, name, k)
if idx == -1: # never closed
return (stack, True)
close_end = idx + len(name) + 1
@@ -584,7 +599,7 @@ def enclosing_span(s, pos, names):
name = s[run_end:k]
i = k
if run_len == 1 and name in LITERAL_KLAMMERS:
- idx = s.find(name + '@', k)
+ idx = find_literal_close(s, name, k)
if idx == -1:
break
i = idx + len(name) + 1
@@ -692,7 +707,7 @@ def scan_lines(content):
name = content[run_end:k]
i = k
if run_len == 1 and name in LITERAL_KLAMMERS:
- idx = content.find(name + '@', k)
+ idx = find_literal_close(content, name, k)
e = n if idx == -1 else idx + len(name) + 1
if line_index(max(hit, e - 1)) != line_index(hit):
block_range(hit, e)
@@ -886,7 +901,7 @@ def diagnostics(s):
name = s[run_end:k]
i = k
if run_len == 1 and name in LITERAL_KLAMMERS:
- idx = s.find(name + '@', k)
+ idx = find_literal_close(s, name, k)
if idx == -1:
probs.append({'start': hit, 'end': k,
'message': ("literal klammer @%s has no "
diff --git a/doc/edit/sublime/Klammertext.sublime-syntax b/doc/edit/sublime/Klammertext.sublime-syntax
index 90bb8b9..9475725 100644
--- a/doc/edit/sublime/Klammertext.sublime-syntax
+++ b/doc/edit/sublime/Klammertext.sublime-syntax
@@ -27,7 +27,7 @@
# caret, a leftover single ^ escapes the following character —
# the '\^.' rule reproduces exactly that parity.)
#
-# Literal klammers: @code ... code@ interior is verbatim (no # or @
+# Literal klammers: @code ... code@ and @c ... c@ interior is verbatim (no # or @
# interpreted). To add another literal klammer 'foo', copy the
# '@code' rule and the 'literal_code' context below, replacing
# code -> foo.
@@ -43,7 +43,7 @@
# * the @NAME verbatim region in doc/edit/vim/syntax/klammertext.vim
# * the @NAME rule in
# doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
-# All are currently seeded with just 'code'.
+# All are currently seeded with 'code' and 'c'.
#
# ---------------------------------------------------------------------------
# How open vs. close is decided (the same rule the Emacs scanner uses):
@@ -131,10 +131,13 @@ contexts:
scope: punctuation.definition.comment.klammertext
push: removal_line
- # --- literal klammer: interior is verbatim (seeded default: @code) ---
+ # --- literal klammers: interior is verbatim (seeded: @code and @c) ---
- match: '@code(?![A-Za-z0-9_])'
scope: entity.name.function.begin.klammertext
push: literal_code
+ - match: '@c(?![A-Za-z0-9_])'
+ scope: entity.name.function.begin.klammertext
+ push: literal_c
# --- system / target commands @@@ ---
- match: '@@@{{name}}'
@@ -185,3 +188,11 @@ contexts:
- match: 'code@'
scope: entity.name.function.end.klammertext
pop: true
+
+ # @c ... c@ — the inline form of @code, same verbatim interior. The
+ # lookbehind keeps a word ending in c ("basic@") from closing the span —
+ # essential for a single-letter name.
+ literal_c:
+ - match: '(? foo (and mirror
" it in the Emacs, Sublime, and VS Code artifacts; all are seeded with
-" just 'code').
+" 'code' and 'c').
"
" How open vs. close is decided (the same rule as every other integration):
" a delimiter whose NAME follows the @-run (@name) is an OPENING; a bare
@@ -76,12 +76,16 @@ syn match klammertextAppOpen /@\@1 foo (and mirror it in the Emacs, Sublime,",
- "and Vim artifacts; all are seeded with just 'code').",
+ "and Vim artifacts; all are seeded with 'code' and 'c').",
"",
"Delimiter matching, indentation, alignment, and diagnostics are not",
"tokenizer concerns — they come from the Klammertext language server",
@@ -77,6 +77,16 @@
"0": { "name": "entity.name.function.end.klammertext" }
}
},
+ {
+ "begin": "@c(?![A-Za-z0-9_])",
+ "beginCaptures": {
+ "0": { "name": "entity.name.function.begin.klammertext" }
+ },
+ "end": "(? 1) {
- throw Argument_error("Multiple katoms for positional argument: " +
+ throw Argument_error("Multiple katoms for positional argument:\n " +
as_string(katoms.begin(), katoms.end(), true) +
"\nPositional arguments are separated by the bar (|) character.",
- katoms[0].m_loc, false);
+ katoms[0].m_loc);
}
Katom k = katoms[0];
std::string name = k.m_text;
@@ -367,7 +367,7 @@ void Parameter_set::check_positional(const katom_lists& positional_arguments, co
ss << " " << arg.m_name << "\n";
}
//std::cout << ss.str();
- throw Argument_error(ss.str(), loc, false);
+ throw Argument_error(ss.str(), loc);
} else if (positional_count < given_count) {
//std::cout << "DESCRIBE\n";
//describe_parameters();
@@ -391,7 +391,7 @@ Parameter_set::check_optional(const katom_lists& optional_arguments, const Locat
}
if (std::ranges::count(optional_names_used, name) > 0) {
throw Argument_error("Optional argument \":" + name + "\" already provided "
- + "with a value of:\n" + values[name], loc, false);
+ + "with a value of:\n" + values[name], loc);
}
katom_list value_katoms(opt.begin()+1, opt.end());
std::string value = trim(to_string(value_katoms));
@@ -477,9 +477,12 @@ void Parameter_set::validate(
std::stringstream ss {};
ss << "The value \"" << value << "\" given for the argument \""
<< parameter.m_name << "\" does not match the \"" << argtype.m_name
- << "\" argument type:\n\n"
- << trim(argtype.m_desc) << "\n";
- throw Argument_error(ss.str(), loc, false);
+ << "\" argument type.\n\nThe \"" << argtype.m_name << "\" argument type describes "
+ // A .k description is prose: collapse its source layout (line
+ // breaks, continuation indentation) so it re-flows with the
+ // sentence -- indented .k lines would otherwise read as verbatim.
+ << collapse_whitespace(argtype.m_desc) << "\n";
+ throw Argument_error(ss.str(), loc);
}
}
@@ -520,7 +523,7 @@ std::string replace_arguments(
}
ss << "To prevent the \"*\" character from specifying an argument, "
<< "precede it with the \"^\" character.";
- throw Argument_error(ss.str(), loc, false);
+ throw Argument_error(ss.str(), loc);
}
return result;
}
diff --git a/mac/argv.cpp b/mac/argv.cpp
index 98aa0bc..b6421d3 100644
--- a/mac/argv.cpp
+++ b/mac/argv.cpp
@@ -257,7 +257,7 @@ void Argv::check_flags_and_options(const std::string& command, strings_t& words)
for (auto w : not_defined) {
ss << " " << w << "\n";
}
- throw Argument_error(ss.str(), Locator(), false);
+ throw Argument_error(ss.str(), Locator::none());
}
}
@@ -288,7 +288,7 @@ void Argv::parse_flags(strings_t& words, string_map& named_args)
for (auto w : not_defined) {
ss << " " << w << "\n";
}
- throw Argument_error(ss.str(), Locator(), false);
+ throw Argument_error(ss.str(), Locator::none());
}
*/
// std::cout << "Flags found: " << flag_args << "\n";
@@ -320,16 +320,29 @@ void Argv::parse_optional(strings_t& words, string_map& named_args)
"The option " + flag_name(opt) + " needs a value: " +
m_args[opt].m_syntax + ". Enter \"" +
file_basename(command_name) + "\" for the list of arguments.",
- Locator(), false);
+ Locator::none());
}
std::string opt_arg = words[index] + " ";
index++;
std::regex opt_regex = m_args[opt].m_rgx;
- // std::cout << "Regex match? " << std::regex_match(trim(opt_arg), opt_regex) << "\n";
-
- while (index < words.size() && words[index][0] != '-'
- // && std::regex_match(trim(opt_arg), opt_regex)
+ // Only a LIST-valued option may span several argv words
+ // (--klammersets a b c; --katoms type index). A single-valued
+ // option takes exactly ONE argv element -- argv boundaries are
+ // authoritative -- so a positional written after it is not
+ // swallowed into the value. The former unconditional run
+ // consumed everything up to the next dash-word: "-s 'text'
+ // doc.kt" folded doc.kt into the -s TEXT (and rendered its
+ // pathname), and "--klammersets none doc.kt" consumed the input
+ // filename, so ktext reported that none was given. -s must be
+ // usable in any argv position: its role with a file argument is
+ // to modify the interpretation of that file, and it is
+ // evaluated first regardless of where it is written. A list
+ // value stops at the "--" delimiter (it begins with '-'), which
+ // remains the escape for filenames after a list option.
+ bool list_valued = m_args[opt].m_rgx_symbol == "'list'"
+ || m_args[opt].m_rgx_symbol == "'katom_display'";
+ while (list_valued && index < words.size() && words[index][0] != '-'
&& std::regex_match(trim(opt_arg + words[index]), opt_regex)
) {
@@ -370,7 +383,7 @@ void Argv::parse_positional(const std::string& command, //strings_t words,
}
std::stringstream ss {};
ss << "The argument " << q_(req) << " was not found in:\n " << command;
- throw Argument_error(ss.str(), Locator(), false);
+ throw Argument_error(ss.str(), Locator::none());
}
named_args[req] = substring;
// Drop the whitespace that separated this positional from the next.
@@ -467,7 +480,7 @@ void Argv::check_flags(const string_map& arg_map, const std::string& command)
for (const std::string& undef : undefined) {
ss << " " << flag_name(undef);
}
- throw Argument_error(ss.str(), Locator());
+ throw Argument_error(ss.str(), Locator::none());
}
}
@@ -508,7 +521,7 @@ void Argv::parse(int argc, char* argv[], bool full_parse)
usage(file_basename(argv[0]));
throw Argument_error(
"Incorrect value for option \"" + name + "\":\n" + m_args[name].m_desc + "\n",
- Locator(), false);
+ Locator());
}
}
}
@@ -594,7 +607,8 @@ int Argv::as_verbosity(const std::string& name)
(void)K::log(2, name);
auto value = get(name);
if (!std::regex_match(value, std::regex(regex_symbols["'verbosity'"]))) {
- throw Argument_error("Argument \"" + value + "\" is not a verbosity level");
+ throw Argument_error("Argument \"" + value + "\" is not a verbosity level",
+ Locator::none());
}
return std::stoi(value);
}
diff --git a/mac/character.cpp b/mac/character.cpp
index 23bea89..429a869 100644
--- a/mac/character.cpp
+++ b/mac/character.cpp
@@ -36,6 +36,9 @@ std::string utf8char(int cp)
c[1] = ((cp>>12)&63)+128;
c[2] = ((cp>>6)&63)+128;
c[3]=(cp&63)+128;
+ } else {
+ // Reachable since ^UUUU^ accepts 6 hex digits: FFFFFF > 10FFFF.
+ return "Invalid Unicode: " + std::to_string(cp);
}
return std::string(c);
}
@@ -65,7 +68,13 @@ std::string unicode_hex_to_char(std::string s, int width=4) //, std::string mark
std::string process_diacritics(std::string s)
{
(void)K::log(4);
- std::regex diacritic_re("\\^([^\\s`'~@|^:*#])([" + diacritic_symbols + "])");
+ // The base may not be whitespace, a digit, or ASCII punctuation (the
+ // four ranges !-/ :-@ [-` {-~): a diacritic sits on a letter. Without
+ // the exclusion, ^ before a quoted punctuation character followed by a
+ // mark character composed nonsense -- ^-- became a hyphen with a macron
+ // instead of a literal hyphen before a hyphen. A multi-byte (non-ASCII)
+ // base is unaffected: its bytes are outside every excluded range.
+ std::regex diacritic_re("\\^([^\\s0-9!-/:-@\\[-`{-~])([" + diacritic_symbols + "])");
std::string result {s};
std::sregex_iterator end {};
@@ -126,9 +135,14 @@ std::string process_unicode_codepoint(std::string s)
//return std::regex_replace(s, unicode_re, hidehat + "$1" + hidehat);
std::string result {s};
std::sregex_iterator end {};
- for (std::sregex_iterator p { s.begin(), s.end(), unicode_re }; p!= end; ++p) {
+ for (std::sregex_iterator p { s.begin(), s.end(), unicode_re }; p!= end; ++p) {
std::regex hit_re { regex_escape((*p)[0].str()) };
- result = std::regex_replace(result, hit_re, unicode_hex_to_char((*p)[1].str()));
+ // Convert the captured hex DIRECTLY: unicode_hex_to_char() re-scans
+ // its argument at a fixed width, and its 4-digit default truncated a
+ // 5-digit code point to its first four digits (^13000^ rendered as
+ // U+1300 followed by a literal "0").
+ result = std::regex_replace(result, hit_re,
+ utf8char(std::stoi((*p)[1].str(), nullptr, 16)));
}
return result;
diff --git a/mac/character.h b/mac/character.h
index 844cbf0..0e9b345 100644
--- a/mac/character.h
+++ b/mac/character.h
@@ -11,7 +11,11 @@
#include
#include
-const std::regex unicode_re(R"(\^(([0-9A-Fa-f]{5})|([0-9A-Fa-f]{4})|([0-9A-Fa-f]))\^)");
+// ^UUUU^ — a Unicode code point in hex, 4 to 6 digits (^263A^ is the BMP,
+// ^13000^ EGYPTIAN HIEROGLYPH A001, ^10FFFD^ the top of the range), or a
+// single digit. Lengths 2-3 are NOT accepted: two- and three-letter
+// sequences of a-f collide with the ^s^-style mnemonic names.
+const std::regex unicode_re(R"(\^(([0-9A-Fa-f]{4,6})|([0-9A-Fa-f]))\^)");
const std::regex unicode_hide_re(R"(=([0-9A-Fa-f]{2})=)");
const std::regex hex2_re(R"(([0-9A-Fa-f]{2}))");
const std::regex hex4_re(R"(([0-9A-Fa-f]{4}))");
diff --git a/mac/command.cpp b/mac/command.cpp
index 1ec4a0d..0d64005 100644
--- a/mac/command.cpp
+++ b/mac/command.cpp
@@ -9,7 +9,16 @@ using namespace std::string_literals;
fs::path construct_command_pathname(char* command)
{
- return fs::path(fs::current_path().string() + "/" + std::string(command));
+ // The pseudo source file for command-line string input (-s, kdiag's
+ // input): a file IN THE CWD named after the command, so that a relative
+ // @read in string input resolves against the directory the user ran the
+ // command from -- the string-input analog of "a document's relative
+ // names resolve against the document". The basename matters: the
+ // former "/" string concatenation anchored resolution at
+ // the BINARY's directory whenever argv[0] was absolute
+ // (/usr/local/bin/ktext, a wrapper script), so every relative @read in
+ // -s input searched bin/ instead of the cwd.
+ return fs::current_path() / fs::path(command).filename();
}
void set_verbose_level(int argc, char* argv[])
@@ -124,7 +133,7 @@ void load_klammersets(Machine& machine, const strings_t& symbols)
"The klammerset \"none\" cannot be combined with other klammersets: "
"it means that none is loaded. Give \"none\" alone, or name only the "
"klammersets to load.",
- Locator());
+ Locator::none());
}
if (symbols.empty()) {
// Which klammerset was loaded, and from where, is a DERIVED value: the
@@ -146,7 +155,7 @@ void load_klammersets(Machine& machine, const strings_t& symbols)
continue;
}
std::string klammerset_filename = resolve_klammerset_symbol(
- symbol, machine.m_state.value("K_input_dir"), Locator()).string();
+ symbol, machine.m_state.value("K_input_dir"), Locator::none()).string();
// Symbol -> file is the search path's answer, and the search
// path has three stages with shadowing: the file it landed on is
// exactly what a user cannot read off "--klammersets x".
diff --git a/mac/error.cpp b/mac/error.cpp
index cb38f28..5171d99 100644
--- a/mac/error.cpp
+++ b/mac/error.cpp
@@ -23,8 +23,7 @@ void Error::print_message(const std::string& epilog)
if (epilog != "") {
m_desc += "\n\n" + epilog + "\n";
}
- if (m_just)
- m_desc = justify(m_desc, 80, 0);
+ m_desc = justify(m_desc, 80, 0);
std::cerr << "\n" << red << command_name << " (" << m_type << " error)";
if (display_source(m_loc.m_filename)) {
std::cerr << ":\n " << m_loc.m_filename;
diff --git a/mac/error.h b/mac/error.h
index c90bed3..202bbea 100644
--- a/mac/error.h
+++ b/mac/error.h
@@ -11,11 +11,10 @@ inline std::string command_pathname { "Pathname of command executed on the comma
class Error : std::exception {
public:
Error(const std::string& error_type, const std::string& description,
- const Locator& locator = Locator(), bool do_justify = true)
+ const Locator& locator = Locator())
: m_type(error_type)
, m_desc(description)
, m_loc(locator)
- , m_just(do_justify)
{}
void print_message(const std::string& epilog="");
@@ -23,57 +22,56 @@ public:
std::string m_type {};
std::string m_desc {};
Locator m_loc; // {};
- bool m_just { true };
};
class Parsing_error : public Error {
public:
explicit Parsing_error(
- const std::string& description, const Locator& locator=Locator(), bool do_justify=true)
- : Error("parsing", description, locator, do_justify) {};
+ const std::string& description, const Locator& locator=Locator())
+ : Error("parsing", description, locator) {};
};
class File_error : public Error {
public:
explicit File_error(
- const std::string& description, const Locator& locator=Locator(), bool do_justify=true)
- : Error("file", description, locator, do_justify) {};
+ const std::string& description, const Locator& locator=Locator())
+ : Error("file", description, locator) {};
};
class Target_error : public Error {
public:
explicit Target_error(
- const std::string& description, const Locator& locator=Locator(), bool do_justify=true)
- : Error("target", description, locator, do_justify) {};
+ const std::string& description, const Locator& locator=Locator())
+ : Error("target", description, locator) {};
};
class Klammerset_error : public Error {
public:
explicit Klammerset_error(
- const std::string& description, const Locator& locator=Locator(), bool do_justify=true)
- : Error("klammerset", description, locator, do_justify) {};
+ const std::string& description, const Locator& locator=Locator())
+ : Error("klammerset", description, locator) {};
};
class Definition_error : public Error {
public:
explicit Definition_error(
- const std::string& description, const Locator& locator=Locator(), bool do_justify=true)
- : Error("definition", description, locator, do_justify) {};
+ const std::string& description, const Locator& locator=Locator())
+ : Error("definition", description, locator) {};
};
class Argument_error : public Error {
public:
explicit Argument_error(
- const std::string& description, const Locator& locator=Locator(), bool do_justify=true)
- : Error("argument", description, locator, do_justify) {};
+ const std::string& description, const Locator& locator=Locator())
+ : Error("argument", description, locator) {};
};
class Environment_error : public Error {
public:
explicit Environment_error(
- const std::string& description, const Locator& locator=Locator(), bool do_justify=true)
- : Error("environment", description, locator, do_justify) {};
+ const std::string& description, const Locator& locator=Locator())
+ : Error("environment", description, locator) {};
};
// Klammer application nested deeper than the engine's limit. Raised by the
@@ -83,13 +81,13 @@ public:
class Recursion_error : public Error {
public:
explicit Recursion_error(
- const std::string& description, const Locator& locator=Locator(), bool do_justify=true)
- : Error("recursion", description, locator, do_justify) {};
+ const std::string& description, const Locator& locator=Locator())
+ : Error("recursion", description, locator) {};
};
class Internal_error : public Error {
public:
explicit Internal_error(
- const std::string& description, const Locator& locator=Locator(), bool do_justify=true)
- : Error("internal", description, locator, do_justify) {};
+ const std::string& description, const Locator& locator=Locator())
+ : Error("internal", description, locator) {};
};
diff --git a/mac/eval.cpp b/mac/eval.cpp
index 1c03ab1..8784143 100644
--- a/mac/eval.cpp
+++ b/mac/eval.cpp
@@ -42,8 +42,8 @@ std::string shell(State state, std::string command, Locator loc)
err_template.push_back('\0');
int err_fd = mkstemp(err_template.data());
if (err_fd == -1) {
- throw Environment_error("Could not create a temporary file for the "
- "command's error output", loc, false);
+ throw Environment_error(
+ "Could not create a temporary file for the command's error output", loc);
}
close(err_fd);
err_path = err_template.data();
@@ -55,7 +55,7 @@ std::string shell(State state, std::string command, Locator loc)
if (!pipe) {
fs::remove(err_path);
throw Environment_error(
- "Could not run command:\n" + command, loc, false);
+ "Could not run command:\n " + command, loc);
}
char buffer[128];
std::string result = "";
@@ -87,7 +87,7 @@ std::string shell(State state, std::string command, Locator loc)
// "environment", not "parsing": the failure is OUTSIDE Klammertext,
// in the command the document invoked -- the same class as a missing
// xelatex or an unset environment variable.
- throw Environment_error(ss.str(), loc, false);
+ throw Environment_error(ss.str(), loc);
}
if (!error_output.empty()) {
(void)K::log(1, "shell command wrote to stderr:", command,
@@ -122,8 +122,8 @@ std::string run_haskell(const std::string& hsfile, Locator loc)
err_template.push_back('\0');
int err_fd = mkstemp(err_template.data());
if (err_fd == -1) {
- throw Environment_error("Could not create a temporary file for runghc's "
- "error output", loc, false);
+ throw Environment_error(
+ "Could not create a temporary file for runghc's error output", loc);
}
close(err_fd);
err_path = err_template.data();
@@ -132,7 +132,7 @@ std::string run_haskell(const std::string& hsfile, Locator loc)
FILE* pipe = popen(command.c_str(), "r");
if (!pipe) {
fs::remove(err_path);
- throw Environment_error("Could not run runghc.", loc, false);
+ throw Environment_error("Could not run runghc.", loc);
}
char buffer[128];
std::string result = "";
@@ -154,12 +154,12 @@ std::string run_haskell(const std::string& hsfile, Locator loc)
}
ss << ".";
if (!error_output.empty()) {
- ss << "\nrunghc reported:\n" << error_output;
+ ss << "\n\nrunghc reported:\n " << error_output;
}
// A compile error arrives here, and so does a program that ran and then
// exited nonzero; the message does not guess which, it shows what
// runghc said.
- throw Environment_error(ss.str(), loc, false);
+ throw Environment_error(ss.str(), loc);
}
if (!error_output.empty()) {
// GHC's warnings, and anything the program itself wrote to stderr.
@@ -175,9 +175,9 @@ std::string haskell(State state, std::string code, Locator loc)
// "environment", not "parsing": a missing external command is the same
// class as a missing xelatex, and "parsing error" misdescribes it.
throw Environment_error(
- "@eval with the :haskell argument requires runghc, which was not found in PATH.\n"
+ "@eval with the :haskell argument requires runghc, which was not found in PATH.\n\n"
"Install it using GHCup; see https://www.haskell.org/ghcup/install/.",
- loc, false);
+ loc);
}
code = state.subst(code);
@@ -193,7 +193,7 @@ std::string haskell(State state, std::string code, Locator loc)
int fd = mkstemp(tmppath.data());
if (fd < 0) {
throw Environment_error(
- "Could not create temporary file for Haskell evaluation.", loc, false);
+ "Could not create temporary file for Haskell evaluation.", loc);
}
std::string hsfile = std::string(tmppath.data()) + ".hs";
close(fd);
@@ -202,8 +202,7 @@ std::string haskell(State state, std::string code, Locator loc)
FILE* f = fopen(hsfile.c_str(), "w");
if (!f) {
unlink(hsfile.c_str());
- throw Parsing_error(
- "Could not write temporary Haskell file.", loc, false);
+ throw Parsing_error("Could not write temporary Haskell file.", loc);
}
fprintf(f, "%s\n", code.c_str());
fclose(f);
@@ -222,7 +221,7 @@ void check_cpp_arguments(katom_list args, Locator loc)
<< "or\n"
<< " @eval :cpp @\n"
<< "In the first case, the library basename is used for the function name.";
- throw Argument_error(ss.str(), loc, false);
+ throw Argument_error(ss.str(), loc);
}
}
@@ -279,7 +278,7 @@ std::string Eval::eval_command(katom_iter begin, katom_iter end)
if (dir.empty() || !fs::is_directory(dir)) {
throw Argument_error(
"The :cwd directory does not exist: \"" + dir + "\"",
- begin->m_loc, false);
+ begin->m_loc);
}
cwd_guard.emplace(dir);
first = resume;
diff --git a/mac/eval_cpp.cpp b/mac/eval_cpp.cpp
index 9181a40..533daf1 100644
--- a/mac/eval_cpp.cpp
+++ b/mac/eval_cpp.cpp
@@ -34,7 +34,7 @@ std::string Eval_cpp::eval(const fs::path& library_path, const std::string& func
std::stringstream ss {};
ss << "Cannot load symbol " << function_name
<< " from library " << library_path << ":\n " << error_desc;
- throw File_error(ss.str(), m_loc, false);
+ throw File_error(ss.str(), m_loc);
}
std::string result = func(m_machine);
dlclose(handle);
diff --git a/mac/eval_python.cpp b/mac/eval_python.cpp
index 88faebc..1a6f0c5 100644
--- a/mac/eval_python.cpp
+++ b/mac/eval_python.cpp
@@ -40,6 +40,21 @@ Eval_python::Eval_python(Machine& machine, const Locator& loc)
Py_DECREF(result_text);
}
import_module("inspect", false);
+ // The :after_apply phase dispatcher: a phase that declares a K
+ // parameter receives the state's class K; one that does not (a stdlib
+ // function like string.capwords, whose second parameter is a separator)
+ // is called with the text alone. Discrimination is by parameter NAME,
+ // not count -- capwords has two parameters and K is not one of them.
+ // Builtins whose signature inspect cannot read are treated as not
+ // wanting K.
+ PyRun_String(
+ "def K_phase_call(f, text, K):\n"
+ " try:\n"
+ " wants = 'K' in inspect.signature(f).parameters\n"
+ " except (ValueError, TypeError):\n"
+ " wants = False\n"
+ " return f(text, K=K) if wants else f(text)\n",
+ Py_file_input, m_globals, m_globals);
if (!m_machine.m_state.m_frames.empty()) {
PyRun_String(m_machine.m_state.python_code().c_str(), Py_file_input, m_globals, m_locals);
}
@@ -154,7 +169,7 @@ void Eval_python::import_module(const std::string& module_name, bool verify)
if (!detail.empty()) {
message += ":\n\n" + detail;
}
- throw Argument_error(message, m_loc, false);
+ throw Argument_error(message, m_loc);
}
// PyDict_SetItemString steals a reference, so we don't need to DECREF module
// The dictionary will own the reference
diff --git a/mac/file.cpp b/mac/file.cpp
index 63624db..6fe203e 100644
--- a/mac/file.cpp
+++ b/mac/file.cpp
@@ -160,7 +160,6 @@ strings_t group_filename_tokens(const strings_t& tokens, const std::string& base
// name exists. A name that never resolves is kept as given, so the
// missing-file error downstream reports what the user wrote.
size_t i = 0;
- bool reported = false; // at most one "tried" report per list
while (i < tokens.size()) {
if (filename_exists(tokens[i], base_dir)) {
result.push_back(tokens[i]);
@@ -188,27 +187,14 @@ strings_t group_filename_tokens(const strings_t& tokens, const std::string& base
}
}
if (!found) {
- // Nothing resolved. The groupings that were TRIED are what
- // the user needs at verbosity 0, because the downstream error
- // names only the first token and cannot say why the others
- // were not joined to it. Recorded on the name so the caller
- // can report them with the missing-file error.
- strings_t tried {};
- std::string acc2 = tokens[i];
- tried.push_back(q_(acc2));
- for (size_t k = i + 1; k < tokens.size(); ++k) {
- acc2 += " " + tokens[k];
- tried.push_back(q_(acc2));
- }
- // Once: the first unresolved token's list already shows
- // every joining from that point on, and one report per
- // leftover word buries it.
- if (tried.size() > 1 && !reported) {
- std::cerr << command_name << ": no file matches "
- << q_(tokens[i]) << "; tried "
- << join(tried, ", ") << "\n";
- reported = true;
- }
+ // Nothing resolved: the token passes through as written and
+ // its DOWNSTREAM OWNER decides -- a bare :files word is the
+ // kt/ shortcut (which resolves or errors there), a missing
+ // file is a located error. A failed rescue itself says
+ // NOTHING at any verbosity (2026-08-22): every outcome is
+ // already either the -v 1 resolution line or an error that
+ // stops the run, so a tried-list adds noise to the first and
+ // nothing to the second.
result.push_back(tokens[i]);
++i;
}
@@ -358,7 +344,7 @@ std::string find_file(const std::string& basename, strings_t search_path, bool e
std::sort(search_path.begin(), search_path.end());
ss << "File with basename \"" << basename << "\" not found in search path:\n "
<< join(search_path, "\n ");
- throw File_error(ss.str(), Locator(), false);
+ throw File_error(ss.str(), Locator());
}
return pathname;
}
diff --git a/mac/katom.cpp b/mac/katom.cpp
index 8550552..31e141b 100644
--- a/mac/katom.cpp
+++ b/mac/katom.cpp
@@ -131,7 +131,7 @@ void warn_unparsed_katoms(katom_list& katoms, bool warn)
// commands issue has one format: it said "[warning]" where
// everything else says "(warning)".
warning("Word not parsed:\n " + k.m_text
- + "\nTo include a special character (@, |, #, and ^), put "
+ + "\nTo include a punctuation character literally, put "
"\"^\" before it.",
k.m_loc);
k.m_unparsed = false;
diff --git a/mac/katom_list.cpp b/mac/katom_list.cpp
index e822149..3fcd3bb 100644
--- a/mac/katom_list.cpp
+++ b/mac/katom_list.cpp
@@ -114,7 +114,7 @@ void missing_open(const Katom& k, bool error_exit)
std::stringstream ss {};
ss << "A klammer ends without a beginning: " << k;
if (error_exit) {
- throw Parsing_error(ss.str(), k.m_loc, false);
+ throw Parsing_error(ss.str(), k.m_loc);
} else {
std::cout << " " << ss.str() << "\n";
}
@@ -134,7 +134,7 @@ void missing_close(const katom_list& bounds, bool error_exit)
ss << " " << k.m_loc << " " << k.m_src << "\n";
}
if (error_exit) {
- throw Parsing_error(ss.str(), bounds[0].m_loc, false);
+ throw Parsing_error(ss.str(), bounds[0].m_loc);
} else {
std::cout << ss.str() << "\n";
}
@@ -148,7 +148,7 @@ void bad_close(const Katom& open, const Katom& close, bool error_exit)
<< " " << open.m_loc << " " << open << "\n"
<< " " << close.m_loc << " " << close;
if (error_exit) {
- throw Parsing_error(ss.str(), open.m_loc, false);
+ throw Parsing_error(ss.str(), open.m_loc);
} else {
std::cout << ss.str() << "\n";
}
@@ -174,7 +174,7 @@ void check_named_katom_span(const Katom& begin, const Katom& end)
ss << " A named end katom does not match:\n"
<< " " << begin.m_loc << " " << begin << "\n"
<< " " << end.m_loc << " " << end;
- throw Parsing_error(ss.str(), end.m_loc, false);
+ throw Parsing_error(ss.str(), end.m_loc);
}
}
}
@@ -370,6 +370,24 @@ void hide_special_katoms(katom_list& katoms)
definition_depth == 0 && code_depth == 0) {
k.m_text = hide_structural_characters(k.m_text);
}
+ // "^" before ANY punctuation character quotes it, not only the six
+ // Klammertext specials the katomizer knows: ^- is a literal hyphen
+ // (no dash transform), ^~ a literal tilde. The pair becomes the
+ // KTESC marker of the character here -- text-level, on writer-text
+ // katoms only, under the same span skips as the quoted specials
+ // above -- so code inside an @eval keeps its carets (grep '^-'
+ // reaches the shell intact) and ^'...'^ content (katom_t::literal,
+ // marked before this pass runs) stays exactly as typed. How the
+ // quoted character renders is the target's decision: the :escape
+ // and :resolve tables map its marker, and an unmapped marker
+ // decodes to the character itself. A katom this converts is no
+ // longer unparsed (the ^- warning would otherwise misfire).
+ if ((k.m_type == katom_t::word || k.m_type == katom_t::text) &&
+ definition_depth == 0 && code_depth == 0) {
+ if (hide_quoted_punctuation(k.m_text)) {
+ k.m_unparsed = false;
+ }
+ }
}
}
@@ -456,7 +474,7 @@ void check_bar_count(katom_iter begin, katom_iter end)
ss << "Incorrectly formatted @cond klammer. There should only be one or two bar characters:\n"
<< " @cond | | | @";
- throw Argument_error(ss.str(), begin->m_loc, false);
+ throw Argument_error(ss.str(), begin->m_loc);
}
}
diff --git a/mac/klammer.cpp b/mac/klammer.cpp
index e325c21..b6a1bf5 100644
--- a/mac/klammer.cpp
+++ b/mac/klammer.cpp
@@ -20,7 +20,7 @@ parse_name(const Target_registry& targets, const Katom& name_katom)
if (!std::regex_match(name_with_target, match, Klammer::name_re)) {
throw Parsing_error(
- "The klammer name \"" + name_with_target + "\" is not correctly defined. "
+ "The klammer name \"" + name_with_target + "\" is not correctly defined.\n\n"
"The form is \"\" for general klammers or \".\" "
"for a specialized target. Several targets that share one body are written as a "
"comma-separated list: \".,\". The klammer "
@@ -44,7 +44,7 @@ parse_name(const Target_registry& targets, const Katom& name_katom)
if (!targets.has(target_name)) {
throw Target_error(
"The target \"" + target_name + "\" in klammer definition \"" + name_with_target + "\" "
- "is not defined. Enter \"kdesc -t\" to see the targets defined by the Standard Klammer Set.",
+ "is not defined.\n\nEnter \"kdesc -t\" to see the targets defined by the Standard Klammer Set.",
name_katom.m_loc);
}
if (is_in(target_name, seen)) {
@@ -105,7 +105,7 @@ parse_definition_katoms(
" @@[.] :: @@ instance (uses .k parameters)\n"
" @@[.] ::: @@ override existing definition\n"
" @@[.] :::: @@ default (can be overridden)",
- begin->m_loc, false);
+ begin->m_loc);
}
katom_list parameter_katoms(begin, deftype);
@@ -274,7 +274,7 @@ void Klammer::disallow_instances() //Klammer::components declaration)
<< plural("instance", icount) << " (defined by \"::\"), but "
<< "no declarations (defined by a \".k\" target)";
throw Definition_error(
- error_list(ss.str(), instances), instances[0].loc, false);
+ error_list(ss.str(), instances), instances[0].loc);
}
}
@@ -295,7 +295,7 @@ bool Klammer::copy_to_instances(const Target_registry& targets)
<< plural("instance", icount) << " (defined by \"::\"), but "
<< dcount << " "<< plural("definition", dcount) << " (defined by \":\")";
throw Definition_error(
- error_list(ss.str(), definitions), definitions[0].loc, false);
+ error_list(ss.str(), definitions), definitions[0].loc);
} else {
copy_components(definitions[0].parameters, m_defs, targets);
return true;
@@ -336,7 +336,7 @@ void Klammer::check_for_multiple_general_klammers()
error_list(
"There is more than one general klammer (a klammer in which no target is defined)",
general_klammers),
- general_klammers[0].loc, false);
+ general_klammers[0].loc);
}
}
@@ -362,7 +362,7 @@ void Klammer::check_for_declaration_and_definitions()
".\nWrite \"::\" instead of \":\" so the definition takes its parameters "
"from the declaration",
definitions),
- declares[0].loc, false);
+ declares[0].loc);
}
}
}
@@ -384,7 +384,7 @@ void Klammer::copy_general_klammer_to_undefined(const Target_registry& targets)
throw Definition_error(
error_list("The general parameters are different than the defined parameters",
general_klammers),
- m_parameters.m_katoms[0].m_loc, false);
+ m_parameters.m_katoms[0].m_loc);
}
}
const auto& [target, deftype, parameters, body, varmap, loc] = general_klammers[0];
@@ -470,19 +470,19 @@ void Klammer::no_declarations(const Target_registry& targets)
// definitions by hand to find which one drifted.
std::stringstream ss {};
ss << "The parameters of klammer \"" << m_name
- << "\" are not the same for every target,\n"
+ << "\" are not the same for every target, "
"and there is no declaration (.k) target to define them once:\n";
for (const auto& def : m_defs) {
if (std::ranges::find(target_names, def.target) == target_names.end())
continue;
std::string target = def.target;
- target.resize(std::max(target.size(), size_t(6)), ' ');
- ss << " " << target << " " << parameter_signature(def.parameters)
- << "\n " << def.loc.desc() << "\n";
+ target.resize(std::max(target.size(), size_t(4)), ' ');
+ ss << " Target: " << target << " Parameters: " << parameter_signature(def.parameters)
+ << "\n " << def.loc.desc() << "\n";
}
ss << "Use a .k target to declare the parameters and describe the klammer,\n"
"and \"::\" with no parameters for each target's definition.";
- throw Definition_error(ss.str(), m_defs[0].loc, false);
+ throw Definition_error(ss.str(), m_defs[0].loc);
} else {
// std::cout << " All equal\n";
copy_components(m_defs[0].parameters, m_defs, targets);
@@ -506,7 +506,7 @@ void Klammer::many_declarations(const std::vector& declares
// std::cout << boldblack << "Many declarations\n" << black;
throw Definition_error(
error_list("More than one declaration (.k) klammer", declares),
- declares[0].loc, false);
+ declares[0].loc);
}
diff --git a/mac/klammerset_registry.cpp b/mac/klammerset_registry.cpp
index 8e94fcb..e54564e 100644
--- a/mac/klammerset_registry.cpp
+++ b/mac/klammerset_registry.cpp
@@ -206,9 +206,9 @@ fs::path resolve_klammerset_symbol(
}
throw Klammerset_error(
"The klammerset \"" + symbol + "\" was not found. A symbol x names the "
- "declaration file x/x.k in one of the search directories: "
- + join(dirs, ", ")
- + ". Enter \"kdesc --klammersets\" to list the available klammersets.",
+ "declaration file x/x.k in one of the search directories:\n "
+ + join(dirs, "\n ")
+ + "\nEnter \"kdesc --klammersets\" to list the available klammersets.",
loc);
}
diff --git a/mac/ktype.h b/mac/ktype.h
index 093e10d..8232195 100644
--- a/mac/ktype.h
+++ b/mac/ktype.h
@@ -192,7 +192,7 @@ std::vector katom_types {
Ktype(katom_t::ws_newline, "ws-newline", ws_newline_s, "Remove all whitespace, leaving newlines (default: 1)"),
Ktype(katom_t::special, "special-char", R"(\^[@|#^:*])", "Klammertext special character treated as regular text"),
//Ktype(katom_t::nonascii, "non-ascii-char", R"(\^\w(?:.|\^))", "Non-ASCII character"),
- Ktype(katom_t::nonascii, "non-ascii-char", R"((\^(\w(?:.|\^)))|(\^[A-Fa-f0-9]{1,5}\^))", "Non-ASCII character"),
+ Ktype(katom_t::nonascii, "non-ascii-char", R"((\^(\w(?:.|\^)))|(\^[A-Fa-f0-9]{1,6}\^))", "Non-ASCII character"),
// Ktype(katom_t::word, "word", R"([a-z][a-z0-9_]*)", "Lower-case letters, numbers, or underscore"),
// Ktype(katom_t::text, "text",
diff --git a/mac/locator.h b/mac/locator.h
index 95b6589..5963908 100644
--- a/mac/locator.h
+++ b/mac/locator.h
@@ -24,6 +24,14 @@ public:
, m_chr(int(location.column()))
{};
Locator(const fs::path& filename, int line, int chr);
+ // "No location": for errors with no document position -- command-line
+ // argument errors, lookups made on the command's behalf. NOT the same
+ // as Locator(): the default constructor captures the C++ CALL SITE via
+ // std::source_location (and a defaulted Locator parameter captures the
+ // CALLER), which is what leaked "argv.cpp, line 597" into user-facing
+ // errors (found by the error gallery, 2026-08-22). Locator() is for
+ // logging; errors either carry a real document location or this.
+ static Locator none() { return Locator(fs::path{}, -1, -1); }
std::string str(bool relative = false) const;
std::string desc(bool relative = false) const;
std::string abbrev(bool include_chr=true) const;
diff --git a/mac/machine.cpp b/mac/machine.cpp
index 49cc787..5a373a6 100644
--- a/mac/machine.cpp
+++ b/mac/machine.cpp
@@ -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 | | | @";
- 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();
}
diff --git a/mac/option_set_registry.cpp b/mac/option_set_registry.cpp
index a2a5f41..94b9329 100644
--- a/mac/option_set_registry.cpp
+++ b/mac/option_set_registry.cpp
@@ -27,7 +27,7 @@ void Option_set_registry::add(
if (deftype.m_initial_type == katom_t::klammer_instance) {
throw Definition_error(
"The option set " + q_(name) + " is declared with \"::\", which takes its "
- "parameters from a \".k\" declaration. An option set IS a declaration: "
+ "parameters from a \".k\" declaration. An option set is a declaration; "
"it declares its parameters itself, after \":\".",
begin->m_loc);
}
@@ -68,16 +68,16 @@ void Option_set_registry::add(
throw Definition_error(
"The option set " + q_(name) + " declares the positional "
+ plural("parameter", static_cast(names.size())) + " "
- + join(names, ", ") + ".\n"
+ + join(names, ", ") + ".\n\n"
"An option set declares only optional parameters -- names written with "
"a leading \":\".",
- begin->m_loc, false);
+ begin->m_loc);
}
if (parameters.m_optional.empty()) {
throw Definition_error(
- "The option set " + q_(name) + " declares no parameters.\n"
+ "The option set " + q_(name) + " declares no parameters.\n\n"
"The form is: @@" + name + ".o :name.argtype default ... : @@",
- begin->m_loc, false);
+ begin->m_loc);
}
// The members as written: these katoms are what is spliced into the
@@ -201,14 +201,14 @@ std::string definition_name(const std::string& klammer_name, const std::string&
std::stringstream ss {};
if (option_sets.has(name) && target_name == Target_registry::optionset_name) {
ss << "The option set " << q_(name) << " is used in the declaration of the option "
- << "set " << q_(klammer_name) << ".\n"
+ << "set " << q_(klammer_name) << ".\n\n"
<< "An option set is used only in the parameter list of a \".k\" declaration, "
<< "so a set does not include another set: a klammer that needs two "
<< "vocabularies names two sets, and each set stays a vocabulary that can "
<< "be learned whole.";
} else if (option_sets.has(name)) {
ss << "The option set " << q_(name) << " is used in the parameter list of "
- << definition_name(klammer_name, target_name) << ".\n"
+ << definition_name(klammer_name, target_name) << ".\n\n"
<< "An option set may be used only in the parameter list of a \".k\" "
<< "declaration, which is where a klammer's interface is declared once "
<< "for all of its targets. Declare "
@@ -216,15 +216,15 @@ std::string definition_name(const std::string& klammer_name, const std::string&
<< "an instance (\"::\"), which inherits the declared parameters.";
} else {
ss << "The klammer " << q_(name) << " is applied in the parameter list of "
- << definition_name(klammer_name, target_name) << ".\n"
+ << definition_name(klammer_name, target_name) << ".\n\n"
<< "A klammer application in a parameter list is not allowed: it is "
<< "resolved after the parameters are parsed, so the parameter list it "
<< "was meant to contribute is not there when the list is read. An "
<< "option set, declared with a \".o\" target, is how parameters are "
- << "shared between klammers. Declared option sets: "
+ << "shared between klammers.\n\nDeclared option sets: "
<< option_sets.available() << ".";
}
- throw Definition_error(ss.str(), loc, false);
+ throw Definition_error(ss.str(), loc);
}
// The default written for each member at the use site:
@@ -237,10 +237,10 @@ std::map use_site_defaults(
if (!positional.empty() || active(rest)) {
throw Definition_error(
"The use of the option set " + q_(option_set.m_name) +
- " gives a value that is not an option.\n"
+ " gives a value that is not an option.\n\n"
"A set's names and types are fixed where the set is declared; only a "
"default may be given where it is used, written as \":name value\".",
- begin->m_loc, false);
+ begin->m_loc);
}
std::map defaults {};
for (const auto& option : optional) {
@@ -248,17 +248,17 @@ std::map use_site_defaults(
if (name.size() + 1 != option[0].m_text.size()) {
throw Definition_error(
"The use of the option set " + q_(option_set.m_name) + " gives a type for \":"
- + name + "\".\n"
+ + name + "\".\n\n"
"A set's names and types are declared where the set is; only a default "
"may be given where it is used.",
- option[0].m_loc, false);
+ option[0].m_loc);
}
const Parameter* member = option_set.find(name);
if (member == nullptr) {
throw Definition_error(
"The option set " + q_(option_set.m_name) + " has no parameter \":"
- + name + "\".\n It declares: " + option_set.member_names(),
- option[0].m_loc, false);
+ + name + "\". It declares:\n " + option_set.member_names(),
+ option[0].m_loc);
}
if (defaults.count(name) > 0) {
throw Definition_error(
@@ -297,7 +297,7 @@ option_set_uses_t expand_option_sets(
throw Definition_error(
"The parameter \":" + name + "\" of " + q_(klammer_name) +
" is declared twice:\n " + previous->second + "\n " + from,
- loc, false);
+ loc);
}
origin[name] = from;
};
diff --git a/mac/target.cpp b/mac/target.cpp
index 959c555..8e85edc 100644
--- a/mac/target.cpp
+++ b/mac/target.cpp
@@ -24,16 +24,21 @@ void Target::add_transforms(const string_pairs& transforms)
}
}
+// The typographic transform pass, run by Machine::apply() at final
+// processing. Per katom rather than over the joined result string, so
+// that ^'...'^ literal content (katom_t::literal) is never transformed --
+// verbatim text must show the characters the writer typed. A transform
+// source therefore cannot match across a katom boundary, which is the
+// correct reading: two hyphens separated by a klammer application were
+// separated by the writer and are not a dash.
void Target::transform(katom_list& katoms) const
{
(void)K::log(3);
std::for_each(
katoms.begin(), katoms.end(),
[this] (Katom& k) {
- // std::cout << "transform: " << k << "\n";
if (k.m_type != katom_t::literal) {
for (const auto& [a, b] : this->m_transforms) {
- // std::cout << " " << a << right_arrow << b << "\n";
k.m_text = string_replace(k.m_text, a, b);
}
}
@@ -67,6 +72,14 @@ void Target::add_escapes(const std::string& escape_spec)
}
}
+void Target::add_resolves(const std::string& resolve_spec)
+{
+ auto words = word_split(resolve_spec);
+ for (size_t i = 0; i + 1 < words.size(); i += 2) {
+ m_resolves.push_back({words[i], words[i+1]});
+ }
+}
+
std::string Target::escape_marker(const std::string& ch)
{
std::stringstream ss {};
@@ -94,11 +107,16 @@ std::string Target::unescape_text(std::string text) const
std::string Target::resolve_escapes(std::string text) const
{
// Target-declared escapes first (marker -> declared replacement), then
- // the generic decode for the remaining markers (marker -> the character
- // itself: quoted Klammertext specials and literal-span content).
+ // the resolution-only entries (:resolve -- how a QUOTED character
+ // renders here), then the generic decode for the remaining markers
+ // (marker -> the character itself: quoted punctuation and literal-span
+ // content).
for (const auto& [ch, repl] : m_escapes) {
text = string_replace(text, escape_marker(ch), repl);
}
+ for (const auto& [ch, repl] : m_resolves) {
+ text = string_replace(text, escape_marker(ch), repl);
+ }
return ktesc_resolve(text);
}
@@ -130,6 +148,29 @@ std::string ktesc_resolve(std::string text)
return text;
}
+bool hide_quoted_punctuation(std::string& s)
+{
+ // ASCII punctuation, EXCEPT "'" -- ^' opens a ^'...'^ literal span, the
+ // one documented exception to the rule (a literal apostrophe is ^0027^).
+ // Hand-rolled scan: no std::regex, this can run over large text.
+ static const std::string punct = R"pct(!"#$%&()*+,-./:;<=>?@[\]^_`{|}~)pct";
+ if (s.find('^') == std::string::npos) return false;
+ std::string result {};
+ bool changed = false;
+ for (size_t i = 0; i < s.size(); ++i) {
+ if (s[i] == '^' && i + 1 < s.size()
+ && punct.find(s[i + 1]) != std::string::npos) {
+ result += Target::escape_marker(std::string(1, s[i + 1]));
+ ++i;
+ changed = true;
+ } else {
+ result += s[i];
+ }
+ }
+ if (changed) s = result;
+ return changed;
+}
+
std::string hide_structural_characters(const std::string& s)
{
std::string result {};
@@ -145,7 +186,26 @@ std::string hide_structural_characters(const std::string& s)
void Target::add_after_apply(const std::string& function_specs)
{
for (const auto& f : regex_split(function_specs, std::regex(R"(\s+;\s+)"), true)) {
- // msg() << "Add " << m_name << " after-apply: " << f << "\n";
+ // A bare spec is ONE Python name (module.function): whitespace
+ // inside it means two specs were written without the " ; "
+ // separator, and the glued call would otherwise fail only at render
+ // time, as a Python SyntaxError located at "phase" rather than at
+ // this declaration (found 2026-08-22, the first time a target
+ // declared two phases). Mode-tagged specs (":cpp
+ // ") are exempt: the library is a filename, and filenames
+ // may contain spaces -- which is exactly why the list separator is
+ // ";" rather than whitespace.
+ if (!f.empty() && f[0] != ':'
+ && f.find_first_of(" \t\n") != std::string::npos) {
+ throw Definition_error(
+ "The :after_apply phase \"" + f + "\" contains whitespace. "
+ "A bare phase is a single Python name (module.function), and "
+ "several phases are separated by \" ; \":\n"
+ " :after_apply first.phase ; second.phase\n"
+ "(A mode-tagged phase -- \":cpp \" -- may "
+ "contain spaces; its library is a filename.)",
+ m_loc);
+ }
m_after_apply.push_back(f);
}
}
diff --git a/mac/target.h b/mac/target.h
index f91041b..7a5fd0c 100644
--- a/mac/target.h
+++ b/mac/target.h
@@ -24,6 +24,7 @@ public:
void transform(std::vector& katoms) const;
void add_escapes(const std::string& escape_spec);
+ void add_resolves(const std::string& resolve_spec);
std::string escape_text(std::string text) const;
std::string unescape_text(std::string text) const;
std::string resolve_escapes(std::string text) const;
@@ -40,6 +41,13 @@ public:
Locator m_loc {};
std::vector> m_transforms {};
std::vector> m_escapes {};
+ // Resolution-only entries (:resolve): how a QUOTED character renders in
+ // this target. The resolve half of :escape without the escape half --
+ // needed where the raw character must stay untouched in writer text
+ // (tex cannot escape "-" without destroying the --- convention) but the
+ // quoted character must not decode to its raw self (a decoded -- would
+ // re-form TeX's dash ligature).
+ std::vector> m_resolves {};
// Argtype_registry m_argtypes {};
@@ -60,3 +68,14 @@ std::string ktesc_resolve(std::string text);
// sub-Machines and the @eval result read-back. Resolved by ktesc_resolve()
// at final processing.
std::string hide_structural_characters(const std::string& s);
+
+// Replace each ^P pair in s -- "^" before any ASCII punctuation character
+// except "'" (which opens a ^'...'^ literal span) -- with the KTESC marker
+// of P: "^" before a punctuation character quotes it, uniformly, not only
+// the six Klammertext specials. A letter or digit is never special, so
+// ^ keeps its existing meanings (diacritics, mnemonics,
+// ^UUUU^ code points). Returns whether anything was replaced. Called from
+// hide_special_katoms() on writer-text katoms OUTSIDE definition and
+// @eval/@read/@cond spans -- code keeps its carets (grep '^-' must reach
+// the shell intact), the same skip set as the quoted-special hiding.
+bool hide_quoted_punctuation(std::string& s);
diff --git a/mac/target_registry.cpp b/mac/target_registry.cpp
index 67f513b..e720e00 100644
--- a/mac/target_registry.cpp
+++ b/mac/target_registry.cpp
@@ -14,7 +14,7 @@ std::string Target_registry::general_name = "*";
std::string Target_registry::optionset_name = "o";
Target_registry::Target_registry()
- : m_parameters(Parameter_set("name | desc :after_apply :after_write :includes :escape | transforms.rest"))
+ : m_parameters(Parameter_set("name | desc :after_apply :after_write :includes :escape :resolve | transforms.rest"))
{
// Registration order is display order. The two that declare an interface
// come first -- "k" a klammer's, "o" an option set's -- and then "*", the
@@ -47,14 +47,25 @@ void Target_registry::add(std::vector::iterator begin, std::vector
Target target(values["name"], values["desc"], begin->m_loc);
target.add_transforms(values["transforms"]);
target.add_escapes(values["escape"]);
+ target.add_resolves(values["resolve"]);
target.add_after_apply(values["after_apply"]);
target.m_includes = word_split(values["includes"]);
- // Inherit escapes from included targets
+ // Inherit escapes AND typographic transforms from included targets: an
+ // including target renders through the included one's syntax (pdf
+ // through tex), so both tables apply there too. Inherited entries are
+ // appended after the including target's own, so a declaration can
+ // override an inherited pair by declaring its source first.
for (const auto& included : target.m_includes) {
if (m_targets.count(included)) {
for (const auto& esc : m_targets[included].m_escapes) {
target.m_escapes.push_back(esc);
}
+ for (const auto& tr : m_targets[included].m_transforms) {
+ target.add_transform(tr.first, tr.second);
+ }
+ for (const auto& res : m_targets[included].m_resolves) {
+ target.m_resolves.push_back(res);
+ }
}
}
// Registration (and the previous-definition check) goes through
@@ -79,7 +90,7 @@ void Target_registry::check_for_previous_definition(const std::string& name, con
if (has(name)) {
const Target& current = m_targets.at(name);
throw Target_error("Target \"" + name + "\" is already defined:\n " + current.m_loc.desc(),
- loc, false);
+ loc);
}
}
diff --git a/mac/target_registry.h b/mac/target_registry.h
index abddcaf..bb988be 100644
--- a/mac/target_registry.h
+++ b/mac/target_registry.h
@@ -51,7 +51,7 @@ public:
/*
- std::string m_parameters_spec {"name | desc :after_apply :after_write :includes | transforms.rest"};
+ std::string m_parameters_spec {"name | desc :after_apply :after_write :includes :escape :resolve | transforms.rest"};
Parameter_set m_parameters =
katomize(line_split(m_parameters_spec), Locator().str());
*/
diff --git a/mac/util.cpp b/mac/util.cpp
index 3349ced..d728fa2 100644
--- a/mac/util.cpp
+++ b/mac/util.cpp
@@ -226,14 +226,66 @@ std::string justify_string(const std::string& s, unsigned int width=80, bool fre
return result;
}
+// The error-message formatting contract (2026-08-21): a message carries no
+// decisions about line breaks EXCEPT by indentation.
+// * a line beginning with whitespace is VERBATIM -- an example, a pattern,
+// a signature, a list entry -- emitted untouched: no folding, no
+// wrapping, no "~" substitution (a pattern may contain a literal ~);
+// * blank lines separate blocks (runs collapse to one);
+// * everything else is prose: consecutive lines fold into one paragraph
+// and are wrapped to the width.
+// So prose stays machine-wrapped however the source hand-wraps it, and the
+// one legitimate exception is marked in the one place it cannot be missed.
+// This replaced a per-call do_justify flag on the Error constructors, whose
+// decision lived apart from the text it governed. Line-based by hand: no
+// std::regex over the whole message (the ambiguous-alternation hazard).
std::string justify(
const std::string& input_text, unsigned int text_width, unsigned int margin_width)
{
- std::string result {};
text_width -= margin_width;
- for (const std::string& par : split_into_paragraphs(trim(input_text))) {
- result += justify_string(par, text_width) + "\n\n";
+ strings_t lines {};
+ {
+ const std::string text = trim(input_text);
+ std::string::size_type from = 0;
+ while (from <= text.size()) {
+ auto nl = text.find('\n', from);
+ if (nl == std::string::npos) {
+ lines.push_back(text.substr(from));
+ break;
+ }
+ lines.push_back(text.substr(from, nl - from));
+ from = nl + 1;
+ }
}
+ std::string result {};
+ strings_t prose {};
+ bool pending_blank = false;
+ auto emit = [&](const std::string& block) {
+ if (!result.empty()) {
+ result += pending_blank ? "\n\n" : "\n";
+ }
+ result += block;
+ pending_blank = false;
+ };
+ auto flush_prose = [&]() {
+ if (!prose.empty()) {
+ emit(justify_string(join(prose, " "), text_width));
+ prose.clear();
+ }
+ };
+ for (const std::string& line : lines) {
+ if (trim(line).empty()) { // block separator
+ flush_prose();
+ pending_blank = !result.empty();
+ } else if (line[0] == ' ' || line[0] == '\t') { // verbatim line
+ flush_prose();
+ emit(trim_right(line));
+ } else { // prose
+ prose.push_back(trim(line));
+ }
+ }
+ flush_prose();
+ result += "\n";
if (margin_width > 0) {
result = add_margin(result, margin_width);
}
@@ -367,8 +419,8 @@ std::vector> environment_variables(bool allo
auto parts = regex_split(environ[i], std::regex("="), true);
if (!allow_empty_definitions && parts.size() < 2) {
throw Internal_error(
- "Incorrect environment variable format:\n" + std::string(environ[i]),
- Locator(), false);
+ "Incorrect environment variable format:\n " + std::string(environ[i]),
+ Locator());
}
std::string name = parts[0];
parts.erase(parts.begin());
diff --git a/sks/block/block.k b/sks/block/block.k
index 6b673ac..f6cc846 100644
--- a/sks/block/block.k
+++ b/sks/block/block.k
@@ -9,49 +9,77 @@ paragraph pass recognizes as a block and leaves alone, and \par in
vertical mode is a no-op.
]#
-@@par.k s : A paragraph of text @@
+# Paragraph
+
+@@par.k s : A paragraph of text. @@
+
@@par.html ::
*s*
@@
-@@par.tex ::
-\par
-*s*
-\par
-@@
+
+@@par.tex :: \par *s* \par @@
+
# In plain text a paragraph is delimited by blank lines, which
# phases.justify_blocks then fills; #/2 inserts them without depending on
# the definition body's own whitespace surviving extraction.
@@par.txt :: #/2*s*#/2 @@
+
+# Non-breaking space
+
@@sp.k : Non-breaking space character @@
@@sp.html :: &^#160; @@
@@sp.tex :: ~ @@
-# TODO: Easy in LaTeX; how to handle in HTML and plain text?
-# @@footnote.k s : Footnote (TBD) @@
-# @@footnote :: [*s*] @@
+# Indented block
-@@indent.k s :w.int 3 :linebreak.bool false : Indented block @@
+@@indent.k s :left.int 8 :right.int -1 :ragged.bool false :
+Indented block. The *left* and *right* values are the number of characters for
+indentation (as roughtly defined by "ex"). By default, *right* is set to the
+*left* value (with -1 as the sentinel).
+@@
@@indent.html,tex,txt :: @eval block.Indent(K) eval@ @@
-@@quote.k s :w.int 1 :source : Quotation block @@
-@@quote.html ::
-
+# Right justification
+
+@@right.k s :margin 0 :top_margin 2 : Right-justified text @@
+
+@@right.html ::
+
+\end{flushright}}
@@
-@@quote.tex ::
-\quoteblock{*s*}{*source*}
+@@right.txt ::
+ @eval import textwrap; "\n".join([line.rjust(*Target_txt_width*, "~")
+ for line in textwrap.wrap("""*s*""", *Target_txt_width*)]) @
@@
-@@quote.txt ::
- @eval block.block_indent(K) eval@
-@@
-@@note.k s :label Note :color 1.0,1.0,0.9 :bordercolor 0.2,0.2,0.2 :level.int 0 :width
- : Rectangular block for a special note @@
+# Quote with attribution
-@@note.html,tex :: @eval block.Note(K) eval@ @@
+@@quote.k text | attribution : A block quote with attribution. @@
+@@quote.html,tex ::
+ @indent *text* :left 8 @
+ @right *attribution* :margin 8
+ :top_margin @cond @eval "*K_target*" == "html" @ | -1 | 1 cond@ @
+@@
+
+
+# Highlighted note in text
+
+@@note.k s :label Note :color 1.0,1.0,0.9 :border_color 0.2,0.2,0.2 :level.int 0 :width.float 0.5
+ : Rectangular block for an editorial note @@
+
+@@note.html,tex :: @eval block.Note(K) @ @@
+
+
+# Center
@@center.k s : Center text @@
@@ -62,42 +90,46 @@ vertical mode is a no-op.
@@
@@center.html ::
+
*s*
+
@@
-@@right.k s : Right-justified text @@
-@@right.html ::
-TBD *s*
-@@
-
-@@right.tex ::
-\begin{flushright}
-*s*
-\end{flushright}
-@@
+# Newline
@@nl.k : Newline character @@
-@@nl.html :: @@
+@@nl.html :: @@
@@nl.tex :: \newline @@
-@@nl.txt :: \n @@
+@@nl.txt :: @eval phases.txt_nl_marker() @ @@
+
+
+# New page
@@newpage.k : Start new page @@
@@newpage.html :: @@
@@newpage.tex :: \newpage @@
@@newpage.txt :: @@
+
+# Extend page
+
@@extendpage.k linecount : Extenad current page @@
@@extendpage.html :: @@
@@extendpage.tex :: \enlargethispage{*linecount*\baselineskip} @@
@@extendpage.txt :: @@
+
+# Vertical space
+
@@vspace.k lines.float : Vertical space, in multiples of the current line height @@
@@vspace.tex :: \vspace{*lines*\baselineskip} @@
@@vspace.html :: @@
-@@vspace.txt :: @eval "__VSPACE__" * round(*lines*) @ @@
+@@vspace.txt :: @eval phases.txt_vspace_marker() * round(*lines*) @ @@
+
+# Vertical fill to end of page
@@vfill.k :
Fill the vertical space so that any following text is flush with the bottom
@@ -112,6 +144,8 @@ only makes some vertical space. @@
@@vfill.txt :: @vspace 3 @ @@
+# Questions and answers
+
@@qa.k question | answer : Question and answer formatting @@
@@qa.html,tex,txt ::
@b Q: @ *question*
@@ -119,6 +153,9 @@ only makes some vertical space. @@
@b A: @ *answer*
@@
+
+# Absolute positioning
+
@@@argtype coords | x and y coordinates :pattern 'float'\s+'float' @@@
@@block.k : to.coords | content :width.float .5 :point.coords 0.0 0.0
@@ -126,18 +163,51 @@ only makes some vertical space. @@
@@block.tex :: @eval block.Block(K) eval@ @@
+
+# Preserved line endings
+
@@lines.k s : Maintain line breaks @@
@@lines.html,tex,txt :: @eval block.Lines(K) eval@ @@
-@@twocolumns.k s : Format *s* in two columns @@
-@@twocolumns.tex ::
-\begin{multicols}{2}
+
+# Multiple columns of text
+
+@@multicolumn.k s :n 2 :gap 3 : Format *s* in *n* columns (default: 2). The *gap* is in "ex" units. @@
+
+@@multicolumn.tex ::
+{\setlength{\columnsep}{*gap*ex}
+\setlength{\multicolsep}{\parskip}
+\begin{multicols}{*n*}
*s*
-\end{multicols}
+\end{multicols}}
@@
-@@left_right.k left | right : Text left- and right- justified on one line @@
-
-@@left_right.tex ::
- \makebox[\dimexpr\linewidth+\labelindent\relax]{*left*\hfill *right*}
+@@multicolumn.html ::
+
+
+ *s*
+
+
@@
+
+
+# Text justified left and right on one line
+
+@@left_right.k parts.rest(2) :
+Pairs of text left- and right- justified. First, left lines are written with a
+bar (|) between each line. Then, two bars (||), and the right lines with a bar
+betwen them.
+@@
+
+@@left_right.html,tex :: @eval block.Left_right(K) @ @@
+
+
+# Footnote
+
+@@footnote.k s : Footnote @@
+@@footnote.html :: *s* @@
+@@footnote.tex :: \unskip\footnote{*s*} @@
+@@footnote.txt :: __ #- @eval phases.txt_footnote_marker() @ *s* @eval phases.txt_footnote_marker() @ #- __ @@
+
+# This doesn't work, either, so it isn't the @eval:
+# @@footnote.txt :: __ #- marker *s* marker #- __ @@
diff --git a/sks/block/block.py b/sks/block/block.py
index 50f63ef..6601f14 100644
--- a/sks/block/block.py
+++ b/sks/block/block.py
@@ -5,72 +5,60 @@ import pprint
import klammer_base
import kutil
import latex_util
+import html_util
+from html_util import E
+import color
class Indent(klammer_base.Klammer_base):
def __init__(self, K):
super().__init__(K)
- self.s = kutil.escape(self.s)
+ # Doesn't do anything now:
+ # self.s = kutil.escape(self.s)
+ if self.right < 0:
+ self.right = self.left
+ self.reduce = self.left + self.right
def html(self):
- return "FIX: INDENT " + self.s
-
+ text_align = "justify" if not self.ragged else "left"
+ return E("div").body(self.s)\
+ .sty("margin", f"0.5rem {self.right}ex 0.5rem {self.left}ex")\
+ .sty("text-align", text_align)\
+ .str()
def tex(self):
- tab = f"\\hspace*{{{self.w}ex}}"
- result = ""
-
- if self.linebreak:
- for e in self.s.split("\n"):
- result += f"{tab}{e}\\\\\n"
- result = result[:-3]
- result = re.sub(r"\t", r"\\t", result)
- else:
- result = tab + latex_util.minipage(
- "\\raggedright " + self.s, f"\\textwidth - {self.w}ex", center=False, vmargin="4pt")
- #print(result)
- return latex_util.block(result)
+ if self.ragged:
+ self.s = "\\raggedright " + self.s
+ result = rf"\hspace*{{{self.left}ex}}" + latex_util.minipage(
+ self.s, rf"\linewidth - {self.reduce}ex", center=False)
+ result = latex_util.block(result)
+ return result
def txt(self):
- indent = " " * self.w
+ indent = "~" * self.left # Removed in phases.justify_blocks
+ width = int(self.Target_txt_width) - self.left - self.right + 1 # Off-by one for textwrap
result = self.s
result = re.sub("KK0022", '"', result)
- result = indent + f"\n{indent}".join(textwrap.wrap(result, width=70, break_on_hyphens=False))
- return f"@lit\n{result}\nlit@"
+ result = indent + f"\n{indent}".join(textwrap.wrap(result, width=width, break_on_hyphens=True))
+ return result
+
class Note(klammer_base.Klammer_base):
def __init__(self, K):
super().__init__(K)
def html(self):
- color = ",".join([f"{float(e)*100}%" for e in self.color.split(",")])
- result = f'''
-{self.label}: {self.s}
-
'''
+ result = E("div").cls("box")\
+ .sty("width", f"{self.width*100}%")\
+ .sty("background-color", html_util.color(self.color))\
+ .sty("border-color", html_util.color(self.border_color))\
+ .sty("margin-left", "auto")\
+ .body(f"{self.label}: {self.s}")\
+ .str()
return result
def tex(self):
- if self.width:
- width = r'{}\\textwidth'.format(self.width)
- else:
- #width = r'\\textwidth - 16pt - {}\\leftmargin'.format(self.level)
- width = r'\\linewidth - \\leftmargin + 2pt'
-
- # The \par on each side comes from latex_util.block() below: an
- # \fcolorbox is box material and must sit in vertical mode.
- result = '''
-\\begingroup
-COLOR\\setlength{\\fboxsep}{8pt}
- \\fcolorbox{bordercolor}{localcolor}{
- \\parbox{WIDTH}{\\raggedright\\setlength{\\parskip}{8pt}
- \\textbf{LABEL:} TEXT
-}}\\endgroup
-'''
- result = re.sub('LABEL', self.label, result)
- result = re.sub('TEXT', re.sub(r'\\', r'\\\\', self.s), result)
- result = re.sub('COLOR', r'\\definecolor{{localcolor}}{{rgb}}{{{}}}\nCOLOR'.format(self.color), result)
- result = re.sub('COLOR', r'\\definecolor{{bordercolor}}{{rgb}}{{{}}}\n'.format(self.bordercolor), result)
- result = re.sub('WIDTH', width, result)
- print(result)
- return latex_util.block(result)
+ return latex_util.color_box(
+ f"\\textbf{{{self.label}:}} {self.s}",
+ self.width, self.color, self.border_color)
class Block(klammer_base.Klammer_base):
@@ -95,13 +83,12 @@ class Block(klammer_base.Klammer_base):
class Lines(klammer_base.Klammer_base):
def __init__(self, K):
super().__init__(K)
- self.s = kutil.escape(self.s)
self.lines = self.s.split("\n")
def html(self):
result = ""
for line in self.lines:
- result += line + " \n"
+ result += line + " \n"
return result
def tex(self):
@@ -109,3 +96,24 @@ class Lines(klammer_base.Klammer_base):
def txt(self):
return self.lines
+
+class Left_right(klammer_base.Klammer_base):
+ def __init__(self, K):
+ super().__init__(K)
+ self.rows = list(zip(self.parts[0], self.parts[1]))
+
+ def html(self):
+ result = ""
+ for left, right in self.rows:
+ result += E("div").body(
+ E("span").cls("left_right").body(left).str() + \
+ E("span").cls("left_right").sty("text-align", "right").body(right).str()).str();
+ result = E("p").body(result).str()
+ return result
+
+ def tex(self):
+ result = ""
+ for left, right in self.rows:
+ result += rf"\parbox{{\linewidth}}{{{left} \hfill {right}}}" + " \\\\\n"
+ result = result[:-3]
+ return result
diff --git a/sks/block/css/block.css b/sks/block/css/block.css
index f593b4c..0a5e53a 100644
--- a/sks/block/css/block.css
+++ b/sks/block/css/block.css
@@ -1,6 +1,6 @@
p {
- margin: .5rem 0 .5rem 0;
+ margin: 1ex 0;
}
.quote {
@@ -15,10 +15,8 @@ p {
overflow: auto;
}
-.centered {
- margin-left: auto;
- margin-right: auto;
- width: fit-content;
+.center {
+ text-align: center;
}
.indent {
@@ -49,3 +47,8 @@ p {
.vfill {
flex-grow: 1;
}
+
+.left_right {
+ display: inline-block;
+ width: calc((100vw - 60px) / 2);
+}
diff --git a/sks/code/code_block.py b/sks/code/code_block.py
index 045083a..c70b313 100644
--- a/sks/code/code_block.py
+++ b/sks/code/code_block.py
@@ -1,30 +1,20 @@
-if __name__ == "__main__":
- import sys
- sys.path.append("../kutil")
- sys.path.append("../target")
+# if __name__ == "__main__":
+# import sys
+# sys.path.append("../kutil")
+# sys.path.append("../target")
-import sys
import re
import klammer_base
-import kutil
import html_util
from html_util import E
import latex_util as L
-import pprint
-import phases
+
def escape_newlines(s):
# A newline becomes the marker the html paragraph pass turns back into a
# line break, so a verbatim source file keeps its lines. Used by Source.
return re.sub("\n", " ___NL___ ", s)
-def undash(s):
- # Verbatim text must show the hyphens the writer typed: the target's
- # "--"/"---" transforms have already run, so put them back. Used by
- # Code_fragment.
- result = re.sub("__MDASH__", "---", s)
- return re.sub("__NDASH__", "--", result)
-
def is_comment(s):
return s.strip().startswith("//")
@@ -168,9 +158,17 @@ def html_line(text):
tex_line -- a raw # in "#include" would start a text removal). The
html entities introduce no Klammertext special, so the two passes
cannot interfere. No target :escape entries apply here, so each
- quoted special decodes back to its own character."""
- return quote_specials(html_escape_rgx.sub(
- lambda m: html_escapes[m.group()], text))
+ quoted special decodes back to its own character.
+
+ Last, the typographically active characters are hidden as KTESC
+ markers (klammer_base.hide_typographic): the html target's transforms
+ run over the final result, and without this a "--check" in a listing
+ became an en-dash. The markers decode after the transform pass.
+ tex_line needs no such step -- the tex target declares no typographic
+ transforms (LaTeX applies its input conventions itself), and its ~ is
+ this code's own markup for a preserved space."""
+ return klammer_base.hide_typographic(quote_specials(html_escape_rgx.sub(
+ lambda m: html_escapes[m.group()], text)))
def html_block(code, comment):
r"""One block: its lines beside its comment.
@@ -192,11 +190,25 @@ def html_block(code, comment):
return f'
{result}
\n'
+def expand_whitespace_markers(text, K=None):
+ def count(count_match):
+ count = int(count_match) if count_match else 1
+ return count
+ def replace_spaces(match):
+ return " " * count(match.group(1))
+ def replace_newlines(match):
+ return "\n" * count(match.group(1))
+ result = text
+ result = re.compile(r" *#- *", re.S).sub("", result)
+ result = re.compile(r" *#\+(\d*) *", re.S).sub(replace_spaces, result)
+ result = re.compile(r"\s*\#\/(\d*)\s*", re.S).sub(replace_newlines, result)
+ return result
+
class Code(klammer_base.Klammer_base):
id = 0
def __init__(self, K):
super().__init__(K)
- self.text = phases.expand_whitespace_markers(self.text)
+ self.text = expand_whitespace_markers(self.text)
def annotated(self, pairs):
"""Does any block of this listing carry a comment?"""
@@ -332,7 +344,7 @@ class Code_fragment(klammer_base.Klammer_base):
# ordinary string and the machine did the escaping; html() got away with
# handling "<" by hand and tex() with nothing at all.
def html(self):
- return f'{html_line(undash(self.code_text.strip()))}'
+ return f'{html_line(self.code_text.strip())}'
def tex(self):
return f"{{\\tt {tex_line(self.code_text.strip())}}}"
@@ -373,7 +385,7 @@ def extract_marked_region(src, marker, filename):
class Source(Code):
- """@source_listing -- a Code listing whose text comes from a FILE.
+ r"""@source_listing -- a Code listing whose text comes from a FILE.
It IS a Code: @source_listing and @code differ only in where the text
comes from, so they must render identically, and subclassing is what
@@ -406,7 +418,7 @@ class Source(Code):
except OSError as e:
raise Exception(
f'Cannot read the source listing "{self.filename}": {e.strerror}.\n'
- f' A relative name resolves against the DOCUMENT\'s directory.')
+ f' A relative name resolves against the document\'s directory.')
if self.marker:
text = extract_marked_region(text, self.marker, self.filename)
self.text = text
diff --git a/sks/code/code_format.py b/sks/code/code_format.py
deleted file mode 100644
index 1f4f034..0000000
--- a/sks/code/code_format.py
+++ /dev/null
@@ -1,403 +0,0 @@
-print("DEPRECATED")
-
-if __name__ == "__main__":
- import sys
- sys.path.append("../kutil")
- sys.path.append("../target")
-
-import re
-import klammer_base
-import kutil
-import html_util
-from html_util import E
-import latex_util as L
-import pprint
-import phases
-
-def escape_newlines(s):
- return re.sub("\n", " ___NL___ ", s)
-
-# def literal_newline(s):
-# def replace(match):
-# before, after = match.groups()
-# return f"{before}\\n{after}"
-# backslash_pat = re.compile(r'(".*?)\n(.*?")', re.S)
-# return backslash_pat.sub(replace, s)
-
-def is_comment(s):
- return s.strip().startswith("//")
-
-def split_blocks(s):
- print('-'*40)
- print(s)
- print('-'*40)
- blocks = []
- in_code = True
- block = ""
- for line in s.rstrip().split("\n"):
- if is_comment(line):
- if in_code:
- blocks.append(block)
- block = line + "\n"
- in_code = False
- else:
- block += line + "\n"
- else:
- if not in_code:
- blocks.append(block)
- block = line + "\n"
- in_code = True
- else:
- block += line + "\n"
- if block:
- blocks.append(block)
- for block in blocks:
- print("B:")
- print(block)
- return blocks
-
-def parse_blocks(blocks):
- box_comment_rgx = re.compile("\s*//(\d+)\s+.*", re.S)
- i = 0
- while i < len(blocks):
- match = box_comment_rgx.match(blocks[i])
- if match:
- print(match.group(1))
- i += 1
-
-
-
-def get_blocks(s):
- comment_pat = re.compile(r"(\s*)//(\d+)\s+(.*)", re.S)
- blocks = []
- lines = s.strip("\n").split("\n")
- i = 0
- uncommented = ""
- while i < len(lines):
- match = comment_pat.match(lines[i])
- if match:
- if uncommented:
- blocks.append([uncommented.rstrip(), None])
- uncommented = ""
- count = int(match.group(2))
- comment = match.group(3)
- code = ""
- j = 0
- i += 1
- while j < count:
- line = re.sub("\n", "\\n", lines[i])
- code += line + "\n"
- j += 1
- i += 1
- blocks.append([code.strip("\n"), comment])
- else:
- uncommented += lines[i] + "\n"
- i += 1
- if uncommented:
- blocks.append([uncommented.rstrip(), None])
- return blocks
-
-def latex_spaces(s):
- def replace(match):
- s = match.group(0)
- if False and len(s) == 1:
- return "~"
- else:
- result = "~" * len(s)
- result = f"\\hphantom{{{result}}}"
- return result
- space_pat = re.compile(" +", re.S)
- return space_pat.sub(replace, s)
-
-def latex_unquote(s):
- quoted = "asciicircum quotesingle asciigrave asciitilde asciitilde backslash".split()
- quoted = [f"{{}}\text{e}{{}}" for e in quoted]
- result = s
- for q in quoted:
- result = re.sub(q, "X", result)
- result = re.sub(" ", "Y", result)
- return result
-
-def longest_line(s):
- result = ""
- for line in latex_unquote(s).split("\n"):
- if len(line) > len(result):
- result = line
- return result
-
-def literal_newline(s):
- def replace(match):
- before, after = match.groups()
- return f"{before}\\n{after}"
- backslash_pat = re.compile(r'(".*?)\n(.*?")', re.S)
- return backslash_pat.sub(replace, s)
-
-class Code(klammer_base.Klammer_base):
- id = 0
- def __init__(self, K):
- super().__init__(K)
- self.text = phases.expand_whitespace_markers(self.text)
-
- def html(self):
- if self.K_target == "html":
- self.text = literal_newline(self.text)
- # Escape Klammertext special characters so they survive
- # re-insertion into the katom stream after @eval
- self.text = self.text.replace("^", "^^")
- self.text = self.text.replace("#", "^#")
- self.text = self.text.replace("@", "^@")
- self.text = self.text.replace("|", "^|")
- self.blocks = get_blocks(self.text)
- result = ''
- for text, comment in self.blocks:
- border = "code_border" if comment else "code_no_border"
- body = E("div").body(text).cls(f"code_text {border}").str(None)
- if comment:
- body += "\n" + E("div").body(comment).cls("code_comment").str()
- result += E("div").body(body).cls("code_block").str()
- if self.number or self.caption:
- #result = html_util.add_caption(
- # result, "Listing", self.number, self.caption, "i", "left", "top")
- caption = kutil.caption_marker("Listing", self.caption)
- result = f'
{caption}
{result}\n'
- return result
-
- @staticmethod
- def escape_latex(s):
- """Escape LaTeX special characters in code text."""
- # Backslash must be first (before adding more backslashes)
- s = s.replace("\\", "\\textbackslash{}")
- s = s.replace("{", "\\{")
- s = s.replace("}", "\\}")
- s = s.replace("%", "\\%")
- s = s.replace("$", "\\$")
- s = s.replace("&", "\\&")
- s = s.replace("_", "\\_")
- s = s.replace("^", "\\textasciicircum{}")
- s = s.replace("~", "\\textasciitilde{}")
- s = s.replace("<", "\\textless{}")
- s = s.replace(">", "\\textgreater{}")
- return s
-
- def code_box(self, text):
- kutil.msg(text)
- result = ""
- for line in text.rstrip().split("\n"):
- indent = len(line) - len(line.lstrip())
- eline = ("~" * indent) + line[indent:]
- print(indent, line)
- print(eline)
- result += eline + "\\\\\n"
- result = result[:-3]
- print("RESULT:")
- print(result)
- return result
-
- def tex(self):
- parse_blocks(split_blocks(self.text))
- return ""
-
-
-
-
- # Escape Klammertext special characters
- self.text = self.text.replace("^", "^^")
- self.text = self.text.replace("#", "^#")
- self.text = self.text.replace("@", "^@")
- self.text = self.text.replace("|", "^|")
- # Escape LaTeX special characters in code text
- self.text = Code.escape_latex(self.text)
- self.blocks = get_blocks(self.text)
- strutvis = "0pt"
- start_strut = f"\\rule[0pt]{{{strutvis}}}{{12pt}}"
- end_strut = f"\\rule[-6pt]{{{strutvis}}}{{12pt}}"
- caption_strut = f"\\rule[-8pt]{{{strutvis}}}{{6pt}}"
- indent = "8pt"
- comment_sep = "10pt"
- i = 0
- result = ""
- count = len(self.blocks)
- for text, comment in self.blocks:
- print("TEXT:")
- print(text)
- print("COMMENT:")
- print(comment)
-
-
-
-
- text = " " + re.sub("\n", " \n ", text) + " "
- longest = longest_line(text)
- text = latex_spaces(text)
- text = re.sub("\n", r"\\\\", text)
- text = f"{start_strut}\\ttfamily {text}{end_strut}"
- width = f"\\widthof{{\\ttfamily {longest}}}"
- code = L.environment("minipage", text, width) + "\\\\\n"
- print(code)
-
- #code = "\\asymbox{" + self.code_box(text) + "}"
- #print(code)
-
- code = text
-
- if comment:
- #width = f"\\linewidth - {width} - {indent} - {comment_sep}"
- """
- width = f"\\linewidth - \widestline - {indent} - {comment_sep}"
- code = f"\\fcolorbox{{Gray}}{{LightGray}}{{{code}}}"
- code += f"\\rule{{{comment_sep}}}{{{strutvis}}}" \
- + L.environment("minipage", "\\sffamily\\small\\raggedright " + comment, width)
- """
- code = "\\asymbox{" + self.code_box(text) + "}" + comment
-
- result += f"\\rule{{{indent}}}{{{strutvis}}}{code}"
- if comment:
- if i < count - 1 and self.blocks[i+1][1]:
- result += "\\\\[4pt]"
- i += 1
- if not self.blocks[count-1][1]:
- result = result[:-4]
- if self.number or self.caption:
- caption = kutil.caption_marker("Listing", self.caption)
- if self.blocks[0][1]:
- caption += caption_strut
- strut = f"\\rule{{{indent}}}{{{strutvis}}}"
- result = f"{strut}\\emph{{\\it {caption}}}\\newline\n" + result + "\n"
- result = f"\\hypertarget{{Reference-Listing-{Code.id}}}{{}}\n{result}"
- Code.id += 1
- return result
-
-def undash(s):
- result = s
- result = re.sub("__MDASH__", "---", result)
- result = re.sub("__NDASH__", "--", result)
- return result
-
-class Code_fragment(klammer_base.Klammer_base):
- def __init__(self, K):
- super().__init__(K)
-
- def html(self):
- #print(f"code: |{self.code_text}|")
- result = self.code_text.strip()
- result = undash(result)
- #result = re.escape(result)
- result = re.sub("<", "<", result)
- result = re.sub(" ", " ", result)
- #print(f"code: |{self.code_text}| -> |{result}|")
- return f'{result}'
-
- def tex(self):
- return f"{{\\tt {self.code_text.strip()}}}"
-
-def show(s):
- print("-"*80)
- print(s)
- print("-"*80)
-
-class Source(klammer_base.Klammer_base):
- def __init__(self, K):
- super().__init__(K)
- with open(self.filename) as fp:
- self.src = fp.read()
-
- def tex(self):
- result = self.src
- # result = re.sub("#", "^#", result)
- # result = re.sub("\\^", "\\^", result)
- result = f"\\begin{{verbatim}}\n{result}\n\\end{{verbatim}}\n"
- return result
-
- def html(self):
- result = escape_newlines(self.src.strip()) + "\n"
- result = re.sub("@", "^@", result)
- result = E("div").body(result).cls("code_text").str()
- return result
-
-# --------------------------------------------------------------------------------
-
-if __name__ == "__main__":
- s = """
-int main(int argc, char* argv[])
-{
- //1 One line commented
- int count = 12;
- //3 Two lines commented
- for (int i = 0; i < count; i++) {
- std::cout << "Counter: " << i << "\n";
- }
- //1 A really long comment for one line. A really long comment for one line. A really long comment for one line.
- std::cout << "End\n";
-}
-"""
- get_blocks(s);
-
-
-# class Code(klammer_base.Klammer_base):
-# def __init__(self, K):
-# super().__init__(K)
-# #self.show("Code")
-# if self.filename and self.text:
-# raise Exception("Both :text and :filename cannot be defined")
-# if K.filename:
-# with open(K.filename) as fp:
-# self.src = fp.read()
-# if K.pattern:
-# rgx = re.compile(f".*?({K.pattern}).*", re.S)
-# match = rgx.match(self.src)
-# if match is None:
-# raise Exception(f"Match fails for @source_code: {K.pattern}")
-# self.src = match.group(1)
-# self.src = kutil.protect_klammertext_special_characters(self.src)
-# else:
-# self.src = self.text
-
-# def html(self):
-# #src = re.sub("\n", "", self.src) ?
-# #result = f'
\n{self.src}\n
\n'
-# result = self.src
-# result = undash(result)
-# result = f'
\n{result}\n
\n'
-# return result
-
-# def tex(self):
-# src = self.src
-# src = re.sub(r"\\{", "{", src)
-# src = re.sub(r"\\}", "}", src)
-# result = f"\\begin{{lstlisting}}\n{src}\n\\end{{lstlisting}}\n"
-# return result
-
-# def txt(self):
-# return "x~ " + self.src
-
-# class Pathname(klammer_base.Klammer_base):
-# def __init__(self, K):
-# super().__init__(K)
-
-# def html(self):
-# return f'{self.s}'
-
-# def tex(self):
-# result = self.s
-# def replace(match):
-# return '\\{}'.format(match.group(1))
-# result = re.sub(r'\\', 'XXXBACKSLASHXXX', result)
-# result = re.compile('\s*__UNSPACE__\s*', re.S).sub('', result)
-# result = re.compile(r'([&${}%#_])').sub(replace, result)
-# result = re.sub('\^', r'\\^{}', result)
-# result = re.sub('~', r'\\~{}', result)
-# result = re.sub(r'XXXBACKSLASHXXX', r'{\\textbackslash}', result)
-# result = re.sub('\n', r'~\\\\\n', result.strip())
-# result = re.sub(' ', '$~$', result)
-# result = re.sub("'", r"{\\textquotesingle}", result)
-# result = re.sub('"', r'{\\textquotedbl}', result)
-# result = re.sub('--', '{-}{-}', result)
-# result = r'{{\normalfont\texttt{{{}}}}}'.format(result.strip())
-# result = re.sub(r'\{\\textbackslash\}\\#', '\\#', result)
-# if self.small:
-# result = '{{\\footnotesize{}}}'.format(result)
-# return result
-
-# def txt(self):
-# return f"'{self.s}'"
-
diff --git a/sks/color/color.py b/sks/color/color.py
index 71d6ac6..d16f0be 100644
--- a/sks/color/color.py
+++ b/sks/color/color.py
@@ -25,13 +25,3 @@ class Color(klammer_base.Klammer_base):
if self.text:
result = '{{{} {}}}'.format(result, self.text)
return result
-
-def hex_color(name, hex):
- def c(h):
- return float(eval('0x' + h)) / 255.0
- r = c(hex[:2])
- g = c(hex[2:4])
- b = c(hex[4:])
- return '\\definecolor{{{}}}{{rgb}}{{{:.3f},{:.3f},{:.3f}}}'.format(
- name, r, g, b)
-
diff --git a/sks/document/document.cpp b/sks/document/document.cpp
index 3b0650f..5a728f0 100644
--- a/sks/document/document.cpp
+++ b/sks/document/document.cpp
@@ -101,7 +101,7 @@ std::string tex_to_pdf(Machine& machine)
ss << " " << line << "\n";
}
ss << "Check log file: " << outbase << ".log";
- throw Definition_error(ss.str(), Locator(), false);
+ throw Definition_error(ss.str(), Locator());
}
if (std::regex_search(xelatex_log, std::regex("Package rerunfilecheck Warning:"))) {
(void)K::log(1, "Rerunning xelatex because document structure has changed");
@@ -115,7 +115,7 @@ std::string tex_to_pdf(Machine& machine)
ss << " " << line << "\n";
}
ss << "Check log file: " << outbase << ".log";
- throw Definition_error(ss.str(), Locator(), false);
+ throw Definition_error(ss.str(), Locator());
}
}
warn_wide_tables(xelatex_log);
diff --git a/sks/document/document.k b/sks/document/document.k
index d511a45..8d50b88 100644
--- a/sks/document/document.k
+++ b/sks/document/document.k
@@ -63,3 +63,20 @@
@@
@@document.html,tex :: @eval :cpp *KLAMMERTEXT_HOME*/sks/document/document document @ @@
+
+# The following definition of @document.txt is not adequate (it ignores :files,
+# for example), but it enables tests of other txt klammers for now. When one of
+# the arguments is empty, the justifcation postprocess should remove multiple
+# lines, but __VSPACE__ inserts a space character, which prevents the lines
+# removal from the justification function. You can also see the extra space
+# before the *subtitle* value.
+
+@@document.txt ::
+@eval "*title*".upper() @ @nl@
+*subtitle*
+
+*author* @nl@
+*date*
+
+*text*
+@@
diff --git a/sks/document/document_class.cpp b/sks/document/document_class.cpp
index f52c493..e21bc9a 100644
--- a/sks/document/document_class.cpp
+++ b/sks/document/document_class.cpp
@@ -16,9 +16,9 @@ bool strbool(const std::string& s, const Locator& loc)
std::vector values = {"false", "False", "0", "true", "True", "1"};
if (is_not_in(s, values)) {
std::stringstream ss {};
- ss << "The value \"" << s << "\" is not a Boolean values. Possible values are:\n"
+ ss << "The value \"" << s << "\" is not a Boolean value. Possible values are:\n "
<< join(values, ", ");
- throw Argument_error(ss.str(), loc, false);
+ throw Argument_error(ss.str(), loc);
}
bool result = (find(values.begin(), values.end(), s) - values.begin()) > 2;
return result;
@@ -51,10 +51,15 @@ Document_class::Document_class(Machine& machine) : Klammer_base(machine)
// spaces, ~ expansion (resolve_filename_list in mac/file.cpp); the
// existence checks resolve relative names against the input directory,
// as parse_input_filename() will.
- m_files = resolve_filename_list(get("files"), get("K_input_dir"));
+ // Filename-bearing values arrive ESCAPED for the target (the state
+ // stores escaped values so they flow correctly into output); a filename
+ // is programmatic use, so decode the markers first -- the C++ mirror of
+ // the Python-side unescape_ktesc() rule. Found 2026-08-22: under tex,
+ // ":files my_chapter" searched for "myKTESC005fKTESCchapter".
+ m_files = resolve_filename_list(ktesc_resolve(get("files")), get("K_input_dir"));
m_css_text = get("css_text");
- m_css_filenames = resolve_filename_list(get("css_files"), get("K_input_dir"));
+ m_css_filenames = resolve_filename_list(ktesc_resolve(get("css_files")), get("K_input_dir"));
m_include_sks_css = strbool(get("include_sks_css"), loc);
frame_background_color = get("frame_background_color");
frame_text_color = get("frame_text_color");
@@ -62,7 +67,7 @@ Document_class::Document_class(Machine& machine) : Klammer_base(machine)
nav_text_color = get("nav_text_color");
js_text = get("js_text");
- m_js_filenames = resolve_filename_list(get("js_files"), get("K_input_dir"));
+ m_js_filenames = resolve_filename_list(ktesc_resolve(get("js_files")), get("K_input_dir"));
m_include_sks_js = strbool(get("include_sks_js"), loc);
// font_dirs = word_split(get("font_dirs"));
@@ -138,33 +143,49 @@ void Document_class::save_string_input_as_file()
fs::path parse_input_filename(const std::string& s, const std::string& input_dir)
{
+ // The two-class rule (2026-08-22), replacing a three-stage search whose
+ // second stage could quietly shadow a file beside the document:
+ //
+ // * a BARE WORD -- no directory separator, no extension -- is the kt/
+ // SHORTCUT: ":files X" MEANS kt/X.kt in the root file's directory,
+ // and nothing else. Missing is an immediate error whose message
+ // teaches the convention, not a fallback. The kt/ directory is the
+ // conventional home for a document's input files, and putting the
+ // meaning entirely in the name lets several root documents share it.
+ //
+ // * anything else is a real PATHNAME, absolute or resolved against the
+ // input file's directory (K_input_dir), so a document renders
+ // identically wherever ktext is run from.
+ //
+ // Which file a name landed on is a DERIVED value, so "-v 1" reports it.
fs::path p(s);
+ bool bare = s.find('/') == std::string::npos && p.extension().empty();
+ if (bare) {
+ fs::path in_kt = fs::path(input_dir) / "kt" / (s + ".kt");
+ if (!file_exists(in_kt.string())) {
+ throw Argument_error(
+ "The \":files\" name \"" + s + "\" is a bare word, which by "
+ "convention means the file kt/" + s + ".kt in the root "
+ "document's directory:\n"
+ " " + in_kt.string() + "\n"
+ "That file does not exist. Create it there, or write a real "
+ "pathname (a name with a directory or the \".kt\" extension) "
+ "to use a file elsewhere.",
+ Locator::none());
+ }
+ // The resolved path itself shows kt/ -- no label needed.
+ (void)K::log(1, "Input file \"" + s + "\": " + in_kt.string());
+ return in_kt;
+ }
if (p.extension() != ".kt") {
p += ".kt";
}
if (p.is_absolute()) {
return p;
}
- // A relative :files name resolves against the input file's directory
- // (K_input_dir), so a document renders identically wherever ktext is
- // run from; then the legacy kt/ subdirectory; a name found in neither
- // is returned as given (cwd-relative) and errors downstream.
- // Which of the three a name landed on is a DERIVED value -- the ".kt" may
- // have been supplied, and the directory certainly was -- so "-v 1" reports
- // it. A ":files chapter1" that quietly found kt/chapter1.kt rather than
- // the file beside the document is exactly what the author cannot see.
fs::path in_input_dir = fs::path(input_dir) / p;
- if (file_exists(in_input_dir.string())) {
- (void)K::log(1, "Input file \"" + s + "\": " + in_input_dir.string());
- return in_input_dir;
- }
- fs::path in_kt_dir = fs::path(input_dir) / "kt" / p;
- if (file_exists(in_kt_dir.string())) {
- (void)K::log(1, "Input file \"" + s + "\": " + in_kt_dir.string()
- + " (found in the kt/ subdirectory)");
- return in_kt_dir;
- }
- return p;
+ (void)K::log(1, "Input file \"" + s + "\": " + in_input_dir.string());
+ return in_input_dir;
}
void Document_class::write(const std::string& filename, const std::string& contents)
diff --git a/sks/document/document_html.cpp b/sks/document/document_html.cpp
index 0209fbb..5567cb6 100644
--- a/sks/document/document_html.cpp
+++ b/sks/document/document_html.cpp
@@ -113,38 +113,14 @@ std::string Document_class::font_definitions()
ss << " --monospace: \"" << m_mono_font << "\", monospace;\n";
ss << "}\n";
}
- // Emit scale factors so sans and mono fonts match the serif font.
- // Three scaling methods (uncomment the desired one):
- // x-height: serif_xh / other_xh (matches lowercase, like fontspec MatchLowercase)
- // cap-height: serif_ch / other_ch (matches capitals)
- // average: mean(serif_xh,serif_ch) / mean(other_xh,other_ch) (compromise)
- float serif_xh = m_resolved_serif.xheight_ratio;
- float serif_ch = m_resolved_serif.capheight_ratio;
- float serif_avg = (serif_xh + serif_ch) / 2.0f;
- if (serif_avg > 0.0f) {
- auto scale = [&](const Resolved_font& other) -> std::string {
- float other_avg = (other.xheight_ratio + other.capheight_ratio) / 2.0f;
- if (other_avg > 0.0f && other_avg != serif_avg) {
- char buf[16];
- // float ratio = serif_xh / other.xheight_ratio; // x-height
- // float ratio = serif_ch / other.capheight_ratio; // cap-height
- float ratio = serif_avg / other_avg; // average
- std::snprintf(buf, sizeof(buf), "%.4f", ratio);
- return buf;
- }
- return "";
- };
- std::string sans_scale = scale(m_resolved_sans);
- std::string mono_scale = scale(m_resolved_mono);
- if (!sans_scale.empty() || !mono_scale.empty()) {
- ss << ":root {\n";
- if (!sans_scale.empty())
- ss << " --sans-serif-scale: " << sans_scale << ";\n";
- if (!mono_scale.empty())
- ss << " --monospace-scale: " << mono_scale << ";\n";
- ss << "}\n";
- }
- }
+ // Font-size normalization across families is no longer emitted from
+ // here (2026-08-22): font.css declares "font-size-adjust: ex-height 0.5"
+ // on body, and the browser renders every font at the same x-height --
+ // the same computation the former --sans-serif-scale/--monospace-scale
+ // factors did from build-time OS/2 metrics, but applied to EVERY family
+ // switch instead of the four CSS sites that remembered to multiply.
+ // The metric extraction in the font store remains (the tex path and
+ // kdesc --font still use it).
// Global font scale: applied to body font-size
if (m_font_scale != 1.0f) {
char buf[16];
@@ -368,7 +344,15 @@ Document_class::insert_section_numbers(const std::string& marker, bool add_to_to
{
std::vector levels(9, 0);
std::smatch match {};
- std::string pattern = R"(<([\w-]+)\s*(.*?)>(.*?)MARKER\s*\s*(.*?)<.*)";
+ // The heading text (group 4) runs to the heading element's OWN closing
+ // tag (the \1 backreference), not to the first "<": a nested element in
+ // a section title -- @c's , an @i's -- would
+ // otherwise end the capture early and silently truncate the title in
+ // BOTH tables of contents (article's single_page_toc and book's
+ // navigation TOC read the same capture). Found 2026-08-22 via a @c in
+ // an @s2 title; heading.cpp's section_rgx already used the
+ // close-on-own-tag idiom.
+ std::string pattern = R"(<([\w-]+)\s*(.*?)>(.*?)MARKER\s*\s*(.*?)\1>.*)";
pattern = string_replace(pattern, "MARKER", marker);
std::regex heading_rgx(pattern);
std::vector> modified_components {};
diff --git a/sks/document/heading.cpp b/sks/document/heading.cpp
index 79507d7..8c0f6fe 100644
--- a/sks/document/heading.cpp
+++ b/sks/document/heading.cpp
@@ -38,104 +38,6 @@ std::string levels_to_section(std::vector levels)
int part_number = 1;
int unnumbered_id = 0;
-/*
-std::tuple, std::vector, unsigned int>
-add_section_numbers(const std::string& s, const std::string& basename, std::vector levels, unsigned int initial_id,
- std::map& section_id_map)
-{
- (void)K::log(3);
- auto depth { levels.size() };
- std::vector headings {};
- std::string text { s };
- std::regex section_rgx (R"((.*?)(.*?))");
- std::regex part_rgx (R"((.*?)Part (\d+)\s* \s*(.*?)\s*)");
- std::regex id_rgx(R"((.*?)id=\"([-\w]+)\"(.*))");
-
- unsigned int id_number { initial_id };
- //std::string result {};
- std::stringstream result {};
- for (std::string line : regex_split(s, std::regex(R"(\n)"), false)) {
- std::smatch match {};
- if (std::regex_match(line, match, part_rgx)) {
- std::string pre { match[1] };
- std::string attr { match[2] };
- std::string level { match[3] };
- std::string title { match[4] };
-
- std::string id_prefix { "_part_"};
- std::string id {};
- std::smatch id_match {};
- if (std::regex_match(attr, id_match, id_rgx)) {
- id = id_match[2];
- } else {
- id = id_prefix + std::to_string(part_number);
- }
- //title = "Part " + std::to_string(part_number) + " - " + title;
- //std::string section = "Part " + level;
- std::string section = "Part " + std::to_string(part_number);
- elements_t part_title
- { html::elt("kt-part",
- { html::elt("span", section).attr("class", "sectionnumber"),
- html::elt("span", trim(title)).attr("class", "sectiontitle") })
- .attr("id", id)
- .attr("data-level", level) };
- result << pre << part_title << "\n";
- headings.push_back(Heading(0, basename, section, id, "0", title));
- part_number += 1;
- } else if (std::regex_match(line, match, section_rgx)) {
- std::string pre { match[1] };
- int level { std::stoi(match[2]) };
- std::string attr { match[3] };
- std::string title { match[4] };
- bool numbered = attr.find("numbered") != std::string::npos;
- std::string section {};
- if (numbered) {
- levels[level-1] = levels[level-1] + 1;
- for (unsigned int li = level; li < depth; li++)
- levels[li] = 0;
- section = levels_to_section(levels);
- }
- std::string id {};
- std::smatch id_match {};
- if (std::regex_match(attr, id_match, id_rgx)) {
- id = id_match[2];
- } else {
- if (section.size() == 0) {
- id = "_su_" + std::to_string(unnumbered_id++);
- } else {
- id = "_s_" + string_replace(section, ".", "_");
- }
- }
- std::string link_target = file_basename(basename) + link_delimiter + id;
- section_id_map[title] = link_target;
-
- //
- // 1Image tests
-
- elements_t title_parts {};
- if (numbered) {
- title_parts.push_back(html::elt("span", trim(section)).attr("class", "sectionnumber"));
- }
- title_parts.push_back(html::elt("span", trim(title)).attr("class", "sectiontitle"));
-
- // msg() << "title_parts: " << title_parts << "\n";
- elements_t section_title
- { html::elt("h"+std::to_string(level), title_parts)
- .attr("id", id)
- .attr("data-level", std::to_string(level))};
- // msg() << pre << section_title << "\n";
- result << pre << section_title << "\n";
- headings.push_back(Heading(level, basename, section, id, "0", title));
- } else {
- result << line << "\n";
- }
- }
- //std::cout << "add_section_numbers: " << result.str() << " "
- //<< headings.size() << " " << levels.size() << " " << id_number << "\n";
- return std::tuple(result.str(), headings, levels, id_number);
-}
-*/
-
std::string make_html_table_of_contents(std::vector headings)
{
elements_t toc { html::elt("h1", "Contents") };
diff --git a/sks/font/css/font.css b/sks/font/css/font.css
index 0bb694a..f0e5192 100644
--- a/sks/font/css/font.css
+++ b/sks/font/css/font.css
@@ -4,18 +4,23 @@
--serif: Libre Baskerville, serif;
--sans-serif: Open Sans, sans-serif;
--monospace: Inconsolata, monospace;
- --sans-serif-scale: 1;
- --monospace-scale: 1;
}
body {
font-family: var(--serif);
font-size: 1rem;
+ /* One normalization for every family switch: render each font so its
+ x-height is 0.5em (the design's anchor -- EB Garamond comes UP ~19%,
+ Open Sans down ~7%). Inherited, so headings, code, footnotes and any
+ future rule are covered with no per-site scale factors; this replaced
+ the --sans-serif-scale/--monospace-scale system (build-time metrics,
+ applied at four sites and silently missed everywhere else). The tex
+ target's equivalent is fontspec's MatchLowercase. */
+ font-size-adjust: ex-height 0.5;
}
tt, .tt, pre {
font-family: var(--monospace);
- font-size: calc(1em * var(--monospace-scale));
}
h1, h2, h3, h4, h5 {
@@ -23,16 +28,11 @@ h1, h2, h3, h4, h5 {
font-weight: normal;
}
-h1 {
- font-size: calc(1.2rem * var(--sans-serif-scale));
-}
-
-h2, h3, h4, h5 {
- font-size: calc(1.1rem * var(--sans-serif-scale));
-}
+/* Heading SIZES live in sks/section/css/section.css (one ladder, one
+ place); this file assigns only the family. */
p {
- line-height: 1.3;
+ line-height: 1.5;
}
.plain {
@@ -50,7 +50,6 @@ p {
.sansserif {
font-family: var(--sans-serif);
- font-size: calc(1em * var(--sans-serif-scale));
}
.ritalic {
diff --git a/sks/font/font.py b/sks/font/font.py
index 9b8ec83..919b312 100644
--- a/sks/font/font.py
+++ b/sks/font/font.py
@@ -33,7 +33,7 @@ def html_fontify(text, font_symbol, font_size):
result = text
if cls:
if font_symbol == "c":
- result = re.sub(" ", " ", result)
+ result = re.sub(" ", " ", result)
cls = f'class="{cls}"'
sty = ""
if font_size != 1:
diff --git a/sks/form/page.k b/sks/form/page.k
index bdd5387..8365278 100644
--- a/sks/form/page.k
+++ b/sks/form/page.k
@@ -1,7 +1,7 @@
@@page.html body :title :
-
+
*title*
\n\n\n{body}\n\n')
diff --git a/tst/Makefile b/tst/Makefile
index 5d3454f..308bca7 100644
--- a/tst/Makefile
+++ b/tst/Makefile
@@ -14,8 +14,12 @@ test:
./check_test.sh
./deftype_test.sh
./escape_test.sh
+ ./transform_test.sh
+ ./character_test.sh
./filename_test.sh
./alone_test.sh
+ ./state_test.sh
+ ./target_test.sh
./modulepath_test.sh
./klammerset_test.sh
./option_set_test.sh
diff --git a/tst/alone_test.sh b/tst/alone_test.sh
index 4cc31ef..84a063b 100755
--- a/tst/alone_test.sh
+++ b/tst/alone_test.sh
@@ -87,7 +87,7 @@ check_error() {
FAIL=$((FAIL + 1))
return
fi
- if echo "$output" | grep -qF "$pattern"; then
+ if echo "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
@@ -233,6 +233,14 @@ check_eq \
"[]" \
--klammersets none -s '@@s :t.word : [*t*] @@ @s :t @' -d
+echo
+# --- The pattern itself is validated at definition time (gallery review) ---
+
+check_error \
+ "22. an invalid :pattern is a definition-time error" \
+ "is not a valid regular expression" \
+ --klammersets none -s '@@@argtype bad | d :pattern ([ @@@' -d
+
echo
echo "================================"
echo "Passed: $PASS Failed: $FAIL"
diff --git a/tst/character_test.sh b/tst/character_test.sh
new file mode 100755
index 0000000..d7d725d
--- /dev/null
+++ b/tst/character_test.sh
@@ -0,0 +1,81 @@
+#!/bin/bash
+#
+# character_test.sh — Regression tests for the ^-character forms the engine
+# resolves at read time: the ^UUUU^ Unicode code point (4-6 hex digits,
+# since 2026-08-23 covering the full range to U+10FFFF), and the character
+# tables (mnemonics, diacritics) it must not disturb.
+#
+# SKS-independent: character handling is katomizer/engine machinery
+# (mac/ktype.h nonascii katom, mac/character.cpp), so everything runs with
+# --klammersets none under the default (general) target.
+#
+# The 2026-08-23 changes these cases pin:
+# - a 5-digit code point converts WHOLE: ^13000^ (EGYPTIAN HIEROGLYPH
+# A001) rendered as U+1300 followed by a literal "0" before, because
+# process_unicode_codepoint() re-scanned its capture at a fixed 4-digit
+# width;
+# - 6 digits are accepted (^10FFFD^ is the top of the range; the katom
+# regex capped at {1,5});
+# - a value above U+10FFFF renders as the "Invalid Unicode" text, the
+# same treatment as a surrogate.
+#
+# Note: expected strings use $'\xHH' UTF-8 BYTE escapes, not $'\U...' code
+# points -- macOS ships bash 3.2, which does not expand \U (it failed there
+# exactly as a naive check would).
+#
+# Usage: ./character_test.sh (needs KLAMMERTEXT_HOME set; ktext on PATH)
+# Exit code: 0 if all tests pass, 1 otherwise.
+
+PASS=0
+FAIL=0
+KTEXT=ktext
+K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
+ERR=$(mktemp)
+
+red=$'\033[31m'
+green=$'\033[32m'
+bold=$'\033[1m'
+reset=$'\033[0m'
+
+trim() { awk '{ sub(/[ \t\r]+$/, "") } { line[NR]=$0 } END { f=1; while (f<=NR && line[f]=="") f++; l=NR; while (l>=1 && line[l]=="") l--; for (i=f;i<=l;i++) print line[i] }'; }
+
+# check NAME EXPECTED INPUT — render INPUT (no klammer set, default
+# target); exit 0 and stdout == EXPECTED.
+check() {
+ local name="$1" expected="$2" input="$3"
+ local out status
+ out=$("$KTEXT" --klammersets none -s "$input" -d 2>"$ERR"); status=$?
+ out=$(printf '%s' "$out" | trim)
+ if [ $status -ne 0 ]; then
+ echo "${red}FAIL${reset} $name — ktext exited $status"
+ head -3 "$ERR" | sed 's/^/ /'; FAIL=$((FAIL+1)); return
+ fi
+ if [ "$out" = "$expected" ]; then
+ echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
+ else
+ echo "${red}FAIL${reset} $name"
+ echo " expected: [$expected]"; echo " got: [$out]"; FAIL=$((FAIL+1))
+ fi
+}
+
+echo "${bold}Character form tests (engine: ^UUUU^ code points and the tables)${reset}"
+echo "================================================================="
+echo
+
+check " 1. 4-digit BMP code point" $'A ☺ B' 'A ^263A^ B'
+check " 2. 5-digit code point, whole" $'A \xf0\x93\x80\x80 B' 'A ^13000^ B'
+check " 3. 5-digit emoji" $'\xf0\x9f\x98\x80' '^1F600^'
+check " 4. 6-digit top of the range" $'\xf4\x8f\xbf\xbd' '^10FFFD^'
+check " 5. above U+10FFFF is invalid" 'Invalid Unicode: 16777215' '^FFFFFF^'
+check " 6. surrogate is invalid" 'Invalid Unicode: 55296' '^D800^'
+check " 7. mnemonic undisturbed" 'ß' '^s^'
+# The diacritic tables compose base + COMBINING mark (a U+0308), not the
+# precomposed letter -- the expected string is built the same way.
+check " 8. diacritic undisturbed" $'Ma\xcc\x88dchen' 'M^a"dchen'
+check " 9. code point beside quoted punctuation" $'☺ --' '^263A^ ^-^-'
+
+echo
+echo "================================================================="
+echo "${bold}Results: $PASS passed, $FAIL failed${reset}"
+rm -f "$ERR"
+[ $FAIL -eq 0 ] && exit 0 || exit 1
diff --git a/tst/command_option_test.sh b/tst/command_option_test.sh
index 3358227..a3161d9 100755
--- a/tst/command_option_test.sh
+++ b/tst/command_option_test.sh
@@ -49,7 +49,7 @@ error() {
if [ $status -eq 0 ]; then
echo "${red}FAIL${reset} $name — reported an error but exited 0"; FAIL=$((FAIL+1)); return
fi
- if printf '%s' "$out" | grep -qF -- "$pattern"; then
+ if printf '%s' "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF -- "$pattern"; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} $name — expected [$pattern]"
@@ -83,6 +83,20 @@ error " 4. ktext -t" "needs a value" ktext -s '@i-x' -t
error " 5. the message names the option" "-v " kdesc -v
error " 6. ... and how to get the usage" "for the list of arguments" kdesc -v
+echo
+echo "-- an option value that fails its pattern (gallery review) --"
+error "21. a non-integer verbosity is an error" "is not a verbosity level" kdesc -v abc
+# The location must be absent, not a C++ source file: the default Locator
+# captures the throw site (argv.cpp, line N) unless the throw says
+# Locator::none() -- the leak class the error gallery found 2026-08-22.
+out=$(kdesc -v abc 2>&1)
+if printf '%s' "$out" | grep -q '\.cpp, line'; then
+ echo "${red}FAIL${reset} 22. the error leaks a C++ source location"
+ echo " got: $(printf '%s' "$out" | grep '\.cpp, line' | head -1)"; FAIL=$((FAIL+1))
+else
+ echo "${green}PASS${reset} 22. no C++ source location in the error"; PASS=$((PASS+1))
+fi
+
echo
echo "-- the same options WITH a value still work --"
# NOT "kdesc -v 1": show_usage() treats exactly " -v " as a
@@ -103,7 +117,7 @@ echo "-- two required positional arguments --"
if [ -x "$TSTDIR/argv_test" ]; then
out=$("$TSTDIR/argv_test" first second --bool 2>&1 | sed 's/\x1b\[[0-9;]*m//g')
for expect in " : first" " : second"; do
- if printf '%s' "$out" | grep -qF "$expect"; then
+ if printf '%s' "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$expect"; then
echo "${green}PASS${reset} 13/14. positional [$expect]"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 13/14. positional [$expect] not parsed"; FAIL=$((FAIL+1))
diff --git a/tst/cond_test.sh b/tst/cond_test.sh
index 2e62e39..ffc0988 100755
--- a/tst/cond_test.sh
+++ b/tst/cond_test.sh
@@ -79,7 +79,7 @@ check_contains() {
FAIL=$((FAIL + 1))
return
fi
- if echo "$output" | grep -qF "$needle"; then
+ if echo "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$needle"; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
@@ -105,7 +105,7 @@ check_error() {
FAIL=$((FAIL + 1))
return
fi
- if echo "$output" | grep -qF "$pattern"; then
+ if echo "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
diff --git a/tst/deftype_test.sh b/tst/deftype_test.sh
index 004a394..b83ea1e 100755
--- a/tst/deftype_test.sh
+++ b/tst/deftype_test.sh
@@ -106,7 +106,7 @@ check_error() {
if [ $status -eq 0 ]; then
echo "${red}FAIL${reset} $name — expected an error but ktext succeeded"; FAIL=$((FAIL+1)); return
fi
- if echo "$out" | grep -qF "$pattern"; then
+ if echo "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} $name — expected error to contain [$pattern]"
diff --git a/tst/editor/indent_literal_c.kt b/tst/editor/indent_literal_c.kt
new file mode 100644
index 0000000..2c89632
--- /dev/null
+++ b/tst/editor/indent_literal_c.kt
@@ -0,0 +1,11 @@
+@document :text
+ @i before @c @right c@ after @ @x@
+ @note
+ inside the note
+ @
+ @c content with basic@ inside c@ and after
+ @c
+ kept as-is # not a comment
+ c@
+ after everything
+@
diff --git a/tst/editor/indent_literal_c_expected.kt b/tst/editor/indent_literal_c_expected.kt
new file mode 100644
index 0000000..1eb4c46
--- /dev/null
+++ b/tst/editor/indent_literal_c_expected.kt
@@ -0,0 +1,11 @@
+@document :text
+@i before @c @right c@ after @ @x@
+@note
+ inside the note
+@
+@c content with basic@ inside c@ and after
+@c
+ kept as-is # not a comment
+ c@
+after everything
+@
diff --git a/tst/editor/vim_feature_test.vim b/tst/editor/vim_feature_test.vim
index 52377e3..c4121c1 100644
--- a/tst/editor/vim_feature_test.vim
+++ b/tst/editor/vim_feature_test.vim
@@ -81,6 +81,22 @@ call s:Check('abbreviated @name-', s:Syn(9, 1) ==# 'klammertextAppOpen'
\ && s:Syn(9, 6) ==# '' && s:Syn(9, 7) ==# '', s:Syn(9, 6))
call s:Check('file marker ##', s:Syn(10, 1) ==# 'klammertextMarker', s:Syn(10, 1))
call s:Check('## removes to EOF', s:Syn(11, 3) ==# 'klammertextRemovedFile', s:Syn(11, 3))
+
+" @c is the single-letter literal klammer (inline @code, 2026-08-16). A
+" fresh buffer: the ## in the buffer above removes to EOF. The traps a
+" single-letter name adds: a word ending in c ("basic@") must not close the
+" region, and "@caption" must not open one.
+enew!
+call setline(1, [
+ \ '@c y @ basic@ z c@ tail',
+ \ '@caption arg @'])
+set filetype=klammertext
+call s:Check('c verbatim open @c', s:Syn(1, 1) ==# 'klammertextAppOpen', s:Syn(1, 1))
+call s:Check('c interior @ verbatim', s:Syn(1, 6) ==# 'klammertextVerbatim', s:Syn(1, 6))
+call s:Check('basic@ does not close @c', s:Syn(1, 13) ==# 'klammertextVerbatim', s:Syn(1, 13))
+call s:Check('real close c@', s:Syn(1, 17) ==# 'klammertextAppClose', s:Syn(1, 17))
+call s:Check('text after c@ plain', s:Syn(1, 20) ==# '', s:Syn(1, 20))
+call s:Check('@caption opens @caption, not @c', s:Syn(2, 1) ==# 'klammertextAppOpen', s:Syn(2, 1))
else
call add(s:results, 'SKIP syntax checks (this Vim lacks +syntax)')
endif
diff --git a/tst/editor_test.sh b/tst/editor_test.sh
index 11d6305..5437821 100755
--- a/tst/editor_test.sh
+++ b/tst/editor_test.sh
@@ -10,7 +10,7 @@
# tst/ is the SKS-independent tier, and these suites check that the
# implementations of Klammertext's structure agree with the canonical
# formatting conventions AND with each other. The klammer names that appear
-# in the fixtures (@ol, @table, @document, @code) are seeded configuration of
+# in the fixtures (@ol, @table, @document, @code, @c) are seeded configuration of
# the editor tools, not SKS dependencies; nothing here runs ktext or loads a
# klammer set.
#
@@ -60,7 +60,7 @@ OUT="$(mktemp -d)"
trap 'rm -rf "$OUT"' EXIT
INDENT_FIXTURES="indent_list indent_document indent_table indent_untouched
- indent_defs indent_escapes indent_named_close"
+ indent_defs indent_escapes indent_named_close indent_literal_c"
ALIGN_FIXTURES="align_mixed align_empty_cells align_boundary align_colspan
align_escapes align_too_wide"
diff --git a/tst/escape_test.sh b/tst/escape_test.sh
index 1270a03..56954dc 100755
--- a/tst/escape_test.sh
+++ b/tst/escape_test.sh
@@ -183,6 +183,33 @@ check_eq "25. a real literal klammer still works after a comment naming it" \
rm -f "$ERR"
+echo
+echo "-- 26-29: a general body escapes only its OWN literal text, never a --"
+echo "-- substituted argument value (ordering defect fixed 2026-08-19) --"
+# 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. apply_klammer used to substitute values first and
+# escape the general body after, so that markup was swept as if it were the
+# body's literal text: "@sig @b Andy @ @" emitted BSLtextbf{Andy}. The escape
+# now runs before substitution. BOLD is a target-specific nested klammer
+# (case 7/9 pattern) whose raw output must survive untouched.
+BOLD='@@b.k s : d @@ @@b.t :: \textbf{*s*} @@'
+check_eq "26. substituted klammer output NOT re-escaped" \
+ 'Sincerely, \textbf{Andy}' --klammersets none -t t \
+ -s "$T $BOLD @@sig name : Sincerely, *name* @@ @sig @b Andy @ @"
+check_eq "27. ... while the body's own literal & still IS escaped" \
+ 'Fish AMP \textbf{Chips}' --klammersets none -t t \
+ -s "$T $BOLD @@sig2 name : Fish & *name* @@ @sig2 @b Chips @ @"
+check_eq "28. writer specials in the argument still escaped (markers)" \
+ 'Sincerely, a AMP b' --klammersets none -t t \
+ -s "$T @@sig name : Sincerely, *name* @@ @sig a & b @"
+# The variable shares one text katom with literal specials on both sides:
+# the katom's literal "_"s must be escaped and the value spliced in raw
+# afterwards -- the ordering this section exists to pin.
+check_eq "29. mixed katom: literal specials escaped, spliced value raw" \
+ 'AUND\textbf{Q}UNDB' --klammersets none -t t \
+ -s "$T $BOLD @@w s : A_*s*_B @@ @w @b Q @ @"
+
echo
echo "============================================"
echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}"
diff --git a/tst/eval_test.sh b/tst/eval_test.sh
index b22d3b1..c88b274 100755
--- a/tst/eval_test.sh
+++ b/tst/eval_test.sh
@@ -219,6 +219,28 @@ main = putStrLn "partial" >> exitWith (ExitFailure 3) @'
fi
fi
+echo
+echo "-- the environment can be missing: the errors name what and how --"
+# Added 2026-08-22 by the error-gallery coverage review: neither message had
+# ever been printed by a test.
+run '@eval :cpp nonexistent_gallery_lib @'
+if [ $STATUS -ne 0 ] && grep -qF "Cannot open library" "$ERRF"; then
+ pass "19. :cpp names the library it could not open"
+else
+ fail "19. exit $STATUS" "$(plain < "$ERRF" | head -2)"
+fi
+# :haskell without runghc: strip PATH so the case is deterministic whether or
+# not GHC is installed; ktext itself is invoked by absolute path (which works
+# since the 2026-08-21 construct_command_pathname fix).
+env PATH=/nonexistent "$K/bin/ktext" --klammersets none \
+ -s '@eval :haskell main = putStrLn "x" @' -d >"$OUTF" 2>"$ERRF"
+hstatus=$?
+if [ $hstatus -ne 0 ] && tr '\n' ' ' < "$ERRF" | grep -qF "requires runghc"; then
+ pass "20. :haskell without runghc says what to install"
+else
+ fail "20. exit $hstatus" "$(plain < "$ERRF" | head -2)"
+fi
+
echo
echo "==========="
echo "Results: ${PASS} passed, ${FAIL} failed"
diff --git a/tst/option_set_test.sh b/tst/option_set_test.sh
index b2ef9fb..8249e23 100755
--- a/tst/option_set_test.sh
+++ b/tst/option_set_test.sh
@@ -84,7 +84,7 @@ $source" -t fix -d 2>&1)
FAIL=$((FAIL + 1))
return
fi
- if printf '%s' "$output" | tr '\n' ' ' | grep -qF "$needle"; then
+ if printf '%s' "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$needle"; then
echo "${green}PASS${reset} $name"
PASS=$((PASS + 1))
else
@@ -121,7 +121,7 @@ $source" -t fix -d -v 1 2>&1)
FAIL=$((FAIL + 1))
return
fi
- if printf '%s' "$output" | tr '\n' ' ' | grep -qF "$needle"; then
+ if printf '%s' "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$needle"; then
echo "${green}PASS${reset} $name"
PASS=$((PASS + 1))
else
@@ -270,7 +270,7 @@ check_fails "22. a set with no parameters is rejected" \
'@@bad.o : nothing at all @@'
check_fails '23. "::" has no meaning for a set' \
- 'An option set IS a declaration' \
+ 'An option set is a declaration' \
'@@cap.o :: nope @@'
# --- Collisions -----------------------------------------------------------
diff --git a/tst/recursion_test.sh b/tst/recursion_test.sh
index 93a7d2f..0181a64 100755
--- a/tst/recursion_test.sh
+++ b/tst/recursion_test.sh
@@ -55,7 +55,7 @@ check_error() {
FAIL=$((FAIL + 1))
return
fi
- if echo "$output" | grep -qF "$pattern"; then
+ if echo "$output" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
diff --git a/tst/state_test.sh b/tst/state_test.sh
new file mode 100755
index 0000000..2b03e42
--- /dev/null
+++ b/tst/state_test.sh
@@ -0,0 +1,121 @@
+#!/bin/bash
+#
+# state_test.sh — The @@@state system command and *name* substitution.
+#
+# Added 2026-08-22 by the error-gallery coverage review, which found that no
+# suite covered @@@state at all: its two error messages had never been
+# printed by any test. What is pinned here, each by outcome:
+#
+# * :value sets a variable and *name* substitutes it in a klammer BODY
+# (bodies are processed at application time). A top-level *name* of a
+# document-declared variable does NOT substitute — the same read-time
+# asymmetry @cond had before 2026-08-15; if that is ever changed, case 2
+# documents today's behaviour and should change with it.
+# * :replace replaces an existing value.
+# * Redefining WITHOUT :replace is an error that names the remedy.
+# * An undefined variable's error lists the frames it searched.
+# * A shell environment variable substitutes like any state variable.
+#
+# NOT to be confused with "state_test", the C++ diagnostic program built from
+# state_test.cpp in this directory: that one constructs engine objects and
+# prints what it gets, for a person to read, and asserts nothing (see the
+# `smoke` target in tst/Makefile). This is the regression suite. The ".sh"
+# is the only thing distinguishing them — the same collision as
+# eval_test/eval_test.sh, documented there first.
+#
+# Usage: ./state_test.sh (needs ktext on PATH)
+# Exit code: 0 if all tests pass, 1 otherwise.
+
+PASS=0
+FAIL=0
+KTEXT=ktext
+export KT_STATE_PROBE="probe-value" # for the environment-variable case
+
+red=$'\033[31m'
+green=$'\033[32m'
+bold=$'\033[1m'
+reset=$'\033[0m'
+
+# check_eq NAME EXPECTED KTEXT_ARGS... — trimmed stdout equals EXPECTED.
+check_eq() {
+ local name="$1" expected="$2"
+ shift 2
+ local out status
+ out=$("$KTEXT" "$@" 2>/dev/null)
+ status=$?
+ out=$(printf '%s' "$out" | sed -e 's/[ \t]*$//' | grep -v '^$')
+ if [ $status -eq 0 ] && [ "$out" = "$expected" ]; then
+ echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
+ else
+ echo "${red}FAIL${reset} $name"
+ echo " expected: [$expected]"; echo " got: [$out] (exit $status)"
+ FAIL=$((FAIL+1))
+ fi
+}
+
+# check_error NAME PATTERN KTEXT_ARGS... — nonzero exit, PATTERN in the
+# message. Wrap-insensitive: error prose is justified to 80 columns, so a
+# phrase may wrap anywhere.
+check_error() {
+ local name="$1" pattern="$2"
+ shift 2
+ local out status
+ out=$("$KTEXT" "$@" 2>&1)
+ status=$?
+ if [ $status -eq 0 ]; then
+ echo "${red}FAIL${reset} $name — expected an error but ktext succeeded"
+ FAIL=$((FAIL+1)); return
+ fi
+ if printf '%s' "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then
+ echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
+ else
+ echo "${red}FAIL${reset} $name — expected error to contain [$pattern]"
+ echo " got: $(printf '%s' "$out" | sed 's/\x1b\[[0-9;]*m//g' | tr '\n' ' ' | head -c 200)"
+ FAIL=$((FAIL+1))
+ fi
+}
+
+echo "${bold}@@@state tests${reset}"
+echo "=============="
+echo
+
+echo "-- setting and substituting --"
+check_eq " 1. :value sets; *name* substitutes in a body" \
+ "[hello]" \
+ --klammersets none -s '@@@state V :value hello @@@ @@f : [*V*] @@ @f@' -d
+
+# Documents TODAY'S behaviour: a top-level *name* of a document-declared
+# variable is left as written (bodies substitute; the top level does not).
+check_eq " 2. a top-level *name* does not substitute (current behaviour)" \
+ "[*V*]" \
+ --klammersets none -s '@@@state V :value hello @@@ [*V*]' -d
+
+check_eq " 3. an environment variable substitutes" \
+ "[probe-value]" \
+ --klammersets none -s '@@f : [*KT_STATE_PROBE*] @@ @f@' -d
+
+echo
+echo "-- replacing --"
+check_eq " 4. :replace replaces an existing value" \
+ "[b]" \
+ --klammersets none \
+ -s '@@@state V :value a @@@ @@@state V :replace b @@@ @@f : [*V*] @@ @f@' -d
+
+check_error " 5. redefining without :replace is an error naming the remedy" \
+ "Use ':replace ' to replace the current value" \
+ --klammersets none -s '@@@state V :value a @@@ @@@state V :value b @@@' -d
+
+echo
+echo "-- the undefined variable --"
+check_error " 6. an undefined *name* in a body is a located error" \
+ 'Variable "Missing" not defined' \
+ --klammersets none -s '@@f : [*Missing*] @@ @f@' -d
+
+check_error " 7. ... that lists the frames it searched" \
+ "(searched:" \
+ --klammersets none -s '@@f : [*Missing*] @@ @f@' -d
+
+echo
+echo "=============="
+echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}"
+[ $FAIL -eq 0 ]
diff --git a/tst/target_list_test.sh b/tst/target_list_test.sh
index 9719531..bb1eefb 100755
--- a/tst/target_list_test.sh
+++ b/tst/target_list_test.sh
@@ -109,7 +109,7 @@ check_error() {
if [ $status -eq 0 ]; then
echo "${red}FAIL${reset} $name — expected an error but ktext succeeded"; FAIL=$((FAIL+1)); return
fi
- if echo "$out" | grep -qF "$pattern"; then
+ if echo "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} $name — expected error to contain [$pattern]"
diff --git a/tst/target_test.sh b/tst/target_test.sh
new file mode 100755
index 0000000..cc9b935
--- /dev/null
+++ b/tst/target_test.sh
@@ -0,0 +1,133 @@
+#!/bin/bash
+#
+# target_test.sh — The @@@target system command's :after_apply phase list.
+#
+# Added 2026-08-22, when the txt target became the first to declare TWO
+# phases and found the list separator undocumented and unguarded: two bare
+# specs written without " ; " were glued into one Python call and failed at
+# render time with a SyntaxError located at "phase, line 1" rather than at
+# the declaration. What is pinned here:
+#
+# * several phases separated by " ; " run, in order, each seeing its
+# predecessor's result;
+# * a bare spec containing whitespace is a DEFINITION-TIME error naming
+# the " ; " convention (the guard in Target::add_after_apply);
+# * a mode-tagged spec (":cpp ") is exempt from the
+# whitespace guard -- its library is a filename, and filenames may
+# contain spaces, which is why the separator is ";" at all.
+#
+# SKS-independent: --klammersets none, an inline fixture target, and a
+# fixture Python module placed next to the input file (the module-resolution
+# rule adds the input file's directory to sys.path).
+#
+# NOT to be confused with "target_test", the C++ diagnostic program built
+# from target_test.cpp in this directory (asserts nothing; see the `smoke`
+# target in tst/Makefile). The ".sh" is the only distinguisher -- the third
+# such collision, after eval_test and state_test.
+#
+# Usage: ./target_test.sh (needs ktext on PATH)
+# Exit code: 0 if all tests pass, 1 otherwise.
+
+PASS=0
+FAIL=0
+KTEXT=ktext
+
+red=$'\033[31m'
+green=$'\033[32m'
+bold=$'\033[1m'
+reset=$'\033[0m'
+
+DIR=$(mktemp -d)
+trap 'rm -rf "$DIR"' EXIT
+
+# Two phases that mark their passage; order is observable in the output.
+cat > "$DIR/phasefix.py" <<'EOF'
+def one(text, K=None):
+ return text + "+ONE"
+
+def two(text, K=None):
+ return text + "+TWO"
+
+def width(text, K=None):
+ # K is the state's "class K"; a fixture state variable proves the phase
+ # receives it (attributes arrive as strings unless the argtype is typed).
+ return text + "|" + str(K.Fixture_width)
+EOF
+
+check_eq() { # check_eq NAME EXPECTED INPUT_FILE_TEXT TARGET_DECL
+ local name="$1" expected="$2" body="$3" decl="$4"
+ printf '%s' "$body" > "$DIR/in.kt"
+ local out status
+ out=$("$KTEXT" "$DIR/in.kt" --klammersets none -s "$decl" -t t -d 2>/dev/null)
+ status=$?
+ out=$(printf '%s' "$out" | tr -d '\n')
+ if [ $status -eq 0 ] && [ "$out" = "$expected" ]; then
+ echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
+ else
+ echo "${red}FAIL${reset} $name"
+ echo " expected: [$expected]"; echo " got: [$out] (exit $status)"
+ FAIL=$((FAIL+1))
+ fi
+}
+
+check_def_error() { # check_def_error NAME PATTERN TARGET_DECL
+ local name="$1" pattern="$2" decl="$3"
+ local out status
+ out=$("$KTEXT" --klammersets none -s "$decl hello" -t t -d 2>&1)
+ status=$?
+ if [ $status -eq 0 ]; then
+ echo "${red}FAIL${reset} $name — expected a definition error but ktext succeeded"
+ FAIL=$((FAIL+1)); return
+ fi
+ if printf '%s' "$out" | tr '\n' ' ' | tr -s ' ' | grep -qF "$pattern"; then
+ echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
+ else
+ echo "${red}FAIL${reset} $name — expected error to contain [$pattern]"
+ echo " got: $(printf '%s' "$out" | sed 's/\x1b\[[0-9;]*m//g' | tr '\n' ' ' | head -c 200)"
+ FAIL=$((FAIL+1))
+ fi
+}
+
+echo "${bold}@@@target :after_apply tests${reset}"
+echo "============================"
+echo
+
+echo "-- several phases: ';'-separated, run in declared order --"
+check_eq " 1. one phase runs" \
+ "text+ONE" "text" \
+ '@@@target t | test | :after_apply phasefix.one @@@'
+check_eq " 2. two phases run in order (ONE then TWO)" \
+ "text+ONE+TWO" "text" \
+ '@@@target t | test | :after_apply phasefix.one ; phasefix.two @@@'
+check_eq " 3. ... and reversing the list reverses the order" \
+ "text+TWO+ONE" "text" \
+ '@@@target t | test | :after_apply phasefix.two ; phasefix.one @@@'
+
+echo
+echo "-- a phase receives the state as K --"
+check_eq " 4. K. is readable inside a phase" \
+ "text|42" "text" \
+ '@@@state Fixture_width :value 42 @@@ @@@target t | test | :after_apply phasefix.width @@@'
+
+echo
+echo "-- the missing separator is a DEFINITION-time error --"
+check_def_error " 5. two bare specs without ';' are rejected at the declaration" \
+ "separated by \" ; \"" \
+ '@@@target t | test | :after_apply phasefix.one phasefix.two @@@'
+check_def_error " 6. ... and the error names the offending spec" \
+ "phasefix.one phasefix.two" \
+ '@@@target t | test | :after_apply phasefix.one phasefix.two @@@'
+
+echo
+echo "-- a mode-tagged spec may contain spaces (its library is a filename) --"
+# Declaration-time only: the :cpp library is never dlopened unless the
+# target renders, so declaring it under another name proves the exemption
+# without needing a real library.
+check_eq " 7. a ':cpp lib func' spec passes the declaration guard" \
+ "text+ONE" "text" \
+ '@@@target u | unused | :after_apply ^:cpp some lib func @@@ @@@target t | test | :after_apply phasefix.one @@@'
+
+echo
+echo "============================"
+echo "Results: ${green}$PASS passed${reset}, ${red}$FAIL failed${reset}"
+[ $FAIL -eq 0 ]
diff --git a/tst/transform_test.sh b/tst/transform_test.sh
new file mode 100755
index 0000000..f4c66db
--- /dev/null
+++ b/tst/transform_test.sh
@@ -0,0 +1,109 @@
+#!/bin/bash
+#
+# transform_test.sh — Regression tests for the Klammermachine's typographic
+# TRANSFORM pass (the third positional of @@@target).
+#
+# Kept SKS-INDEPENDENT on purpose: a target is a Machine construct, so the
+# fixture target is declared inline and no klammer set is loaded. The SKS's
+# own transform tables (html, txt, tex) are exercised by
+# sks/tst/typography_test.sh.
+#
+# What the pass IS: each pair replaces a source character sequence in the
+# final output with its target spelling (the LaTeX input conventions carried
+# to other targets: --- to an em dash, quote pairs, ~ to a non-breaking
+# space). Since 2026-08-23 it runs PER KATOM at final processing, not over
+# the joined result string, which is what these cases pin:
+#
+# - ordinary writer text -> transformed
+# - ^'...'^ literal span (katom_t::literal) -> NEVER transformed
+# - a KTESC marker in the text -> never matched; decodes after
+# the pass (this is the contract hide_typographic() in the SKS's
+# klammer_base.py relies on to protect @c/@code verbatim text)
+# - a plain-text @eval result (a renderer) -> transformed like writer text
+# - a source split across a katom boundary -> NOT transformed (the writer
+# separated the characters structurally; they are not a dash)
+# - :includes -> transforms are inherited,
+# as escapes are
+# - the pairs run in declared order
+#
+# The transform/escape interplay is also pinned: escapes become markers
+# before klammer application, transforms run at final processing, markers
+# resolve last — so the two mechanisms cannot corrupt each other's output.
+#
+# Usage: ./transform_test.sh (needs KLAMMERTEXT_HOME set; ktext on PATH)
+# Exit code: 0 if all tests pass, 1 otherwise.
+
+PASS=0
+FAIL=0
+KTEXT=ktext
+K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
+ERR=/tmp/transform_test_err.$$
+
+red=$'\033[31m'
+green=$'\033[32m'
+bold=$'\033[1m'
+reset=$'\033[0m'
+
+# strip leading/trailing blank lines and trailing whitespace
+trim() { awk '{ sub(/[ \t\r]+$/, "") } { line[NR]=$0 } END { f=1; while (f<=NR && line[f]=="") f++; l=NR; while (l>=1 && line[l]=="") l--; for (i=f;i<=l;i++) print line[i] }'; }
+
+# check_eq NAME EXPECTED KTEXT_ARGS... — exit 0 and stdout==EXPECTED.
+check_eq() {
+ local name="$1" expected="$2"; shift 2
+ local out status err
+ out=$("$KTEXT" "$@" 2>"$ERR"); status=$?
+ err=$(cat "$ERR")
+ out=$(printf '%s' "$out" | trim)
+ if [ $status -ne 0 ]; then
+ echo "${red}FAIL${reset} $name — ktext exited $status"
+ echo " stderr: $(echo "$err" | head -2)"; FAIL=$((FAIL+1)); return
+ fi
+ if [ "$out" = "$expected" ]; then
+ echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
+ else
+ echo "${red}FAIL${reset} $name"
+ echo " expected: [$expected]"; echo " got: [$out]"; FAIL=$((FAIL+1))
+ fi
+}
+
+echo "${bold}Typographic transform tests (Machine mechanism, fixture target 't')${reset}"
+echo "==================================================================="
+echo
+
+# The fixture target: one escape (& -> AMP) and two transforms, whose
+# replacements are distinct tokens that are easy to assert.
+T='@@@target t | test target :escape & AMP | --- MDASH | -- NDASH @@@'
+
+check_eq " 1. writer text: --- transformed" 'a MDASH b' --klammersets none -t t -s "$T a --- b"
+check_eq " 2. pairs in declared order: -- after ---" 'a NDASH b' --klammersets none -t t -s "$T a -- b"
+check_eq " 3. literal span: NOT transformed" 'a -- b' --klammersets none -t t -s "$T ^'a -- b'^"
+check_eq " 4. escape and transform coexist" 'x AMP y NDASH' --klammersets none -t t -s "$T x & y --"
+# 5-6: the @eval sits in a klammer BODY, applied after the fixture target
+# is extracted — a top-level @eval runs at read time, before same-input
+# @@@ extraction (the documented pass-ordering caveat), and would not
+# find target t.
+check_eq " 5. renderer @eval result: transformed" 'aNDASHb' --klammersets none -t t -s "$T @@e.t : @eval \"a--b\" @ @@ @e@"
+check_eq " 6. KTESC marker: skipped, then decoded" '--' --klammersets none -t t -s "$T @@e.t : @eval \"KTESC002dKTESC\" * 2 @ @@ @e@"
+check_eq " 7. source across a katom boundary: NOT transformed" '- x -' --klammersets none -t t -s "$T @@g : x @@ - @g@ -"
+
+# 8. :includes — an including target inherits the included target's
+# transforms (and escapes), so a target built over another renders the
+# same writer conventions.
+T2="$T @@@target t2 | over t :includes t @@@"
+check_eq " 8. :includes inherits transforms" 'a NDASH b AMP c' --klammersets none -t t2 -s "$T2 a -- b & c"
+
+# 9-12: "^" before any punctuation character quotes it
+# (hide_quoted_punctuation, 2026-08-23): the pair becomes the KTESC marker
+# of the character, which the transform pass cannot match. The target
+# decides the rendering — a :resolve entry, an :escape entry, or (neither)
+# the raw character. Inside an @eval span the code keeps its carets.
+check_eq " 9. quoted hyphens: no transform, decode raw" '--' --klammersets none -t t -s "$T ^-^-"
+check_eq "10. :resolve maps a quoted character" 'LIT-' --klammersets none -t r -s '@@@target r | r :resolve - LIT- @@@ ^-'
+check_eq "11. quoted char with an :escape entry takes its replacement" 'AMP' --klammersets none -t t -s "$T ^&"
+check_eq "12. @eval code keeps its carets" '2' --klammersets none -t t -s "$T @@e.t : @eval 3 ^(1) @ @@ @e@"
+
+echo
+echo "===================================================================="
+echo "${bold}Results: $PASS passed, $FAIL failed${reset}"
+rm -f "$ERR"
+[ $FAIL -eq 0 ] && exit 0 || exit 1