From 9b0d0d78979583222e2c93ff405abbd4fef5c052 Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Sat, 12 Sep 2026 18:11:48 +0200 Subject: [PATCH] The ansi target, txt table styles, kdesc reference from ref/, typeface and table-width fixes (from dev fcec5e2e77ac) Since the previous release: Machine::read_string() (the fs::path/std::string overload pair split); kdesc -c and -u rendered from the ref/ reflection klammerset; a fit column's tex width measured in TeX over every entry; @typeface resolving to the family its css declares (a store directory name now works in html) and hieroglyphs in @unicode_examples set in their typeface; the ansi target -- plain text with the type styles a terminal can show, identical to txt once the escape sequences are stripped -- with the txt @table carrying :header_style, :style and :hline; @block anchoring, @table spacing and the Image_quality state variable; and the rewritten doc/klammertext_for_llm.md. --- README.md | 2 +- com/kdesc.cpp | 43 +++- com/ktext.cpp | 2 +- doc/klammertext_for_llm.md | 429 +++++++++++++++++++++++++------- kpy/.gitignore | 1 + kpy/klammertext.cpp | 2 +- mac/character.cpp | 173 +------------ mac/character.h | 12 +- mac/eval.cpp | 2 +- mac/font_store.cpp | 9 + mac/machine.cpp | 24 +- mac/machine.h | 11 +- ref/ref.k | 24 +- ref/reflect.cpp | 62 +++++ sks/block/block.k | 2 +- sks/block/block.py | 8 + sks/block/sty/block.sty | 12 +- sks/document/document_class.cpp | 16 +- sks/document/document_class.h | 7 +- sks/document/document_html.cpp | 11 +- sks/font/font.k | 11 +- sks/font/font.py | 29 ++- sks/image/image.k | 9 + sks/image/image.py | 3 +- sks/image/image_cache.py | 71 +++++- sks/image/image_grid.py | 3 +- sks/kutil/klammer_base.py | 19 +- sks/link/link.py | 2 +- sks/table/sty/table.sty | 5 + sks/table/table.py | 170 ++++++++++--- sks/target/phases.py | 88 ++++++- sks/target/target.k | 23 +- sks/target/txt_util.py | 2 +- tst/kdesc_test.sh | 2 +- 34 files changed, 895 insertions(+), 394 deletions(-) create mode 100644 kpy/.gitignore diff --git a/README.md b/README.md index 04d0af3..08a917c 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 `ed13dfaa5b7a`. +This snapshot was assembled from development commit `fcec5e2e77ac`. ## License diff --git a/com/kdesc.cpp b/com/kdesc.cpp index 8c15ec7..b890fa3 100644 --- a/com/kdesc.cpp +++ b/com/kdesc.cpp @@ -147,6 +147,36 @@ static void font_command(const strings_t& words) } } +// The target for the Klammertext kdesc renders for its own output: the +// ansi target (plain text with type styles as escape sequences) when +// stdout is a terminal, the txt target otherwise. The COMMAND decides, +// because only it knows the stream -- the same per-stream rule Color +// follows for the listings, NO_COLOR included -- and the targets stay +// formats a writer names with -t. +static std::string text_target() +{ + return color_enabled(std::cout) ? "ansi" : "txt"; +} + +void show_special_characters() +{ + Machine M; + M.read(M.m_state.value("KLAMMERTEXT_HOME") + "/ref/ref.k"); + 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@"); + M.read_string("@pinyin_examples@"); + std::cout << "\n" << M.apply(text_target()) << "\n"; +} + +void show_unicode_examples() +{ + Machine M; + M.read(M.m_state.value("KLAMMERTEXT_HOME") + "/ref/ref.k"); + M.read_string("@unicode_examples@"); + std::cout << "\n" << M.apply(text_target()) << "\n\n"; +} + int main(int argc, char* argv[]) { try { @@ -189,13 +219,9 @@ int main(int argc, char* argv[]) args.describe(); } - if (p("c")) { - show_special_characters(); - std::cout << "\n"; - } if (p("u")) { - unicode_examples(); + show_unicode_examples(); } if (p("argtypes")) { @@ -292,12 +318,17 @@ int main(int argc, char* argv[]) } } - strings_t own_klammers_only {}; if (!input_filenames.empty()) { own_klammers_only = context_files; } + + if (p("c")) { + show_special_characters(); + std::cout << "\n"; + } + if (p("t")) { std::cout << boldblack << "Targets\n" << black << M.m_targets.describe(2, true, own_klammers_only); diff --git a/com/ktext.cpp b/com/ktext.cpp index c9b57d9..f0d7de8 100644 --- a/com/ktext.cpp +++ b/com/ktext.cpp @@ -64,7 +64,7 @@ int main(int argc, char* argv[]) load_klammersets(M, word_split(args.as_string("klammersets"))); if (!input_text.empty()) { - M.read(input_text + "\n"); + M.read_string(input_text + "\n"); } for (auto p : input_filenames) { M.read(fs::path(p)); diff --git a/doc/klammertext_for_llm.md b/doc/klammertext_for_llm.md index b4603a3..f4ebcd6 100644 --- a/doc/klammertext_for_llm.md +++ b/doc/klammertext_for_llm.md @@ -8,18 +8,23 @@ wanting to learn Klammertext should read the other documents in `doc/`. has not seen this language will pattern-match it to LaTeX, Markdown, or Lisp and produce confidently wrong syntax. -The klammers named here are the **Standard Klammer Set**, which is what loads by -default. A klammer set is user-extensible, so the authority for what exists in -any particular setup is `kdesc`, not this file — see §1. +The klammers named here are the **Standard Klammer Set** (SKS), which is what +loads by default. A klammer set is user-extensible, so the authority for what +exists in any particular setup is `kdesc`, not this file — see §1. Where this +file and `kdesc` disagree, `kdesc` is right and this file is stale. + +Vocabulary: text is **katomized** into **katoms** (the indivisible input units) +by **katom rewrite rules**; a **klammer** is the function-like operator; a +**target** is an output format; a **klammerset** is a named group of klammer +definitions. The three user modes are **author** (`@`), **designer** (`@@`), +**programmer** (`@@@`). --- ## 1. Do not guess — the machine will tell you Klammertext is introspectable. `kdesc` **prints the live state of the machine's -data structures in digested form**: what klammers exist, what arguments each -takes, what the argument types accept, what targets exist. Query it instead of -inferring from examples. +data structures in digested form**. Query it instead of inferring from examples. | ask | command | |---|---| @@ -29,12 +34,16 @@ inferring from examples. | argument types and what each accepts | `kdesc --argtypes` | | shared option groups and who uses them | `kdesc --optionsets` | | which targets each klammer can render to | `kdesc --coverage` | -| what a user's own file defines | `kdesc -i FILE -k` | -| the usage of any command | run it with no arguments | +| what a user's own file defines (against the SKS) | `kdesc -i FILE -k` | +| the klammersets available, with provenance | `kdesc --klammersets` | +| how to write accented / non-English characters | `kdesc -c` | +| how to write any Unicode character, with examples | `kdesc -u` | +| installed typefaces; install or preview fonts | `kdesc --typeface`, `--typeface help` | +| the usage of any command | run it with no arguments (exit 0) | `kdesc -k ` before using an unfamiliar klammer costs one command and -replaces a guess. The parameter list it prints is authoritative; this file is -not. +replaces a guess. The parameter list it prints is authoritative: a parameter +shows as `name.type default`, an option as `:name.type default`. **Check a document without rendering it:** @@ -43,13 +52,15 @@ kdiag --process --check doc.kt ``` reports every klammer application whose name, argument count, option names, or -target coverage is wrong — including inside branches this render will not take. -It applies nothing, so it is safe and fast. Use it before claiming a document is -correct. +target coverage is wrong — including inside `@cond` branches this render will +not take and inside klammer bodies never applied. It applies nothing. Use it +before claiming a document is correct. -**Inspect how text is being parsed:** `kdiag --type ''` shows the katoms -("katoms") and their types. This settles arguments about what a piece of syntax -actually means. +**Inspect how text is parsed:** `kdiag --type ''` shows the katoms and +their types; `kdiag --args ''` shows how text splits into arguments; +`kdiag --rewrite ''` names the rewrite rule that fired. This settles +arguments about what a piece of syntax means. `kdiag` deliberately loads no +klammerset; give it `@read sks/sks.k @` in the input if it needs one. --- @@ -57,15 +68,21 @@ actually means. | assumption | reality | |---|---| -| It is a macro language like TeX | No catcodes. Katom structure — the indivisible input units, which `kdiag` displays — is fixed when a file is read and cannot be changed by anything later. This is why static checking works. | +| It is a macro language like TeX | No catcodes. Katom structure is fixed when a file is read and cannot be changed by anything later. This is why static checking works. | | `\command{arg}` | `@name arg @` — an `@` opens, and a **closing delimiter is required**. | -| Arguments are `{a}{b}` | Positional arguments are separated by `|` — `@sup 2 \| 3 @`. | +| Arguments are `{a}{b}` | Positional arguments are separated by `\|` — `@sup 2 \| 3 @`. | | Options are `[key=value]` | `:name value`, anywhere in the argument list — `@link URL :text Click @`. | -| `%` starts a comment | `#` removes the rest of the line. `##` removes the rest of the file. `#[ … ]#` removes a block. | +| `%` starts a comment | `#` removes the rest of the line. `##` removes the rest of the file. `#[ … ]#` removes a block (nestable). | +| `*x*` is emphasis (Markdown) | `@i x @` is emphasis. `*name*` is **variable substitution** inside a klammer body; in prose write `^*` for a literal asterisk. | +| `\\` escapes | `^` quotes: `^@ ^\| ^# ^^ ^: ^*` and **`^` before any ASCII punctuation** gives that character literally. | +| `&`, `%`, `_`, `<`, `\` must be escaped for LaTeX/HTML | **Never.** The machine escapes the target's characters. An author quotes only Klammertext's own specials. Inside `@c`/`@code` nothing needs quoting. | +| `--` is two hyphens | Under every target `--` is an en dash, `---` an em dash, `` `` `` / `''` curly double quotes, `` ` ``/`'` curly single quotes, `~` a non-breaking space (the LaTeX conventions). A literal `--flag` is `^-^-flag`; a literal tilde `^~`. Verbatim text (`@c`, `@code`, `^'…'^`) is exempt. | | Whitespace is insignificant | It is content, and it is preserved. Blank lines separate paragraphs. | -| `\\` escapes | `^` quotes: `^@ ^\| ^# ^^ ^:` produce those characters literally. | | Text is nested in braces | Text lives between the opening and closing delimiter with no wrapper. | | 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. | --- @@ -81,17 +98,15 @@ Three prefix tiers, distinguished by how many `@`: **Closing a klammer.** The close is `@` for the application tier, `@@` for definitions, `@@@` for system commands. A close may be *named* by putting the -klammer's name immediately before it: `@i text i@. +klammer's name immediately before it: `@i text i@`. > **When to name the close.** Use a bare `@` by default. Use the named form -> `name@` when the klammer's body **contains other klammer calls**. The reason is -> diagnostic, not stylistic: if delimiters are unbalanced, a named close lets the -> machine say exactly which klammer was left open. With bare closes everywhere it -> can only report that the count did not work out. Do not name closes on short, -> klammer-free bodies — it is noise. +> `name@` when the body **contains other klammer calls**: if delimiters are +> unbalanced, a named close lets the machine say which klammer was left open. +> Do not name closes on short, klammer-free bodies — it is noise. ``` -@i just text i@ ← unnecessary; @i just text @ is better +@i just text @ ← bare close @s1 A @c fragment c@ heading s1@ ← name it: the body contains a klammer call ``` @@ -103,48 +118,59 @@ klammer's name immediately before it: `@i text i@. - `|` separates positional arguments. - `:name value` is an optional argument; it may appear anywhere in the list. + The value runs to the next `|` or `:option`. - `:name` written alone takes the argument type's "alone" value — for a boolean - that is `true`, so `:number` means `:number true`. -- An optional argument that is not written takes its default. + that is `true`, so `:number` means `:number true`; `@table :hline` means + `:hline all`. +- An optional argument that is not written takes its default (kdesc shows it). **Abbreviated form.** `@name-arg1-arg2` is `@name arg1 | arg2 @`, valid only while every argument is letters and digits: `@i-italic`, `@sup-2-3`, `@date@`. **Literal klammers** take their content uninterpreted. It is not examined for -`@`, `|`, `#`, or `^`, so the machine cannot find the end by counting and **the -named close is required**: +`@`, `|`, `#`, `^` or `*`, so the machine cannot find the end by counting and +**the named close is required**: ``` @code int x = a[0]; // # and @ are safe in here code@ -``` -``` @c a | b c@ ← inline: same rule, and the close must be "c@" ``` -`@c` and `@code` are the in-line and block forms of the same thing, and both are -literal: **nothing inside either is interpreted**, so `#`, `@`, `|`, `^` and `*` -all pass through as written and none of them needs quoting. - -Code that lives in a **file** is `@source_listing`, never `@code` — `@code` is -only for a block written out inline, and has no filename argument: +`@c` and `@code` are the in-line and block forms of the same thing. Code that +lives in a **file** is `@source_listing`, never `@code` (which has no filename +argument): ``` -@source_listing src/derivations.cpp @ ← the whole file -@source_listing src/derivations.cpp :marker fromRdd55 @ ← one region of it +@source_listing src/derivations.cpp @ ← the whole file +@source_listing src/derivations.cpp :marker fromRdd55 @ ← one region of it ``` -`:marker P` lists what lies between two lines that are exactly `//P`, each -beginning in the first column. The source file thus declares its own extractable -regions and the document asks for one by name, so editing the code cannot -silently change what the document shows. A marker that is missing, or that does -not appear exactly twice, is an **error** — never a silent fallback to the whole -file. +`:marker P` lists what lies between two lines that are exactly `//P` in column +one. A marker missing or not appearing exactly twice is an **error**, never a +fallback. `kdesc -k ` shows a literal parameter as `text.literal`. -`kdesc -k ` is how you tell: a parameter shown as `text.literal` is a -literal parameter. Do not infer it from a klammer's purpose — check. +**Whitespace and removal (`#`).** `#` rest of line, `##` rest of file, `#[ … ]#` +block; `#-` removes surrounding whitespace, `#+N` inserts N spaces, `#/N` inserts +N newlines (default 1). + +**Characters (`^`).** One mechanism, four readings, in this order: + +| form | gives | example | +|---|---|---| +| `^P` (P any ASCII punctuation) | that character literally | `^-^-flag`, `^~`, `^^`, `^@` | +| `^'…'^` | a **literal span**: raw target markup passed through untouched | `^'$\cdot$'^` | +| `^UUUU^` (4–6 hex digits) | the code point | `^0418^` И, `^263A^` ☺, `^13000^` 𓀀 | +| `^UUUU UUUU …^` | a run of code points, one caret pair | `^9053 53EF 9053^` 道可道 | +| `^` + base letter + mark | a diacritic | `^e'` é, `^u"` ü, `^n~` ñ, `^cc` ç, `^oh` ô | +| `^xx^` mnemonic | an extended-Latin letter | `^s^` ß, `^ae^` æ, `^o^` ø | + +`kdesc -c` prints the diacritic, extended-Latin and pinyin tables; `kdesc -u` +the code-point notation with examples. The one `^` exception: `^'` opens a +literal span, so a literal apostrophe is `^0027^`. A caret form that names +nothing is an error, not silence. --- @@ -156,15 +182,17 @@ literal parameter. Do not infer it from a klammer's purpose — check. :subtitle Predictability and provenance :author Joseph Goldstone :date @date@ +:structure article :point_size 12 :text @s1 Principles s1@ Ordinary paragraph text. A blank line starts a new paragraph. -Use @i emphasis i@ and @b bold @ inline, and @c code_fragment c@ for code. +Use @i emphasis @ and @b bold @ inline, and @c code_fragment c@ for code. +A footnote @footnote Its text. @ is written inline. -@link https://openexr.com/ :text the OpenEXR site link@ +@link https://openexr.com/ :text the OpenEXR site @ @ul first item @@ -176,86 +204,295 @@ ul@ @image figure_name :width .95w :caption A caption @ +@table :caption Sizes :column_width fit fill + | Name | Description + || alpha | the first + || beta | the second +table@ + @ ``` -Render it: +A long document names its chapters instead of holding them: ``` -ktext doc.kt -t pdf # also: -t html, -t tex, -t txt -ktext doc.kt -t html # writes a DIRECTORY doc/ containing index.html -ktext -s '@i-x' -t html -d # -d displays instead of writing; -s takes a string +@document :title The Book :structure book :files preface one two @ ``` -Output goes **next to the input file** unless `-o` says otherwise. +which reads `kt/preface.kt`, `kt/one.kt`, `kt/two.kt` beside the root file. + +**Render it:** + +``` +ktext doc.kt -t pdf # also: -t html, -t tex +ktext doc.kt -t html # writes a DIRECTORY doc/ containing index.html (+css, fonts, js) +ktext doc.kt -t txt -d # -d displays on stdout instead of writing -- but this + # example errors under txt/ansi: @c and @image have no + # txt definition (§5), and coverage is never silently degraded +ktext -s '@i-x' -t html -d # -s takes a string; no file needed +ktext doc.kt -s '@@version : 3.2.1 @@' -t pdf # -s is processed BEFORE the files: + # define a klammer the document uses +``` + +- Output goes **next to the input file** unless `-o` names a directory and/or + basename. String-only input writes to the current directory. +- `-s`, `-t`, `-o` take exactly one argument (quote spaces). `--klammersets` + takes a list; end it with `--` if filenames follow. +- Filenames may contain spaces: quote them on the command line; in a list + (`:files`, several input files) separate names with a standalone ` / `. +- `@document :structure` is `plain` (default; no TOC), `article` (in-page + TOC), `book` (cover, navigation panel in html; chapters, recto/verso in + pdf). Headings `@s1`… are `\chapter` in a book and `\section` otherwise. +- Role typefaces: `:serif_typeface`, `:sans_typeface`, `:mono_typeface` + (family name or store directory name, e.g. `EB Garamond` or `eb-garamond`); + `:text_scale` scales all text; `:point_size`, `:leading`, `:paper_size`, + `:landscape`, `:two_column`. `kdesc -k document` lists all forty. +- **Outside a `@document`** nothing makes paragraphs: a fragment rendered on + its own writes `@par text @` explicitly. --- ## 5. Klammers most used when authoring -Confirm parameters with `kdesc -k ` — this list is names and intent only. +Confirm parameters with `kdesc -k ` — this is names and intent only. +Not every klammer exists for every target (`kdesc --coverage`); txt and ansi +are the limited ones. | | | |---|---| -| inline | `@i` italic, `@b` bold, `@t` typewriter, `@u` underline, `@c` inline code (literal; close with `c@`), `@sup`, `@sub` | -| structure | `@s1`…`@s7` numbered headings, `@h1`…`@h7` unnumbered, `@part`, `@preface`, `@par` | -| blocks | `@code` (a listing written inline), `@source_listing` (a listing read from a file), `@quote`, `@note`, `@indent`, `@block`, `@center`, `@right`, `@columns` | -| lists | `@ul` unordered, `@ol` ordered, `@define` term/definition — items separated by `\|` | -| tables | `@table` — cells by `\|`, rows by `\|\|`; `@tbl`, `@rowcolor` | -| figures | `@image`, `@image_grid`, `@fig`, `@reference` | -| other | `@link`, `@date`, `@datetime`, `@color`, `@font`, `@newpage`, `@vspace`, `@email` | +| 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 | +| 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` | +| figures & references | `@image basename :width :caption`, `@image_grid`, `@fig id`, `@tbl id`, `@reference spec \| name @`, `@target id :text` | +| footnotes & links | `@footnote text @`, `@link target :text :footnote`, `@email name \| domain @` | +| dates | `@date`, `@datetime` (`:days N`, `:lang de`, `:number`), `@year`, `@timestamp` | +| symbols | `@copyright`, `@registered`, `@trademark`, `@euro amount`, `@pi`, `@rightarrow`, `@dot`, `@sp` (space) | 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 37**, 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`, `@footnote @link`, `@date +@datetime`. Not for txt: `@c @code @source_listing @image @image_grid @quote +@note @center @block @columns @multicolumn @define @sup @sub @color @h1`–`@h7`. +`@u` has no html definition. A klammer applied under a target that lacks it is +an **error**, never silently dropped. --- -## 6. Defining a klammer (only if asked) +## 6. Tables ``` -@@name.k arg1 | arg2 :opt default : Description of what it does @@ +@table :caption Quarterly :column_width fit fit fill :cell_hpos l r r :calc 3(1-2) = + 1-2(1-2) + | Item | Q1 | Q2 + || Widgets | 10.5 | 12 + || Gadgets | 3 | 4.25 + || Total | | +table@ +``` + +Cell indices are **zero-origin and count the header row**: `3(1-2)` is the +fourth row, second and third columns; `1-2(1-2)` the two body rows above it; +`-1` is the last row or column. A ranged target repeats the calculation across +the range (here: one sum per column). + +- First row is the header (`:header false` otherwise); header style + `:header_style i` by default; `:style r i b` per column. +- `:column_width` per column: `fit` (widest entry), `fill` (the remaining + width, capped at the widest entry), a fraction of the text width (`.2`), or + `*` (the remainder). `fill` may not combine with a fraction or `*`. +- `: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. +- `:colspan`/`:rowspan` with the indexed-range syntax; `:allow_break true` + makes a page-breaking table (pdf). +- `:calc` — `;`-separated `() = `, prefix `+ - * /` + folding left over the operands, which are cell ranges or numbers; + **display-precision** (a printed total equals the sum of the printed lines); + `notes/calc_notation.md` is the gallery. `:format module.function` + formats cells with user Python. `:decimal comma` for comma decimals. +- Placement: `:hpos left|center|right|none`, `:offset 2em` (inset from that + margin); a caption goes **above** a table (`:caption_side top` default), + below a figure. `:number false` for an unnumbered caption. + +--- + +## 7. Targets + +| target | output | notes | +|---|---|---| +| `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; **37 of 89 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 +(interface, option set, all targets), not outputs. + +State variables an author may set (`@@@state Name :value V @@@` in the +document, or `-s`): `Language` (`en`/`de`, for dates), `Image_quality` (JPEG +quality of cached photographs, 85), `Target_txt_width`, `Target_txt_indent`, +`Image_default_width`, `Image_search_path`. + +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. + +--- + +## 8. Defining klammers (only if asked) + +``` +@@name.k arg1 | arg2 :opt.type default : Description of what it does @@ @@name.html :: *arg1* @@ @@name.tex :: \textbf{*arg1*} @@ +@@name.txt :: *arg1* @@ ``` - `.k` declares the interface and documents it; per-target definitions inherit - the parameter list with `::`. -- `*arg*` substitutes an argument into the body. -- A definition with no target suffix applies to every target. + the parameter list with `::`. Every target's list must agree. +- `*arg*` substitutes an argument into the body; `*Name*` also reads a state or + environment variable. +- Several targets may share a body: `@@name.html,tex :: … @@`. A definition + with **no suffix** applies to every target (the writer's macro form; its body + is escaped for the target, so `@@corp : Smith & Jones @@` is safe). `.*` + written out is a *claim* of target independence. - Separators: `:` create, `::` create inheriting parameters, `:::` override an - existing definition, `::::` define a default that a later `:` may replace. + existing definition, `::::` define a default a later `:` may replace. +- Argument types: `name.type` — `string` (default), `int`, `float`, `bool`, + `list`, `length`, `literal` (raw content, named close required), `rest` + (everything left, bars included; `rest(2)` = list of lists), and any + `@@@argtype` the klammerset declares (`kdesc --argtypes`). An argtype may + carry `:default` and `:alone`; a klammer may override the default, never the + alone value. +- **Option sets** (`.o`): a named group of optional parameters used by several + klammers — `@@name.k arg @caption_args :caption_side top @ : … @@` splices + the SKS's caption options with one default overridden. Usable only in a `.k` + parameter list. A klammer *application* inside a parameter list is an error. +- **`@eval`** is the primitive behind every non-trivial klammer: + `@eval python_expr @` (module.function auto-imports; `K` holds the + arguments), `@eval :cpp lib.function @`, `@eval :shell cmd @`, + `@eval :haskell src @`; `:cwd DIR` runs it there (`:cwd *K_input_dir*`). + A Python module is found next to the file that names it. **The result is + re-read as Klammertext**: a result holding klammers is *generator* output and + its text is escaped for the target; a result with none is *renderer* output + and left alone. A literal `@` in a result must be emitted as `^@`. +- A klammer whose tex body is target markup with a backslash must not be + passed through a generator raw — the SKS carries such things as markers + (`@nl`), resolved in an `:after_apply` phase. +- **Klammersets**: `@@@klammerset sym | description :requires sks :files a.k b.k @@@` + in `sym/sym.k`; loaded with `ktext --klammersets sym doc.kt` (symbols only, + resolved in the document's directory, then `KLAMMERTEXT_KLAMMERSETS`, then + the distribution; several combine in order, later definitions deciding + collisions via the separators above). `kdesc -i file.k -k` shows what a file + defines against the SKS. `--klammersets ref` adds the reflection klammers + (`@diacritics_examples@`, `@unicode_examples@`, …) that generate the + reference tables `kdesc -c`/`-u` print. --- -## 7. Behaviour worth knowing before you debug something +## 9. Behaviour worth knowing before you debug something - **`@cond` is strict about truth.** True: `true`, `True`, `1`. False: `false`, - `False`, `0`, and empty. **Anything else is an error**, not "probably false". -- **`@cond` evaluates only the branch it selects.** The branch not taken is not - read, not evaluated, not expanded — a `@read` of a missing file in the unused - branch is harmless. -- **A state variable reaches a `@cond` predicate.** The document behaves like a - function body whose arguments are its `@@@state` variables. -- **`@eval :shell` and `:haskell`:** the command's standard output becomes - document text; its standard error does not (it appears under `-v 1`); a nonzero - exit is an error. Append `|| true` if a nonzero exit is expected. -- **A definition inside a `@cond` branch does not register** — in either branch. - Define outside and select between *applications*. + `False`, `0`, empty. **Anything else is an error**, not "probably false". +- **`@cond` is resolved at application time and evaluates only the branch it + selects.** A `@read`/`@eval` in the other branch does not run. A top-level + `@cond *Flag* | … @` sees the variable's value. +- **A state variable is visible from the moment its declaration is read** — + in every klammer body applied afterwards and in the top-level text of later + input units, but not in the top-level text of the same unit that declares it. + Declare early (`-s`, a first file, a klammerset). +- **A definition inside a `@cond` branch does not register** — in either + branch. Define outside and select between *applications*. +- **`@eval :shell` and `:haskell`:** stdout becomes document text; stderr does + not (shown under `-v 1`); a nonzero exit is an error. Append `|| true` if a + nonzero exit is expected. +- **Arguments arrive as written**, nested klammers already rendered; a macro + escapes only its own body text. Never escape an output format's characters + yourself, anywhere. +- **Typographic transforms are per katom and skip verbatim text**: a `--` split + by a klammer is two hyphens; inside `@c`/`@code` nothing is transformed. +- **Recursion** is guarded: a klammer reaching itself 200 deep is a named + `Recursion_error`; a document needing more than 100 top-level passes is an + error. +- **`@eval` results are trimmed**; a klammer that must emit leading space or + pure vertical space uses the txt/tex markers the SKS provides + (`phases.txt_nbsp_marker()`, `@vspace`). +- The html **page cache** (`/dev/shm/_klammertext_cache/` on Linux) can serve + a stale page after a change to a klammer's html; clear it when output looks + old (`@document :cache false` disables it). --- -## 8. Diagnostics +## 10. Diagnostics and what the commands print -- **`-v 1`** reports every decision the command made that the user could not have - read off their own input: which klammerset was loaded and from where, which - file a font or a `:files` name resolved to, how the output path was built. - Higher levels are an implementation trace. -- Logging and errors go to **standard error**; the rendered document goes to - standard output, so `ktext doc.kt -d | …` is safe. -- An error stops the run and names a file, line, and character. There is no - warn-and-continue except in two documented cases. -- A bare `ktext`, `kdesc`, or `kdiag` prints its usage and exits successfully. +- Three cases only: **logging** (stderr, `-v` > 0), **an error** (stderr, then + exit ≠ 0), **the requested output** (stdout). So `ktext doc.kt -t txt -d | …` + is safe; nothing else lands in the document. +- **`-v 1`** reports every decision the user could not read off their input: + which klammerset loaded and from where, which file a typeface or `:files` + name resolved to, how the output path was built, an override of another + set's definition. `-v 2`+ is an implementation trace. +- An error stops the run and names a file, line and character; messages are + justified to 80 columns. There is **no warn-and-continue** except two + aggregated heuristics: unknown target coverage, and a table wider than the + page. +- *"Word not parsed: …"* means a piece of text matched no katom type — usually + a letter touching a delimiter (§2) or a punctuation run needing `^`. +- Colour on stderr only when it is a terminal; `NO_COLOR` disables it. kdesc's + `-c`/`-u` render through `ansi` on a terminal and `txt` in a pipe. -**When something is wrong:** run `kdiag --process --check doc.kt` first — it -finds name, arity, option and coverage faults without rendering. Then `kdesc -k -` to check the parameter list against what was written. Then `kdiag ---type ''` if the question is how something is being parsed. +**When something is wrong:** `kdiag --process --check doc.kt` first (name, +arity, option and coverage faults without rendering); then `kdesc -k ` +against what was written; then `kdiag --type ''` if the question is +how something is parsed; then `ktext … -v 1` if the question is which file or +klammerset was used. + +--- + +## 11. Klammertext from Python + +```python +import klammertext # kpy/ on PYTHONPATH (env/runtime.env) +M = klammertext.Machine(target="html", files=["doc.kt"]) # as ktext sets itself up +M.read("@@version : 3.2.1 @@") # what "ktext -s" does +M.read_files() # the constructor's files, in order +html = M.apply() # the rendered text +``` + +`Machine(target='*', klammersets=None, files=None, output=None, display=False, +verbose=0)`; `read(text)`, `read_file(name)`, `read_files()`, +`apply(target=None, final_processing=True, escape_characters=True)`. **One +Machine at a time** (a second raises `RuntimeError`); `apply()` reduces in +place, so a second target needs a second Machine. Errors are `klammertext.Error` +subclasses under the C++ names (`Parsing_error`, `Argument_error`, +`Definition_error`, `Environment_error`, …) with `kind`, `description`, +`filename`, `line`, `column`; `str(e)` is ktext's report; an `@eval`'s Python +exception is chained as `__cause__`. + +--- + +## 12. Adjacent tools + +- **Markdown → Klammertext:** `sks/tns/md_to_sks.py` converts a `.md` file; + what it cannot convert exactly is left in the output as `#[MD … ]#` markers, + so the draft carries its own worklist (spec: `doc/markdown_to_klammertext.md`). + `mdpdf file.md` renders Markdown straight to PDF through a headless Chromium, + bypassing Klammertext. +- **Editors:** `doc/edit/` holds Emacs, Sublime Text, Vim and VS Code support + (highlighting, delimiter matching, reindent, table alignment, a language + server); `doc/edit/README.md`. +- **Containers:** `akopra/klammertext:latest` (and `:haskell`) hold the three + commands, the SKS, TeX Live and the default fonts; `doc/linux_container_install.md`, + `doc/macos_container_install.md`. +- **Environment:** `KLAMMERTEXT_HOME` (the tree), `KLAMMERTEXT_FONTS`, + `KLAMMERTEXT_KLAMMERSETS` (colon-separated search paths); `env/runtime.env` + sets them when sourced. diff --git a/kpy/.gitignore b/kpy/.gitignore new file mode 100644 index 0000000..a438335 --- /dev/null +++ b/kpy/.gitignore @@ -0,0 +1 @@ +*.d diff --git a/kpy/klammertext.cpp b/kpy/klammertext.cpp index bc5b9a0..c1b521b 100644 --- a/kpy/klammertext.cpp +++ b/kpy/klammertext.cpp @@ -326,7 +326,7 @@ PyObject* Machine_read(PyObject* obj, PyObject* arg) std::string text = PyUnicode_AsUTF8(arg); return guarded([&]() -> PyObject* { set_string_source(); - machine_of(obj).read(text + "\n"); + machine_of(obj).read_string(text + "\n"); Py_RETURN_NONE; }); } diff --git a/mac/character.cpp b/mac/character.cpp index 03152ec..84c27a0 100644 --- a/mac/character.cpp +++ b/mac/character.cpp @@ -172,7 +172,7 @@ std::string process_pinyin(std::string s) return result; } -std::string process_unicode_codepoint(std::string s, const Locator& loc = Locator::none()) +std::string process_unicode_codepoint(std::string s, const Locator& loc) { (void)K::log(4); std::string result {s}; @@ -267,174 +267,3 @@ std::string encode(const std::string& s, const Locator& loc) return result; } - - - -// Decode - - -// Display - -void write_kt_example_file(std::stringstream& kt, std::string kt_filename) -{ - kt << "|| @line@\n"; - kt << "@\n"; - std::cout << "Writing " << kt_filename << "..."; - std::ofstream out(kt_filename); - out << kt.str(); - out.close(); - std::cout << "done\n"; -} - - -/* -void diacritics_examples(const std::string& kt_filename) -{ - if (diacritics.size() != diacritic_symbols_order.size()) { - //throw Internal_error("Mismatch between diacritics order list and their definitions"); - std::cout << "Mismatch error\n"; - } - - std::stringstream kt {}; - bool write_kt_file = kt_filename.size() != 0; - if (write_kt_file) - kt << "@table :caption Diacritics (with typical base characters) |\n" - << " @i-Displayed | @i-Written | @i-Name\n"; - else - std::cout << boldblack - << "\nDiacritics (with typical base characters)\n\n" << black; - - std::string line_sep = "|| @line@ "; - for (char symbol : diacritic_symbols_order) { - std::string sym { symbol }; - auto [code, name] = diacritics[sym]; - std::string letter = diacritic_example_letter[sym]; - std::string written = "^" + letter + sym; - if (sym == "~") { - written = "^" + letter + "=7e="; - } - if (write_kt_file) { - std::string display = "^" + letter + sym; - kt << line_sep << display << " | @t ^" << written << " @ | " << name << "\n"; - line_sep = "|| "; - } - else { - std::cout << " " << letter << unicode_hex_to_char(code) - << " " << "^" << letter << symbol << " " << name << "\n"; - } - } - if (write_kt_file) - write_kt_example_file(kt, kt_filename); -} -*/ - -/* -void extended_latin_examples(const std::string& kt_filename) -{ - std::stringstream kt {}; - bool write_kt_file = kt_filename.size() != 0; - std::string title = "Extended Latin characters and ligatures"; - if (write_kt_file) - kt << "@table :caption " << title << " |\n" - << " @i-Displayed | @i-Written | @i-Name\n"; - else - std::cout << "\n" << boldblack << title << black << "\n\n"; - - std::string line_sep = "|| @line@ "; - for (const std::string& symbol : extended_latin_symbols) { - auto [code, name] = extended_latin[symbol]; - if (write_kt_file) { - std::string coded = "^" + symbol + "^"; - std::string file_literal = "^^ #- " + symbol + " #- ^^"; - kt << line_sep << coded << " | @t " << file_literal << " @ | " << name << "\n"; - line_sep = "|| "; - } - else { - std::string screen_literal = "^" + symbol + "^"; - std::cout << " " << unicode_hex_to_char(code) - << " " << std::setw(4) << std::left << screen_literal << " " << name << "\n"; - } - } - if (write_kt_file) - write_kt_example_file(kt, kt_filename); -} -*/ - -void pinyin_examples(const std::string& kt_filename) -{ - std::stringstream kt {}; - bool write_kt_file = kt_filename.size() != 0; - if (write_kt_file) - kt << "@table :caption Mandarin pinyin tones (using vowel ``a'') |\n" - << " @i-Displayed | @i-Written | @i-Name\n"; - else - std::cout << boldblack - << "\nMandarin pinyin tones (using vowel \"a\")\n\n" << black; - - std::string line_sep = "|| @line@ "; - for (auto [symbol, codename] : pinyin_tones) { - auto [code, name] = codename; - // std::string hat_code = "^a" + code; - std::string literal = "^a" + symbol; - if (write_kt_file) { - kt << line_sep << literal << " | @t ^" << literal << " @ | " << name << "\n"; - line_sep = "|| "; - } else - std::cout << " a" << unicode_hex_to_char(code) - << " "<< std::setw(4) << std::left << literal << " " << name << "\n"; - } - if (write_kt_file) - write_kt_example_file(kt, kt_filename); -} - -/* - Outside my window - crows build a nest in a tree... - slowly... carefully. -*/ - -void unicode_examples(const std::string& kt_filename) -{ - std::stringstream kt {}; - bool write_kt_file = kt_filename.size() != 0; - - std::cout << boldblack << "\nIncluding Unicode in Klammertext files\n\n" << black - << "To specify a wide variety of different letter forms, the Unicode Standard\n" - << "defines a large set of \"code points\" organized by groups. To include a\n" - << "Unicode character in Klammertext, you surround its hexadecimal number with\n" - << "the \"^\" character. For example, the Cyrillic captial letter \"I\" is \"И\",\n" - << "included in Klammertext as ^0418^.\n\n" - << "To search for Unicode characters by their description, see:\n" - << " https://www.fileformat.info/info/unicode/char/search.htm\n"; - - std::cout << "\n⟐ Examples of Chinese characters\n\n" - << " Pinyin\n"; - for (auto chr : dao) { - std::string code = "^" + chr[1] + "^"; - std::cout << " " << process_unicode_codepoint(code) << " " - << code << " " - << chr[0] << "\n"; - } - - std::cout << "\n⟐ Examples of Egyptian hieroglyphs\n\n"; - std::cout << std::setw(16) << " " << "Sound Gardiner\n"; - for (auto glyph : ptolemy) { - std::cout << " " << std::setw(4) << process_unicode_codepoint("^" + glyph[2] + "^") << " " - << "^" + glyph[2] + "^ " - << std::setw(4) - << glyph[1] << " " - << std::setw(4) - << glyph[0] << "\n"; - } - std::cout << "\nSee: https://en.wikipedia.org/wiki/List_of_Egyptian_hieroglyphs\n" - << "The Unicode codepoint in the second column of the \"List of hieroglyphs\"\n" - << "table is the number in the ^...^ code used by Klammertext.\n\n"; -} - -void show_special_characters() -{ - std::cout << std::setfill(' '); - // diacritics_examples(); - // extended_latin_examples(); - pinyin_examples(); -} diff --git a/mac/character.h b/mac/character.h index bc069a9..f9e11ca 100644 --- a/mac/character.h +++ b/mac/character.h @@ -59,7 +59,7 @@ inline std::map> diacritics { {"`", {"0300", "grave accent"}}, {"'", {"0301", "acute accent"}}, - {"h", {"0302", "circumflex"}}, + {"h", {"0302", "circumflex (hat)"}}, {"~", {"0303", "tilde"}}, {"-", {"0304", "macron"}}, {"\"", {"0308", "diaresis"}}, @@ -138,6 +138,8 @@ const std::string italic_on() { return is_tty() ? "\033[3m" : ""; } inline const std::string italic_off() { return is_tty() ? "\033[0m" : ""; } +std::string process_unicode_codepoint(std::string s, const Locator& loc = Locator::none()); + std::string encode(const std::string& s, const Locator& loc); // UTF-8 encoding of one code point (to U+10FFFF; surrogates and values @@ -154,11 +156,3 @@ std::string utf8char(int cp); // "Invalid Unicode" text is no longer reachable from input). bool valid_codepoint(int cp); std::string invalid_codepoint_reason(int cp); - -// void diacritics_examples(const std::string& kt_filename = ""); -// void extended_latin_examples(const std::string& kt_filename = ""); - -void pinyin_examples(const std::string& kt_filename = ""); -void unicode_examples(const std::string& kt_filename = ""); - -void show_special_characters(); diff --git a/mac/eval.cpp b/mac/eval.cpp index 8784143..3b0a0b4 100644 --- a/mac/eval.cpp +++ b/mac/eval.cpp @@ -336,7 +336,7 @@ katom_list Eval::eval(katom_iter begin, katom_iter end) Machine M = m_machine; M.m_warn_unparsed = false; // Result text is machine-generated (see machine.h) size_t before = M.m_katoms.size(); - M.read(eval_result); + M.read_string(eval_result); // If the @eval produced more Klammertext -- the read-back result still // holds klammers to reduce (a "generator", e.g. a Python klammer returning // a @table call with data) -- then its text is writer content: escape it diff --git a/mac/font_store.cpp b/mac/font_store.cpp index 05d0836..26b87a4 100644 --- a/mac/font_store.cpp +++ b/mac/font_store.cpp @@ -89,6 +89,15 @@ static void classify_from_css(Resolved_font& font, std::string css_path) { // Parse the @font-face blocks in the CSS to determine variant → filename mapping std::string css = string_from_file(css_path); + // The family name is what the css DECLARES, not what the caller wrote: + // a typeface may be asked for by its directory name + // ("noto-sans-egyptian-hieroglyphs"), and a font-family value in + // generated html must match the @font-face or the browser substitutes + // silently (until 2026-09-12 the request was echoed back). + static const std::regex family_rgx(R"(font-family:\s*'([^']+)')"); + std::smatch match; + if (std::regex_search(css, match, family_rgx)) + font.family_name = match[1]; std::regex face_rgx( R"(@font-face\s*\{[^}]*font-style:\s*(\w+);[^}]*font-weight:\s*(\w+);[^}]*url\('([^']+\.ttf)'\)[^}]*\})", std::regex::multiline); diff --git a/mac/machine.cpp b/mac/machine.cpp index 02d8807..87cd089 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -482,25 +482,25 @@ void Machine::read(const fs::path& pathname) // :cpp libraries live next to the file that uses them). m_state.add_search_dir(fs::absolute(pathname).parent_path().string()); m_state.open_frame("Machine state: " + pathname.string()); - std::string text = m_state.subst(trim_right(string_from_file(pathname))); - katom_list katoms = process(text, pathname); - m_katoms.insert(m_katoms.end(), katoms.begin(), katoms.end()); - extract_machine_definitions(); - extract_klammer_definitions(); + input(string_from_file(pathname), pathname.string()); m_sources.push_back(pathname); } -void Machine::read(const std::string& s) +void Machine::read_string(const std::string& text) { - (void)K::log(3, s); - m_state.open_frame("Machine state: " + s); - std::string text = m_state.subst(trim_right(s)); - katom_list katoms = process(text, command_pathname); + (void)K::log(3, text); + m_state.open_frame("Machine state: " + text); + input(text, command_pathname); + m_sources.push_back(text); +} + +void Machine::input(const std::string& text, const std::string& source) +{ + katom_list katoms = process(m_state.subst(trim_right(text)), source); m_katoms.insert(m_katoms.end(), katoms.begin(), katoms.end()); extract_machine_definitions(); extract_klammer_definitions(); - m_sources.push_back(s); -} +} // Read diff --git a/mac/machine.h b/mac/machine.h index f8e7d06..01bd8d3 100644 --- a/mac/machine.h +++ b/mac/machine.h @@ -69,8 +69,15 @@ public: bool nonascii=true, bool literal=true, bool ignore=true, bool whitespace=true, bool klammers=true, bool eval=true, bool cond=true, bool read=true); - void read(const fs::path& pathname); - void read(const std::string& s); + // Two ways to give the Machine its input. They are named, not + // overloaded: fs::path and std::string convert into each other freely + // enough that a string literal is ambiguous between them and a + // std::string holding a filename silently selects the text reading. + void read(const fs::path& pathname); // the file's contents + void read_string(const std::string& text); // the string itself + // What both do once they have the text: substitute, katomize, extract + // definitions. `source` names where the text came from in messages. + void input(const std::string& text, const std::string& source); void expand_read_katoms( std::vector& katoms, std::string source_filename, diff --git a/ref/ref.k b/ref/ref.k index a87fcb4..ded1ee7 100644 --- a/ref/ref.k +++ b/ref/ref.k @@ -7,10 +7,26 @@ :requires sks @@@ -@@diacritics_examples.k : Show the diacritics codes and their result in the text @@ +@@diacritics_examples.k : + Show the diacritics codes and their result in the text @@ -@@diacritics_examples.* :: @eval :cpp *KLAMMERTEXT_HOME*/ref/reflect diacritics_examples @ @@ +@@diacritics_examples.* :: + @eval :cpp *KLAMMERTEXT_HOME*/ref/reflect diacritics_examples @ @@ -@@extended_latin_examples.k : Show the codes and the resulting extended Latin characters @@ +@@extended_latin_examples.k : + Show the codes and the resulting extended Latin characters @@ -@@extended_latin_examples.* :: @eval :cpp *KLAMMERTEXT_HOME*/ref/reflect extended_latin_examples @ @@ +@@extended_latin_examples.* :: + @eval :cpp *KLAMMERTEXT_HOME*/ref/reflect extended_latin_examples @ @@ + +@@pinyin_examples.k : + Show the four tone markings for Mandarin @@ + +@@pinyin_examples.* :: + @eval :cpp *KLAMMERTEXT_HOME*/ref/reflect pinyin_examples @ @@ + +@@unicode_examples.k : + Show examples of Unicode characters @@ + +@@unicode_examples.* :: + @eval :cpp *KLAMMERTEXT_HOME*/ref/reflect unicode_examples @ @@ diff --git a/ref/reflect.cpp b/ref/reflect.cpp index 5f68ad3..580fae4 100644 --- a/ref/reflect.cpp +++ b/ref/reflect.cpp @@ -53,3 +53,65 @@ std::string extended_latin_examples([[maybe_unused]] Machine& machine) return kt.str(); } + +extern "C" +std::string pinyin_examples([[maybe_unused]] Machine& machine) +{ + std::stringstream kt {}; + kt << "@table :caption Mandarin pinyin tones (using vowel ``a'') :cell_hpos c c l |\n" + << " @i-Displayed | @i-Written | @i-Name ||\n"; + + for (auto [symbol, codename] : pinyin_tones) { + auto [code, name] = codename; + std::string literal = "^a" + symbol; + kt << literal << " | @t ^" << literal << " @ | " << name << " ||\n"; + } + kt << "table@"; + return kt.str(); +} + +extern "C" +std::string unicode_examples([[maybe_unused]] Machine& machine) +{ + std::stringstream kt {}; + kt << "\n@b Including Unicode in Klammertext files @\n\n" + << "To specify a wide variety of different letter forms, the Unicode Standard " + << "defines a large set of \"code points\" organized by groups. To include a " + << "Unicode character in Klammertext, you surround its hexadecimal number with " + << "the \"^^\" character. For example, the Cyrillic captial letter \"I\" is \"И\", " + << "included in Klammertext as ^^0418^^.\n\n" + << "To search for Unicode characters by their description, see: @nl@\n" + << "@link https://www.fileformat.info/info/unicode/char/search.htm @\n\n"; + + kt << "@table :caption Examples of Chinese characters :cell_hpos c |\n" + << " Character | Unicode | Pinyin ||\n"; + + for (auto chr : dao) { + std::string code = "^" + chr[1] + "^"; + kt << process_unicode_codepoint(code) << " | " + << "^" << code << "^" << " | " + << chr[0] << " ||\n"; + } + kt << "table@\n\n"; + + + kt << "@table :caption Examples of Egyptian hieroglyphs :cell_hpos c c c l |\n" + << "Hieroglyph | Unicode | Sound | Gardiner ||\n"; + + // The body font carries no hieroglyphs, so the glyph names the + // typeface it needs (shipped in fnt/); @document declares and localizes + // it. Under txt @typeface passes the text through unchanged. + for (auto glyph : ptolemy) { + kt << "@typeface noto-sans-egyptian-hieroglyphs :scale 1.5 | " + << process_unicode_codepoint("^" + glyph[2] + "^") << " @ | " + << "^^" + glyph[2] + "^^ | " + << glyph[1] << " | " + << glyph[0] << " ||\n"; + } + kt << "table@\n\n" + << "\nIn @link https://en.wikipedia.org/wiki/List_of_Egyptian_hieroglyphs @, " + << "the Unicode codepoint in the second column of the \"List of hieroglyphs\" " + << "table is the number in the ^^...^^ code used by Klammertext.\n\n"; + + return kt.str(); +} diff --git a/sks/block/block.k b/sks/block/block.k index 0cc9672..6c75950 100644 --- a/sks/block/block.k +++ b/sks/block/block.k @@ -161,7 +161,7 @@ only makes some vertical space. @@ @@@argtype coords | x and y coordinates :pattern 'float'\s+'float' @@@ -@@block.k : to.coords | content :width.float .5 :point.coords 0.0 0.0 +@@block.k to.coords | content :width.float .5 :point.coords 0.0 0.0 : Absolute positioning of text block @@ @@block.tex :: @eval block.Block(K) eval@ @@ diff --git a/sks/block/block.py b/sks/block/block.py index 456a374..02567f4 100644 --- a/sks/block/block.py +++ b/sks/block/block.py @@ -70,12 +70,20 @@ class Block(klammer_base.Klammer_base): # NOT latex_util.block(): a textblock is absolutely positioned and # does not participate in the normal flow, so breaking the paragraph # around it would move the surrounding text. + # + # The trailing \par\kern0pt gives the block's vbox zero depth. + # textpos anchors :point against the box HEIGHT alone, so content + # whose last line has depth -- an @image, whose [c] minipage sits + # half below the baseline -- was anchored a quarter of its height + # too low (2in for a page-high picture) until 2026-09-08. to_x, to_y = [float(e) for e in self.to.split()] pt_x, pt_y = [float(e) for e in self.point.split()] result = f""" \\begin{{textblock}}{{{self.width}}}[{pt_x},{pt_y}]({to_x},{to_y}) +\\setlength{{\\textwidth}}{{\\linewidth}} \\vspace*{{-1\\parskip}} {self.content.strip()} +\\par\\kern0pt \\end{{textblock}} """ return result diff --git a/sks/block/sty/block.sty b/sks/block/sty/block.sty index ec8f77c..6a47c47 100644 --- a/sks/block/sty/block.sty +++ b/sks/block/sty/block.sty @@ -5,11 +5,17 @@ %\usepackage[absolute,showboxes]{textpos} \usepackage[absolute]{textpos} -\setlength{\TPHorizModule}{\paperheight} -\setlength{\TPVertModule}{\paperwidth} +\setlength{\TPHorizModule}{\paperwidth} +\setlength{\TPVertModule}{\paperheight} %\setlength{\TPVertModule}{1\paperheight} -\textblockorigin{0in}{0in} +% The paper's top-left corner. textpos's absolute mode subtracts TeX's +% 1in origin itself (\textblockorigin stores x-1in, y-1in), and +% document.sty already sets \hoffset and \voffset to -1in for the same +% reason, so {0in}{0in} here shifted every block 1in left and 1in up +% (until 2026-09-08). Cancelling the offsets is right under either +% convention. +\textblockorigin{-\hoffset}{-\voffset} \newlength{\fwboxwidth} \newcommand{\fwboxn}[1]{\setlength{\fwboxwidth}{#1}} diff --git a/sks/document/document_class.cpp b/sks/document/document_class.cpp index c54cae6..3ded423 100644 --- a/sks/document/document_class.cpp +++ b/sks/document/document_class.cpp @@ -276,9 +276,12 @@ void Document_class::collect_typefaces(const std::string& text) // located error naming the available typefaces if the store // does not have it, which is the whole error path for a // misspelled or uninstalled typeface. + // Compared by directory name: a @typeface may write either + // the family name or the store's directory name, and both + // resolve to the same directory. bool seen = false; for (const auto& rf : m_resolved_extra) - if (rf.family_name == name) seen = true; + if (rf.dir_name == name_to_dirname(name)) seen = true; if (!seen) { // What a @typeface klammer asked for, and what the store // resolved it to: a decision the user cannot read off @@ -293,13 +296,22 @@ void Document_class::collect_typefaces(const std::string& text) } } -std::string Document_class::strip_typeface_markers(const std::string& text) +const Resolved_font& Document_class::resolved_typeface(const std::string& name) const +{ + for (const auto& rf : m_resolved_extra) + if (rf.dir_name == name_to_dirname(name)) return rf; + // collect_typefaces() resolves every marker before this is asked. + throw Internal_error("Typeface \"" + name + "\" was not resolved"); +} + +std::string Document_class::resolve_typeface_families(const std::string& text) { std::string result {}; size_t pos = 0, start = 0, end = 0; std::string name {}, scale {}; while (next_typeface_marker(text, pos, start, end, name, scale)) { result += text.substr(pos, start - pos); + result += resolved_typeface(name).family_name; pos = end; } result += text.substr(pos); diff --git a/sks/document/document_class.h b/sks/document/document_class.h index 065e25d..9429d5d 100644 --- a/sks/document/document_class.h +++ b/sks/document/document_class.h @@ -144,7 +144,12 @@ public: void collect_typefaces(const std::string& text); // html: the marker carries no inline switch (the span holds the family // name), so each is replaced by nothing. - std::string strip_typeface_markers(const std::string& text); + // html: each marker is replaced by the family name the store resolved + // its typeface to (the name as written may be the store's directory + // name, which no @font-face declares). + std::string resolve_typeface_families(const std::string& text); + // The resolved font for a typeface name as a @typeface wrote it. + const Resolved_font& resolved_typeface(const std::string& name) const; // tex: each distinct (typeface, scale) gets a \newfontfamily command in // the preamble; the marker is replaced by that command. std::string resolve_typeface_markers(const std::string& text); diff --git a/sks/document/document_html.cpp b/sks/document/document_html.cpp index 1593a5c..e8d7a30 100644 --- a/sks/document/document_html.cpp +++ b/sks/document/document_html.cpp @@ -109,12 +109,15 @@ std::string Document_class::font_definitions() // var() with such a value computes to inherit, silently losing // the font. ss << ":root {\n"; + // The family the store RESOLVED, which is what the localized css + // declares -- the name as written may be the store's directory + // name ("crimson-pro"), which matches no @font-face. if (!m_serif_typeface.empty()) - ss << " --serif: \"" << m_serif_typeface << "\", serif;\n"; + ss << " --serif: \"" << m_resolved_serif.family_name << "\", serif;\n"; if (!m_sans_typeface.empty()) - ss << " --sans-serif: \"" << m_sans_typeface << "\", sans-serif;\n"; + ss << " --sans-serif: \"" << m_resolved_sans.family_name << "\", sans-serif;\n"; if (!m_mono_typeface.empty()) - ss << " --monospace: \"" << m_mono_typeface << "\", monospace;\n"; + ss << " --monospace: \"" << m_resolved_mono.family_name << "\", monospace;\n"; ss << "}\n"; } // Font-size normalization across families is no longer emitted from @@ -761,7 +764,7 @@ std::string Document_class::html() install_resolved_font(rf, output_directory); } for (auto& [basename, text] : m_file_components) { - text = strip_typeface_markers(text); + text = resolve_typeface_families(text); } auto headings = insert_section_numbers(); insert_section_numbers("___NUM2___", false); diff --git a/sks/font/font.k b/sks/font/font.k index 7961a19..c1aeb0b 100644 --- a/sks/font/font.k +++ b/sks/font/font.k @@ -49,12 +49,16 @@ unchanged. @@b.k text : Use a bold weight of the current font for *text* @@ @@b.html :: *text* @@ @@b.tex :: \textbf{*text*} @@ -@@b.txt :: *text* @@ +# The txt bodies of @b, @i, @u and @ri emit STYLE MARKERS around the text +# (phases.txt_style_marker), which the txt target strips and the ansi +# target turns into escape sequences -- see the ansi target in +# sks/target/target.k. "#-" glues the marker to the text. +@@b.txt :: @eval phases.txt_style_marker("bold") @ #- *text* #- @eval phases.txt_style_marker("bold", False) @ @@ @@i.k text : Use an italic style of the current font for *text* @@ @@i.html :: *text* @@ @@i.tex :: \emph{*text*} @@ # \begin{emph}{*text*}\end{emph} @@ -@@i.txt :: *text* @@ +@@i.txt :: @eval phases.txt_style_marker("italic") @ #- *text* #- @eval phases.txt_style_marker("italic", False) @ @@ @@t.k text : Use the currently defined monospace font for *text* @@ @@t.html :: *text* @@ @@ -68,11 +72,12 @@ unchanged. @@u.k text : Underline *text* in the current font @@ @@u.tex :: \underline{\botstrut{2pt}*text*} @@ +@@u.txt :: @eval phases.txt_style_marker("underline") @ #- *text* #- @eval phases.txt_style_marker("underline", False) @ @@ @@ri.k text : Use a roman italic font for *text* (to override monospace environment) @@ @@ri.html :: *text* @@ @@ri.tex :: \textrm{\textit{*text*}} @@ -@@ri.txt :: *text* @@ +@@ri.txt :: @eval phases.txt_style_marker("italic") @ #- *text* #- @eval phases.txt_style_marker("italic", False) @ @@ @@ts.k scale.float | text : Resize *text* by *scale* @@ # @@ts.tex :: {\scalefont{*scale*}\em *text*} @@ diff --git a/sks/font/font.py b/sks/font/font.py index af9f9bb..d73e9a7 100644 --- a/sks/font/font.py +++ b/sks/font/font.py @@ -31,8 +31,12 @@ class Subsuper(klammer_base.Klammer_base): # this marker where the typeface is needed, and @document collects the # markers, resolves and localizes each typeface, declares it, and replaces # the marker with whatever inline switch the target needs (a font-switch -# command for tex, nothing for html, which carries the family name in the -# span's own style). Same shape as the __CAPTION__ markers. +# command for tex; for html the marker sits IN the span's font-family value +# and becomes the family name the store resolved the typeface to, which +# is what the localized css declares -- the name as WRITTEN may be the +# store's directory name, "noto-sans-egyptian-hieroglyphs", and a span +# carrying that matched no @font-face until 2026-09-12). Same shape as +# the __CAPTION__ markers. # # SYNC: typeface_marker_rgx in sks/document/document_class.h -- a C++/Python # pair, so the constant cannot be single-sourced the way a .k/.py pair can @@ -53,18 +57,23 @@ class Typeface(klammer_base.Klammer_base): # hieroglyphs, cuneiform -- and would resize them unpredictably. # The family name is quoted -- an unquoted digit-initial word # ("Source Sans 3") is invalid CSS -- with SINGLE quotes, because - # this sits inside a double-quoted html attribute. - style = (f"font-family: '{self.name}', serif; " - f"font-size-adjust: none") + # this sits inside a double-quoted html attribute. What is quoted + # here is the MARKER: @document replaces it with the resolved + # family name (see the marker note above). + style = "', serif; font-size-adjust: none" if float(self.scale) != 1.0: style += f"; font-size: {float(self.scale)}em" # The STYLE is markup, so its typographically active characters are # hidden: the html target's transforms turn a single quote into a - # curly one, which broke the css font-family value. The TEXT is - # writer content and is deliberately left alone -- its quotes and - # dashes should be transformed. - return (typeface_marker(self.name, self.scale) - + f'' + # curly one, which broke the css font-family value. The MARKER is + # not hidden: a hyphen in the typeface name would become a KTESC + # sequence, which on the :files path is still there when @document + # looks for the marker. The TEXT is writer content and is + # deliberately left alone -- its quotes and dashes should be + # transformed. + hide = klammer_base.hide_typographic + return (f'' + f'{self.text}') def tex(self): diff --git a/sks/image/image.k b/sks/image/image.k index 73fb23c..595e209 100644 --- a/sks/image/image.k +++ b/sks/image/image.k @@ -12,6 +12,15 @@ @@@state Image_default_width :desc Default width for images :value .667w @@@ +@@@state Image_quality + :desc JPEG quality, 1 to 100, at which a photograph -- a raster with + no transparency, whether it arrives as JPEG, PNG or TIFF -- is + written to the image cache for the html and pdf targets. The + default keeps print quality at a fraction of the size; 100 copies + or converts the source unchanged, as before 2026-09-09. + :value 85 +@@@ + # # @@@argtype pixels | a pixel count :pattern \d+px @@@ # @@image.k basename : Image read from a file @@ diff --git a/sks/image/image.py b/sks/image/image.py index 6cfbd24..11a9f3c 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -27,7 +27,8 @@ class Image(klammer_base.Klammer_base): if not os.path.isdir(cache_dir): import tempfile cache_dir = tempfile.gettempdir() - self.cache = image_cache.Image_cache(cache_dir, self.Image_search_path, verbose=False) + self.cache = image_cache.Image_cache(cache_dir, self.Image_search_path, verbose=False, + quality=self.Image_quality) self.basename = klammer_base.unescape_ktesc(self.basename) self.source, self.pwidth, self.pheight, self.file_error = self.cache.get(self.K_target, self.basename) if self.file_error: diff --git a/sks/image/image_cache.py b/sks/image/image_cache.py index 897c6c5..891a1fe 100644 --- a/sks/image/image_cache.py +++ b/sks/image/image_cache.py @@ -40,14 +40,49 @@ def image_dimensions(filename): return width, height, resize +# Raster formats a photograph arrives in. Vector sources (pdf, eps) are +# never re-encoded, nor is a gif or webp (they may be animated, and only +# html takes them). +photograph_formats = {"jpg", "jpeg", "png", "tif", "tiff"} + +def is_photograph(filename): + """True if the file is a raster with no alpha channel: a photograph + (or a grayscale one) that JPEG stores well. A png with transparency, + or a palette image, is not one -- JPEG would flatten or posterize it.""" + ext = os.path.splitext(filename)[1][1:].lower() + if ext not in photograph_formats: + return False + inp = oiio.ImageInput.open(filename) + if not inp: + return False + spec = inp.spec() + inp.close() + return spec.alpha_channel < 0 and spec.nchannels in (1, 3) + + class Image_cache: max_width = 1600 # Should be a state variable cache_basedir = "_klammertext_imgsrc" # Should be a state variable error_marker_filename = "_error_marker.png" verbose_label = " [klammertext]" # Should parameterize verbosity - def __init__(self, cache_dir, search_path, verbose=False): + def __init__(self, cache_dir, search_path, verbose=False, quality=100): #self.cache_dir = kutil.cache_directory( self.dir = kutil.cache_directory(cache_dir, "_image_cache") + # JPEG quality (1-100) at which a photograph is written to the cache. + # 100 means the source is copied or converted as it is (the + # behaviour before 2026-09-09); below it, every photograph -- a + # JPEG source included -- is re-encoded at that quality, which is + # where a document's size mostly lives: a source JPEG is often + # near-lossless (quality 95+), and a png photograph is stored + # losslessly, at three to six times the size of a quality-85 JPEG + # with no visible difference at print resolution. The Image_quality + # state variable (sks/image/image.k) sets it. + try: + self.quality = int(quality) + except (TypeError, ValueError): + raise Exception(f'Image_quality must be an integer from 1 to 100, not "{quality}"') + if not 1 <= self.quality <= 100: + raise Exception(f"Image_quality must be from 1 to 100, not {self.quality}") self.search = search_path.split(" ") self.verbose = verbose or os.environ.get("KLAMMERTEXT_IMAGE_CACHE_DISPLAY") self.required_format = { "html" : ["jpg", "jpeg", "JPEG", "png", "gif", "webp"], @@ -75,6 +110,18 @@ class Image_cache: return f"{self.dir}/{filename}.{cache_ext}" + def photograph_cache_filename(self, basename): + # The quality is part of the name, so changing it re-encodes and + # two documents at different qualities do not fight over one file. + return f"{self.dir}/{basename}.q{self.quality}.jpg" + + def reencode(self, target, source_filename): + """Whether this source is written to the cache as a quality-N JPEG.""" + return self.quality < 100 \ + and target in self.required_format \ + and source_filename != self.error_image_filename \ + and is_photograph(source_filename) + def find_file(self, target, basename): result = None candidates = [] @@ -131,6 +178,8 @@ class Image_cache: if src[:2] == "..": src = source_filename dest = os.path.splitext(cached_filename)[1] + if cached_filename.endswith(f".q{self.quality}.jpg"): + dest = f"{dest} (quality {self.quality})" dim = "" if width == new_width else f" [{new_width}x{new_height}]" desc = "(copied)" if copied else f"-> {dest}{dim}" #print(f"{Image_cache.verbose_label} To cache: {src} [{width}x{height}] {desc}") @@ -148,7 +197,20 @@ class Image_cache: cached_filename = f"{os.path.dirname(cached_filename)}/{os.path.basename(source_filename)}" copied = False - if resize and width > Image_cache.max_width: + if self.reencode(target, source_filename): + cached_filename = self.photograph_cache_filename(basename) + buf = oiio.ImageBuf(source_filename) + if buf.has_error: + raise Exception(f'Cannot read image "{source_filename}": {buf.geterror()}') + if width > Image_cache.max_width: + new_width = Image_cache.max_width + new_height = int(round(new_width * float(height) / float(width))) + roi = oiio.ROI(0, new_width, 0, new_height, 0, 1, 0, buf.nchannels) + buf = oiio.ImageBufAlgo.resize(buf, roi=roi) + buf.specmod().attribute("CompressionQuality", self.quality) + if not buf.write(cached_filename): + raise Exception(f'Cannot write "{cached_filename}": {buf.geterror()}') + elif resize and width > Image_cache.max_width: new_width = Image_cache.max_width new_height = int(round(new_width * float(height) / float(width))) if source_filename.endswith(".pdf"): @@ -190,6 +252,11 @@ class Image_cache: def get(self, target, basename): cached_filename = self.find_cached_file(target, basename) source_filename = self.find_file(target, basename) + if self.reencode(target, source_filename): + # A copy cached at another quality (or untouched, by an earlier + # run at 100) does not serve: the wanted file is the quality-N + # one, and cache_update_required() makes it if it is missing. + cached_filename = self.photograph_cache_filename(basename) if cache_update_required(source_filename, cached_filename): cached_filename, width, height = self.update_cache(target, basename, source_filename) else: diff --git a/sks/image/image_grid.py b/sks/image/image_grid.py index 8cdc0b4..3da011b 100644 --- a/sks/image/image_grid.py +++ b/sks/image/image_grid.py @@ -65,7 +65,8 @@ class Image_grid(klammer_base.Klammer_base): self.cache = image_cache.Image_cache( self.K_input_dir, self.Image_search_path, - verbose=False) + verbose=False, + quality=self.Image_quality) self.margin = 0.01 diff --git a/sks/kutil/klammer_base.py b/sks/kutil/klammer_base.py index 6a7a59f..2ee9bb9 100644 --- a/sks/kutil/klammer_base.py +++ b/sks/kutil/klammer_base.py @@ -67,16 +67,25 @@ class Klammer_base: print(label) pprint.pprint(self.__dict__) + # A target that :includes another renders through the included one's + # method: pdf is tex compiled, ansi is txt with escape sequences. The + # Python side cannot see :includes, so the relation is restated here. + # SYNC: the @@@target declarations in sks/target/target.k. + TARGET_METHOD = {"html": "html", + "tex": "tex", "pdf": "tex", + "txt": "txt", "ansi": "txt"} + def __str__(self): result = None - if self.K_target == 'html': + method = Klammer_base.TARGET_METHOD.get(self.K_target) + if method == 'html': result = self.html() - elif self.K_target in {'tex', 'pdf'}: + elif method == 'tex': result = kutil.escape(self.tex()) - elif self.K_target == 'txt': + elif method == 'txt': result = self.txt() if result is None: - #print(self.__dict__) raise Exception( - f'Target "{self.K_target}" is undefined for klammer "@{self.K_klammer}"') + f'Target "{self.K_target}" is undefined for klammer class ' + f'"{self.__class__.__name__}"') return result diff --git a/sks/link/link.py b/sks/link/link.py index b74f54f..0f01d82 100755 --- a/sks/link/link.py +++ b/sks/link/link.py @@ -70,7 +70,7 @@ class Link(klammer_base.Klammer_base): def txt(self): text = f'{self.text} ' if self.text else '' - return f'{text}[{self.url}]' + return f'{text}[{self.target}]' class Target(klammer_base.Klammer_base): def __init__(self, K): diff --git a/sks/table/sty/table.sty b/sks/table/sty/table.sty index 04b801e..d7925f6 100644 --- a/sks/table/sty/table.sty +++ b/sks/table/sty/table.sty @@ -36,3 +36,8 @@ \newlength{\klfillb} \newlength{\klfillc} \newlength{\klfilld} + +% Scratch register in which a 'fit' or 'fill' column's width is measured +% (\settowidth, then \maxof over each further entry) before being frozen +% into the per-column macro \klfit the column spec reads. +\newlength{\klfitwidth} diff --git a/sks/table/table.py b/sks/table/table.py index 85aa9bd..033944b 100644 --- a/sks/table/table.py +++ b/sks/table/table.py @@ -440,28 +440,33 @@ class Table(klammer_base.Klammer_base): self.rowspan_covered.add((row_i, col_i)) self.covered = self.colspan_covered | self.rowspan_covered - def column_width_text(self): - # For each column, the text used to measure a 'fit' width in the - # tex target. The longest cell's font is applied, so a bold or - # italic cell is measured in the font it will be set in. - self.column_widths = [] + def column_width_candidates(self): + # For each column, the texts whose widths a 'fit' or 'fill' column's + # tex width is the maximum of: every cell of the column, each line + # of a multi-line cell separately, set in its own cell's font. The + # maximum is taken in TeX (tex_column_widths), not here: until + # 2026-09-12 one "longest" entry was chosen by len() of the tex + # MARKUP, so \texttt{\textasciicircum{}e'} (26 characters, 3 + # visible) outranked a header "Written" and the header overflowed + # into the next column (TODO #59). + self.column_candidates = [] for col_i in range(len(self.cells[0])): - longest = "" - longest_style = "r" + candidates = [] for row_i in range(len(self.cells)): cell = self.cells[row_i][col_i] # A colspan anchor's text spans several columns and must # not set the width of its own column. - if cell is not None and cell.colspan <= 1: - lines = [e.strip() for e in cell.text.split("\\newline")] - lines = sorted(lines, key=len) - longest_in_line = lines[-1] - if len(longest_in_line) > len(longest): - longest = longest_in_line - longest_style = cell.font - if longest_style != "r": - longest = font.tex_fontify(longest, longest_style, 1.0) - self.column_widths.append(longest) + if cell is None or cell.colspan > 1: + continue + for line in cell.text.split("\\newline"): + line = line.strip() + if not line: + continue + if cell.font != "r": + line = font.tex_fontify(line, cell.font, 1.0) + if line not in candidates: + candidates.append(line) + self.column_candidates.append(candidates) # ---- :hpos ------------------------------------------------------------- # @@ -530,7 +535,7 @@ class Table(klammer_base.Klammer_base): cell_i + max(cspan, 1) == self.row_size))) cells.append(row_cells) self.cells = cells - self.column_width_text() + self.column_width_candidates() # HTML @@ -626,6 +631,41 @@ class Table(klammer_base.Klammer_base): # in table.sty; one per fill column, in column order. fill_registers = ["\\klfilla", "\\klfillb", "\\klfillc", "\\klfilld"] + @staticmethod + def width_macro(col_i): + # The macro holding a column's measured width: \klfitA ... \klfitZ, + # \klfitAA, ... (a control-sequence name holds letters only). + letters = "" + i = col_i + while i >= 0: + letters = chr(ord("A") + i % 26) + letters + i = i // 26 - 1 + return "\\klfit" + letters + + def tex_column_widths(self): + # Set each 'fit' or 'fill' column's width macro to the width of its + # widest entry, measured in TeX: \settowidth of the first candidate, + # then calc's \maxof against \widthof of each further one, in the + # scratch register \klfitwidth (table.sty); the result is frozen + # into \klfit by \edef, so the column spec -- which array's p{} + # evaluates once per CELL -- reads a number instead of re-measuring + # every candidate. Flat, one line per candidate: a nested \maxof + # would recurse as deep as the column is long. + result = "" + for i, w in enumerate(extend(self.column_width, self.row_size)): + if w not in ("fit", "f", "fill"): + continue + candidates = self.column_candidates[i] + if not candidates: + result += "\\setlength{\\klfitwidth}{0pt}\n" + else: + result += f"\\settowidth{{\\klfitwidth}}{{{candidates[0]}}}\n" + for c in candidates[1:]: + result += (f"\\setlength{{\\klfitwidth}}" + f"{{\\maxof{{\\klfitwidth}}{{\\widthof{{{c}}}}}}}\n") + result += f"\\edef{Table.width_macro(i)}{{\\the\\klfitwidth}}\n" + return result + def tex_fill_widths(self): # Set each 'fill' column's register to min(its share of the # remaining width, its widest entry) -- the same rule the html auto @@ -637,11 +677,10 @@ class Table(klammer_base.Klammer_base): if not self.fill_columns: return "" widths = extend(self.column_width, self.row_size) - fit = [f"\\widthof{{{self.column_widths[i]}}}" + fit = [Table.width_macro(i) for i, w in enumerate(widths) if w in ("fit", "f")] remaining = "\\tablewidth" + "".join(f" - {e}" for e in fit) - widest = {i: f"\\widthof{{{self.column_widths[i]}}}" - for i in self.fill_columns} + widest = {i: Table.width_macro(i) for i in self.fill_columns} total = " + ".join(widest[i] for i in self.fill_columns) result = "" for k, i in enumerate(self.fill_columns): @@ -658,7 +697,7 @@ class Table(klammer_base.Klammer_base): def tex_hpos(self): # One column specification per column: the width comes from - # :column_width ('fit', 'fill' via its precomputed register, a + # :column_width ('fit' and 'fill' via their precomputed macros, a # fraction of \tablewidth, or '*' for the remaining width), the # justification from :cell_hpos. def par_format(s, justification): @@ -671,7 +710,7 @@ class Table(klammer_base.Klammer_base): fill_ordinal = 0 for i, w in enumerate(extend(self.column_width, self.row_size)): if w in ("fit", "f"): - widths.append(f"\\widthof{{{self.column_widths[i]}}}") + widths.append(Table.width_macro(i)) elif w == "fill": widths.append(Table.fill_registers[fill_ordinal]) fill_ordinal += 1 @@ -804,9 +843,9 @@ class Table(klammer_base.Klammer_base): # marker and prints the console warning with the :column_width # primer -- the widths are only known at LaTeX run time, and prose # kept out of TeX avoids the log's 79-column line wrapping. - return ("\\ifdim\\tableboxwidth>\\dimexpr\\textwidth+2pt\\relax\n" + return ("\\ifdim\\tableboxwidth>\\dimexpr\\linewidth+2pt\\relax\n" f"\\message{{^^JKT-WIDE-TABLE {name} overfull by " - "\\the\\dimexpr\\tableboxwidth-\\textwidth\\relax^^J}\n" + "\\the\\dimexpr\\tableboxwidth-\\linewidth\\relax^^J}\n" "\\fi\n") def tex(self): @@ -816,15 +855,25 @@ class Table(klammer_base.Klammer_base): # minipage below: it computes the width the minipage consumes. measure = self.get_width() + self.tex_width_check(name) result = f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n" - if self.allow_break: - result += "\\vspace*{12pt}\n" - result += "\\begin{longtable}{" + # A page-breaking table is a longtable. A boxed one is a plain + # tabular: inside its minipage a longtable could not break anyway, + # and longtable refuses to run in a multicols or two-column page, + # which is where a boxed table is wanted (until 2026-09-08 both + # paths emitted longtable, so @table could not be used in + # @multicolumn at all). + env = "longtable" if self.allow_break else "tabular" + result += f"\\begin{{{env}}}{{" result += self.tex_column_spec() result += "}\n" - if self.allow_break: + if self.allow_break and (self.number or self.caption): + # The caption rides in longtable's last foot so it tracks the + # table across page breaks. Without a caption there is no foot: + # an empty one is still a full row (\vspace*{8pt} plus an empty + # \small line), which put ~20pt of blank under every uncaptioned + # breaking table until 2026-09-08. result += self.make_caption() result += self.tex_rows() - result += "\\end{longtable}\n" + result += f"\\end{{{env}}}\n" if not self.allow_break: # Box the table at its measured width so the caption tracks it @@ -845,18 +894,27 @@ class Table(klammer_base.Klammer_base): result = measure + result result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}" - # The wrapper (add_caption/caption_wrapper) owns all vertical space - # around the table; longtable's own glue is zeroed. + # A boxed table's wrapper (add_caption/caption_wrapper) owns all + # vertical space around it, so longtable's own glue is zeroed. A + # page-breaking table has no wrapper and is set by longtable itself, + # so its glue is \parskip on both sides: it then sits in the text + # with the spacing of a paragraph (it was a fixed \vspace*{12pt} + # above and nothing below until 2026-09-08). + lt_glue = "\\parskip" if self.allow_break else "0pt" result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n" - "\\setlength{\\LTpre}{0pt}\n" - "\\setlength{\\LTpost}{0pt}\n" + f"\\setlength{{\\LTpre}}{{{lt_glue}}}\n" + f"\\setlength{{\\LTpost}}{{{lt_glue}}}\n" + self.tex_position() + # 2 \tabcolsep per column, minus the ones @{} removes at - # flush (lineless) edges. - f"\\setlength{{\\tablewidth}}{{\\textwidth - " + # flush (lineless) edges. \linewidth, not \textwidth: in + # a multicols column or a minipage the table must fit the + # column it is set in (the two are equal in plain text). + f"\\setlength{{\\tablewidth}}{{\\linewidth - " f"{2 * self.row_size - self.flush_l - self.flush_r}\\tabcolsep}}\n" - # Fill widths need \tablewidth and must precede the - # measuring \savebox, whose column spec reads them. + # Column widths precede the fill widths, which read + # them; both precede the measuring \savebox, whose + # column spec reads them. + + self.tex_column_widths() + self.tex_fill_widths() + result) result = re.sub(r"\newline", r"\\\\", result) @@ -907,16 +965,46 @@ class Table(klammer_base.Klammer_base): left = space // 2 return txt_util.NBSP * left + cell + txt_util.NBSP * (space - left) return cell + txt_util.NBSP * space + # A cell's type style, as make_cells() assigns it for html and tex: + # the column's :style code, overridden by :header_style in the + # header row. Plain text has one face, so only the styles a + # terminal can show are carried -- italic and bold, as the markers + # the ansi target resolves and the txt target strips (phases.py); + # the other codes (roman, sans, typewriter, code) style nothing. + STYLE = {"i": "italic", "b": "bold"} + def styled(cell, row_i, cell_i): + code = self.header_style if row_i == 0 and self.header else self.style[cell_i] + if cell and code in STYLE: + return (phases.txt_style_marker(STYLE[code]) + cell + + phases.txt_style_marker(STYLE[code], False)) + return cell widths = self.txt_column_widths() pos = [hpos(p) for p in self.cell_hpos] result = "" indent = txt_util.NBSP * int(self.Target_txt_indent) colsep = " " - for row in self.rows: + # Horizontal lines are the same rule as the other targets (:hline, + # defaulting to under the header row and at the bottom when the + # table has a header): a rule of hyphens the width of the table at + # each boundary that has a line. A line over part of the columns + # is drawn full width here -- a partial rule in a plain-text table + # reads as an accident. Vertical lines have no plain-text form. + # The hyphens are HIDDEN (KTESC markers): a klammer's result is + # re-read as Klammertext and the txt target's dash transforms turn + # "--" into "-", so a bare run of hyphens came out a fraction of + # its length. (The caption rule escapes this by being drawn in a + # phase, after the transforms.) + rule = (indent + klammer_base.hide_typographic( + "-" * (sum(widths) + len(colsep) * (len(widths) - 1))) + "\n") + for row_i, row in enumerate(self.rows): + if row_i in self.s_hline.by_index: + result += rule result += indent + colsep.join( - [pad(elt, width, align) for elt, width, align in - list(zip(row, widths, pos))]) + [pad(styled(elt, row_i, cell_i), width, align) + for cell_i, (elt, width, align) in enumerate(zip(row, widths, pos))]) result += "\n" + if len(self.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)) diff --git a/sks/target/phases.py b/sks/target/phases.py index 9c0a579..e87b085 100644 --- a/sks/target/phases.py +++ b/sks/target/phases.py @@ -1,7 +1,7 @@ import re -import textwrap import kutil +import klammer_base # Emitted by @vspace.txt (sks/block/block.k), one marker per line of space. # An @eval result consisting only of whitespace is trimmed away by the @@ -25,6 +25,75 @@ def txt_vspace_marker(): def txt_nbsp_marker(): return "__KTEXTNBSP__" +# The type-style markers, emitted by @b.txt, @i.txt, @u.txt and @ri.txt +# (sks/font/font.k) around their text. Plain text has no bold, but a +# terminal does: the txt target STRIPS these in its last phase +# (txt_strip_styles) and the ansi target, which :includes txt, RESOLVES +# them to SGR escape sequences in its last phase (ansi_resolve_styles). +# So the two targets differ in nothing but that phase, and stripping the +# escape sequences from an ansi render gives the txt render byte for byte +# (sks/tst/ansi_test.sh pins it). Every phase before the last measures +# them as ZERO width -- txt_visible_length below, which txt_wrap and the +# table layout use -- or a bold word would cost thirteen columns of line +# that the reader never sees. +# +# The OFF sequences are the specific resets (22 ends bold, 23 italic, 24 +# underline), never "\033[0m": @b @i x @ y @ must leave y bold, and a +# blanket reset would cancel the outer style with the inner one. +TXT_STYLES = { + "bold": ("\033[1m", "\033[22m"), + "italic": ("\033[3m", "\033[23m"), + "underline": ("\033[4m", "\033[24m"), +} + +def txt_style_marker(style, on=True): + if style not in TXT_STYLES: + raise ValueError(f"unknown type style {style!r}; one of {sorted(TXT_STYLES)}") + return f"__KTEXT{style.upper()}{'' if on else 'OFF'}__" + +_STYLE_MARKER_RGX = re.compile( + "|".join(re.escape(txt_style_marker(s, on)) + for s in TXT_STYLES for on in (True, False))) + +def txt_strip_styles(text, K): + return _STYLE_MARKER_RGX.sub("", text) + +def ansi_resolve_styles(text, K): + for style, (on, off) in TXT_STYLES.items(): + text = text.replace(txt_style_marker(style), on) + text = text.replace(txt_style_marker(style, False), off) + return text + +def txt_visible_length(s): + """How many columns s occupies once its markers resolve: a KTESC + marker is one glyph, an nbsp marker one space, a style marker nothing. + len() is the wrong measure for anything laid out (see txt_util).""" + s = _STYLE_MARKER_RGX.sub("", s) + return len(klammer_base.unescape_ktesc(s).replace(txt_nbsp_marker(), " ")) + +def txt_wrap(text, width, subsequent_indent=""): + """textwrap.wrap(text, width, break_long_words=False) measured by + VISIBLE width. textwrap counts characters, so a marker inside a word + -- a bold word carries a thirteen-character marker at each end -- made + it break lines a dozen columns early. Whitespace runs are kept as + written (sentence spacing survives), a word longer than the measure + stands on its own line, and no word is broken at a hyphen.""" + chunks = re.split(r"(\s+)", text.strip()) + lines, line, sep = [], "", "" + for i in range(0, len(chunks), 2): + word = chunks[i] + if word: + if line and (txt_visible_length(line) + len(sep) + + txt_visible_length(word) > width): + lines.append(line) + line = subsequent_indent + word + else: + line += sep + word + sep = " " * len(chunks[i + 1]) if i + 1 < len(chunks) else "" + if line: + lines.append(line) + return lines + # The tex line break, carried as a marker. # # @nl.tex used to emit "\\newline" directly, and that is destroyed the moment @@ -92,8 +161,8 @@ def txt_justify_blocks(text, K): nbsp = txt_nbsp_marker() if par and par[0] != ' ' and not par.startswith(nbsp): label = re.match("(?:" + re.escape(nbsp) + r")*\[\d+\] ", par) - par = "\n".join(textwrap.wrap( - par, width=int(K.Target_txt_width), break_long_words=False, + par = "\n".join(txt_wrap( + par, int(K.Target_txt_width), subsequent_indent=" " * len(label.group(0)) if label else "")) result += par + "\n\n" result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result) @@ -121,15 +190,12 @@ def txt_insert_captions(text, K): caption = f"{name} {caption_number} - {text}" else: caption = text - caption_width = len(caption) + indent - if len(caption) > indented_width: - caption = textwrap.wrap( + # Visible widths throughout: a caption may carry style markers. + if txt_visible_length(caption) > indented_width: + caption = "\n".join(txt_wrap( caption, indented_width, - #initial_indent ="~"*indent, - subsequent_indent=txt_nbsp_marker()*indent) - caption = "\n".join(caption) - caption_width = indented_width - line_length = max([len(e) for e in caption.split("\n")]) + 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 result = caption_rgx.sub(replace, text) diff --git a/sks/target/target.k b/sks/target/target.k index 946b1fa..16a44a4 100644 --- a/sks/target/target.k +++ b/sks/target/target.k @@ -65,7 +65,28 @@ of this: neither the transforms nor the quoting reach it. :after_apply phases.txt_format_footnotes ; phases.txt_insert_captions ; - phases.txt_justify_blocks + phases.txt_justify_blocks ; + phases.txt_strip_styles +@@@ + +# Plain text for a TERMINAL: the txt target's output with the type styles +# a terminal can show (@b, @i, @u) as SGR escape sequences. Named for its +# FORMAT, not its destination -- a file of it is what "less -R" shows, and +# "ktext -t txt -d | less" is a stream that must stay plain -- so the +# writer picks it with -t, as they pick pdf over tex; kdesc picks it for +# its own output when stdout is a terminal. It includes txt and defines no +# klammers: @b.txt and its siblings emit style markers, which the txt +# target's last phase strips and this target's last phase resolves. So +# the two renders are equal once the escape sequences are removed +# (sks/tst/ansi_test.sh), and only the last phase differs. Phases are +# not inherited through :includes (see pdf), hence the restated list. +@@@target ansi | Plain text with ANSI escape sequences for type styles + :includes txt + :after_apply + phases.txt_format_footnotes ; + phases.txt_insert_captions ; + phases.txt_justify_blocks ; + phases.ansi_resolve_styles @@@ @@@target html | HTML page diff --git a/sks/target/txt_util.py b/sks/target/txt_util.py index caed35f..92e49ed 100644 --- a/sks/target/txt_util.py +++ b/sks/target/txt_util.py @@ -44,7 +44,7 @@ def visible_length(s): escape sequences as visible width, and the reason escape resolution runs BEFORE the txt phases.) """ - return len(klammer_base.unescape_ktesc(s).replace(NBSP, " ")) + return phases.txt_visible_length(s) def indent_text(text, indent): """Every line of text prefixed with `indent` columns of NBSP.""" diff --git a/tst/kdesc_test.sh b/tst/kdesc_test.sh index 304833b..9615e77 100755 --- a/tst/kdesc_test.sh +++ b/tst/kdesc_test.sh @@ -169,7 +169,7 @@ shows "34. without -i the klammerset is shown" '@table' "$KDESC" -k DERIVE=$(mktemp /tmp/kdesc_derive.XXXXXX.k) printf '@@wave.k s : Wave @@\n@@wave :: @i *s* @ @@\n' > "$DERIVE" shows "35. coverage is derived through the unlisted klammerset" \ - '@wave +html pdf tex txt +from @i' "$KDESC" -i "$DERIVE" --coverage + '@wave +ansi html pdf tex txt +from @i' "$KDESC" -i "$DERIVE" --coverage absent "36. ... and the klammerset is not reported" '@table' "$KDESC" -i "$DERIVE" --coverage # A klammer the input REDEFINES came from the klammerset, but the input # changed it -- which is exactly what a reader needs to know.