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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '(?<![A-Za-z0-9_])c@'
scope: entity.name.function.end.klammertext
pop: true

View File

@@ -20,13 +20,13 @@
" character is never read as a delimiter. A run of carets pairs
" left-to-right, reproducing the language's parity rule.
"
" Literal klammers: @code ... code@ — the interior is verbatim (no # or @
" Literal klammers: @code ... code@ and @c ... c@ — the interior is verbatim (no # or @
" interpreted). SYNC: the literal-klammer set's source of truth is
" LITERAL_KLAMMERS in doc/edit/shared/klammertext_edit.py; a static
" syntax file cannot read it, so when you add a literal klammer 'foo',
" copy the klammertextVerbatim region below with code -> 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<!@\w\+/
syn match klammertextAppClose /@\@1<!@\%(\w\|@\)\@!/
syn match klammertextAppClose /@\@1<!\w\+@\%(\w\|@\)\@!/
" --- literal klammer: interior verbatim (seeded default: @code) -----------
" --- literal klammers: interior verbatim (seeded: @code and @c) -----------
" Defined AFTER the @-tier matches: in Vim, when several items match at the
" same position the LAST defined wins, and this region must beat the plain
" klammertextAppOpen match at '@code'. (Sublime's tokenizer picks the FIRST
" listed rule — the opposite convention; don't copy that ordering here.)
syn region klammertextVerbatim matchgroup=klammertextAppOpen start=/@\@1<!@code\%(\w\)\@!/ matchgroup=klammertextAppClose end=/code@/
" @c is the inline form of @code (literal since 2026-08-16). The \w\@1<!
" guard on the close keeps a word ending in c ("basic@") from ending the
" region — essential for a single-letter name.
syn region klammertextVerbatim matchgroup=klammertextAppOpen start=/@\@1<!@c\%(\w\)\@!/ matchgroup=klammertextAppClose end=/\w\@1<!c@/
" --- colors ---------------------------------------------------------------
" The shared palette, dark and light values (see the header). cterm values

View File

@@ -8,7 +8,7 @@
"whitespace operators left unscoped), the three @-tiers — application",
"(@), definition (@@), system (@@@) — each as an opening (@name, one",
"unit) or a close (name@, bare @), ^-escapes (consumed, unscoped),",
"and verbatim @code ... code@ interiors.",
"and verbatim @code ... code@ / @c ... c@ interiors.",
"",
"How open vs. close is decided (the same rule as every integration):",
"a delimiter whose NAME follows the @-run is an OPENING; a bare",
@@ -19,7 +19,7 @@
"in doc/edit/shared/klammertext_edit.py. A static grammar cannot",
"read it: to add a literal klammer 'foo', copy the @code begin/end",
"rule below with code -> 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": "(?<![A-Za-z0-9_])c@",
"endCaptures": {
"0": { "name": "entity.name.function.end.klammertext" }
}
},
{
"match": "@@@[A-Za-z0-9_]+",
"name": "keyword.control.begin.klammertext"

View File

@@ -48,7 +48,7 @@ std::string Argtype_registry::replace_symbols(const std::string& pattern, const
ss << "Argtype symbol " << symbol << " not defined.\n\n"
<< "Defined argtypes:\n";
ss << describe();
throw Definition_error(ss.str(), loc, false);
throw Definition_error(ss.str(), loc);
}
}
return expanded;
@@ -88,7 +88,7 @@ void Argtype_registry::add(const std::string& name, const std::string& desc,
if (pattern != expanded_pattern) {
ss << "expanded to:\n " << expanded_pattern << "\n";
}
throw Definition_error(ss.str(), loc, false);
throw Definition_error(ss.str(), loc);
}
if (!default_value.empty() &&
!std::regex_match(default_value, m_types[name].m_regex)) {
@@ -96,7 +96,7 @@ void Argtype_registry::add(const std::string& name, const std::string& desc,
ss << "The default value \"" << default_value << "\" for argument type \""
<< name << "\" does not match its own pattern:\n"
<< " " << pattern << "\n";
throw Definition_error(ss.str(), loc, false);
throw Definition_error(ss.str(), loc);
}
if (!alone_value.empty()) {
// A pattern that matches running text cannot delimit a bare option
@@ -110,21 +110,21 @@ void Argtype_registry::add(const std::string& name, const std::string& desc,
// which is equally unable to delimit.
if (std::regex_match(std::string("one two"), m_types[name].m_regex)) {
std::stringstream ss {};
ss << "The argument type \"" << name << "\" cannot declare an :alone value "
<< "because its pattern matches running text:\n"
ss << "The argument type \"" << name << "\" cannot declare an :alone value"
<< "because its pattern matches running text:"
<< " " << pattern << "\n\n"
<< "An :alone value is used when an option name is written without a "
<< "value. A type that matches running text cannot tell a bare option "
<< "name from one whose value follows it, so the text after the name "
<< "would be taken as the value instead.\n";
throw Definition_error(ss.str(), loc, false);
<< "An :alone value is used when an option name is written without a"
<< "value. A type that matches running text cannot tell a bare option"
<< "name from one whose value follows it, so the text after the name"
<< "would be taken as the value instead.";
throw Definition_error(ss.str(), loc);
}
if (!std::regex_match(alone_value, m_types[name].m_regex)) {
std::stringstream ss {};
ss << "The alone value \"" << alone_value << "\" for argument type \""
<< name << "\" does not match its own pattern:\n"
<< " " << pattern << "\n";
throw Definition_error(ss.str(), loc, false);
throw Definition_error(ss.str(), loc);
}
}
m_names.push_back(name);

View File

@@ -86,10 +86,10 @@ Parameter parse_positional_parameter(const katom_list& katoms, const Argtype_reg
{
// (void)K::log(3, katoms);
if (katoms.size() > 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;
}

View File

@@ -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);
}

View File

@@ -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 {};
@@ -128,7 +137,12 @@ std::string process_unicode_codepoint(std::string s)
std::sregex_iterator end {};
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;

View File

@@ -11,7 +11,11 @@
#include <regex>
#include <unistd.h>
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}))");

View File

@@ -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 "<cwd>/<argv[0]>" 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".

View File

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

View File

@@ -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) {};
};

View File

@@ -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 <library-basename> <function-name> @\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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <predicate> | <result-if-true @\nor:\n"
<< " @cond <predicate> | <result-if-true> | <result-if-false> @";
throw Argument_error(ss.str(), begin->m_loc, false);
throw Argument_error(ss.str(), begin->m_loc);
}
}

View File

@@ -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 \"<klammer-name>\" for general klammers or \"<klammer-name>.<target-name>\" "
"for a specialized target. Several targets that share one body are written as a "
"comma-separated list: \"<klammer-name>.<target-name>,<target-name>\". 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(
" @@<name>[.<target>] :: <body> @@ instance (uses .k parameters)\n"
" @@<name>[.<target>] ::: <body> @@ override existing definition\n"
" @@<name>[.<target>] <parameters> :::: <body> @@ 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<Klammer::components>& 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);
}

View File

@@ -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);
}

View File

@@ -192,7 +192,7 @@ std::vector<Ktype> katom_types {
Ktype(katom_t::ws_newline, "ws-newline", ws_newline_s, "Remove all whitespace, leaving <number> 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",

View File

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

View File

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

View File

@@ -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<int>(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 ... : <description> @@",
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<std::string, std::string> 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<std::string, std::string> defaults {};
for (const auto& option : optional) {
@@ -248,17 +248,17 @@ std::map<std::string, std::string> 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;
};

View File

@@ -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 <library>
// <function>") 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 <library> <function>\" -- may "
"contain spaces; its library is a filename.)",
m_loc);
}
m_after_apply.push_back(f);
}
}

View File

@@ -24,6 +24,7 @@ public:
void transform(std::vector<Katom>& 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<std::pair<std::string, std::string>> m_transforms {};
std::vector<std::pair<std::string, std::string>> 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<std::pair<std::string, std::string>> 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
// ^<letter-or-digit> 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);

View File

@@ -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<Katom>::iterator begin, std::vector<Katom>
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);
}
}

View File

@@ -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());
*/

View File

@@ -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<std::pair<std::string, std::string>> 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());

View File

@@ -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 :: <p>*s*</p> @@
@@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 ::
<div class="quote">
# Right justification
@@right.k s :margin 0 :top_margin 2 : Right-justified text @@
@@right.html ::
<div style="text-align: right; margin: *top_margin*ex *margin*ex 2ex 0;">*s*</div>
@@
@@right.tex ::
{\setlength{\topsep}{0pt}\setlength{\partopsep}{0pt}\setlength{\parskip}{*top_margin*ex}
\begin{flushright}
\raggedleft\rightskip=*margin*ex
*s*
</div>
\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@
# Quote with attribution
@@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@ @
@@
@@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 @@
@@note.html,tex :: @eval block.Note(K) eval@ @@
# 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 ::
<p>
<div class="center">
*s*
</div>
</p>
@@
@@right.k s : Right-justified text @@
@@right.html ::
<b>TBD</b> *s*
@@
@@right.tex ::
\begin{flushright}
*s*
\end{flushright}
@@
# Newline
@@nl.k : Newline character @@
@@nl.html :: <br> @@
@@nl.html :: <br/> @@
@@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 :: <div style="height: *lines*lh"></div> @@
@@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 ::
<p>
<div style="column-count: *n*; column-gap: *gap*ex; text-align: justify;">
*s*
</div>
</p>
@@
# 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 :: <kt-footnote>*s*</kt-footnote> @@
@@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 #- __ @@

View File

@@ -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'''<div class="box" style="background-color: rgb({color}); border-color: rgb({self.bordercolor}) ;">
<b>{self.label}:</b> {self.s}
</div>'''
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"<b>{self.label}:</b> {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 + "<br>\n"
result += line + "<br/>\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

View File

@@ -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);
}

View File

@@ -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'<div class="code_block">{result}</div>\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'<span class="code">{html_line(undash(self.code_text.strip()))}</span>'
return f'<span class="code">{html_line(self.code_text.strip())}</span>'
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

View File

@@ -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'<div class="plain_caption code_caption">{caption}</div>{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("<", "&lt;", result)
result = re.sub(" ", "&nbsp;", result)
#print(f"code: |{self.code_text}| -> |{result}|")
return f'<span class="code">{result}</span>'
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'<pre class="code">\n{self.src}\n</pre>\n'
# result = self.src
# result = undash(result)
# result = f'<pre>\n{result}\n</pre>\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'<span class="monospace">{self.s}</span>'
# 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}'"

View File

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

View File

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

View File

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

View File

@@ -16,9 +16,9 @@ bool strbool(const std::string& s, const Locator& loc)
std::vector<std::string> 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)

View File

@@ -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<int> levels(9, 0);
std::smatch match {};
std::string pattern = R"(<([\w-]+)\s*(.*?)>(.*?)MARKER\s*</span>\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 <span class="code">, an @i's <em> -- 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*</span>\s*(.*?)</\1>.*)";
pattern = string_replace(pattern, "MARKER", marker);
std::regex heading_rgx(pattern);
std::vector<std::pair<std::string, std::string>> modified_components {};

View File

@@ -38,104 +38,6 @@ std::string levels_to_section(std::vector<unsigned int> levels)
int part_number = 1;
int unnumbered_id = 0;
/*
std::tuple<std::string, std::vector<Heading>, std::vector<unsigned int>, unsigned int>
add_section_numbers(const std::string& s, const std::string& basename, std::vector<unsigned int> levels, unsigned int initial_id,
std::map<std::string, std::string>& section_id_map)
{
(void)K::log(3);
auto depth { levels.size() };
std::vector<Heading> headings {};
std::string text { s };
std::regex section_rgx (R"((.*?)<h(\d)(.*?)>(.*?)</h\2>)");
std::regex part_rgx (R"((.*?)<kt-part(.*?)>Part (\d+)\s*<br>\s*(.*?)\s*</kt-part>)");
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;
//<h1 id="image-tests" style="clear:both;" class="headerlink">
// <span class="sectionnumber">1</span>Image tests</h1>
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<Heading> headings)
{
elements_t toc { html::elt("h1", "Contents") };

View File

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

View File

@@ -33,7 +33,7 @@ def html_fontify(text, font_symbol, font_size):
result = text
if cls:
if font_symbol == "c":
result = re.sub(" ", "&nbsp;", result)
result = re.sub(" ", "&#160;", result)
cls = f'class="{cls}"'
sty = ""
if font_size != 1:

View File

@@ -1,7 +1,7 @@
@@page.html body :title :
<html>
<head>
<meta charset="utf-8">
<meta charset="utf-8"/>
<title>*title*</title>
<style>
body { font-family:Palatino,roman; margin:2em 5em; }

View File

@@ -13,15 +13,39 @@ def unescape_ktesc(s):
return result
return re.sub(r'KTESC([0-9a-f]+)KTESC', replace, s)
def escape_ktesc(s):
"""A KTESC marker for the characters of s -- the mirror of
Target::escape_marker() in mac/target.cpp (4 lowercase hex digits per
byte). The marker is inert through every re-read and through the
target's typographic transform pass, and decodes back to the characters
at final escape resolution (ktesc_resolve)."""
return 'KTESC' + ''.join(f'{ord(c):04x}' for c in s) + 'KTESC'
# The characters the SKS targets' typographic transforms act on: the
# hyphen runs (-- and ---), the quote conventions (` ' `` ''), and ~.
# Verbatim text (@c, @code, @source_listing) must reach the output exactly
# as written, so the code klammers hide each of these as a KTESC marker --
# a transform source can then never match -- and the markers decode after
# the transform pass has run.
# SYNC: the transform tables of the html and txt targets in
# sks/target/target.k. A transform built from a new character needs it
# added here, or verbatim text will show the transformed form.
TYPOGRAPHIC_CHARS = "-'`~"
def hide_typographic(s):
"""Hide the typographically active characters of s as KTESC markers so
the target's transform pass cannot change verbatim text."""
for c in TYPOGRAPHIC_CHARS:
s = s.replace(c, escape_ktesc(c))
return s
class Klammer_base:
def __init__(self, K):
#args = {k:escape(v) for k, v in K.__dict__.items()
args = {k:v for k, v in K.__dict__.items()
if not k.startswith('__')}
for key in args:
setattr(self, key, args[key])
#setattr(self, "_klammer_name", klammer_name)
#pprint.pprint(self.__dict__)

View File

@@ -21,10 +21,11 @@ def msg(text=""):
def escape(s):
result = s
# These were commented out -- has it been replaced?
# It's only used in kutil/klammer_base.py and block/block.py
# and does nothing now.
#result = re.sub(r"\b", r"\b", result)
#result = re.sub(r"\t", r"\t", result)
#result = re.sub("\f", r"\\f", result)
#result = re.sub("\v", r"\\v", result)
#result = re.compile("\\(.)").sub(r"\\\1", result)

View File

@@ -13,8 +13,8 @@ def html_link(target, link_text, is_section, no_quotation_marks):
link_text = target if no_quotation_marks else f"{target}"
else:
# Allow long URLs to break at a slash:
link_text = re.sub('/', '/<wbr>', target)
link_text = re.sub('/<wbr>/<wbr>', '//', link_text)
link_text = re.sub('/', '/<wbr/>', target)
link_text = re.sub('/<wbr/>/<wbr/>', '//', link_text)
link_text = f'<tt>{link_text}</tt>'
target = f"[{target}]" if is_section else target
#result = f"[__link__{target}__{link_text}__]"

View File

@@ -86,7 +86,7 @@ class Define(klammer_base.Klammer_base):
strut = r"\rule[-8pt]{0pt}{8pt}"
result = "\n\n".join([f"\\item[{{{fontify(term)}{strut}}}] {definition}"
for term,definition in self.items])
result = f"\\begin{{description}}[labelindent=12pt,nosep,itemsep=6pt,parsep=.5\parskip,style=nextline]\n{result}\n\\end{{description}}"
result = f"\\begin{{description}}[labelindent=12pt,nosep,itemsep=6pt,parsep=.5\\parskip,style=nextline]\n{result}\n\\end{{description}}"
return result
# Needs to be implemented:
@@ -99,11 +99,11 @@ class Lines(klammer_base.Klammer_base):
super().__init__(K)
def html(self):
#return " <br>".join(self.s.strip().split("\n"))
#return " <br/>".join(self.s.strip().split("\n"))
#return f'<pre style="font-family: var(--serif)">{self.s}</pre>'
#return f'<pre>{self.s}</pre>'
result = self.s
result = re.sub("\n", "<br>\n", result)
result = re.sub("\n", "<br/>\n", result)
result = re.sub(" ", "&^#160;", result)
return result
@@ -114,331 +114,6 @@ class Lines(klammer_base.Klammer_base):
return self.s.strip()
"""
def lines(K):
return {'html' : html_lines,
'tex' : tex_lines,
'pdf' : tex_lines}[K._target](K)
def tex_lines(K):
return " \\\\\n".join(K.s.strip().split("\n"))
def html_lines(K):
return " <br>".join(K.s.strip().split("\n"))
"""
"""
def definition_item(K):
if K._format == 'html':
return '<dt>{}</dt>\n<dd>{}</dd>'.format(
format_for_paragraphs(K.term),
format_for_paragraphs(K.desc))
else:
leading = '' if K.inlist else '\\vspace*{-16pt}'
return '\\item[{}]\\leavevmode{}\n\n{}'.format(
K.term, leading, K.desc)
"""
"""
def list(K, list_type):
items = kutil.rest_args(K.list_items)
return {'html' : html_list,
'tex' : tex_list,
'pdf' : tex_list}[K._target](K, list_type, items)
def html_list(K, list_type, items):
body = "\n" + "".join([E("li").body(e).str()+"\n" for e in items])
result = E(list_type).body(body).str().rstrip()
return result
def tex_list(K, list_type, items):
body = "\n\n".join([f"\\item {e}" for e in items])
command = {'ol' : 'enumerate', 'ul' : 'itemize'}[list_type]
topsep = '[topsep=0pt]'
listsep = '\\setlist{nolistsep}' if K.cmp else ''
result = f'{listsep}\n\\begin{{{command}}}{topsep}\n{body}\n\\end{{{command}}}\n'
return result
def describe(K):
items = "\n\n".join(kutil.rest_args(K.descriptions))
return {'html' : html_describe,
'tex' : tex_describe,
'pdf' : tex_describe}[K._target](K, items)
def html_describe(K, items):
return E("dl").body(items).str()
def tex_describe(K, items):
return f'\\begin{{description}}\n{items}\n\\end{{description}}'
def entry(K):
return {'html' : html_entry,
'tex' : tex_entry,
'pdf' : tex_entry}[K._target](K)
def html_entry(K):
#return f'<dt>{K.item}</dt>\n<dd>{K.description}</dd>'
return E("dt").body(K.item).str(0) + E("dd").body(K.description).str(0)
def tex_entry(K):
return f'\\item[{K.item}]\n{K.description}\n'
def lines(K):
return {'html' : html_lines,
'tex' : tex_lines,
'pdf' : tex_lines}[K._target](K)
def tex_lines(K):
return " \\\\\n".join(K.s.strip().split("\n"))
def html_lines(K):
return " <br>".join(K.s.strip().split("\n"))
"""
r"""
# List items
paragraph_separator_re = re.compile('\n *\n', re.S)
def format_for_paragraphs(s):
result = s
if len(paragraph_separator_re.findall(result)) > 0:
result = '\n\n{}\n\n'.format(result)
return result
def li(K):
if K._format == 'html':
result = K.s.strip()
result = format_for_paragraphs(result)
result = '<li>{}</li>'.format(result)
else:
#result = '\\item{{\\Kinlisttrue {}}} '.format(K.s.strip())
#result = '\\item\\begin\\Kinlisttrue {}\\end '.format(K.s.strip())
#result = '\\item\\parbox{{\\linewidth}}{{\\Kinlisttrue {}}} '.format(K.s.strip())
#result = '\\item\\Kinlisttrue {} '.format(K.s.strip())
result = '\\item {} '.format(K.s.strip())
if K.keep:
result = '\\parbox{{\\linewidth}}{{{}}}'.format(result)
return result
def mli(K):
result = '@0item {} | @0ulc\n'.format(K.args[0])
result += "\n".join(['@0li {} li@'.format(e) for e in K.args[1:]]) + '\n'
result += ' ulc@ item@\n'
return result
# Ordered and unordered lists:
def list_html(K, list_type):
result = ''
if K.list_items[0][0:4] != '<li>':
result = "\n".join(['<li>{}</li>'.format(e) for e in K.list_items])
else:
result = K.list_items[0]
tag = 'ol'
if list_type == 'ul':
tag = 'ul style="list-style-type:{};"'.format(K.bullet)
cls = 'compressed' if K.cmp else ''
if list_type == 'ul' and K.bullet == 'none':
cls += ' nobullet'
cls = ' class="{}"'.format(cls.strip())
result = '<{}{}>\n{}\n</{}>\n'.format(tag, cls, result, list_type)
return result
def list_latex(K, list_type):
result = ''
first_word = K.list_items[0][:5]
if first_word not in {'\\item', '\\parb'}:
result = " " + "\n".join(['@0li {} li@'.format(e.strip()) for e in K.list_items])
else:
result = K.list_items[0]
#options = 'nosep,itemsep=1pt,topsep=0pt,partopsep=8pt,parsep=4pt'
options = 'nosep,itemsep={}pt'.format(2 if K.cmp else 6)
if K.cmp:
itemsep=',nosep'
else:
itemsep=''
'''
if list_type == 'ul' and K.bullet == 'none' and K.indent:
left_margin = ',leftmargin=18pt'
else:
left_margin = ''
'''
left_margin = ''
if K.in_define:
if list_type == 'ul':
left_margin = ',leftmargin=4pt'
else:
left_margin = ',leftmargin=16pt'
if K.parsep:
parsep = K.parsep
else:
parsep = 2 if K.cmp else 6
#options = '[nosep,parsep={}pt{},{}]'.format(parsep, left_margin,itemsep)
options = '[parsep={}{}{}]'.format(parsep, left_margin, itemsep)
'''
if K.cmp:
options = '[nosep]'
else:
options = ''
'''
#options = '[itemsep={}]'.format(K.itemsep) if K.itemsep else ''
#options = '[topsep=0pt,parsep={}pt]'.format(2 if K.cmp else 6)
#options = '\\setlength{{\\topsep}{0pt}}\n\\setlength{{\parsep}}{{{}}}\n'.format(
bullet = ''
if list_type == 'ul' and K.bullet == 'none':
bullet = '\\renewcommand\\labelitemi{\\hspace*{-8pt}}'
itemsep = '\n\\setlength{{\\itemsep}}{{{}}}\n'.format(K.itemsep) if K.itemsep else ''
#result = '''\\listvmargin%
result = '''\\begin{{{0}}}{1}{2}
{3}
{4}
\\end{{{0}}}
'''.format('itemize' if list_type == 'ul' else 'enumerate',
options, itemsep, bullet, result.strip())
result = re.compile('\n\n', re.S).sub('\n', result)
result = re.compile(r' +\\begin', re.S).sub(r'\\begin', result)
if K.uncover:
N = 1
def replace(match):
nonlocal N
result = '{}<{}-> '.format(match.group(1), N)
N += 1
return result
result = re.compile(r'(\\item(\[|\s))').sub(replace, result)
return result
#\\listvmargin
def ul(K):
if K._format == 'html':
return list_html(K, 'ul')
elif K._format == 'latex':
return list_latex(K, 'ul')
else:
util.format_not_defined(K._format, 'ul')
def ol(K):
if K._format == 'html':
return list_html(K, 'ol')
elif K._format == 'latex':
return list_latex(K, 'ol')
else:
util.format_not_defined(K._format, 'ol')
# Definitions
def definition_item(K):
if K._format == 'html':
return '<dt>{}</dt>\n<dd>{}</dd>'.format(
format_for_paragraphs(K.term),
format_for_paragraphs(K.desc))
else:
leading = '' if K.inlist else '\\vspace*{-16pt}'
return '\\item[{}]\\leavevmode{}\n\n{}'.format(
K.term, leading, K.desc)
def dfont(K):
if K._format == 'latex':
result = {'r' : '\\normalfont',
'i' : '\\normalfont\\itshape',
'b' : '\\normalfont\\bfseries',
't' : '\\normalfont\\ttfamily',
's' : '\\normalfont\\sffamily\\large'
}[K.font]
elif K._format == 'html':
s = K.term
result = {'r' : s,
'i' : '<i>{}</i>'.format(s),
'b' : '<b>{}</b>'.format(s),
't' : '<span class="monospace">{}</span>'.format(s),
's' : '<span class="sansserif">{}</span>'.format(s)
}[K.font]
else:
error.Klammertext_error(
'There is not defined format named "{}".'.format(K._format))
return result
def escape_code_term(s):
def replace(match):
left, term, right = match.groups()
term = re.sub('\[', r'$\\lbrack$\\,', term)
term = re.sub('\]', r'\\,$\\rbrack$', term)
#term = re.sub('\[', '\\[', term)
#term = re.sub('\]', '\\]', term)
term = re.sub('--', '-{}-', term)
result = '{}{}{}'.format(left, term, right)
return result
pat = re.compile(r'(\\item\[)(.*?)(\]\\)', re.S)
return pat.sub(replace, s)
def define(K):
if K._format == 'latex':
#itemsep=1pt,topsep=2pt,partopsep=8pt,parsep=4pt,%
options = 'nosep,parsep=4pt,leftmargin=32pt'
fontname = {'r' : '\\normalfont',
'i' : '\\normalfont\\itshape',
'b' : '\\normalfont\\bfseries',
't' : '\\normalfont\\ttfamily',
's' : '\\normalfont\\sffamily',
'si' : '\\normalfont\\sffamily\\itshape',
'sb' : '\\normalfont\\sffamily\\bfseries',
'c' : '\\normalfont',
}[K.font]
term = K.s
if K.font == 'c':
term = escape_code_term(term)
#term = re.compile('item\[(.*?)\]', re.S).sub(r'item[\\verb+\1+]', term)
#term = re.compile('
return '''
\\listvmargin \\begin{{description}}[{},
labelindent=16pt,%
style=nextline,font={}]
%\\raggedright
{}
\\end{{description}} \\listvmargin\n'''.format(options,fontname, term)
elif K._format == 'html':
term_class = {'r' : 'normal',
'i' : 'italic',
'b' : 'bold',
't' : 'monospace',
's' : 'sansserifbody',
'si' : 'sansserifitalic',
'sb' : 'sansserifbold',
'c' : 'normal',
}[K.font]
term = K.s
if K.font == 'c':
#term = re.sub('&#8211;', '&#8208;'*2, term)
term = re.sub('&#8211;', '- __UNSPACE__ -', term)
body = re.sub('<dt>', '<dt class="{}">'.format(term_class), term)
return '''
<dl>
{}
</dl>'''.format(body)
"""
def make_column_lists(lst, count):
cols = math.ceil(len(lst) / count)

View File

@@ -8,7 +8,7 @@ kt-part {
padding-top: 1.5rem;
margin-top: 0px;
font-family: var(--sans-serif);
font-size: 1.5rem;
/* font-size: 2.03rem; */ /* ladder top: 1.125^6 ... */
}
kt-chapter {
@@ -16,10 +16,11 @@ kt-chapter {
padding-top: 1.5rem;
margin-top: 0px;
font-family: var(--sans-serif);
font-size: 1.5rem;
/* font-size: 1.80rem; */ /* ... 1.125^5 */
}
/*
h1 {
padding-top: 1.5rem;
margin-top: 0;
@@ -27,10 +28,58 @@ h1 {
h2, h3, h4, h5 {
padding-top: 0.5rem;
margin-bottom: .5rem;
padding-top: 0.25rem;
margin-bottom: .25rem;
padding-bottom: 0rem;
}
*/
/* The heading ladder: a modular scale, ratio 1.125 (a "major second"),
flattening to weight below h4 -- seven visibly distinct sizes is more
hierarchy than an eye tracks. font-size-adjust (font.css) makes these
steps family-independent, so the rem values are the whole truth. */
/*
h1 {
font-size: 1.60rem;
}
h2 {
font-size: 1.42rem;
}
h3 {
font-size: 1.27rem;
}
h4 {
font-size: 1.13rem;
}
h5, h6 {
font-size: 1rem;
font-weight: bold;
}
*/
/* Too big. How about: */
kt-part { font-size: 1.4rem; }
kt-chapter { font-size: 1.4rem; }
h1 { font-size: 1.3rem; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1.15rem; }
h4, h5, h6 { font-size: 1.125rem; }
/* Vertical space not used to indicate hierarchical level: */
kt-part, kt-chapter, h1, h2, h3, h4, h5, h6 {
margin-top: 1.2rem;
margin-bottom: 0.0rem;
padding-top: 0;
padding-bottom: 0;
}
/* Table of contents: */
.sectiontitle {
}

View File

@@ -12,6 +12,9 @@
@@s1.k title :id :n : Top level division, numbered @@
@@s1.html,tex :: @eval section.Section(K, 1) eval@ @@
# Temporary hack for testing txt target; need to number section headings.
@@s1.txt :: *title* @@
@@s2.k title :id :n : Second level division, numbered @@
@@s2.html,tex :: @eval section.Section(K, 2) eval@ @@

View File

@@ -15,11 +15,6 @@
vertical-align: middle;
}
.center {
display: flex;
justify-content: center;
}
/* -------------------------------------------------------------------------------- */
.bgap {
@@ -179,3 +174,20 @@
margin: 0 1em 0 1em;
}
*/
.footnote_in_text {
vertical-align: baseline;
position: relative;
top: -0.4em;
font-size: 70%;
line-height: 0;
padding-left: 1px;
font-weight: bold;
}
.footnote_rule {
width: 40%;
margin: 3lh 0 .5lh 0;
border: none;
border-top: 1px solid black;
}

View File

@@ -8,7 +8,7 @@ import kutil
_novalue = '__no_value__'
class E:
void_elements = set("area base br col embed hr img input link meta param source track wbr".split())
void_elements = "area base br col embed hr img input link meta source track wbr".split()
def __init__(self, tag):
self._tag = tag
self._sty = []
@@ -17,6 +17,7 @@ class E:
self._attr = []
self.no_value = '__novalue__'
def sty(self, name, value=_novalue):
if name and value is _novalue:
self._sty.append(name.strip(';'))
@@ -49,6 +50,7 @@ class E:
self._attr.append(attr)
return self
def __str__(self):
def label(name, s):
return ' {}="{}"'.format(name, s) if s else ''
@@ -60,7 +62,16 @@ class E:
b = "\n".join(self._body)
tag = '{}{}{}{}'.format(self._tag, a, c, s).strip()
end_tag = '</{}>'.format(self._tag) if self._tag not in E.void_elements else ""
return '<{}>{}{}\n'.format(tag, b, end_tag)
no_body = self._tag in E.void_elements
if b and no_body:
raise Exception(f"Tag {self._tag} incorrectly defines a body.")
if no_body:
result = f'<{tag}/>'
else:
result = f'<{tag}>{b}{end_tag}\n' # .format(tag, b, end_tag)
if no_body:
print(result)
return result
def __repr__(self):
return self.__str__()
@@ -80,6 +91,9 @@ class E:
result = result.rstrip() + "\n"
return result
def color(rgb):
return "rgb(" + ",".join([f"{float(e)*100}%" for e in rgb.split(",")]) + ")"
def html_indent(filename):
command = "(progn (setq make-backup-files nil) (mark-whole-buffer) "
command += "(indent-region (point-min) (point-max) nil) (save-buffer))"
@@ -99,10 +113,10 @@ def page(head_elt, body, js_files=[], load_jquery=True):
def head(title, js_files=[], js_code="", css_files=[], css_code="",
include_fonts=True, google_font=[], favicon=None, load_jquery=True):
result = '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
result += '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">\n'
result = '<meta name="viewport" content="width=device-width, initial-scale=1"/>\n'
result += '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>\n'
if favicon:
result += f'<link rel="icon" type="image/x-icon" href="{favicon}">\n'
result += f'<link rel="icon" type="image/x-icon" href="{favicon}"/>\n'
result += "".join([E("link").attr("href", e).attr("rel", "stylesheet").str() for e in css_files])
fonts = None
css = ''

View File

@@ -35,7 +35,7 @@ def block(material):
required to know that a target distinguishes horizontal from
vertical mode. Asserted by sks/tst/paragraph_test.sh.
"""
return "\\par\n" + material.strip("\n") + "\n\\par\n"
return "^'\\par'^\n" + material.strip("\n") + "\n^'\\par'^\n"
def environment(name, body, required=None, optional=None):
req = f"{{{required}}}" if required else ""
@@ -164,3 +164,35 @@ def add_caption(element, caption_label, number, caption_text, latex_width,
element = minipage(caption + "\\rule[-0.75\\baselineskip]{0pt}{0pt}\n" + element,
latex_width, vertical="t")
return caption_wrapper(element, hpos, offset=offset)
def define_color(name, rgb_or_hex):
if "," in rgb_or_hex:
r, g, b = [float(e) for e in rgb_or_hex.split(",")]
else:
def c(h):
return float(eval('0x' + h)) / 255.0
r = c(rgb_or_hex[:2])
g = c(rgb_or_hex[2:4])
b = c(rgb_or_hex[4:])
return r'\\definecolor{{{}}}{{rgb}}{{{:.3f},{:.3f},{:.3f}}}'.format(name, r, g, b)
COLOR_BOX_TEMPLATE = r'''
^'\hfill\begingroup
COLOR
\setlength{\fboxsep}{8pt}
\fcolorbox{bordercolor}{localcolor}{
\parbox{WIDTH\linewidth}{\raggedright\setlength{\parskip}{8pt}'^
TEXT
^'}}\endgroup'^
'''.strip()
def color_box(text, width=1, box_color="1,1,1", border_color="0,0,0"):
color_definitions = define_color("localcolor", box_color) + "\n" \
+ define_color("bordercolor", border_color)
result = COLOR_BOX_TEMPLATE
for old, new in [['TEXT', re.sub(r'\\', r'\\\\', text)],
['COLOR', color_definitions],
['WIDTH', str(width)]]:
result = re.sub(old, new, result)
result = block(result)
return result

View File

@@ -1,331 +1,93 @@
import sys, os, re, textwrap, pprint
basedir = f'{os.environ["KLAMMERTEXT_HOME"]}/sks'
sys.path = [f"{basedir}/kutil"] + sys.path
sys.path = [f"{basedir}/target"] + sys.path
import kutil
import html_util
def tex_to_pdf(text):
print("in tex_to_pdf")
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.sub(r"\s*#-\s*", "", result)
# space_pat = re.compile(r" *#\+(\d*) *", re.S)
# print("hits:", space_pat.findall(text))
# for p in space_pat.findall(text):
# print("FOUND:", p)
#
#result = re.sub(" ", "SPACE", result)
#result = re.sub("#/", "\n", result)
#result = re.sub("X", " ", result)
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)
#print(result)
#sys.exit(0)
#print("expand_whitespace_markers", text, result)
return result
def handle_dashes(html_text, K=None):
def replace(match):
tag_start, text, tag_end = match.groups()
text = re.sub("__MDASH__", "---", text)
text = re.sub("__NDASH__", "--", text)
result = f"{tag_start}{text}{tag_end}"
return result
result = html_text
result = re.compile('(<span class="monospace">)(.*?)(</span>)', re.S).sub(replace, result)
result = re.sub("__MDASH__", "&#x2014;", result);
result = re.sub("__NDASH__", "&#x2013;", result);
return result
def add_tex_caption_numbers(tex_text, K=None):
chapter_pat = re.compile(r"\\section\\{")
index = {}
result = ""
chapter_number = 1
for line in tex_text.split("\n"):
if chapter_pat.search(line):
chapter_number += 1
reset_indices(index)
if kutil.caption_delimiter() in line:
before, caption_type, caption, after = line.split(kutil.caption_delimiter())
if index.get(caption_type) is None:
index[caption_type] = 1
n = index[caption_type]
number = f"{chapter_number}.{n}" if chapter_number != 0 else n
line = f"{before}{caption_type} {number} {caption}{after}"
index[caption_type] += 1
result += line + "\n"
return result
def remove_redundant_vspace(tex_text, K=None):
#print("remove_redundant_vspace")
rgx = re.compile(r"(\\vspace\*\{-[^}]+\})\s*\\vspace\*\{-[^}]+\}", re.S)
return rgx.sub(r"\1", tex_text)
def restore_backslash(tex_text, K=None):
#print("restore backslash")
#return re.compile(r"\^/").sub(r"\\", tex_text)
return re.sub("\6", "", tex_text)
#--------------------------------------------------------------------------------
def levels_to_section(levels):
result = ".".join([str(e) for e in levels])
result = re.sub(r"\.0", "", result)
return result
def chapter_title_span():
return '<span class="chapter_title">'
def add_html_section_numbers(html_text, K=None, start=0):
depth = 6
levels = [start-1] + ([0] * (depth-1))
section_pat = re.compile(r"(.*?)<h(\d)(.*?)>(.*?)</h\2>")
id_pat = re.compile(r'.*?id="([-\w]+)".*', re.S)
result = ""
id_number = 1
id_map = []
for line in html_text.split('\n'):
match = section_pat.match(line)
if match:
pre, level, attr, text = match.groups()
id_match = id_pat.fullmatch(attr)
if id_match:
id = id_match.group(1)
else:
id = f"id{id_number}"
attr = " " + f'id="{id}" {attr}'.strip()
id_number += 1
level = int(level)
levels[level-1] = levels[level-1] + 1
for i in range(level, depth):
levels[i] = 0
section = levels_to_section(levels)
line = f'{pre}<h{level}{attr}>{chapter_title_span()}{section}&#160;&#160;</span>{text}</h{level}>'
id_map.append([level, section, id, text])
result += line + "\n"
[print(e) for e in id_map]
return result, id_map
def make_html_table_of_contents(titles):
result = '<div id="_toc" class="toc-title">Table of contents</div>\n'
for level, section, id, text in titles:
result += f'<div class="level{level}"><a href="#{id}" class="level">{section} {text}</a></div>\n'
return result
def process_html_sections(html_text, K=None, start=1):
html, id_map = add_html_section_numbers(html_text)
toc = make_html_table_of_contents(id_map)
result = re.sub(r'(<div id="middle">)', rf"\1\n{toc}", html)
return result
def reset_indices(indices):
for key in indices.keys():
indices[key] = 1
def add_html_caption_numbers(html_text, K=None):
chapter_pat = re.compile(rf"{chapter_title_span()}(\d+)</span>")
index = {}
result = ""
chapter_number = ""
for line in html_text.split("\n"):
if '<span' in line:
match = chapter_pat.search(line)
print(match.groups())
if match and match.group(1) != "0":
chapter_number = f"{match.group(1)}."
reset_indices(index)
if kutil.caption_delimiter() in line:
before, caption_type, caption, after = line.split(kutil.caption_delimiter())
if index.get(caption_type) is None:
index[caption_type] = 1
n = index[caption_type]
line = f"{before}{caption_type} {chapter_number}{n} {caption}{after}"
index[caption_type] += 1
result += line + "\n"
return result
def escape_pre_angle_brackets(code, K=None):
def replace(match):
code = re.sub(r"<", "&#x003C;", match.group(1))
return f"<pre>{code}</pre>"
pat = re.compile("<pre>(.*?)</pre>", re.S)
return pat.sub(replace, code)
def block_elements():
return """
address article aside blockquote details dialog dd div dl dt fieldset
figcaption figure footer form h0 h1 h2 h3 h4 h5 h6 header hgroup hr li
main nav ol p section table ul td tr pre
""".strip().split()
def not_a_block(par):
if par.strip()[0] != "<":
return True
pat = re.compile(r'^</?([^\s>]+).*?>', re.S|re.M)
match = pat.match(par.strip())
return match.group(1) not in block_elements()
def make_paragraphs(html_text, K):
def replace(match):
before, content, after = match.groups()
result = ""
for par in [e.strip() for e in
re.compile(r"\n *(\n *)+", re.S).split(content)]:
if par.strip() and not_a_block(par):
#jpar = "\n".join(textwrap.wrap(par, width=80))
#result += f"\n<p>\n{jpar}\n</p>\n"
jpar = "\n".join(textwrap.wrap(f"<p>{par}</p>", width=80))
result += f"\n{jpar}\n"
else:
result += f"\n{par}\n"
result = re.compile(r"\s*</(\w+)>\n<\1>").sub(r"\n</\1>\n\n<\1>", result)
return f"{before}{result}{after}"
pat = re.compile(r'(.*?<div id="middle">)(.*?)(</div>\s*<div id="bottom">.*)', re.S)
match = pat.match(html_text)
result = pat.sub(replace, html_text)
result = re.compile(r"</td>\s+<td>", re.S).sub("</td>\n<td>", result)
result = re.compile(r"\n *(\n *)+", re.S).sub("\n\n", result)
return result
def indent_html(filename, K):
#print('indent_html')
html_util.html_indent(filename)
def copy_html_resources(filename, K):
output_dir = os.path.dirname(K._output_filename) or "."
css_dir = f"{output_dir}/{html_util.css_reldir()}"
kutil.make_dir_if_necessary(css_dir, delete_contents=True)
for basename, source in kutil.sks_files_of_type("css"):
command = f"cp {source} {css_dir}/{basename}.css"
os.system(command)
# def latex_escapes(latex_text):
# return latex_text
# result = latex_text
# result = re.sub('#', '\\#', result)
# result = re.sub('\^', '\\^', result)
# result += "LATEX"
# return result
# def restore_backslash(latex_text, K): # ?
# return latex_text
# print(latex_text)
# print("restore_backslash")
# result = latex_text
# result = re.sub('\b', r'\\b', result)
# result = re.sub(r'\\t', r'\\b', result)
# return result
def make_pdf_from_tex(filename, K, twice=True):
#debug_mode = int(K.K_verbose_level) > 1
verbose_level = int(K.K_verbose_level)
pathname = os.path.abspath(filename)
dirname, filename = os.path.split(pathname)
basename, ext = os.path.splitext(filename)
command = f"mv {pathname} {dirname}/{basename}.tex"
log_file = f"{dirname}/{basename}.log"
pdf_file = f"{dirname}/{basename}.pdf"
result = os.system(command)
#debug_mode = True
if verbose_level == 3:
remove_log = ''
else:
dbg_log = "/dev/null"
remove_log = ' >{} 2>&1'.format(dbg_log)
env_var = "KLAMMERTEXT_TEXLIVE_BIN"
texbin = os.environ.get(env_var)
if texbin is None:
msg = f"The directory of TeX Live commands must be defined by ${env_var}"
raise Exception(msg)
latex_command = f"{texbin}/xelatex"
#latex_command = f"{texbin}/pdflatex"
if not os.path.exists(latex_command):
raise Exception(f"LaTeX command not found: {latex_command}")
flags = "--halt-on-error"
#flags = "-file-line-error --shell-escape -halt-on-error -interaction nonstopmode -output-directory"
env = "export max_print_line=1000 ; export TEXINPUTS=${KLAMMERTEXT_HOME}/sks//: ;"
command = f"{env} cd {dirname} ; {latex_command} {flags} {basename}.tex {remove_log}"
#print(command)
result = os.system(command)
if result != 0:
os.system(f"tail -n 20 {log_file}")
msg = f"Error in LaTeX processing. See file {os.path.relpath(log_file)}"
#print(f"Error in LaTeX processing. See file {os.path.relpath(log_file)}")
raise Exception(msg)
# No error; is repetition necessary for links, etc? Check log for this.
# result = os.system(command)
# print(f"Wrote {os.path.relpath(pdf_file)}")
if twice:
result = os.system(command)
aux_files = 'aux out toc'.split()
if verbose_level < 2:
aux_files += 'tex log'.split()
for unused_ext in aux_files:
os.system('rm -rf {}/{}.{}'.format(dirname, basename, unused_ext))
import re
import textwrap
# Emitted by @vspace.txt (sks/block/block.k), one marker per line of space.
# An @eval result consisting only of whitespace is trimmed away by the
# Klammermachine, so vertical space must travel as markers and become
# newlines here, after blank-line runs have been normalized.
vspace_marker = "__VSPACE__"
def justify_blocks(text, K=None):
def txt_nl_marker():
return "__KTEXTNEWLINE__"
def txt_vspace_marker():
return "__KTEXTVSPACE__"
def txt_justify_blocks(text, K):
delim = '__DIVIDE__'
text = re.sub("\n\n+", delim, text)
result = ""
for par in text.split(delim):
stripped = par.strip()
n = stripped.count(vspace_marker)
if n and stripped == vspace_marker * n:
n = stripped.count(txt_vspace_marker())
if n and stripped == txt_vspace_marker() * n:
# A paragraph of only @vspace markers: n blank lines in addition
# to the normal paragraph separation.
result += "\n" * n
continue
if par and par[0] not in {' ', '['}:
par = "\n".join(textwrap.wrap(par, width=80))
result += par + "\n\n"
return result.replace(vspace_marker, "\n")
if par and par[0] != ' ':
label = re.match(r"~*\[\d+\] ", par)
par = "\n".join(textwrap.wrap(
par, width=int(K.Target_txt_width), break_long_words=False,
subsequent_indent=" " * len(label.group(0)) if label else ""))
# if par and par[0] != ' ':
# par = "\n".join(textwrap.wrap(
# par, width=int(K.Target_txt_width), break_long_words=False))
result += par + "\n\n"
result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result)
result = re.sub(r"\n? *" + txt_nl_marker() + r" *\n?", "\n", result)
result = re.sub("~", " ", result)
return result
def txt_footnote_marker():
return "KTEXTFOOTNOTE"
def txt_format_footnotes(text, K):
footnote_number = 0
footnotes = []
def replace(m):
nonlocal footnote_number, footnotes
footnote_number += 1
footnotes.append(m.group(1))
return f"[{footnote_number}]"
start_mark = "__" + txt_footnote_marker()
end_mark = txt_footnote_marker() + "__"
footnote_rgx = re.compile(rf"\s*{start_mark}\s+(.*?)\s+{end_mark}", re.S)
result = footnote_rgx.sub(replace, text).rstrip() + "\n\n"
if footnotes:
rule_count = int(round(float(K.Target_txt_width) * 0.4))
result += "-" * rule_count + "\n\n"
# Calculate maximum width for right-justified numbers:
width = len(f"[{len(footnotes)}]")
for n, footnote in enumerate(footnotes, 1):
label = f"[{n}]"
result += "~" * (width - len(label)) + label + " " + footnote + "\n\n"
return result
def html_footnote_marker():
return "kt-footnote"
def html_format_footnotes(text, K=None):
fnum = 0
footnotes = []
def replace(m):
nonlocal fnum, footnotes
fnum += 1
footnotes.append(m.group(1))
href = f'href ="#_footnote_{fnum}"'
id = f'id="_footnote_src_{fnum}"'
return f'<a {id} {href}><sup class="footnote_in_text">{fnum}</sup></a>'
#return f'<a href="#_footnote_{fnum}" id="_footnote_src_{fnum}"><sup>{fnum}</sup></a>'
start_mark = f"<{html_footnote_marker()}>"
end_mark = f"</{html_footnote_marker()}>"
footnote_rgx = re.compile(rf"\s*{start_mark}\s*(.*?)\s*{end_mark}", re.S)
result = footnote_rgx.sub(replace, text).rstrip() + "\n\n"
if footnotes:
result += '<hr class="footnote_rule"/>\n'
for n, footnote in enumerate(footnotes, 1):
result += f'<a href="#_footnote_src_{n}" id="_footnote_{n}"><sup>{n} </sup></a>{footnote}<br/>\n'
# Temporarily add room for footnotes to move to the top of the window for the test:
result += '<div style="height: 100lh"></div>\n'
return result

View File

@@ -15,10 +15,30 @@ The LaTeX transformations supported by the SKS are:
'' -> close double quote
~ -> non-breaking space
"^" before any punctuation character quotes it (an engine rule, not an SKS
one): the character reaches the output literally, taking no part in the
transforms above -- ^-^- writes two plain hyphens (a --flag in prose), ^~ a
plain tilde. The one exception is the apostrophe: "^" before it opens a
literal span instead (a literal apostrophe is ^0027^), and writing that
pair even in THIS comment would open one -- the span sentinels are placed
before text removal. A target where the raw character would
still be re-interpreted declares its rendering with :resolve -- the tex
target maps a quoted hyphen to {-} (a bare -- in .tex re-forms TeX's dash
ligature) and a quoted tilde to \textasciitilde{} (a raw ~ is TeX's
non-breaking space). Verbatim text (@c, @code, ^'...'^) never needs any
of this: neither the transforms nor the quoting reach it.
]#
@@@target txt | Plain text with formatting | # This txt target is not full implemented in the SKS yet.
@@@state Target_txt_width
:desc Width of the text in the plain text target (txt)
:value 72
@@@
# The txt target is not fully implemented in the SKS yet.
@@@target txt | Plain text with formatting |
-- - |
--- -- |
`` " |
@@ -27,24 +47,35 @@ The LaTeX transformations supported by the SKS are:
' ' |
~ ^0020^
:after_apply
phases.justify_blocks
phases.txt_format_footnotes ;
phases.txt_justify_blocks
@@@
@@@target html | HTML page
# :escape < &lt; & &amp; |
|
--- &mdash; |
-- &ndash; |
--- &^#x2014; |
-- &^#x2013; |
`` “ |
'' ” |
` |
' |
^^~ &tilde; |
~ &nbsp;
~ &^#160;
:after_apply
phases.html_format_footnotes
@@@
# No transforms: LaTeX applies the input conventions itself. The :resolve
# entries say how a QUOTED character renders where the raw character would
# still be re-interpreted by TeX: ^- resolves to {-}, not a bare -, or the
# hyphens re-form a run and the dash ligature turns ^-^- into an en-dash;
# ^~ resolves to \textasciitilde{}, since a raw ~ is TeX's non-breaking
# space. Other quoted punctuation either has an :escape entry (its marker
# takes the escape replacement: ^$ renders as \$) or decodes to the raw
# character.
@@@target tex | LaTeX
:escape \ \textbackslash{} & \& { \{ } \} $ \$ % \% _ \_ ^# \^# ^^ \textasciicircum{}
:escape \ \textbackslash{} & \& { \{ } \} $ \$ % \% _ \_ ^# \^# ^^ \textasciicircum{}
:resolve - {-} ~ \textasciitilde{}
@@@
@@@target pdf | PDF from LaTeX

View File

@@ -570,8 +570,8 @@ def build_html(md_path, css_paths, fonts_css="", wrap=0, base=None, report=True)
css = fonts_css + "\n".join(Path(p).read_text(encoding="utf-8") for p in css_paths)
if base is None:
base = md_path.resolve().parent.as_uri() + "/"
return (f'<!doctype html>\n<html><head><meta charset="utf-8">\n'
f'<base href="{base}">\n'
return (f'<!doctype html>\n<html><head><meta charset="utf-8"/>\n'
f'<base href="{base}"/>\n'
f'<title>{md_path.stem}</title>\n'
f'<style>\n{css}\n</style>\n</head>\n<body>\n{body}\n</body></html>\n')

View File

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

View File

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

81
tst/character_test.sh Executable file
View File

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

View File

@@ -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 <level>" 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 "<command> -v <n>" 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 "<input> : first" "<input2> : 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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

121
tst/state_test.sh Executable file
View File

@@ -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 <new-value>' 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 ]

View File

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

133
tst/target_test.sh Executable file
View File

@@ -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 <library> <function>") 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.<state variable> 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 ]

109
tst/transform_test.sh Executable file
View File

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