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.
This commit is contained in:
ack committed 2026-09-12 18:11:48 +02:00
1 parent 760fda5470
commit 9b0d0d7897
34 files changed
+895 -394

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ are regenerated on each release — patches cannot be merged directly.
Report problems (or send patches) to the author; accepted changes are
applied to the development tree and appear in a following snapshot.
This snapshot was assembled from development commit `ed13dfaa5b7a`.
This snapshot was assembled from development commit `fcec5e2e77ac`.
## License
+37 -6
View File
@@ -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);
+1 -1
View File
@@ -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));
+333 -96
View File
@@ -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 <name>` 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 '<text>'` 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 '<text>'` shows the katoms and
their types; `kdiag --args '<text>'` shows how text splits into arguments;
`kdiag --rewrite '<text>'` 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 <name>` shows a literal parameter as `text.literal`.
`kdesc -k <name>` 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 <name>` — this list is names and intent only.
Confirm parameters with `kdesc -k <name>` — 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 <cells> <pos>; …` 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 `<row>(<col>) = <op> <operands>`, 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 <cells> 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 `<name>/` with `index.html`, `css/`, `fonts/`, `js/` | self-contained, fonts localized; `book` structure adds navigation, search |
| `tex` | `<name>.tex` | XeLaTeX source; fonts via fontspec from the font store |
| `pdf` | `<name>.pdf` | `tex` plus a xelatex run (TeX Live required) |
| `txt` | `<name>.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 <dir>`
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 :: <span>*arg1*</span> @@
@@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
<klammer>` to check the parameter list against what was written. Then `kdiag
--type '<fragment>'` 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 <klammer>`
against what was written; then `kdiag --type '<fragment>'` 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.
+1
View File
@@ -0,0 +1 @@
*.d
+1 -1
View File
@@ -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;
});
}
+1 -172
View File
@@ -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();
}
+3 -9
View File
@@ -59,7 +59,7 @@ inline
std::map<std::string, std::pair<std::string, std::string>> 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();
+1 -1
View File
@@ -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
+9
View File
@@ -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);
+12 -12
View File
@@ -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
+9 -2
View File
@@ -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<Katom>& katoms, std::string source_filename,
+20 -4
View File
@@ -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 @ @@
+62
View File
@@ -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();
}
+1 -1
View File
@@ -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@ @@
+8
View File
@@ -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
+9 -3
View File
@@ -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}}
+14 -2
View File
@@ -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);
+6 -1
View File
@@ -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);
+7 -4
View File
@@ -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);
+8 -3
View File
@@ -49,12 +49,16 @@ unchanged.
@@b.k text : Use a bold weight of the current font for *text* @@
@@b.html :: <b>*text*</b> @@
@@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 :: <i>*text*</i> @@
@@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 :: <span class="tt">*text*</span> @@
@@ -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 :: <span class="ritalic">*text*</span> @@
@@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*} @@
+19 -10
View File
@@ -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'<span style="{klammer_base.hide_typographic(style)}">'
# 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'<span style="{hide("font-family: " + chr(39))}'
+ typeface_marker(self.name, self.scale) + hide(style) + '">'
+ f'{self.text}</span>')
def tex(self):
+9
View File
@@ -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 @@
+2 -1
View File
@@ -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:
+69 -2
View File
@@ -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:
+2 -1
View File
@@ -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
+14 -5
View File
@@ -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
+1 -1
View File
@@ -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):
+5
View File
@@ -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<X> the column spec reads.
\newlength{\klfitwidth}
+129 -41
View File
@@ -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<X> 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))
+77 -11
View File
@@ -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)
+22 -1
View File
@@ -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
+1 -1
View File
@@ -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."""
+1 -1
View File
@@ -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.