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
+894 -393

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 Report problems (or send patches) to the author; accepted changes are
applied to the development tree and appear in a following snapshot. 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 ## 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[]) int main(int argc, char* argv[])
{ {
try { try {
@@ -189,13 +219,9 @@ int main(int argc, char* argv[])
args.describe(); args.describe();
} }
if (p("c")) {
show_special_characters();
std::cout << "\n";
}
if (p("u")) { if (p("u")) {
unicode_examples(); show_unicode_examples();
} }
if (p("argtypes")) { if (p("argtypes")) {
@@ -292,12 +318,17 @@ int main(int argc, char* argv[])
} }
} }
strings_t own_klammers_only {}; strings_t own_klammers_only {};
if (!input_filenames.empty()) { if (!input_filenames.empty()) {
own_klammers_only = context_files; own_klammers_only = context_files;
} }
if (p("c")) {
show_special_characters();
std::cout << "\n";
}
if (p("t")) { if (p("t")) {
std::cout << boldblack << "Targets\n" << black std::cout << boldblack << "Targets\n" << black
<< M.m_targets.describe(2, true, own_klammers_only); << 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"))); load_klammersets(M, word_split(args.as_string("klammersets")));
if (!input_text.empty()) { if (!input_text.empty()) {
M.read(input_text + "\n"); M.read_string(input_text + "\n");
} }
for (auto p : input_filenames) { for (auto p : input_filenames) {
M.read(fs::path(p)); 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 has not seen this language will pattern-match it to LaTeX, Markdown, or Lisp and
produce confidently wrong syntax. produce confidently wrong syntax.
The klammers named here are the **Standard Klammer Set**, which is what loads by The klammers named here are the **Standard Klammer Set** (SKS), which is what
default. A klammer set is user-extensible, so the authority for what exists in loads by default. A klammer set is user-extensible, so the authority for what
any particular setup is `kdesc`, not this file — see §1. 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 ## 1. Do not guess — the machine will tell you
Klammertext is introspectable. `kdesc` **prints the live state of the machine's Klammertext is introspectable. `kdesc` **prints the live state of the machine's
data structures in digested form**: what klammers exist, what arguments each data structures in digested form**. Query it instead of inferring from examples.
takes, what the argument types accept, what targets exist. Query it instead of
inferring from examples.
| ask | command | | ask | command |
|---|---| |---|---|
@@ -29,12 +34,16 @@ inferring from examples.
| argument types and what each accepts | `kdesc --argtypes` | | argument types and what each accepts | `kdesc --argtypes` |
| shared option groups and who uses them | `kdesc --optionsets` | | shared option groups and who uses them | `kdesc --optionsets` |
| which targets each klammer can render to | `kdesc --coverage` | | which targets each klammer can render to | `kdesc --coverage` |
| what a user's own file defines | `kdesc -i FILE -k` | | what a user's own file defines (against the SKS) | `kdesc -i FILE -k` |
| the usage of any command | run it with no arguments | | 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 `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 replaces a guess. The parameter list it prints is authoritative: a parameter
not. shows as `name.type default`, an option as `:name.type default`.
**Check a document without rendering it:** **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 reports every klammer application whose name, argument count, option names, or
target coverage is wrong — including inside branches this render will not take. target coverage is wrong — including inside `@cond` branches this render will
It applies nothing, so it is safe and fast. Use it before claiming a document is not take and inside klammer bodies never applied. It applies nothing. Use it
correct. before claiming a document is correct.
**Inspect how text is being parsed:** `kdiag --type '<text>'` shows the katoms **Inspect how text is parsed:** `kdiag --type '<text>'` shows the katoms and
("katoms") and their types. This settles arguments about what a piece of syntax their types; `kdiag --args '<text>'` shows how text splits into arguments;
actually means. `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 | | 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**. | | `\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 @`. | | 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. | | 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. | | 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. | | 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 **Closing a klammer.** The close is `@` for the application tier, `@@` for
definitions, `@@@` for system commands. A close may be *named* by putting the 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 > **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 > `name@` when the body **contains other klammer calls**: if delimiters are
> diagnostic, not stylistic: if delimiters are unbalanced, a named close lets the > unbalanced, a named close lets the machine say which klammer was left open.
> machine say exactly which klammer was left open. With bare closes everywhere it > Do not name closes on short, klammer-free bodies — it is noise.
> can only report that the count did not work out. 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 @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. - `|` separates positional arguments.
- `:name value` is an optional argument; it may appear anywhere in the list. - `: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 - `:name` written alone takes the argument type's "alone" value — for a boolean
that is `true`, so `:number` means `:number true`. that is `true`, so `:number` means `:number true`; `@table :hline` means
- An optional argument that is not written takes its default. `: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 **Abbreviated form.** `@name-arg1-arg2` is `@name arg1 | arg2 @`, valid only
while every argument is letters and digits: `@i-italic`, `@sup-2-3`, `@date@`. while every argument is letters and digits: `@i-italic`, `@sup-2-3`, `@date@`.
**Literal klammers** take their content uninterpreted. It is not examined for **Literal klammers** take their content uninterpreted. It is not examined for
`@`, `|`, `#`, or `^`, so the machine cannot find the end by counting and **the `@`, `|`, `#`, `^` or `*`, so the machine cannot find the end by counting and
named close is required**: **the named close is required**:
``` ```
@code @code
int x = a[0]; // # and @ are safe in here int x = a[0]; // # and @ are safe in here
code@ code@
```
```
@c a | b c@ ← inline: same rule, and the close must be "c@" @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 `@c` and `@code` are the in-line and block forms of the same thing. Code that
literal: **nothing inside either is interpreted**, so `#`, `@`, `|`, `^` and `*` lives in a **file** is `@source_listing`, never `@code` (which has no filename
all pass through as written and none of them needs quoting. argument):
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:
``` ```
@source_listing src/derivations.cpp @ ← the whole file @source_listing src/derivations.cpp @ ← the whole file
@source_listing src/derivations.cpp :marker fromRdd55 @ ← one region of it @source_listing src/derivations.cpp :marker fromRdd55 @ ← one region of it
``` ```
`:marker P` lists what lies between two lines that are exactly `//P`, each `:marker P` lists what lies between two lines that are exactly `//P` in column
beginning in the first column. The source file thus declares its own extractable one. A marker missing or not appearing exactly twice is an **error**, never a
regions and the document asks for one by name, so editing the code cannot fallback. `kdesc -k <name>` shows a literal parameter as `text.literal`.
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.
`kdesc -k <name>` is how you tell: a parameter shown as `text.literal` is a **Whitespace and removal (`#`).** `#` rest of line, `##` rest of file, `#[ … ]#`
literal parameter. Do not infer it from a klammer's purpose — check. 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 :subtitle Predictability and provenance
:author Joseph Goldstone :author Joseph Goldstone
:date @date@ :date @date@
:structure article
:point_size 12 :point_size 12
:text :text
@s1 Principles s1@ @s1 Principles s1@
Ordinary paragraph text. A blank line starts a new paragraph. 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 @ul
first item first item
@@ -176,86 +204,295 @@ ul@
@image figure_name :width .95w :caption A caption @ @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 @document :title The Book :structure book :files preface one two @
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
``` ```
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 ## 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` | | 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` | | structure | `@s1`…`@s7` numbered headings, `@h1`…`@h7` unnumbered, `@part`, `@preface`, `@par`, `@newpage`, `@vspace lines`, `@vfill`, `@nl` line break |
| blocks | `@code` (a listing written inline), `@source_listing` (a listing read from a file), `@quote`, `@note`, `@indent`, `@block`, `@center`, `@right`, `@columns` | | 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` unordered, `@ol` ordered, `@define` term/definition — items separated by `\|` | | lists | `@ul :bullet`, `@ol :initial`, `@define :style` term/definition, `@qa question \| answer @` — items separated by `\|` |
| tables | `@table` — cells by `\|`, rows by `\|\|`; `@tbl`, `@rowcolor` | | tables | `@table` — cells by `\|`, rows by `\|\|` (§6); `@rowcolor` |
| figures | `@image`, `@image_grid`, `@fig`, `@reference` | | figures & references | `@image basename :width :caption`, `@image_grid`, `@fig id`, `@tbl id`, `@reference spec \| name @`, `@target id :text` |
| other | `@link`, `@date`, `@datetime`, `@color`, `@font`, `@newpage`, `@vspace`, `@email` | | 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. 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.html :: <span>*arg1*</span> @@
@@name.tex :: \textbf{*arg1*} @@ @@name.tex :: \textbf{*arg1*} @@
@@name.txt :: *arg1* @@
``` ```
- `.k` declares the interface and documents it; per-target definitions inherit - `.k` declares the interface and documents it; per-target definitions inherit
the parameter list with `::`. the parameter list with `::`. Every target's list must agree.
- `*arg*` substitutes an argument into the body. - `*arg*` substitutes an argument into the body; `*Name*` also reads a state or
- A definition with no target suffix applies to every target. 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 - 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`, - **`@cond` is strict about truth.** True: `true`, `True`, `1`. False: `false`,
`False`, `0`, and empty. **Anything else is an error**, not "probably false". `False`, `0`, empty. **Anything else is an error**, not "probably false".
- **`@cond` evaluates only the branch it selects.** The branch not taken is not - **`@cond` is resolved at application time and evaluates only the branch it
read, not evaluated, not expanded — a `@read` of a missing file in the unused selects.** A `@read`/`@eval` in the other branch does not run. A top-level
branch is harmless. `@cond *Flag* | … @` sees the variable's value.
- **A state variable reaches a `@cond` predicate.** The document behaves like a - **A state variable is visible from the moment its declaration is read** —
function body whose arguments are its `@@@state` variables. in every klammer body applied afterwards and in the top-level text of later
- **`@eval :shell` and `:haskell`:** the command's standard output becomes input units, but not in the top-level text of the same unit that declares it.
document text; its standard error does not (it appears under `-v 1`); a nonzero Declare early (`-s`, a first file, a klammerset).
exit is an error. Append `|| true` if a nonzero exit is expected. - **A definition inside a `@cond` branch does not register** — in either
- **A definition inside a `@cond` branch does not register** — in either branch. branch. Define outside and select between *applications*.
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 - Three cases only: **logging** (stderr, `-v` > 0), **an error** (stderr, then
read off their own input: which klammerset was loaded and from where, which exit ≠ 0), **the requested output** (stdout). So `ktext doc.kt -t txt -d | …`
file a font or a `:files` name resolved to, how the output path was built. is safe; nothing else lands in the document.
Higher levels are an implementation trace. - **`-v 1`** reports every decision the user could not read off their input:
- Logging and errors go to **standard error**; the rendered document goes to which klammerset loaded and from where, which file a typeface or `:files`
standard output, so `ktext doc.kt -d | …` is safe. name resolved to, how the output path was built, an override of another
- An error stops the run and names a file, line, and character. There is no set's definition. `-v 2`+ is an implementation trace.
warn-and-continue except in two documented cases. - An error stops the run and names a file, line and character; messages are
- A bare `ktext`, `kdesc`, or `kdiag` prints its usage and exits successfully. 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 **When something is wrong:** `kdiag --process --check doc.kt` first (name,
finds name, arity, option and coverage faults without rendering. Then `kdesc -k arity, option and coverage faults without rendering); then `kdesc -k <klammer>`
<klammer>` to check the parameter list against what was written. Then `kdiag against what was written; then `kdiag --type '<fragment>'` if the question is
--type '<fragment>'` if the question is how something is being parsed. 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); std::string text = PyUnicode_AsUTF8(arg);
return guarded([&]() -> PyObject* { return guarded([&]() -> PyObject* {
set_string_source(); set_string_source();
machine_of(obj).read(text + "\n"); machine_of(obj).read_string(text + "\n");
Py_RETURN_NONE; Py_RETURN_NONE;
}); });
} }
+1 -172
View File
@@ -172,7 +172,7 @@ std::string process_pinyin(std::string s)
return result; 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); (void)K::log(4);
std::string result {s}; std::string result {s};
@@ -267,174 +267,3 @@ std::string encode(const std::string& s, const Locator& loc)
return result; 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 { std::map<std::string, std::pair<std::string, std::string>> diacritics {
{"`", {"0300", "grave accent"}}, {"`", {"0300", "grave accent"}},
{"'", {"0301", "acute accent"}}, {"'", {"0301", "acute accent"}},
{"h", {"0302", "circumflex"}}, {"h", {"0302", "circumflex (hat)"}},
{"~", {"0303", "tilde"}}, {"~", {"0303", "tilde"}},
{"-", {"0304", "macron"}}, {"-", {"0304", "macron"}},
{"\"", {"0308", "diaresis"}}, {"\"", {"0308", "diaresis"}},
@@ -138,6 +138,8 @@ const std::string italic_on() { return is_tty() ? "\033[3m" : ""; }
inline inline
const std::string italic_off() { return is_tty() ? "\033[0m" : ""; } 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); std::string encode(const std::string& s, const Locator& loc);
// UTF-8 encoding of one code point (to U+10FFFF; surrogates and values // 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). // "Invalid Unicode" text is no longer reachable from input).
bool valid_codepoint(int cp); bool valid_codepoint(int cp);
std::string invalid_codepoint_reason(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; Machine M = m_machine;
M.m_warn_unparsed = false; // Result text is machine-generated (see machine.h) M.m_warn_unparsed = false; // Result text is machine-generated (see machine.h)
size_t before = M.m_katoms.size(); 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 // If the @eval produced more Klammertext -- the read-back result still
// holds klammers to reduce (a "generator", e.g. a Python klammer returning // 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 // 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 // Parse the @font-face blocks in the CSS to determine variant → filename mapping
std::string css = string_from_file(css_path); 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( std::regex face_rgx(
R"(@font-face\s*\{[^}]*font-style:\s*(\w+);[^}]*font-weight:\s*(\w+);[^}]*url\('([^']+\.ttf)'\)[^}]*\})", R"(@font-face\s*\{[^}]*font-style:\s*(\w+);[^}]*font-weight:\s*(\w+);[^}]*url\('([^']+\.ttf)'\)[^}]*\})",
std::regex::multiline); std::regex::multiline);
+11 -11
View File
@@ -482,24 +482,24 @@ void Machine::read(const fs::path& pathname)
// :cpp libraries live next to the file that uses them). // :cpp libraries live next to the file that uses them).
m_state.add_search_dir(fs::absolute(pathname).parent_path().string()); m_state.add_search_dir(fs::absolute(pathname).parent_path().string());
m_state.open_frame("Machine state: " + pathname.string()); m_state.open_frame("Machine state: " + pathname.string());
std::string text = m_state.subst(trim_right(string_from_file(pathname))); input(string_from_file(pathname), pathname.string());
katom_list katoms = process(text, pathname);
m_katoms.insert(m_katoms.end(), katoms.begin(), katoms.end());
extract_machine_definitions();
extract_klammer_definitions();
m_sources.push_back(pathname); 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); (void)K::log(3, text);
m_state.open_frame("Machine state: " + s); m_state.open_frame("Machine state: " + text);
std::string text = m_state.subst(trim_right(s)); input(text, command_pathname);
katom_list katoms = process(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()); m_katoms.insert(m_katoms.end(), katoms.begin(), katoms.end());
extract_machine_definitions(); extract_machine_definitions();
extract_klammer_definitions(); extract_klammer_definitions();
m_sources.push_back(s);
} }
// Read // Read
+9 -2
View File
@@ -69,8 +69,15 @@ public:
bool nonascii=true, bool literal=true, bool ignore=true, bool whitespace=true, bool nonascii=true, bool literal=true, bool ignore=true, bool whitespace=true,
bool klammers=true, bool eval=true, bool cond=true, bool read=true); bool klammers=true, bool eval=true, bool cond=true, bool read=true);
void read(const fs::path& pathname); // Two ways to give the Machine its input. They are named, not
void read(const std::string& s); // 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( void expand_read_katoms(
std::vector<Katom>& katoms, std::string source_filename, std::vector<Katom>& katoms, std::string source_filename,
+20 -4
View File
@@ -7,10 +7,26 @@
:requires sks :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(); 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' @@@ @@@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 @@ : Absolute positioning of text block @@
@@block.tex :: @eval block.Block(K) eval@ @@ @@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 # NOT latex_util.block(): a textblock is absolutely positioned and
# does not participate in the normal flow, so breaking the paragraph # does not participate in the normal flow, so breaking the paragraph
# around it would move the surrounding text. # 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()] to_x, to_y = [float(e) for e in self.to.split()]
pt_x, pt_y = [float(e) for e in self.point.split()] pt_x, pt_y = [float(e) for e in self.point.split()]
result = f""" result = f"""
\\begin{{textblock}}{{{self.width}}}[{pt_x},{pt_y}]({to_x},{to_y}) \\begin{{textblock}}{{{self.width}}}[{pt_x},{pt_y}]({to_x},{to_y})
\\setlength{{\\textwidth}}{{\\linewidth}}
\\vspace*{{-1\\parskip}} \\vspace*{{-1\\parskip}}
{self.content.strip()} {self.content.strip()}
\\par\\kern0pt
\\end{{textblock}} \\end{{textblock}}
""" """
return result return result
+9 -3
View File
@@ -5,11 +5,17 @@
%\usepackage[absolute,showboxes]{textpos} %\usepackage[absolute,showboxes]{textpos}
\usepackage[absolute]{textpos} \usepackage[absolute]{textpos}
\setlength{\TPHorizModule}{\paperheight} \setlength{\TPHorizModule}{\paperwidth}
\setlength{\TPVertModule}{\paperwidth} \setlength{\TPVertModule}{\paperheight}
%\setlength{\TPVertModule}{1\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} \newlength{\fwboxwidth}
\newcommand{\fwboxn}[1]{\setlength{\fwboxwidth}{#1}} \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 // located error naming the available typefaces if the store
// does not have it, which is the whole error path for a // does not have it, which is the whole error path for a
// misspelled or uninstalled typeface. // 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; bool seen = false;
for (const auto& rf : m_resolved_extra) 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) { if (!seen) {
// What a @typeface klammer asked for, and what the store // What a @typeface klammer asked for, and what the store
// resolved it to: a decision the user cannot read off // 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 {}; std::string result {};
size_t pos = 0, start = 0, end = 0; size_t pos = 0, start = 0, end = 0;
std::string name {}, scale {}; std::string name {}, scale {};
while (next_typeface_marker(text, pos, start, end, name, scale)) { while (next_typeface_marker(text, pos, start, end, name, scale)) {
result += text.substr(pos, start - pos); result += text.substr(pos, start - pos);
result += resolved_typeface(name).family_name;
pos = end; pos = end;
} }
result += text.substr(pos); result += text.substr(pos);
+6 -1
View File
@@ -144,7 +144,12 @@ public:
void collect_typefaces(const std::string& text); void collect_typefaces(const std::string& text);
// html: the marker carries no inline switch (the span holds the family // html: the marker carries no inline switch (the span holds the family
// name), so each is replaced by nothing. // 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 // tex: each distinct (typeface, scale) gets a \newfontfamily command in
// the preamble; the marker is replaced by that command. // the preamble; the marker is replaced by that command.
std::string resolve_typeface_markers(const std::string& text); 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 // var() with such a value computes to inherit, silently losing
// the font. // the font.
ss << ":root {\n"; 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()) 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()) 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()) if (!m_mono_typeface.empty())
ss << " --monospace: \"" << m_mono_typeface << "\", monospace;\n"; ss << " --monospace: \"" << m_resolved_mono.family_name << "\", monospace;\n";
ss << "}\n"; ss << "}\n";
} }
// Font-size normalization across families is no longer emitted from // 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); install_resolved_font(rf, output_directory);
} }
for (auto& [basename, text] : m_file_components) { for (auto& [basename, text] : m_file_components) {
text = strip_typeface_markers(text); text = resolve_typeface_families(text);
} }
auto headings = insert_section_numbers(); auto headings = insert_section_numbers();
insert_section_numbers("___NUM2___", false); 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.k text : Use a bold weight of the current font for *text* @@
@@b.html :: <b>*text*</b> @@ @@b.html :: <b>*text*</b> @@
@@b.tex :: \textbf{*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.k text : Use an italic style of the current font for *text* @@
@@i.html :: <i>*text*</i> @@ @@i.html :: <i>*text*</i> @@
@@i.tex :: \emph{*text*} @@ # \begin{emph}{*text*}\end{emph} @@ @@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.k text : Use the currently defined monospace font for *text* @@
@@t.html :: <span class="tt">*text*</span> @@ @@t.html :: <span class="tt">*text*</span> @@
@@ -68,11 +72,12 @@ unchanged.
@@u.k text : Underline *text* in the current font @@ @@u.k text : Underline *text* in the current font @@
@@u.tex :: \underline{\botstrut{2pt}*text*} @@ @@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.k text : Use a roman italic font for *text* (to override monospace environment) @@
@@ri.html :: <span class="ritalic">*text*</span> @@ @@ri.html :: <span class="ritalic">*text*</span> @@
@@ri.tex :: \textrm{\textit{*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.k scale.float | text : Resize *text* by *scale* @@
# @@ts.tex :: {\scalefont{*scale*}\em *text*} @@ # @@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 # this marker where the typeface is needed, and @document collects the
# markers, resolves and localizes each typeface, declares it, and replaces # markers, resolves and localizes each typeface, declares it, and replaces
# the marker with whatever inline switch the target needs (a font-switch # 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 # command for tex; for html the marker sits IN the span's font-family value
# span's own style). Same shape as the __CAPTION__ markers. # 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 # 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 # 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. # hieroglyphs, cuneiform -- and would resize them unpredictably.
# The family name is quoted -- an unquoted digit-initial word # The family name is quoted -- an unquoted digit-initial word
# ("Source Sans 3") is invalid CSS -- with SINGLE quotes, because # ("Source Sans 3") is invalid CSS -- with SINGLE quotes, because
# this sits inside a double-quoted html attribute. # this sits inside a double-quoted html attribute. What is quoted
style = (f"font-family: '{self.name}', serif; " # here is the MARKER: @document replaces it with the resolved
f"font-size-adjust: none") # family name (see the marker note above).
style = "', serif; font-size-adjust: none"
if float(self.scale) != 1.0: if float(self.scale) != 1.0:
style += f"; font-size: {float(self.scale)}em" style += f"; font-size: {float(self.scale)}em"
# The STYLE is markup, so its typographically active characters are # The STYLE is markup, so its typographically active characters are
# hidden: the html target's transforms turn a single quote into a # hidden: the html target's transforms turn a single quote into a
# curly one, which broke the css font-family value. The TEXT is # curly one, which broke the css font-family value. The MARKER is
# writer content and is deliberately left alone -- its quotes and # not hidden: a hyphen in the typeface name would become a KTESC
# dashes should be transformed. # sequence, which on the :files path is still there when @document
return (typeface_marker(self.name, self.scale) # looks for the marker. The TEXT is writer content and is
+ f'<span style="{klammer_base.hide_typographic(style)}">' # 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>') + f'{self.text}</span>')
def tex(self): def tex(self):
+9
View File
@@ -12,6 +12,15 @@
@@@state Image_default_width :desc Default width for images :value .667w @@@ @@@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 @@@ # @@@argtype pixels | a pixel count :pattern \d+px @@@
# @@image.k basename : Image read from a file @@ # @@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): if not os.path.isdir(cache_dir):
import tempfile import tempfile
cache_dir = tempfile.gettempdir() 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.basename = klammer_base.unescape_ktesc(self.basename)
self.source, self.pwidth, self.pheight, self.file_error = self.cache.get(self.K_target, self.basename) self.source, self.pwidth, self.pheight, self.file_error = self.cache.get(self.K_target, self.basename)
if self.file_error: if self.file_error:
+69 -2
View File
@@ -40,14 +40,49 @@ def image_dimensions(filename):
return width, height, resize 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: class Image_cache:
max_width = 1600 # Should be a state variable max_width = 1600 # Should be a state variable
cache_basedir = "_klammertext_imgsrc" # Should be a state variable cache_basedir = "_klammertext_imgsrc" # Should be a state variable
error_marker_filename = "_error_marker.png" error_marker_filename = "_error_marker.png"
verbose_label = " [klammertext]" # Should parameterize verbosity 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.cache_dir = kutil.cache_directory(
self.dir = kutil.cache_directory(cache_dir, "_image_cache") 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.search = search_path.split(" ")
self.verbose = verbose or os.environ.get("KLAMMERTEXT_IMAGE_CACHE_DISPLAY") self.verbose = verbose or os.environ.get("KLAMMERTEXT_IMAGE_CACHE_DISPLAY")
self.required_format = { "html" : ["jpg", "jpeg", "JPEG", "png", "gif", "webp"], self.required_format = { "html" : ["jpg", "jpeg", "JPEG", "png", "gif", "webp"],
@@ -75,6 +110,18 @@ class Image_cache:
return f"{self.dir}/{filename}.{cache_ext}" 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): def find_file(self, target, basename):
result = None result = None
candidates = [] candidates = []
@@ -131,6 +178,8 @@ class Image_cache:
if src[:2] == "..": if src[:2] == "..":
src = source_filename src = source_filename
dest = os.path.splitext(cached_filename)[1] 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}]" dim = "" if width == new_width else f" [{new_width}x{new_height}]"
desc = "(copied)" if copied else f"-> {dest}{dim}" desc = "(copied)" if copied else f"-> {dest}{dim}"
#print(f"{Image_cache.verbose_label} To cache: {src} [{width}x{height}] {desc}") #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)}" cached_filename = f"{os.path.dirname(cached_filename)}/{os.path.basename(source_filename)}"
copied = False 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_width = Image_cache.max_width
new_height = int(round(new_width * float(height) / float(width))) new_height = int(round(new_width * float(height) / float(width)))
if source_filename.endswith(".pdf"): if source_filename.endswith(".pdf"):
@@ -190,6 +252,11 @@ class Image_cache:
def get(self, target, basename): def get(self, target, basename):
cached_filename = self.find_cached_file(target, basename) cached_filename = self.find_cached_file(target, basename)
source_filename = self.find_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): if cache_update_required(source_filename, cached_filename):
cached_filename, width, height = self.update_cache(target, basename, source_filename) cached_filename, width, height = self.update_cache(target, basename, source_filename)
else: else:
+2 -1
View File
@@ -65,7 +65,8 @@ class Image_grid(klammer_base.Klammer_base):
self.cache = image_cache.Image_cache( self.cache = image_cache.Image_cache(
self.K_input_dir, self.K_input_dir,
self.Image_search_path, self.Image_search_path,
verbose=False) verbose=False,
quality=self.Image_quality)
self.margin = 0.01 self.margin = 0.01
+14 -5
View File
@@ -67,16 +67,25 @@ class Klammer_base:
print(label) print(label)
pprint.pprint(self.__dict__) 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): def __str__(self):
result = None result = None
if self.K_target == 'html': method = Klammer_base.TARGET_METHOD.get(self.K_target)
if method == 'html':
result = self.html() result = self.html()
elif self.K_target in {'tex', 'pdf'}: elif method == 'tex':
result = kutil.escape(self.tex()) result = kutil.escape(self.tex())
elif self.K_target == 'txt': elif method == 'txt':
result = self.txt() result = self.txt()
if result is None: if result is None:
#print(self.__dict__)
raise Exception( 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 return result
+1 -1
View File
@@ -70,7 +70,7 @@ class Link(klammer_base.Klammer_base):
def txt(self): def txt(self):
text = f'{self.text} ' if self.text else '' text = f'{self.text} ' if self.text else ''
return f'{text}[{self.url}]' return f'{text}[{self.target}]'
class Target(klammer_base.Klammer_base): class Target(klammer_base.Klammer_base):
def __init__(self, K): def __init__(self, K):
+5
View File
@@ -36,3 +36,8 @@
\newlength{\klfillb} \newlength{\klfillb}
\newlength{\klfillc} \newlength{\klfillc}
\newlength{\klfilld} \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.rowspan_covered.add((row_i, col_i))
self.covered = self.colspan_covered | self.rowspan_covered self.covered = self.colspan_covered | self.rowspan_covered
def column_width_text(self): def column_width_candidates(self):
# For each column, the text used to measure a 'fit' width in the # For each column, the texts whose widths a 'fit' or 'fill' column's
# tex target. The longest cell's font is applied, so a bold or # tex width is the maximum of: every cell of the column, each line
# italic cell is measured in the font it will be set in. # of a multi-line cell separately, set in its own cell's font. The
self.column_widths = [] # 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])): for col_i in range(len(self.cells[0])):
longest = "" candidates = []
longest_style = "r"
for row_i in range(len(self.cells)): for row_i in range(len(self.cells)):
cell = self.cells[row_i][col_i] cell = self.cells[row_i][col_i]
# A colspan anchor's text spans several columns and must # A colspan anchor's text spans several columns and must
# not set the width of its own column. # not set the width of its own column.
if cell is not None and cell.colspan <= 1: if cell is None or cell.colspan > 1:
lines = [e.strip() for e in cell.text.split("\\newline")] continue
lines = sorted(lines, key=len) for line in cell.text.split("\\newline"):
longest_in_line = lines[-1] line = line.strip()
if len(longest_in_line) > len(longest): if not line:
longest = longest_in_line continue
longest_style = cell.font if cell.font != "r":
if longest_style != "r": line = font.tex_fontify(line, cell.font, 1.0)
longest = font.tex_fontify(longest, longest_style, 1.0) if line not in candidates:
self.column_widths.append(longest) candidates.append(line)
self.column_candidates.append(candidates)
# ---- :hpos ------------------------------------------------------------- # ---- :hpos -------------------------------------------------------------
# #
@@ -530,7 +535,7 @@ class Table(klammer_base.Klammer_base):
cell_i + max(cspan, 1) == self.row_size))) cell_i + max(cspan, 1) == self.row_size)))
cells.append(row_cells) cells.append(row_cells)
self.cells = cells self.cells = cells
self.column_width_text() self.column_width_candidates()
# HTML # HTML
@@ -626,6 +631,41 @@ class Table(klammer_base.Klammer_base):
# in table.sty; one per fill column, in column order. # in table.sty; one per fill column, in column order.
fill_registers = ["\\klfilla", "\\klfillb", "\\klfillc", "\\klfilld"] 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): def tex_fill_widths(self):
# Set each 'fill' column's register to min(its share of the # Set each 'fill' column's register to min(its share of the
# remaining width, its widest entry) -- the same rule the html auto # 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: if not self.fill_columns:
return "" return ""
widths = extend(self.column_width, self.row_size) 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")] for i, w in enumerate(widths) if w in ("fit", "f")]
remaining = "\\tablewidth" + "".join(f" - {e}" for e in fit) remaining = "\\tablewidth" + "".join(f" - {e}" for e in fit)
widest = {i: f"\\widthof{{{self.column_widths[i]}}}" widest = {i: Table.width_macro(i) for i in self.fill_columns}
for i in self.fill_columns}
total = " + ".join(widest[i] for i in self.fill_columns) total = " + ".join(widest[i] for i in self.fill_columns)
result = "" result = ""
for k, i in enumerate(self.fill_columns): for k, i in enumerate(self.fill_columns):
@@ -658,7 +697,7 @@ class Table(klammer_base.Klammer_base):
def tex_hpos(self): def tex_hpos(self):
# One column specification per column: the width comes from # 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 # fraction of \tablewidth, or '*' for the remaining width), the
# justification from :cell_hpos. # justification from :cell_hpos.
def par_format(s, justification): def par_format(s, justification):
@@ -671,7 +710,7 @@ class Table(klammer_base.Klammer_base):
fill_ordinal = 0 fill_ordinal = 0
for i, w in enumerate(extend(self.column_width, self.row_size)): for i, w in enumerate(extend(self.column_width, self.row_size)):
if w in ("fit", "f"): if w in ("fit", "f"):
widths.append(f"\\widthof{{{self.column_widths[i]}}}") widths.append(Table.width_macro(i))
elif w == "fill": elif w == "fill":
widths.append(Table.fill_registers[fill_ordinal]) widths.append(Table.fill_registers[fill_ordinal])
fill_ordinal += 1 fill_ordinal += 1
@@ -804,9 +843,9 @@ class Table(klammer_base.Klammer_base):
# marker and prints the console warning with the :column_width # marker and prints the console warning with the :column_width
# primer -- the widths are only known at LaTeX run time, and prose # primer -- the widths are only known at LaTeX run time, and prose
# kept out of TeX avoids the log's 79-column line wrapping. # 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 " f"\\message{{^^JKT-WIDE-TABLE {name} overfull by "
"\\the\\dimexpr\\tableboxwidth-\\textwidth\\relax^^J}\n" "\\the\\dimexpr\\tableboxwidth-\\linewidth\\relax^^J}\n"
"\\fi\n") "\\fi\n")
def tex(self): def tex(self):
@@ -816,15 +855,25 @@ class Table(klammer_base.Klammer_base):
# minipage below: it computes the width the minipage consumes. # minipage below: it computes the width the minipage consumes.
measure = self.get_width() + self.tex_width_check(name) measure = self.get_width() + self.tex_width_check(name)
result = f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n" result = f"\\renewcommand*{{\\arraystretch}}{{{self.leading}}}\n"
if self.allow_break: # A page-breaking table is a longtable. A boxed one is a plain
result += "\\vspace*{12pt}\n" # tabular: inside its minipage a longtable could not break anyway,
result += "\\begin{longtable}{" # 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 += self.tex_column_spec()
result += "}\n" 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.make_caption()
result += self.tex_rows() result += self.tex_rows()
result += "\\end{longtable}\n" result += f"\\end{{{env}}}\n"
if not self.allow_break: if not self.allow_break:
# Box the table at its measured width so the caption tracks it # 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 = measure + result
result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}" result = f"\\hypertarget{{{name}}}{{}}\\label{{Label-{name}}}\n{result}"
# The wrapper (add_caption/caption_wrapper) owns all vertical space # A boxed table's wrapper (add_caption/caption_wrapper) owns all
# around the table; longtable's own glue is zeroed. # 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" result = (f"\\setlength{{\\tabcolsep}}{{{self.colsep}}}\n"
"\\setlength{\\LTpre}{0pt}\n" f"\\setlength{{\\LTpre}}{{{lt_glue}}}\n"
"\\setlength{\\LTpost}{0pt}\n" f"\\setlength{{\\LTpost}}{{{lt_glue}}}\n"
+ self.tex_position() + + self.tex_position() +
# 2 \tabcolsep per column, minus the ones @{} removes at # 2 \tabcolsep per column, minus the ones @{} removes at
# flush (lineless) edges. # flush (lineless) edges. \linewidth, not \textwidth: in
f"\\setlength{{\\tablewidth}}{{\\textwidth - " # 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" f"{2 * self.row_size - self.flush_l - self.flush_r}\\tabcolsep}}\n"
# Fill widths need \tablewidth and must precede the # Column widths precede the fill widths, which read
# measuring \savebox, whose column spec reads them. # them; both precede the measuring \savebox, whose
# column spec reads them.
+ self.tex_column_widths()
+ self.tex_fill_widths() + self.tex_fill_widths()
+ result) + result)
result = re.sub(r"\newline", r"\\\\", result) result = re.sub(r"\newline", r"\\\\", result)
@@ -907,16 +965,46 @@ class Table(klammer_base.Klammer_base):
left = space // 2 left = space // 2
return txt_util.NBSP * left + cell + txt_util.NBSP * (space - left) return txt_util.NBSP * left + cell + txt_util.NBSP * (space - left)
return cell + txt_util.NBSP * space 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() widths = self.txt_column_widths()
pos = [hpos(p) for p in self.cell_hpos] pos = [hpos(p) for p in self.cell_hpos]
result = "" result = ""
indent = txt_util.NBSP * int(self.Target_txt_indent) indent = txt_util.NBSP * int(self.Target_txt_indent)
colsep = " " 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( result += indent + colsep.join(
[pad(elt, width, align) for elt, width, align in [pad(styled(elt, row_i, cell_i), width, align)
list(zip(row, widths, pos))]) for cell_i, (elt, width, align) in enumerate(zip(row, widths, pos))])
result += "\n" result += "\n"
if len(self.rows) in self.s_hline.by_index:
result += rule
result = txt_util.add_caption( result = txt_util.add_caption(
result, "Table", self.number, self.caption, self.caption_side, \ result, "Table", self.number, self.caption, self.caption_side, \
int(self.Target_txt_indent)) int(self.Target_txt_indent))
+77 -11
View File
@@ -1,7 +1,7 @@
import re import re
import textwrap
import kutil import kutil
import klammer_base
# Emitted by @vspace.txt (sks/block/block.k), one marker per line of space. # 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 # An @eval result consisting only of whitespace is trimmed away by the
@@ -25,6 +25,75 @@ def txt_vspace_marker():
def txt_nbsp_marker(): def txt_nbsp_marker():
return "__KTEXTNBSP__" 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. # The tex line break, carried as a marker.
# #
# @nl.tex used to emit "\\newline" directly, and that is destroyed the moment # @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() nbsp = txt_nbsp_marker()
if par and par[0] != ' ' and not par.startswith(nbsp): if par and par[0] != ' ' and not par.startswith(nbsp):
label = re.match("(?:" + re.escape(nbsp) + r")*\[\d+\] ", par) label = re.match("(?:" + re.escape(nbsp) + r")*\[\d+\] ", par)
par = "\n".join(textwrap.wrap( par = "\n".join(txt_wrap(
par, width=int(K.Target_txt_width), break_long_words=False, par, int(K.Target_txt_width),
subsequent_indent=" " * len(label.group(0)) if label else "")) subsequent_indent=" " * len(label.group(0)) if label else ""))
result += par + "\n\n" result += par + "\n\n"
result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result) result = re.sub(" *" + txt_vspace_marker() + " *", "\n", result)
@@ -121,15 +190,12 @@ def txt_insert_captions(text, K):
caption = f"{name} {caption_number} - {text}" caption = f"{name} {caption_number} - {text}"
else: else:
caption = text caption = text
caption_width = len(caption) + indent # Visible widths throughout: a caption may carry style markers.
if len(caption) > indented_width: if txt_visible_length(caption) > indented_width:
caption = textwrap.wrap( caption = "\n".join(txt_wrap(
caption, indented_width, caption, indented_width,
#initial_indent ="~"*indent, subsequent_indent=txt_nbsp_marker()*indent))
subsequent_indent=txt_nbsp_marker()*indent) line_length = max([txt_visible_length(e) for e in caption.split("\n")])
caption = "\n".join(caption)
caption_width = indented_width
line_length = max([len(e) for e in caption.split("\n")])
line = txt_nbsp_marker() * indent + "-" * line_length line = txt_nbsp_marker() * indent + "-" * line_length
return caption + "\n" + line return caption + "\n" + line
result = caption_rgx.sub(replace, text) 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 :after_apply
phases.txt_format_footnotes ; phases.txt_format_footnotes ;
phases.txt_insert_captions ; 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 @@@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 escape sequences as visible width, and the reason escape resolution
runs BEFORE the txt phases.) 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): def indent_text(text, indent):
"""Every line of text prefixed with `indent` columns of NBSP.""" """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) DERIVE=$(mktemp /tmp/kdesc_derive.XXXXXX.k)
printf '@@wave.k s : Wave @@\n@@wave :: @i *s* @ @@\n' > "$DERIVE" printf '@@wave.k s : Wave @@\n@@wave :: @i *s* @ @@\n' > "$DERIVE"
shows "35. coverage is derived through the unlisted klammerset" \ 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 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 # A klammer the input REDEFINES came from the klammerset, but the input
# changed it -- which is exactly what a reader needs to know. # changed it -- which is exactly what a reader needs to know.