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 `) | +| structure | `@s1`…`@s7` numbered headings (`:n 5` forces the number), `@h1`…`@h7` unnumbered, `@part`, `@preface`, `@par`, `@newpage`, `@vspace lines`, `@vfill`, `@nl` line break. Headings are numbered **only inside a `@document`**, in every target; a fragment's heading is its bare title | | blocks | `@code` (inline listing), `@source_listing file :marker P`, `@quote text \| attribution @`, `@note`, `@indent :left :right`, `@center`, `@right`, `@block to \| content @` (absolute placement), `@columns items :n @`, `@multicolumn text :n :gap @`, `@lines` | | lists | `@ul :bullet`, `@ol :initial`, `@define :style` term/definition, `@qa question \| answer @` — items separated by `\|` | | tables | `@table` — cells by `\|`, rows by `\|\|` (§6); `@rowcolor` | @@ -275,13 +278,13 @@ are the limited ones. Bar depth is the dimension: one `|` separates items, two `||` separates rows. Argument order is free for options, fixed for positionals. -**Coverage today** (`kdesc --coverage`; 89 klammers): html, tex and pdf cover -nearly all. **txt and ansi cover 40**, among them `@b @i @t @u @s @r @ri @ts +**Coverage today** (`kdesc --coverage`; 83 klammers): html, tex and pdf cover +nearly all. **txt and ansi cover 60**, among them `@b @i @t @u @s @r @ri @ts @typeface`, `@s1`–`@s7`, `@par @newpage @vspace @vfill @nl`, `@indent @right @lines @qa`, `@ul @ol`, `@table @tbl @reference @define @left_right`, -`@footnote @link`, `@date @datetime`, `@c`. Not for txt: `@code -@source_listing @image @image_grid @quote @note @center @block @columns -@multicolumn @sup @sub @color @h1`–`@h7`. +`@footnote @link`, `@date @datetime`, `@c @code @source_listing @columns`, +`@h1`–`@h7`. Not for txt: `@image @image_grid @quote @note @center @block +@multicolumn @sup @sub @color`. `@u` has no html definition. A klammer applied under a target that lacks it is an **error**, never silently dropped. @@ -311,6 +314,11 @@ the range (here: one sum per column). - `:cell_hpos l c r` per column; `:justify ; …` per cell. - Lines: default under the header and at the bottom; `:hline`/`:vline` replace them (`all`, `none`, or indexed ranges); `:grid` for all lines. +- Vertical rhythm, the same in html and pdf: **`:leading L`** is the row + height in lines of the surrounding text (default `1`: as dense as a + paragraph); **`:rulesep R`** is the space between a horizontal line and + the text either side, as a multiple of the standard amount (`0` = none; + a pdf table with vertical lines gets none). - `:colspan`/`:rowspan` with the indexed-range syntax; `:allow_break true` makes a page-breaking table (pdf). - `:calc` — `;`-separated `() = `, prefix `+ - * /` @@ -331,7 +339,7 @@ the range (here: one sum per column). | `html` | a directory `/` with `index.html`, `css/`, `fonts/`, `js/` | self-contained, fonts localized; `book` structure adds navigation, search | | `tex` | `.tex` | XeLaTeX source; fonts via fontspec from the font store | | `pdf` | `.pdf` | `tex` plus a xelatex run (TeX Live required) | -| `txt` | `.txt` | plain text filled to `Target_txt_width` (72), blocks indented by `Target_txt_indent` (3); tables ruled and padded; captions numbered; **40 of 89 klammers** (§5) | +| `txt` | `.txt` | plain text filled to `Target_txt_width` (72), blocks indented by `Target_txt_indent` (2); tables ruled and padded (`:leading`, `:rulesep` and `:colsep` have no meaning here: a row is a line of characters); `@c` set between `‹ ›` (`@c :nd \| text c@` omits them); listings flush left under their caption; headings numbered inside a `@document`; captions numbered per kind; **60 of 83 klammers** (§5) | | `ansi` | txt + SGR escape sequences | `@b`/`@i`/`@u` and table header styles shown by a terminal; **identical to txt once the escape sequences are stripped**; pick it with `-t ansi -d`; a terminal needs a font with an italic face (macOS Terminal: Menlo, not Monaco) | `k`, `o`, `*` also appear in `kdesc -t`: they are declaration pseudo-targets @@ -344,10 +352,16 @@ quality of cached photographs, 85), `Target_txt_width`, `Target_txt_indent`, Typefaces: the **font store** is closed-world (no network). The distribution ships eb-garamond, crimson-pro, open-sans, source-sans-3, inconsolata, -source-code-pro, noto-sans-egyptian-hieroglyphs, noto-sans-cuneiform (see -`kdesc --typeface`); more are installed with `kdesc --typeface install ` -into `KLAMMERTEXT_FONTS`. An unknown typeface is an error listing the available -ones. CJK is deliberately not shipped. +source-code-pro, noto-sans-egyptian-hieroglyphs, noto-sans-cuneiform and +newgardineromni (see `kdesc --typeface`); more are installed with +`kdesc --typeface install ` into `KLAMMERTEXT_FONTS`. An unknown typeface +is an error listing the available ones. CJK is deliberately not shipped: +`@chinese` errors until Noto Serif SC is installed, and `kdesc -u` says so. +Two traps: a text font may carry OpenType features that rewrite plain text +(EB Garamond sets `^9053^` as a raised 9053, since a caret before figures +means superscript to it — show Klammertext codes in `@t` or `@c`, which are +monospace); and the txt/ansi targets pass a `@typeface` through unchanged, +so the terminal's own font decides what a script looks like there. --- diff --git a/mac/ktype.cpp b/mac/ktype.cpp index 15099a3..298361b 100644 --- a/mac/ktype.cpp +++ b/mac/ktype.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "locator.h" #include "ktype.h" @@ -43,127 +44,21 @@ bool is_deftype(katom_t type) type == katom_t::klammer_default; } -std::string pattern_display(Ktype t) +const Ktype& type_to_katom_instance(katom_t type) { - std::string pat{}; - if (t.m_type == katom_t::space) { - pat = ""; - } else if (t.m_type == katom_t::newline) { - pat = "\\n"; - } else if (t.m_type == katom_t::ws_space) { - pat = "#+ or #+"; - } else if (t.m_type == katom_t::ws_newline) { - pat = "#/ or #/"; - } else if (t.m_type == katom_t::special) { - pat = "^@, ^|, ^#, ^:, or ^^"; - } else if (t.m_type == katom_t::apply_end) { - pat = "@ or @"; - } else if (t.m_type == katom_t::define_end) { - pat = "@@ or @@"; - } else if (t.m_type == katom_t::machine_end) { - pat = "@@@ or @@@"; - } else if (t.m_type == katom_t::ws_added) { - pat = " or \\n"; - } else if (t.m_type == katom_t::word) { - pat = ""; - } else if (t.m_type == katom_t::text) { - pat = ""; - } else if (t.m_type == katom_t::nonascii) { - pat = "^ 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