diff --git a/README.md b/README.md index c711add..8eb1e03 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ are regenerated on each release — patches cannot be merged directly. Report problems (or send patches) to the author; accepted changes are applied to the development tree and appear in a following snapshot. -This snapshot was assembled from development commit `9fa469dd74ed`. +This snapshot was assembled from development commit `2e453744f5e9`. ## License diff --git a/com/kdesc.cpp b/com/kdesc.cpp index c1c314d..a59a44c 100644 --- a/com/kdesc.cpp +++ b/com/kdesc.cpp @@ -158,10 +158,17 @@ static std::string text_target() return color_enabled(std::cout) ? "ansi" : "txt"; } -void show_special_characters() +// The Machine for a description kdesc renders from the ref/ klammerset. +static Machine ref_machine() { Machine M; M.read(M.m_state.value("KLAMMERTEXT_HOME") + "/ref/ref.k"); + return M; +} + +void show_special_characters() +{ + Machine M = ref_machine(); M.read_string("@b Writing characters with diacritics and non-English characters @\n\n"); M.read_string("@diacritics_examples@"); M.read_string("@extended_latin_examples@"); @@ -171,20 +178,29 @@ void show_special_characters() void show_unicode_examples() { - Machine M; - M.read(M.m_state.value("KLAMMERTEXT_HOME") + "/ref/ref.k"); + Machine M = ref_machine(); M.read_string("@unicode_examples@"); std::cout << "\n" << M.apply(text_target()) << "\n\n"; } void show_argtype_definitions() { - Machine M; - M.read(M.m_state.value("KLAMMERTEXT_HOME") + "/ref/ref.k"); + Machine M = ref_machine(); M.read_string("@argtype_definitions@"); std::cout << "\n" << M.apply(text_target()) << "\n\n"; } +// The katom table is wider than the text: four columns, the last of them a +// sentence. The width is the klammer's argument rather than a state +// variable set here, so the paragraphs above the table keep the text width. +void describe_katoms(bool show_regex) +{ + Machine M = ref_machine(); + M.read_string(std::string("@katom_descriptions :txt_width ") + + (show_regex ? "160 :regex" : "120") + " @"); + std::cout << "\n" << M.apply(text_target()) << "\n\n"; +} + int main(int argc, char* argv[]) { try { diff --git a/doc/klammertext_for_llm.md b/doc/klammertext_for_llm.md index cbc59b1..64c1145 100644 --- a/doc/klammertext_for_llm.md +++ b/doc/klammertext_for_llm.md @@ -84,7 +84,8 @@ klammerset; give it `@read sks/sks.k @` in the input if it needs one. | The document body is the first argument | For `@document` the body goes in the **`:text` option** (or `:files`). A positional body is an error. | | `x@i` is "x, then italic" | A letter may not touch an opening `@`: `x@i` could be the close of a klammer named `x`. Write `x @i`. Brackets and sentence punctuation may abut: `(@i-x)`, `@i x @.` | | `:files ch1` names a file `ch1` | A bare word means **`kt/ch1.kt`** beside the root file. A name with a directory or extension is a pathname. | -| `@font` sets the font | Retired. `@typeface name \| text @` is the temporary change; `@document :serif_typeface` etc. set the roles. | +| `@font` sets the font | Retired. `@typeface name \| text @` is the temporary change; `@document :serif_typeface` etc. set the roles. `:scale N` means N times the text as it appears, the same in html and pdf; `:middle` centres a script that does not sit on the baseline (hieroglyphs); `:lift 0.15em` shifts one that draws its glyphs off it. Put those in the klammer that names the font (`@egyptian`), never on a Latin typeface. | +| `:raise`, `:class`, `:import` as option names | An option name becomes a Python attribute, so a Python keyword is a SyntaxError at the first application, reported against the `.k`. Choose another word (`:lift`). | --- @@ -139,6 +140,8 @@ while every argument is letters and digits: `@i-italic`, `@sup-2-3`, `@date@`. code@ @c a | b c@ ← inline: same rule, and the close must be "c@" +@c ls :w | wc c@ ← an option-shaped word and a bar are code too; only an + option the klammer HAS (@c :nd | text c@) is read as one ``` `@c` and `@code` are the in-line and block forms of the same thing. Code that @@ -262,8 +265,8 @@ are the limited ones. | | | |---|---| -| inline | `@i` italic, `@b` bold, `@t` typewriter, `@u` underline, `@s` sans, `@r` roman, `@ri` roman italic, `@c` inline code (literal; close `c@`), `@sup base \| script @`, `@sub`, `@ts scale \| text @` resize, `@color rgb :text`, `@typeface name \| text :scale @`, `@cjk` (needs an installed CJK typeface) | -| structure | `@s1`…`@s7` numbered headings, `@h1`…`@h7` unnumbered, `@part`, `@preface`, `@par`, `@newpage`, `@vspace lines`, `@vfill`, `@nl` line break | +| inline | `@i` italic, `@b` bold, `@t` typewriter, `@u` underline, `@s` sans, `@r` roman, `@ri` roman italic, `@c` inline code (literal; close `c@`), `@sup base \| script @`, `@sub`, `@ts scale \| text @` resize, `@color rgb :text`, `@typeface name \| text :scale :middle :lift @`, `@egyptian text @` (hieroglyphs, NewGardinerOmni, shipped), `@chinese text @` (needs Noto Serif SC installed: `kdesc --typeface install
or ^^";
- } else {
- pat = t.m_pattern;
- pat = string_replace(pat, definition_name, "");
- pat = string_replace(pat, "\\", "");
+ auto var = std::find_if(katom_types.begin(), katom_types.end(),
+ [&] (const Ktype& t) { return t.m_type == type; });
+ if (var == katom_types.end()) {
+ throw std::logic_error(
+ "type_to_katom_instance: katom type " +
+ std::to_string(static_cast(type)) +
+ " has no entry in katom_types. See file mac/ktype.h.");
}
- return pat;
-}
-
-std::string regex_display(Ktype t)
-{
- std::string rgx{};
- if (t.m_type == katom_t::space) {
- rgx = "";
- } else if (t.m_type == katom_t::newline) {
- rgx = "\\n";
- } else {
- rgx = t.m_pattern;
- }
- return rgx;
-}
-
-Ktype find_katom_type(katom_t type)
-{
- for (auto t : katom_types) {
- if (t.m_type == type) {
- return t;
- }
- }
- throw Internal_error("Unknown katom_t: " + std::to_string((int)type));
-}
-
-void describe_katoms(bool show_regex)
-{
- size_t name_w = 0;
- size_t pattern_w = 0;
- size_t regex_w = 0;
- size_t description_w = 0;
- std::stringstream ss {};
- ss << R"(
-A "katom" is an individual element in the Klammertext input text.
-Each katom has a type, listed below. You can see how Klammertext
-divides up text into katoms with the "kdiag" command.)" << "\n\n";
- if (show_regex) {
- ss << R"(The "Regex" column is the argument to the std::regex C++ function.)";
- } else {
- ss << R"(In the katom patterns, "" is a word that begins with a letter
- and only contains letters, numbers, or the underscore (_) or period (.) characters. )";
- }
- ss << "A is an integer greater than or equal to 1. "
- "A is one of the non-ASCII codes that are displayed by the command "
- "\"kdesc -c\".";
-
- for (auto t : katom_types) {
- name_w = std::max(name_w, t.m_name.size());
- pattern_w = std::max(pattern_w, pattern_display(t).size());
- regex_w = std::max(regex_w, regex_display(t).size());
- description_w = std::max(description_w, t.m_description.size());
- }
- pattern_w++;
- regex_w++;
-
- std::cout << justify(ss.str()) << "\n\n"
- << std::setfill(' ') << boldblack << std::right << std::setw(6) << "Index"
- << std::left
- << std::setw(name_w) << " Name" << " ";
- if (show_regex) {
- std::cout << std::setw(regex_w) << " Regex" << " ";
- } else {
- std::cout << std::setw(pattern_w) << " Pattern" << " ";
- }
- std::cout << std::setw(description_w) << "Description"
- << "\n" << black;
-
- for (auto typ : katom_type_display_order) {
- auto t = find_katom_type(typ);
- std::cout << " " << std::right << std::setw(3)
- << static_cast(t.m_type) << " "
- << std::left
- << std::setw(name_w) << t.m_name << " ";
- if (show_regex) {
- std::cout << std::setw(regex_w) << regex_display(t) << " ";
- } else {
- std::cout << std::setw(pattern_w) << pattern_display(t) << " ";
- }
-
- std::cout << std::setw(description_w) << t.m_description << "\n";
- }
- std::cout << "\n";
+ return *var;
}
std::string type_to_name(katom_t type)
{
- auto var = std::find_if(katom_types.begin(), katom_types.end(),
- [&] (Ktype t) { return t.m_type == type; });
- return var->m_name;
+ return type_to_katom_instance(type).m_name;
}
diff --git a/mac/ktype.h b/mac/ktype.h
index 02bcb56..0cd2af6 100644
--- a/mac/ktype.h
+++ b/mac/ktype.h
@@ -69,10 +69,10 @@ enum class katom_t {
karg,
machine_begin,
machine_end,
+ ignore_line,
ignore_begin,
ignore_end,
ignore_rest,
- ignore_line,
ws_remove,
ws_space,
ws_newline,
@@ -88,109 +88,67 @@ enum class katom_t {
};
-inline const
-std::vector katom_type_display_order {
- katom_t::space,
- katom_t::word,
- katom_t::newline,
- katom_t::text,
- katom_t::apply_begin,
- katom_t::apply_end,
- katom_t::bar,
- katom_t::double_bar,
- katom_t::option_name,
- katom_t::read_begin,
- katom_t::eval_begin,
- katom_t::cond_begin,
- katom_t::define_begin,
- katom_t::define_end,
- katom_t::klammer_definition,
- katom_t::klammer_instance,
- katom_t::klammer_override,
- katom_t::klammer_default,
- katom_t::karg,
- katom_t::machine_begin,
- katom_t::machine_end,
- katom_t::ignore_begin,
- katom_t::ignore_end,
- katom_t::ignore_rest,
- katom_t::ignore_line,
- katom_t::ws_remove,
- katom_t::ws_space,
- katom_t::ws_newline,
- katom_t::special,
- katom_t::nonascii,
- katom_t::literal_begin,
- katom_t::literal_end,
- katom_t::ws_added,
- katom_t::literal,
- katom_t::eval_result,
- katom_t::replaced,
- katom_t::ignored
-};
-
class Ktype;
inline std::map katom_type_names {};
-inline std::map katom_type_descs {};
inline std::vector katom_type_list {};
class Ktype {
public:
// Ktype() = default;
- Ktype(katom_t type, std::string name, std::string pattern, std::string description, bool use_equal = false)
+ Ktype(katom_t type, std::string name, std::string pattern, bool use_equal = false)
: m_type(type),
m_name(name),
m_pattern(pattern),
- m_description(description),
m_use_equal(use_equal),
m_rgx(std::regex(pattern)) {
katom_type_list.push_back(type);
katom_type_names.insert({m_type, m_name});
- katom_type_descs.insert({m_type, m_description});
}
+ // A description used to be the fourth argument. A string literal there
+ // would now convert to bool and silently set use_equal, so it is refused.
+ Ktype(katom_t, std::string, std::string, const char*) = delete;
bool match(const std::string& s) const {
return m_use_equal ? s == m_pattern : std::regex_match(s, m_rgx);
}
katom_t m_type;
std::string m_name;
std::string m_pattern;
- std::string m_description;
bool m_use_equal;
std::regex m_rgx;
};
inline const
std::vector katom_types {
- Ktype(katom_t::space, "space", " ", "One space character", false),
- Ktype(katom_t::word, "word", "[a-zA-Z]+", "Text only containing letters", false),
- Ktype(katom_t::newline, "newline", "\n", "One newline character", false),
- Ktype(katom_t::bar, "bar", "\\|", "Bar character used as positional argument separator", false),
- Ktype(katom_t::double_bar, "double-bar", "\\|\\|", "Separator for compound positional arguments", false),
- Ktype(katom_t::read_begin, "read-begin", at_s+read_s, "Beginning of the file input klammer", false),
- Ktype(katom_t::eval_begin, "eval-begin", at_s+eval_s, "Beginning of the evaluation klammer", false),
- Ktype(katom_t::cond_begin, "cond-begin", at_s+cond_s, "Beginning of the conditional (if/then/else) klammer", false),
- Ktype(katom_t::apply_begin, "apply-begin", at_s + definition_name, "Beginning of a klammer call"),
- Ktype(katom_t::apply_end, "apply-end", "(" + definition_name + ")?" + at_s, "End of a klammer call"),
- Ktype(katom_t::option_name, "option-name", ":" + definition_name, "Optional argument name"),
- Ktype(katom_t::define_begin, "define-begin", at2_s + definition_begin_name, "Beginning of a klammer definition"),
- Ktype(katom_t::define_end, "define-end", "(" + definition_name + ")?" + at2_s, "End of a klammer definition"),
- Ktype(katom_t::klammer_default, "klammer-default", "::::", "Define klammer default value for possible override"),
- Ktype(katom_t::klammer_override, "klammer-override", ":::", "Override existing klammer definition"),
- Ktype(katom_t::klammer_definition, "klammer-definition", ":", "Klammer definition, including parameters"),
- Ktype(katom_t::klammer_instance, "klammer-instance", "::", "Klammer definition using previously defined parameters"),
+ Ktype(katom_t::space, "space", " ", false),
+ Ktype(katom_t::word, "word", "[a-zA-Z]+", false),
+ Ktype(katom_t::newline, "newline", "\n", false),
+ Ktype(katom_t::bar, "bar", "\\|", false),
+ Ktype(katom_t::double_bar, "double-bar", "\\|\\|", false),
+ Ktype(katom_t::read_begin, "read-begin", at_s+read_s, false),
+ Ktype(katom_t::eval_begin, "eval-begin", at_s+eval_s, false),
+ Ktype(katom_t::cond_begin, "cond-begin", at_s+cond_s, false),
+ Ktype(katom_t::apply_begin, "apply-begin", at_s + definition_name),
+ Ktype(katom_t::apply_end, "apply-end", "(" + definition_name + ")?" + at_s),
+ Ktype(katom_t::option_name, "option-name", ":" + definition_name),
+ Ktype(katom_t::define_begin, "define-begin", at2_s + definition_begin_name),
+ Ktype(katom_t::define_end, "define-end", "(" + definition_name + ")?" + at2_s),
+ Ktype(katom_t::klammer_default, "klammer-default", "::::"),
+ Ktype(katom_t::klammer_override, "klammer-override", ":::"),
+ Ktype(katom_t::klammer_definition, "klammer-definition", ":"),
+ Ktype(katom_t::klammer_instance, "klammer-instance", "::"),
- Ktype(katom_t::karg, "klammer-arg", "\\*" + definition_name +"\\*", "Klammer argument in klammer body definition"),
- Ktype(katom_t::machine_begin, "machine-begin", at3_s + definition_name, "Beginning of a processor modification definition"),
- Ktype(katom_t::machine_end, "machine-end", "(" + definition_name + ")?" + at3_s, "End of a processor modification definition"),
- Ktype(katom_t::ignore_begin, "ignore-begin", ignore_begin_s, "Beginning of text to remove"),
- Ktype(katom_t::ignore_end, "ignore-end", ignore_end_s, "End of text to remove"),
- Ktype(katom_t::ignore_rest, "ignore-rest", ignore_rest_s, "Remove all text to the end of file or string"),
- Ktype(katom_t::ignore_line, "ignore-line", ignore_line_s, "Remove all text to the first newline, inclusive"),
- Ktype(katom_t::ws_remove, "ws-remove", ws_remove_s, "Remove all whitespace at this point"),
- Ktype(katom_t::ws_space, "ws-space", ws_space_s, "Remove all whitespace, leaving spaces (default: 1)"),
- Ktype(katom_t::ws_newline, "ws-newline", ws_newline_s, "Remove all whitespace, leaving newlines (default: 1)"),
- Ktype(katom_t::special, "special-char", R"(\^[@|#^:*])", "Klammertext special character treated as regular text"),
+ Ktype(katom_t::karg, "klammer-arg", "\\*" + definition_name +"\\*"),
+ Ktype(katom_t::machine_begin, "machine-begin", at3_s + definition_name),
+ Ktype(katom_t::machine_end, "machine-end", "(" + definition_name + ")?" + at3_s),
+ Ktype(katom_t::ignore_begin, "ignore-begin", ignore_begin_s),
+ Ktype(katom_t::ignore_end, "ignore-end", ignore_end_s),
+ Ktype(katom_t::ignore_rest, "ignore-rest", ignore_rest_s),
+ Ktype(katom_t::ignore_line, "ignore-line", ignore_line_s),
+ Ktype(katom_t::ws_remove, "ws-remove", ws_remove_s),
+ Ktype(katom_t::ws_space, "ws-space", ws_space_s),
+ Ktype(katom_t::ws_newline, "ws-newline", ws_newline_s),
+ Ktype(katom_t::special, "special-char", R"(\^[@|#^:*])"),
//Ktype(katom_t::nonascii, "non-ascii-char", R"(\^\w(?:.|\^))", "Non-ASCII character"),
// The second alternative is the "^...^" delimited form: a code point
// (^263A^, 4-6 hex digits) OR a mnemonic (^s^, ^oe^). Its character
@@ -207,26 +165,95 @@ std::vector katom_types {
// decided there, by the tables, not here -- katomization's job is only to
// recognize the shape. A shape that resolves to no character at all is
// a located error, raised at the end of encode().
- Ktype(katom_t::nonascii, "non-ascii-char", R"((\^(\w(?:.|\^)))|(\^[A-Za-z0-9]{1,6}\^))", "Non-ASCII character"),
+ Ktype(katom_t::nonascii, "non-ascii-char", R"((\^(\w(?:.|\^)))|(\^[A-Za-z0-9]{1,6}\^))"),
// Ktype(katom_t::word, "word", R"([a-z][a-z0-9_]*)", "Lower-case letters, numbers, or underscore"),
// Ktype(katom_t::text, "text",
- Ktype(katom_t::literal_begin, "literal-begin", R"(\^')", "Begin unprocessed text"),
- Ktype(katom_t::literal_end, "literal-end", R"('\^)", "End unprocessed text"),
- Ktype(katom_t::text, "text", R"([^^@#|]+)", "No special characters, whitespace, or \":\" at the beginning"), // spaces, @, #, |, or ^,
+ Ktype(katom_t::literal_begin, "literal-begin", R"(\^')"),
+ Ktype(katom_t::literal_end, "literal-end", R"('\^)"),
+ Ktype(katom_t::text, "text", R"([^^@#|]+)"), // spaces, @, #, |, or ^,
- Ktype(katom_t::ws_added, "ws-added", " or \\n", "Added whitespace characters from #+ and #/"),
- Ktype(katom_t::literal, "literal", "", "Literal katom (changed from its original type by ^'...'^)"),
- Ktype(katom_t::eval_result, "eval-result", "", "Katom produced by @eval klammer"),
+ Ktype(katom_t::ws_added, "ws-added", " or \\n"),
+ Ktype(katom_t::literal, "literal", ""),
+ Ktype(katom_t::eval_result, "eval-result", ""),
- Ktype(katom_t::replaced, "replaced", "", "A katom replaced by definitions, applications, or file input"),
- Ktype(katom_t::ignored, "ignored", "", "A katom ignored by the action of a \"#\" katom"),
+ Ktype(katom_t::replaced, "replaced", ""),
+ Ktype(katom_t::ignored, "ignored", ""),
// Ktype(katom_t::undefined, "undefined", "", "Undefined pattern")
};
+// The human-facing description of the katom types, and the ONLY one: class
+// Ktype above holds what the program needs (type, name, regex), this holds
+// what a reader is told, and katom_t joins them. The pattern and the
+// description are Klammertext, rendered by katom_descriptions() in
+// ref/reflect.cpp (kdesc --katoms; a Handbook chapter). A title is
+// Klammertext too, without styling -- the generator styles every title alike.
+//
+// The ORDER is the display order, from what every author meets to what only
+// a programmer does, and it is also the order of the katom_t enumerators:
+// the table's Index column is the enum value, which is the subscript kdiag
+// prints. tst/ktype_test.cpp asserts both halves of the tie -- every type
+// in katom_types described exactly once, and the indices increasing.
+struct Katom_type_description {
+ katom_t m_type;
+ std::string m_pattern;
+ std::string m_description;
+};
+struct Katom_type_group {
+ std::string m_title;
+ std::vector m_types;
+};
+
+inline const
+std::vector katom_type_groups {
+ {"Plain text", {
+ {katom_t::space, "@symb-space", "One space character"},
+ {katom_t::word, "@symb only-letters @", "Text only containing letters"},
+ {katom_t::newline, "\\n", "One newline character"},
+ {katom_t::text, "@symb no-special-chars @", "No special characters, whitespace, or an initial colon"}}},
+ {"Klammer application", {
+ {katom_t::apply_begin, "^@ #- @symb-name", "Beginning of a klammer call"},
+ {katom_t::apply_end, "^@ @ri-or @symb-name #- ^@", "End of a klammer call"},
+ {katom_t::bar, "^|", "Bar character used as positional argument separator"},
+ {katom_t::double_bar, "^|^|", "Separator for compound positional arguments"},
+ {katom_t::option_name, ": #- @symb-name", "Name of an optional argument"},
+ {katom_t::read_begin, "@t ^@read @", "Beginning of the file input klammer"},
+ {katom_t::eval_begin, "@t ^@eval @", "Beginning of the evaluation klammer"},
+ {katom_t::cond_begin, "@t ^@cond @", "Beginning of the conditional (if/then/else) klammer"}}},
+ {"Klammer definition", {
+ {katom_t::define_begin, "^@^@ #- @symb-name", "Beginning of a klammer definition"},
+ {katom_t::define_end, "^@^@ @ri-or @symb-name #- ^@^@", "End of a klammer definition"},
+ {katom_t::klammer_definition, ":", "Klammer definition, including parameters"},
+ {katom_t::klammer_instance, "::", "Klammer definition using previously defined parameters"},
+ {katom_t::klammer_override, ":::", "Override existing klammer definition"},
+ {katom_t::klammer_default, "::::", "Define klammer default value for possible override"},
+ {katom_t::karg, "* #- @symb-name #- *", "Klammer argument in klammer body definition"}}},
+ {"System commands", {
+ {katom_t::machine_begin, "^@^@^@ #- @symb-name", "Beginning of a system command"},
+ {katom_t::machine_end, "^@^@^@ @ri-or @symb-name #- ^@^@^@", "End of a system command"}}},
+ {"Text removal and whitespace", {
+ {katom_t::ignore_line, "^#", "Remove all text to the first newline, inclusive"},
+ {katom_t::ignore_begin, "^#[", "Beginning of text to remove"},
+ {katom_t::ignore_end, "]^#", "End of text to remove"},
+ {katom_t::ignore_rest, "^#^#", "Remove all text to the end of file or string"},
+ {katom_t::ws_remove, "^#-", "Remove all whitespace at this point"},
+ {katom_t::ws_space, "^#+ @ri-or ^#+ #- @symb-n", "Remove all whitespace, leaving @symb-n spaces (default: 1)"},
+ {katom_t::ws_newline, "^#/ @ri-or ^#/ #- @symb-n", "Remove all whitespace, leaving @symb-n newlines (default: 1)"}}},
+ {"Special characters and literal text", {
+ {katom_t::special, "^@, ^|, ^#, ^:, @ri-or ^^", "Klammertext special character treated as regular text"},
+ {katom_t::nonascii, "^^ #- @symb-code @ri-or ^^ #- @symb-code #- ^^", "Non-ASCII character"},
+ {katom_t::literal_begin, "^^ #- '", "Begin unprocessed text"},
+ {katom_t::literal_end, "'#- ^^", "End unprocessed text"}}},
+ {"Types assigned during processing", {
+ {katom_t::ws_added, "@symb-space @ri-or \\n", "Added whitespace characters from @t ^#+ @ and @t ^#/ @"},
+ {katom_t::literal, "@symb any-text @", "Literal katom (type changed by @t ^^ #- '...'#- ^^ @ #- )"},
+ {katom_t::eval_result, "@symb any-text @", "Katom produced by @t ^@eval @ klammer"},
+ {katom_t::replaced, "@symb any-text @", "A katom replaced by definitions or klammers"},
+ {katom_t::ignored, "@symb any-text @", "A katom ignored by the action of a text-removal katom"}}}
+};
inline const
std::set printable_katom_types {
@@ -277,8 +304,7 @@ std::map katom_spans {
bool is_active(katom_t t);
bool is_printable(katom_t type);
-void describe_katoms(bool show_regex = false);
-
bool is_deftype(katom_t type);
+const Ktype& type_to_katom_instance(katom_t type);
std::string type_to_name(katom_t type);
diff --git a/mac/machine.cpp b/mac/machine.cpp
index 87cd089..53fefe6 100644
--- a/mac/machine.cpp
+++ b/mac/machine.cpp
@@ -1,3 +1,4 @@
+#include
#include
#include
#include "machine.h"
@@ -402,11 +403,19 @@ void Machine::mark_literal_klammer_content(katom_list& katoms)
// Find where literal content starts.
// Skip bars_before_literal bars (separating preceding positional args).
// If options are present, skip past the bar after them.
- // Options are identified by :name katoms before any bar.
+ // Options are identified by :name katoms before any bar -- but only
+ // a name THE KLAMMER DECLARES. Any ":word" counted until 2026-09-20,
+ // so code holding one before a bar ("@c ls :w | wc c@", a regex's
+ // "(?:a|b)") had its text up to the bar taken for options and the
+ // file failed with 'Optional argument ":w" not defined' -- an error
+ // about an option the author never wrote, in content that is
+ // promised to be read as nothing but text.
+ const auto& optional_names = klammer.m_parameters.m_optional_names;
auto literal_start = k + 1;
bool has_options = false;
for (auto j = k + 1; j < close; ++j) {
- if (j->m_type == katom_t::option_name) {
+ if (j->m_type == katom_t::option_name &&
+ std::ranges::count(optional_names, std::string(j->m_text, 1)) > 0) {
has_options = true;
}
if (j->m_type == katom_t::bar) {
diff --git a/ref/ref.k b/ref/ref.k
index 30a8ba5..8b123c0 100644
--- a/ref/ref.k
+++ b/ref/ref.k
@@ -36,3 +36,15 @@
@@argtype_definitions.* ::
@eval :cpp *KLAMMERTEXT_HOME*/ref/reflect argtype_definitions @ @@
+
+@@katom_descriptions.k :regex.bool :txt_width.uint :
+ Describe the katom types, in groups, with the informal pattern of each.
+ With ^:regex a further column shows the regular expression a katom is
+ matched by. ^:txt_width is the width of the table in characters under the
+ txt and ansi targets, where a table cannot be wider than the text: it
+ widens the table alone, and the text around it keeps Target_txt_width. @@
+
+@@symb s : @ri ⟨*s*⟩ @ @@
+
+@@katom_descriptions.* ::
+ @eval :cpp *KLAMMERTEXT_HOME*/ref/reflect katom_descriptions @ @@
diff --git a/ref/reflect.cpp b/ref/reflect.cpp
index baef41d..2e9ae3e 100644
--- a/ref/reflect.cpp
+++ b/ref/reflect.cpp
@@ -5,6 +5,7 @@
#include "error.h"
#include "show.h"
#include "font_store.h"
+#include "ktype.h"
// extern "C" is needed for dlsym name lookup. Returning std::string from
// C-linkage functions is technically non-standard but works correctly on
@@ -191,3 +192,101 @@ std::string argtype_definitions(Machine& machine)
kt << "define@\n\n";
return kt.str();
}
+
+
+// The Regex cell of a katom type. A type the Machine assigns is matched by
+// no regex and its cell is empty -- ws_added among them, whose m_pattern is a
+// description and not a regex (see the note in mac/katom_matching.cpp). The
+// two whitespace types are named, since "@c c@" would show nothing.
+static std::string katom_regex_cell(const Ktype& k)
+{
+ if (k.m_pattern.empty() || k.m_type == katom_t::ws_added) {
+ return "";
+ }
+ if (k.m_type == katom_t::space) {
+ return "@symb-space";
+ }
+ if (k.m_type == katom_t::newline) {
+ return "@t \\n @";
+ }
+ // A literal value has its caret forms processed as it is built, so a
+ // caret is written quoted: bare, "[^^@#|]+" would display as "[^@#|]+"
+ // and "[@|#^:*]" would lose its caret to the colon.
+ // The exception is a caret before an apostrophe: "^'" is not a quoting
+ // form (it opens a literal span elsewhere) and passes through as written.
+ std::string quoted {};
+ for (size_t i = 0; i < k.m_pattern.size(); i++) {
+ const char c = k.m_pattern[i];
+ const bool before_apostrophe = i + 1 < k.m_pattern.size() && k.m_pattern[i + 1] == '\'';
+ quoted += (c == '^' && !before_apostrophe) ? "^^" : std::string(1, c);
+ }
+ return "@c :nd | " + quoted + " c@";
+}
+
+extern "C"
+std::string katom_descriptions([[maybe_unused]] Machine& machine)
+{
+ std::stringstream kt {};
+ kt << R"(
+A ``katom'' is an individual element in the Klammertext input text. Each katom
+has a type, listed below. You can see how Klammertext divides up text into
+katoms with the @t-kdiag command.
+
+In the katom patterns, @symb-name is a word that begins with a letter and only
+contains letters, numbers, or the underscore (_) or period (.) characters. The
+@symb-n symbol is an integer greater than or equal to 1. The @symb-code symbol
+is one of the codes used with the @t ^^ @ character to create characters that
+are not available in the English alphabet. The codes for producing those
+characters can be displayed by the @t-kdesc command: @t kdesc -c -u @
+
+)";
+ // ":txt_width N" widens the TABLE alone. This text is re-read by a
+ // Machine of its own (Eval::eval), so a @@@state here changes the width
+ // the @table below is laid out to and nothing else: the prose above
+ // leaves that Machine as plain text and is filled to the caller's width
+ // by the caller's phases, which skip the table because it is laid out.
+ const std::string txt_width = machine.m_state.value("txt_width", false);
+ if (!txt_width.empty()) {
+ kt << "@@@state Target_txt_width :replace " << txt_width << " @@@\n";
+ }
+ const bool show_regex = machine.m_state.value("regex", false) == "true";
+
+ // The rows first, so that the rules can be placed by the number of rows
+ // before them (the header is the first): one above each group's title
+ // and one below it, which is what marks the row as a title. The first
+ // group needs none above: "head" already rules the header off.
+ const std::string b = " | ";
+ const std::string title_cells = show_regex ? b + b + b + b : b + b + b;
+ std::stringstream rows {};
+ std::stringstream hlines {};
+ int row_count = 1;
+ for (const auto& group : katom_type_groups) {
+ if (row_count > 1) {
+ hlines << " " << row_count;
+ }
+ rows << title_cells << "@i " << group.m_title << " @ ||\n";
+ row_count++;
+ hlines << " " << row_count; // ... and one below the title
+ for (const auto& type : group.m_types) {
+ const Ktype& k = type_to_katom_instance(type.m_type);
+ rows << static_cast(k.m_type) << b << k.m_name << b << type.m_pattern << b;
+ if (show_regex) {
+ rows << katom_regex_cell(k) << b;
+ }
+ rows << type.m_description << " ||\n";
+ row_count++;
+ }
+ }
+ kt << "@table "
+ << ":number false "
+ << ":hline head bottom" << hlines.str() << " :leading .95 ";
+ if (show_regex) {
+ kt << ":cell_hpos c l :style r r t t r :column_width fit fit fit fit fill |\n"
+ << "Index | Name | Pattern | Regex | Description ||\n";
+ } else {
+ kt << ":cell_hpos c l :style r r t r :column_width fit fit fit fill |\n"
+ << "Index | Name | Pattern | Description ||\n";
+ }
+ kt << rows.str() << "table@\n";
+ return kt.str();
+}
diff --git a/sks/code/code.k b/sks/code/code.k
index 48ac665..9b126f3 100644
--- a/sks/code/code.k
+++ b/sks/code/code.k
@@ -13,15 +13,16 @@ katom when no bar is written, "^@code :filename f :pattern p" DISPLAYED THOSE
WORDS as the listing rather than acting on them.
@@
-@@code.html,tex :: @eval code_block.Code(K) @ @@
+@@code.html,tex,txt :: @eval code_block.Code(K) @ @@
-@@c.k code_text.literal :
+@@c.k :nd.bool | code_text.literal :
A word or phrase displayed verbatim in a line. Like ^@code, its content is
LITERAL -- nothing in it is interpreted as Klammertext -- so it must be closed
with the named delimiter "c^@". ^@c is the in-line form and ^@code the block
form of the same thing; before 2026-08-16 this parameter was an ordinary
string, so a "^#" or a bare "^@" inside it was read as Klammertext and usually
failed the file, which is not what "verbatim" can mean.
+If :nd (no delimiters) is true, then the txt target delimiters are excluded.
@@
@@c.html,tex,txt :: @eval code_block.Code_fragment(K) eval@
@@ -44,4 +45,4 @@ quietly different listing.
Named "^@source_file" until 2026-08-16. # retired-ok
@@
-@@source_listing.html,tex :: @eval :cwd *K_input_dir* code_block.Source(K) @ @@
+@@source_listing.html,tex,txt :: @eval :cwd *K_input_dir* code_block.Source(K) @ @@
diff --git a/sks/code/code_block.py b/sks/code/code_block.py
index 0d93572..7bf7b91 100644
--- a/sks/code/code_block.py
+++ b/sks/code/code_block.py
@@ -5,11 +5,13 @@
import re
import klammer_base
+import kutil
import html_util
+import txt_util
+import phases
from html_util import E
import latex_util as L
-
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.
@@ -119,7 +121,13 @@ def tex_line(line):
a monospace font, which LaTeX will not collapse -- so the code's
indentation and internal alignment survive.
"""
- return quote_specials(escape_latex(line)).replace(" ", "~")
+ # An apostrophe is hidden as a marker: after a quoted caret it would
+ # otherwise read back as "^" + "^'", the opening of a literal span, and
+ # "@c \^' c@" -- the regex of that very katom -- failed the document
+ # with a span that does not end. html_line hides it with the other
+ # typographic characters; the marker decodes to the plain character.
+ quoted = quote_specials(escape_latex(line)).replace("'", klammer_base.escape_ktesc("'"))
+ return quoted.replace(" ", "~")
def widest_line(lines):
r"""The line to measure the block's width with.
@@ -325,12 +333,64 @@ class Code(klammer_base.Klammer_base):
# As in tex: the caption goes through the shared helper, so it sits
# in the same position container as the listing and moves with it.
if self.number or self.caption:
+ # A left-placed (or flowing) listing's caption is flush with it,
+ # as in tex, where such a listing is unboxed and its caption a
+ # paragraph on the same indent; a centred or right-placed one is
+ # boxed there and its caption centred over the box, which is
+ # the helper's default here too.
+ align = "left" if self.hpos in ("left", "none") else None
return html_util.add_caption(
listing, "Listing", self.number, self.caption,
self.caption_style, self.hpos, self.caption_side, True,
- self.caption_size, offset=self.offset)
+ self.caption_size, offset=self.offset, caption_align=align)
return html_util.hpos_container(listing, self.hpos, self.offset).str()
+ def txt_indent(self):
+ # The block's indent in columns, by @table's rule: a left- or
+ # right-placed block is inset by its :offset (0 = flush), ":hpos
+ # none" flows with no indent, and "center" -- not attempted in plain
+ # text -- keeps the block convention, Target_txt_indent.
+ if self.hpos in ("left", "right"):
+ return int(kutil.parse_width("txt", self.offset)[0])
+ if self.hpos == "none":
+ return 0
+ return int(self.Target_txt_indent)
+
+ def txt(self):
+ # The listing is LAID OUT here, line by line, and says so with the
+ # verbatim marker (phases.txt_lay_out) so the justification pass
+ # leaves it alone -- a blank line in the code included, which holds
+ # a marker and therefore does not end the paragraph.
+ #
+ # Each line is prepared exactly as txt()'s inline sibling prepares a
+ # fragment: the Klammertext specials QUOTED, because this result is
+ # re-read as Klammertext (raw, a "#" removed the rest of its line and
+ # a "code@" in a quoted source file closed the document's own span:
+ # "A named end katom does not match"), and the typographic characters
+ # hidden, so "--x" stays two hyphens. Spaces become the nbsp marker
+ # AFTER that, not "~": hide_typographic has already hidden the
+ # code's own tildes, and a "~" written here would be turned into a
+ # space by the txt target's transform only as long as it survived.
+ lines = self.text.strip("\n").split("\n")
+ indent = txt_util.NBSP * self.txt_indent()
+ def prepared(line):
+ line = klammer_base.hide_typographic(quote_specials(line.rstrip()))
+ return line.replace(" ", txt_util.NBSP)
+ width = max(len(line.rstrip()) for line in lines)
+ rule = indent + klammer_base.hide_typographic("-" * width)
+ body = [indent + prepared(line) for line in lines]
+ # A caption above the listing is ruled off by the caption pass, so
+ # the listing draws its own top rule only when nothing does.
+ captioned = self.number or self.caption
+ caption_above = captioned and self.caption_side in ("top", "left")
+ if not caption_above:
+ body.insert(0, rule)
+ body.append(rule)
+ result = phases.txt_lay_out("\n".join(body))
+ return txt_util.add_caption(
+ result, "Listing", self.number, self.caption, self.caption_side,
+ self.txt_indent())
+
class Code_fragment(klammer_base.Klammer_base):
def __init__(self, K):
@@ -356,10 +416,13 @@ class Code_fragment(klammer_base.Klammer_base):
# without a beginning) and the typographically active characters
# hidden, so a "--" in code stays two hyphens.
result = klammer_base.hide_typographic(quote_specials(self.code_text.strip()))
- # Surround code symbols with ‹ and ›, though there are conflicts in Swiss
- # German, Italian and some Scandinavian languages. Should be clear in
- # context, though.
- result = "\u2039" + result + "\u203A"
+
+ # If *nd* ("no delimiters) is false (the default), surround code symbols
+ # with the characters ‹ and ›. There are conflicts in Swiss German,
+ # Italian and some Scandinavian languages, but the meaning should be
+ # clear in context.
+ if not self.nd:
+ result = "\u2039" + result + "\u203A"
return result
def extract_marked_region(src, marker, filename):
@@ -424,6 +487,9 @@ class Source(Code):
# not exist until the file has been read. File content carries no
# whitespace markers anyway, since those come from the katomizer.
klammer_base.Klammer_base.__init__(self, K)
+ # The filename arrives with the target's escapes as markers -- under
+ # tex an underscore is one -- and a marker names no file.
+ self.filename = klammer_base.unescape_ktesc(self.filename)
try:
with open(self.filename) as fp:
text = fp.read()
diff --git a/sks/code/css/code.css b/sks/code/css/code.css
index 6ba7afb..b3e53b3 100644
--- a/sks/code/css/code.css
+++ b/sks/code/css/code.css
@@ -30,7 +30,11 @@
.code_block {
display: flex;
align-items: center;
- margin: 0 0 0 1rem;
+ /* No left margin: it was 1rem, an html-only inset with no tex
+ counterpart, which set the code a rem to the right of its own
+ flush-left caption and of the same listing's pdf. A listing's
+ inset is :offset, in both targets (the hpos_margin precedent). */
+ margin: 0;
padding: 0;
}
diff --git a/sks/font/css/font.css b/sks/font/css/font.css
index f0e5192..ff30501 100644
--- a/sks/font/css/font.css
+++ b/sks/font/css/font.css
@@ -4,6 +4,14 @@
--serif: Libre Baskerville, serif;
--sans-serif: Open Sans, sans-serif;
--monospace: Inconsolata, monospace;
+ /* The pitch of a line of body text, in em of the NOMINAL font size.
+ It is 1.5 rather than LaTeX's 1.2 because font-size-adjust (below)
+ draws the type larger than its nominal size -- EB Garamond by a
+ fifth -- and line-height is computed from the nominal one. It is
+ the html counterpart of \baselineskip, and a klammer whose spacing
+ is stated in lines of text multiplies THIS (@table :leading), so a
+ table and the paragraphs around it cannot drift apart. */
+ --text_line_height: 1.5;
}
body {
@@ -32,7 +40,7 @@ h1, h2, h3, h4, h5 {
place); this file assigns only the family. */
p {
- line-height: 1.5;
+ line-height: var(--text_line_height);
}
.plain {
diff --git a/sks/kutil/kutil.k b/sks/kutil/kutil.k
index 78e609f..d2d5665 100644
--- a/sks/kutil/kutil.k
+++ b/sks/kutil/kutil.k
@@ -195,7 +195,7 @@ other structures. The ^:offset is used only if the ^:hpos value is "left" or
:number.bool true
:caption_side.side
:caption_style.style_code i
- :caption_size.float .9
+ :caption_size.float 1
: Arguments that define a caption for a block element.
@@
diff --git a/sks/section/section.k b/sks/section/section.k
index fc7208f..b9dc463 100644
--- a/sks/section/section.k
+++ b/sks/section/section.k
@@ -2,8 +2,8 @@
# the front and back matter of a book):
@@section_args.o :id :n.int -1 :
- Common arguments for section headings. If *n* is not the default, it is used
- as the section number.
+ Common arguments for section headings. If *n* is not the default,
+ it is used as the section number.
@@
@@preface.k :id :name Preface : Preface text @@
diff --git a/sks/table/css/table.css b/sks/table/css/table.css
index 81d9eff..bcd8568 100644
--- a/sks/table/css/table.css
+++ b/sks/table/css/table.css
@@ -58,8 +58,21 @@ td {
--cell-border: 1px black solid;
}
+/* The space between a rule and the text on either side of it: a cell is
+ padded on the side that CARRIES the rule, so a row with no rule is
+ untouched, a partial :hline pads only the cells it crosses, and the
+ table's outer rules are padded on the inner side alone. The amounts
+ began as booktabs' defaults (0.4ex above a rule, 0.65ex below one) and
+ were tuned by eye, 2026-09-20: half of the space below moved above, so
+ 0.725ex above and 0.325ex below, the total unchanged. The tex target's
+ pair is in table.py (TEX_RULE_ABOVE/_BELOW) and differs on purpose -- a
+ TeX row and an html row distribute their own height differently around
+ the text. --rulesep is @table's :rulesep, a multiplier, set on the
+ by table.py. "ex" is the x-height as RENDERED
+ (font-size-adjust), so the space tracks the visible type. */
.Bt {
border-top: var(--cell-border);
+ padding-top: calc(var(--rulesep, 1) * 0.325ex);
}
.Br {
@@ -68,6 +81,7 @@ td {
.Bb {
border-bottom: var(--cell-border);
+ padding-bottom: calc(var(--rulesep, 1) * 0.725ex);
}
.Bl {
diff --git a/sks/table/table.k b/sks/table/table.k
index 0258e92..0665329 100644
--- a/sks/table/table.k
+++ b/sks/table/table.k
@@ -213,7 +213,8 @@
:calc.table_calc
:format.table_format
:decimal.decimal_mark
- :leading.float 1.3
+ :leading.float 1.0
+ :rulesep.float 1
:colsep 4pt
:
A table of rows of cells. The cells of a row are separated by "|" and the
@@ -226,6 +227,15 @@ draw lines, ^:colspan and ^:rowspan merge cells, ^:justify and ^:cell_hpos
place text within them, ^:calc computes cells from other cells and ^:format
formats them, and ^:hpos and ^:offset place the whole table in the text
column.
+
+^:leading is the height of a row in lines of the surrounding text, the same
+in html and pdf. ^:rulesep is the space between a horizontal line and the
+text on either side of it, as a multiple of the standard amount (slightly
+more below a line than above it); 0 sets the lines against the rows. The
+lines at the top and the bottom of the table are spaced on their inner side
+only. A table with vertical lines gets no such space in a pdf, where it
+would break them. Neither argument has a meaning in plain text, where a row
+is a line of characters and a horizontal line already has a line to itself.
@@
@@table.html,tex,txt :: @eval table.Table(K) eval@ @@
diff --git a/sks/table/table.py b/sks/table/table.py
index a34b9ab..722fa19 100644
--- a/sks/table/table.py
+++ b/sks/table/table.py
@@ -575,23 +575,39 @@ class Table(klammer_base.Klammer_base):
# would wrap it as a stray inside the table.
return E("colgroup").body(cols.strip(), newline=False).str(), layout, table_width
- def html_style(self):
- # :leading and :colsep under html (2026-09-18; both were tex-only).
- # \arraystretch multiplies the row strut, which is \baselineskip,
- # 1.2 times the font size -- so :leading L is a 1.2 L em row, and
- # the html equivalent is that line-height on the table, inherited
- # by every cell. The cells' vertical padding went with it
- # (table.css): an html-only extra with no tex counterpart, which
- # kept a :leading 1 table 0.3rem looser per row than its pdf.
- # :colsep is a custom property the td rule reads for its horizontal
- # padding (padding does not inherit); the flush-edge classes still
- # zero the outer ones. Measured 2026-09-18: rows of :leading 1 and
- # :leading 2 in the ratio 1:2 in both targets.
- line_height = f"{1.2 * float(self.leading):.4g}"
+ def html_style(self, layout=""):
+ # The WHOLE style attribute of the
, written once: "layout" is
+ # the width declarations the caller has worked out (max-width for a
+ # fill table, table-layout/width for fractions). E.attr() appends,
+ # so a second .attr("style", ...) made a second attribute, which a
+ # browser discards -- from 2026-09-18, when this function arrived,
+ # to 2026-09-20 every width declaration was lost that way.
+ # :leading and :colsep under html. ":leading L" is L times the pitch
+ # of a line of the surrounding TEXT, in every target. tex has that
+ # by construction: \arraystretch multiplies the row strut, which is
+ # the \baselineskip in force. The html counterpart of \baselineskip
+ # is --text_line_height (font.css, 1.5), so the table's line-height
+ # is L times that, inherited by every cell. It was 1.2 L from
+ # 2026-09-18 to 09-20, LaTeX's ratio of \baselineskip to font size:
+ # wrong in html, where font-size-adjust draws the type larger than
+ # the nominal size line-height is computed from, so a :leading 1
+ # table sat 16% tighter than the paragraphs around it (measured:
+ # 20.2px rows beside 24px text, where the pdf had 14.2pt beside
+ # 14.2pt). The fallback serves a fragment without the stylesheet.
+ # The cells have no vertical padding (table.css): an html-only extra
+ # with no tex counterpart. :colsep is a custom property the td rule
+ # reads for its horizontal padding (padding does not inherit); the
+ # flush-edge classes still zero the outer ones.
+ line_height = f"calc({float(self.leading):.4g} * var(--text_line_height, 1.5))"
# Hidden: the result is re-read and the html target's transforms
# would turn the property's "--" into an en dash (font.py's trap).
- return klammer_base.hide_typographic(
- f"line-height: {line_height}; --colsep: {self.colsep}")
+ # :rulesep is a custom property too: the .Bt/.Bb rules (table.css)
+ # read it for the padding on the side of a cell that carries a rule.
+ style = (f"line-height: {line_height}; --colsep: {self.colsep}; "
+ f"--rulesep: {float(self.rulesep):.4g}")
+ if layout:
+ style += "; " + layout.strip().rstrip(";")
+ return klammer_base.hide_typographic(style)
def html(self):
result = ""
@@ -610,8 +626,10 @@ class Table(klammer_base.Klammer_base):
# algorithm), matching the tex \ratio division. The max-width
# cap goes on the caption wrapper when there is one (the table's
# own percentage would be circular in a shrink-to-fit wrapper).
- result = E("table").body(result).attr("style", self.html_style())
- if self.number or self.caption:
+ captioned = self.number or self.caption
+ result = E("table").body(result).attr(
+ "style", self.html_style("" if captioned else "max-width: 100%"))
+ if captioned:
result = html_util.add_caption(
result, "Table", self.number, self.caption,
self.caption_style, hpos=self.hpos,
@@ -619,17 +637,19 @@ class Table(klammer_base.Klammer_base):
font_size=self.caption_size, max_width="100%",
offset=self.offset)
else:
- result.attr("style", "max-width: 100%")
result = html_util.hpos_container(result, self.hpos, self.offset).str()
return result
colgroup, layout, table_width = self.html_colgroup()
- result = E("table").body(colgroup + result).attr("style", self.html_style())
- if self.number or self.caption:
+ captioned = self.number or self.caption
+ table_layout = ""
+ if table_width:
+ table_layout = f"{layout}width: " + ("100%" if captioned else table_width)
+ result = E("table").body(colgroup + result).attr(
+ "style", self.html_style(table_layout))
+ if captioned:
# The caption wrapper carries the width (a percentage on the
# shrink-to-fit wrapper itself would be circular) and the table
# fills it -- which also makes the caption track the table.
- if table_width:
- result.attr("style", f"{layout}width: 100%")
result = html_util.add_caption(
result, "Table", self.number, self.caption, self.caption_style,
hpos=self.hpos, side=self.caption_side,
@@ -638,8 +658,6 @@ class Table(klammer_base.Klammer_base):
else:
# An uncaptioned table still gets the position container, so
# html and tex agree on where the table sits.
- if table_width:
- result.attr("style", f"{layout}width: {table_width}")
result = html_util.hpos_container(result, self.hpos, self.offset).str()
return result
@@ -761,9 +779,32 @@ class Table(klammer_base.Klammer_base):
# print("tex_column_spec:", "".join(parts))
return "".join(parts)
+ TEX_RULE_ABOVE = 0.4 # ex; booktabs' \aboverulesep
+ TEX_RULE_BELOW = 0.35 # 468 # ex; booktabs' \belowrulesep (0.65) less 20%, less 10%
+
+ def tex_rule_separations(self, top, bottom):
+ # The space above and below a rule, as booktabs' \specialrule and
+ # \cmidrule take them: :rulesep times TEX_RULE_ABOVE/_BELOW. They
+ # began as booktabs' defaults (0.4ex above a rule, 0.65ex below) and
+ # were tuned by eye, per target, 2026-09-20 (Andy): the amounts that
+ # LOOK alike differ, because a TeX row carries its strut's depth
+ # under the text and an html row its half-leading. The html pair is
+ # in table.css (.Bt/.Bb). The table's outer rules are padded on the
+ # INNER side only.
+ rulesep = float(self.rulesep)
+ above = "0pt" if top else f"{self.TEX_RULE_ABOVE * rulesep:.4g}ex"
+ below = "0pt" if bottom else f"{self.TEX_RULE_BELOW * rulesep:.4g}ex"
+ return above, below
+
def tex_hline(self, index):
- # Contiguous cell borders coalesce into single \cline runs; a
- # full-width line becomes \hline.
+ # Contiguous cell borders coalesce into single rule runs. The rules
+ # are booktabs' -- an \hline sits flush against the strut of the
+ # rows on either side, and the only way to open space there is a
+ # strut in every neighbouring row -- EXCEPT in a table with vertical
+ # lines: a booktabs rule is set between rows, so it breaks every
+ # vertical line it crosses (its author provides for none), and such
+ # a table keeps \hline/\cline and gets no space at its rules.
+ top = index == 0
bottom = index == self.row_count
if bottom:
index -= 1
@@ -773,7 +814,11 @@ class Table(klammer_base.Klammer_base):
# No line through the interior of a merged (rowspan) cell.
flags = [flag and (index, col_i) not in self.rowspan_covered
for col_i, flag in enumerate(flags)]
+ spaced = not self.s_vline.by_index and float(self.rulesep) > 0
+ above, below = self.tex_rule_separations(top, bottom)
if flags and all(flags):
+ if spaced:
+ return f"\\specialrule{{\\arrayrulewidth}}{{{above}}}{{{below}}}\n"
return "\\hline\n"
hline = ""
start = None
@@ -781,7 +826,10 @@ class Table(klammer_base.Klammer_base):
if flag and start is None:
start = i
elif not flag and start is not None:
- hline += f"\\cline{{{start + 1}-{i}}} "
+ if spaced:
+ hline += f"\\cmidrule[\\arrayrulewidth]{{{start + 1}-{i}}} "
+ else:
+ hline += f"\\cline{{{start + 1}-{i}}} "
start = None
return hline.strip() + "\n"
@@ -1063,7 +1111,7 @@ class Table(klammer_base.Klammer_base):
if len(rows) in self.s_hline.by_index:
result += rule
result = txt_util.add_caption(
- result, "Table", self.number, self.caption, self.caption_side, \
- int(self.Target_txt_indent))
+ result, "Table", self.number, self.caption, self.caption_side,
+ len(indent) // len(txt_util.NBSP))
return result
diff --git a/sks/target/css/target.css b/sks/target/css/target.css
index 71eaed6..bfb9e2f 100644
--- a/sks/target/css/target.css
+++ b/sks/target/css/target.css
@@ -103,6 +103,18 @@
font-style: italic;
}
+/* A caption flush with its element's left edge (html_util.add_caption,
+ caption_align "left"): @code's caption when the listing is placed left,
+ as the tex target sets it. AFTER the two rules above, which it must
+ override at equal specificity. */
+.caption_flush_left {
+ align-items: flex-start;
+}
+
+.caption_flush_left .caption {
+ text-align: left;
+}
+
.caption_left {
display: inline-flex;
flex-direction: row;
diff --git a/sks/target/html_util.py b/sks/target/html_util.py
index 0463971..1a7ed80 100644
--- a/sks/target/html_util.py
+++ b/sks/target/html_util.py
@@ -238,7 +238,12 @@ def hpos_container(element, hpos, offset=None):
def add_caption(element, caption_label, number, caption_text,
font_symbol="i", hpos="center", side="bottom", as_string=True,
- font_size=.9, width=None, max_width=None, offset=None):
+ font_size=.9, width=None, max_width=None, offset=None,
+ caption_align=None):
+ # caption_align "left" sets the caption flush with the element's left
+ # edge, where the default centres it over the element: a caption above
+ # a left-placed listing, which the tex target sets as a paragraph on the
+ # listing's own indent.
tag = element_tag(element)
# Caption
caption = ""
@@ -276,6 +281,8 @@ def add_caption(element, caption_label, number, caption_text,
#print("-"*80)
element = E("div").cls("caption_" + side).attr("data-label", caption_label).body(element)
+ if caption_align == "left" and side in {"top", "bottom"}:
+ element.cls("caption_flush_left")
if width:
# The element's width (e.g. a table's :column_width total) lives
# on this wrapper: the element fills it, and the caption tracks
diff --git a/sks/target/phases.py b/sks/target/phases.py
index e57ff2e..1909754 100644
--- a/sks/target/phases.py
+++ b/sks/target/phases.py
@@ -147,6 +147,36 @@ def tex_nl_marker():
def tex_resolve_markers(text, K):
return text.replace(tex_nl_marker(), "\\newline")
+def txt_runs(par):
+ """A paragraph as runs of lines: [(laid_out, text), ...].
+
+ "Laid out or prose" was decided ONCE per paragraph, by its first line,
+ so a laid-out block written directly after prose -- no blank line
+ between them -- was re-flowed with it, every one of its lines marked
+ or not: a generator whose prose ran straight into its @table put the
+ table's header on the last line of the sentence (2026-09-20). A blank
+ line should not be what protects a block that has already said, line
+ by line, that it is laid out.
+
+ So a line that BEGINS WITH THE VERBATIM MARKER is laid out wherever it
+ stands, and the paragraph divides into runs at each change. Only the
+ marker divides: it is explicit and per line (txt_lay_out). A leading
+ space or nbsp keeps the older, whole-paragraph meaning and only on the
+ FIRST line, since a later line of prose may legitimately begin with
+ one (a footnote's hanging indent).
+ """
+ if par.startswith((" ", txt_nbsp_marker())):
+ return [(True, par)]
+ mark = txt_verbatim_marker()
+ runs = []
+ for line in par.split("\n"):
+ laid_out = line.startswith(mark)
+ if runs and runs[-1][0] == laid_out:
+ runs[-1][1].append(line)
+ else:
+ runs.append((laid_out, [line]))
+ return [(laid_out, "\n".join(lines)) for laid_out, lines in runs]
+
def txt_justify_blocks(text, K):
delim = '__DIVIDE__'
text = re.sub("\n\n+", delim, text)
@@ -178,13 +208,20 @@ def txt_justify_blocks(text, K):
# verbatim paragraph was not left alone, it was DELETED: the one
# branch that had nothing to do also did not copy the text through.
# An indented paragraph vanished from the output with no error.
+ #
+ # The decision is per RUN of lines, not per paragraph (txt_runs):
+ # prose is filled, a laid-out run is copied through, and the runs
+ # stay on consecutive lines as they were written.
nbsp = txt_nbsp_marker()
- if par and not txt_is_laid_out(par):
- label = re.match("(?:" + re.escape(nbsp) + r")*\[\d+\] ", par)
- par = "\n".join(txt_wrap(
- par, int(K.Target_txt_width),
- subsequent_indent=" " * len(label.group(0)) if label else ""))
- result += par + "\n\n"
+ filled = []
+ for laid_out, run in txt_runs(par):
+ if run and not laid_out:
+ label = re.match("(?:" + re.escape(nbsp) + r")*\[\d+\] ", run)
+ run = "\n".join(txt_wrap(
+ run, int(K.Target_txt_width),
+ subsequent_indent=" " * len(label.group(0)) if label else ""))
+ filled.append(run)
+ result += "\n".join(filled) + "\n\n"
result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result)
result = re.sub(r"\n? *" + txt_nl_marker() + r" *\n?", "\n", result)
result = result.replace(txt_verbatim_marker(), "")
@@ -192,33 +229,45 @@ def txt_justify_blocks(text, K):
return result
def txt_insert_captions(text, K):
+ # The caption's INDENT is the block's, read off the run of nbsp markers
+ # written before the caption marker (txt_util.add_caption) -- not
+ # Target_txt_indent, which put the caption of a flush block two columns
+ # to the right of the block it captions. Every line produced here is
+ # marked laid-out: a flush caption begins with neither a space nor an
+ # nbsp, and an unmarked first line made the justification pass re-flow
+ # the caption, its rule and the whole block under it into one paragraph.
mark = kutil.caption_delimiter()
- caption_rgx = re.compile(f"{mark}(.*?){mark}(.*?){mark}", re.S)
- caption_number = 0
+ nbsp = txt_nbsp_marker()
+ caption_rgx = re.compile(
+ f"((?:{re.escape(nbsp)})*){mark}(.*?){mark}(.*?){mark}", re.S)
+ # One sequence PER LABEL, as in html and tex: a listing after a table is
+ # "Listing 1", not "Listing 2". It was one counter for every label.
+ caption_numbers = {}
width = int(K.Target_txt_width)
- indent = int(K.Target_txt_indent)
- indented_width = width - indent
def replace(m):
- nonlocal caption_number
- name, text = m.groups()
+ indent_run, name, text = m.groups()
+ indent = len(indent_run) // len(nbsp)
+ indented_width = max(1, width - indent)
# An empty label means an unnumbered caption (":number false"). It
# must NOT take a number from the sequence: a reader counting the
# captions they can see would then find the next one a number
# ahead. The same is true of html and tex, where an unnumbered
# caption emits no marker and so never reaches their counter.
if name:
- caption_number += 1
- caption = f"{name} {caption_number} - {text}"
+ caption_numbers[name] = caption_numbers.get(name, 0) + 1
+ caption = f"{name} {caption_numbers[name]} - {text}"
else:
caption = text
# Visible widths throughout: a caption may carry style markers.
+ lines = [caption]
if txt_visible_length(caption) > indented_width:
- caption = "\n".join(txt_wrap(
- caption, indented_width,
- subsequent_indent=txt_nbsp_marker()*indent))
- line_length = max([txt_visible_length(e) for e in caption.split("\n")])
- line = txt_nbsp_marker() * indent + "-" * line_length
- return caption + "\n" + line
+ lines = txt_wrap(caption, indented_width)
+ line_length = max(txt_visible_length(e) for e in lines)
+ lines.append("-" * line_length)
+ lead = txt_verbatim_marker() + indent_run
+ # The first line's marker is already in the text, before the indent
+ # run this match begins with (add_caption); the others get theirs.
+ return indent_run + ("\n" + lead).join(lines)
result = caption_rgx.sub(replace, text)
return result
diff --git a/sks/target/txt_util.py b/sks/target/txt_util.py
index 92e49ed..3f50f92 100644
--- a/sks/target/txt_util.py
+++ b/sks/target/txt_util.py
@@ -101,7 +101,11 @@ def add_caption(element, caption_label, number, caption_text,
caption = kutil.caption_marker("", caption_text, None)
else:
return element
- caption = indent_text(re.sub(r"\s*\n\s*", " ", caption.strip()), indent)
+ # Marked laid-out, like the block: with no indent the caption begins
+ # with neither a space nor an nbsp, and the justification pass would
+ # re-flow it together with everything under it.
+ caption = phases.txt_verbatim_marker() + indent_text(
+ re.sub(r"\s*\n\s*", " ", caption.strip()), indent)
if side in {"top", "left"}:
return caption + "\n" + element
return element + "\n" + caption
diff --git a/tst/escape_test.sh b/tst/escape_test.sh
index 56954dc..0498df3 100755
--- a/tst/escape_test.sh
+++ b/tst/escape_test.sh
@@ -181,6 +181,36 @@ check_eq "25. a real literal klammer still works after a comment naming it" \
'[x]' --klammersets none -t t -s "$T $LIT # mentions @lit here
@lit x lit@"
+echo
+echo "-- 25a-e: only a DECLARED option name opens an option region --"
+# The content of a literal klammer begins after the bar that follows its
+# options. ANY ":word" before a bar counted as an option until 2026-09-20,
+# declared or not, so code holding one ("ls :w | wc", a regex's "(?:a|b)")
+# lost everything up to the bar to an option region and the file failed with
+# 'Optional argument ":w" not defined' -- an error about an option the author
+# never wrote, in content promised to be read as nothing but text.
+# OPT declares one option, :flag, so both directions can be asserted.
+OPT='@@opt.k :flag.bool | t.literal : A literal klammer with an option @@ @@opt.t :: [*flag*/*t*] @@'
+OPTUSE=$(mktemp /tmp/escape_opt.XXXXXX).kt
+opt_case() { # opt_case NAME EXPECTED USE
+ printf '%s\n' "$3" > "$OPTUSE"
+ check_eq "$1" "$2" "$OPTUSE" --klammersets none -t t -d -s "$T $OPT"
+}
+opt_case "25a. an UNDECLARED :name before a bar is content" \
+ '[/x :y | z]' '@opt x :y | z opt@'
+opt_case "25b. ... as is a regex's (?:a|b)" \
+ '[/(?:a|b)]' '@opt (?:a|b) opt@'
+opt_case "25c. a DECLARED option still opens the option region" \
+ '[true/x]' '@opt :flag | x opt@'
+opt_case "25d. ... and after its bar an undeclared :name is content again" \
+ '[true/x :y | z]' '@opt :flag | x :y | z opt@'
+# The one ambiguity left, and the author's way out of it: a name the klammer
+# DOES declare, written inside the code before a bar, is an option -- unless
+# an option region has already been written, which ends at ITS bar.
+opt_case "25e. a declared name in the code is content once the options are written" \
+ '[false/x :flag | z]' '@opt :flag false | x :flag | z opt@'
+rm -f "$OPTUSE"
+
rm -f "$ERR"
echo
diff --git a/tst/kdesc_test.sh b/tst/kdesc_test.sh
index 1253fe2..e949058 100755
--- a/tst/kdesc_test.sh
+++ b/tst/kdesc_test.sh
@@ -107,6 +107,17 @@ echo
echo "-- result detail is a word, not a verbosity level --"
shows " 9. --katoms full adds the regex column" 'Regex' "$KDESC" --katoms full
absent "10. --katoms alone omits it" 'Regex' "$KDESC" --katoms
+# The regex goes through @c, which processes caret forms: a bare "[^^@#|]+"
+# displayed as "[^@#|]+", a different regex, until the generator quoted its
+# carets. These two are the patterns that carry a caret before a special.
+shows " 9a. ... a regex keeps a caret before a special" '\[\^\^@#\|\]\+' "$KDESC" --katoms full
+shows " 9b. ... and a caret before a colon" '\[@\|#\^:\*\]' "$KDESC" --katoms full
+shows " 9c. the types are grouped under titles" '^ +Klammer definition *$' "$KDESC" --katoms
+# The table is wider than the text by the klammer's own argument, so the
+# paragraphs above it keep the text width (72) while a table row exceeds it.
+absent "10a. the prose above the table keeps the text width" \
+ '^[A-Za-z].{72,}$' "$KDESC" --katoms
+shows "10b. ... while the table is wider than the text" '^ +Index .{72,}$' "$KDESC" --katoms
shows "11. --coverage all adds the file column" 'sks/table/table\.k' "$KDESC" --coverage all
absent "12. --coverage alone omits it" 'sks/table/table\.k' "$KDESC" --coverage
# "all" is one word for both kinds of missing information: if you want some of