Verbatim-safe typography, ^-punctuation quoting, full-range ^UUUU^, polyglot html

Typographic transforms (---, quote pairs, ~) no longer touch verbatim
text: @c/@code/@source_listing content and ^'...'^ spans show exactly the
characters written. "^" before any punctuation character quotes it in
every target (the apostrophe excepted: ^' opens a literal span), with the
new :resolve option on @@@target declaring per-target renderings. The
^UUUU^ code-point form accepts 4-6 hex digits, the full Unicode range.
The html output and transform spellings are polyglot (XML-valid), in
preparation for an EPUB target. New suites: transform_test, character_test
(engine), typography_test (SKS).

(from dev 07ce5ea86a0a)
This commit is contained in:
2026-08-23 20:48:26 +02:00
parent d982c0d6cc
commit 37b6ba1c4f
77 changed files with 1665 additions and 1608 deletions

View File

@@ -133,8 +133,7 @@ same way the font-lock scanner steps over them."
((and (= len 1)
(member name klammertext-literal-klammers))
;; Verbatim interior: find the closing NAME@ by name.
(if (re-search-forward
(concat (regexp-quote name) "@") nil t)
(if (klammertext--search-literal-close name)
(when (< pos (point))
(setq opaque t done t))
(setq opaque t done t))) ; never closed

View File

@@ -131,15 +131,32 @@ Register one with `klammertext-add-literal-klammer', e.g. in your init file:
;; doc/edit/sublime/Klammertext.sublime-syntax
;; * the @NAME verbatim region in doc/edit/vim/syntax/klammertext.vim
;; * the @NAME rule in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
;; All are currently seeded with just "code".
;; All are currently seeded with "code" and "c" (@c is the inline form of
;; @code and took a literal argument 2026-08-16; the miscounted stack from an
;; unrecognized "@c ... c@" shifted a whole document's indentation by one).
(defun klammertext-add-literal-klammer (name)
"Register NAME as a klammer whose literal content must not be interpreted.
NAME is the klammer name without the leading @ (e.g. \"code\")."
(add-to-list 'klammertext-literal-klammers name))
(defun klammertext--search-literal-close (name &optional bound)
"Move point past the exact close token NAME@ at or after point.
Return the position after the close, or nil if there is none before BOUND.
NAME@ preceded by a name character is verbatim content, not a close --
\"basic@\" does not close @c, and \"barcode@\" does not close @code
\(the engine's close is a whole katom). Mirrors the shared core's
find_literal_close."
(let ((close (concat (regexp-quote name) "@")) (found nil))
(while (and (setq found (re-search-forward close bound t))
(let ((b (match-beginning 0)))
(and (> b (point-min))
(klammertext--name-char-p (char-before b))))))
found))
;; Seed the list through the same entry point future users will use.
(klammertext-add-literal-klammer "code")
(klammertext-add-literal-klammer "c")
;; --- Helpers -----------------------------------------------------------
@@ -275,13 +292,12 @@ BEFORE is the character before POS."
;; BEFORE `klammertext--set-match', because `re-search-forward'
;; clobbers the match data.
(if (member name klammertext-literal-klammers)
(let ((close (concat (regexp-quote name) "@")))
(if (re-search-forward close nil t)
(let ((close-end (point)))
(put-text-property pos close-end 'font-lock-multiline t)
(goto-char (- close-end (length name) 1)))
(put-text-property pos (point-max) 'font-lock-multiline t)
(goto-char (point-max))))
(if (klammertext--search-literal-close name)
(let ((close-end (point)))
(put-text-property pos close-end 'font-lock-multiline t)
(goto-char (- close-end (length name) 1)))
(put-text-property pos (point-max) 'font-lock-multiline t)
(goto-char (point-max)))
(goto-char name-end))
;; Set the match data for the opening LAST, so it survives to the
;; highlight step.
@@ -478,8 +494,8 @@ delimiter (or skipped region) and return (POS . KIND) with KIND `open or
(let ((name (buffer-substring-no-properties (1+ hit) (point))))
(cond
((member name klammertext-literal-klammers) ; literal span: skip
(let ((close (concat (regexp-quote name) "@")))
(unless (re-search-forward close nil t) (goto-char (point-max)))))
(unless (klammertext--search-literal-close name)
(goto-char (point-max))))
((eq (char-after) ?-)) ; @name-arg : no span
(t (throw 'found (cons hit 'open))))))
(t ; name@ / bare @ : closing
@@ -574,7 +590,7 @@ name, not by depth counting."
The verbatim content is opaque, so we search for the literal close string."
(save-excursion
(goto-char (+ open-pos 1 (length name)))
(when (search-forward (concat name "@") nil t)
(when (klammertext--search-literal-close name)
(1- (point)))))
(defun klammertext--literal-match-backward (close-pos name)
@@ -586,7 +602,11 @@ CLOSE-POS, or nil. Literal spans do not nest, so the nearest preceding real
(let ((open-str (concat "@" name)) (result nil))
(while (and (not result) (search-backward open-str nil t))
(let ((op (point)))
;; The name must end where the token ends: "@c" found inside
;; "@caption" is not an opener of @c.
(unless (or (eq (char-before op) ?@) ; @@NAME = definition
(klammertext--name-char-p
(char-after (+ op 1 (length name))))
(klammertext--escaped-p op))
(setq result op))))
result)))

View File

@@ -36,10 +36,10 @@
# * the Emacs defcustoms (klammertext-literal-klammers, -transparent-,
# -code-, -align-klammers, -indent-offset, -align-cell-max, -align-row-max)
# in doc/edit/emacs/klammertext-mode.el / -indent.el / -align.el
# * the '@code' rule + literal_code context in
# * the '@code'/'@c' rules + literal_code/literal_c contexts in
# doc/edit/sublime/Klammertext.sublime-syntax
# * the '@code' verbatim region in doc/edit/vim/syntax/klammertext.vim
# * the '@code' rule in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
# * the '@code'/'@c' verbatim regions in doc/edit/vim/syntax/klammertext.vim
# * the '@code'/'@c' rules in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
#
# Installation note: editors locate this file either next to their own plugin
# files (a vendored copy, placed there by doc/make_editing_zip.sh), as
@@ -57,7 +57,7 @@ import sys
# Klammer names whose content is a literal argument (verbatim interior,
# closed by a named NAME@ delimiter).
LITERAL_KLAMMERS = set(["code"])
LITERAL_KLAMMERS = set(["code", "c"])
# Klammers that contribute no indentation level (a @document's paragraphs
# stay at the left margin).
@@ -91,6 +91,19 @@ def name_char_p(ch):
or ('0' <= ch <= '9') or ch == '_')
def find_literal_close(s, name, start):
"""Index of the exact close token NAME@ at or after START, or -1.
The engine's close is a whole katom, so NAME@ preceded by a name
character is content, not a close -- "basic@" does not close @c, and
"barcode@" does not close @code. Single-letter literal names (@c) make
this guard essential rather than theoretical."""
close = name + '@'
idx = s.find(close, start)
while idx > 0 and name_char_p(s[idx - 1]):
idx = s.find(close, idx + 1)
return idx
def escaped_p(s, pos):
"""True if the char at POS is escaped by an odd run of ^ before it.
In Klammertext ^# and ^@ are literal, so such a char is not a delimiter."""
@@ -188,9 +201,8 @@ def next_app_delim(s, i, limit):
name = s[hit + 1:k]
after = s[k] if k < n else None
if name in LITERAL_KLAMMERS: # literal span: skip to its close
close = name + '@'
idx = s.find(close, k)
i = n if idx == -1 else idx + len(close)
idx = find_literal_close(s, name, k)
i = n if idx == -1 else idx + len(name) + 1
continue
elif after == '-': # @name-arg : opens no span
i = k
@@ -318,7 +330,7 @@ def literal_match_forward(s, open_pos, name):
"""Index of the @ of the NAME@ that closes the literal @NAME at OPEN_POS, or
None. The content is opaque, so search for the literal close string."""
start = open_pos + 1 + len(name)
idx = s.find(name + '@', start)
idx = find_literal_close(s, name, start)
return idx + len(name) if idx != -1 else None
@@ -333,7 +345,10 @@ def literal_match_backward(s, close_pos, name):
if idx == -1:
return None
before = s[idx - 1] if idx > 0 else None
if before != '@' and not escaped_p(s, idx):
# The name must end where the token ends: "@c" found inside
# "@caption" is not an opener of @c.
follower = s[idx + len(open_str)] if idx + len(open_str) < len(s) else None
if before != '@' and not name_char_p(follower) and not escaped_p(s, idx):
return idx
end = idx
@@ -446,7 +461,7 @@ def state_at(s, pos):
i = k
if run_len == 1 and name in LITERAL_KLAMMERS:
# Verbatim interior: find the closing NAME@ by name.
idx = s.find(name + '@', k)
idx = find_literal_close(s, name, k)
if idx == -1: # never closed
return (stack, True)
close_end = idx + len(name) + 1
@@ -584,7 +599,7 @@ def enclosing_span(s, pos, names):
name = s[run_end:k]
i = k
if run_len == 1 and name in LITERAL_KLAMMERS:
idx = s.find(name + '@', k)
idx = find_literal_close(s, name, k)
if idx == -1:
break
i = idx + len(name) + 1
@@ -692,7 +707,7 @@ def scan_lines(content):
name = content[run_end:k]
i = k
if run_len == 1 and name in LITERAL_KLAMMERS:
idx = content.find(name + '@', k)
idx = find_literal_close(content, name, k)
e = n if idx == -1 else idx + len(name) + 1
if line_index(max(hit, e - 1)) != line_index(hit):
block_range(hit, e)
@@ -886,7 +901,7 @@ def diagnostics(s):
name = s[run_end:k]
i = k
if run_len == 1 and name in LITERAL_KLAMMERS:
idx = s.find(name + '@', k)
idx = find_literal_close(s, name, k)
if idx == -1:
probs.append({'start': hit, 'end': k,
'message': ("literal klammer @%s has no "

View File

@@ -27,7 +27,7 @@
# caret, a leftover single ^ escapes the following character —
# the '\^.' rule reproduces exactly that parity.)
#
# Literal klammers: @code ... code@ interior is verbatim (no # or @
# Literal klammers: @code ... code@ and @c ... c@ interior is verbatim (no # or @
# interpreted). To add another literal klammer 'foo', copy the
# '@code' rule and the 'literal_code' context below, replacing
# code -> foo.
@@ -43,7 +43,7 @@
# * the @NAME verbatim region in doc/edit/vim/syntax/klammertext.vim
# * the @NAME rule in
# doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
# All are currently seeded with just 'code'.
# All are currently seeded with 'code' and 'c'.
#
# ---------------------------------------------------------------------------
# How open vs. close is decided (the same rule the Emacs scanner uses):
@@ -131,10 +131,13 @@ contexts:
scope: punctuation.definition.comment.klammertext
push: removal_line
# --- literal klammer: interior is verbatim (seeded default: @code) ---
# --- literal klammers: interior is verbatim (seeded: @code and @c) ---
- match: '@code(?![A-Za-z0-9_])'
scope: entity.name.function.begin.klammertext
push: literal_code
- match: '@c(?![A-Za-z0-9_])'
scope: entity.name.function.begin.klammertext
push: literal_c
# --- system / target commands @@@ ---
- match: '@@@{{name}}'
@@ -185,3 +188,11 @@ contexts:
- match: 'code@'
scope: entity.name.function.end.klammertext
pop: true
# @c ... c@ — the inline form of @code, same verbatim interior. The
# lookbehind keeps a word ending in c ("basic@") from closing the span —
# essential for a single-letter name.
literal_c:
- match: '(?<![A-Za-z0-9_])c@'
scope: entity.name.function.end.klammertext
pop: true

View File

@@ -20,13 +20,13 @@
" character is never read as a delimiter. A run of carets pairs
" left-to-right, reproducing the language's parity rule.
"
" Literal klammers: @code ... code@ — the interior is verbatim (no # or @
" Literal klammers: @code ... code@ and @c ... c@ — the interior is verbatim (no # or @
" interpreted). SYNC: the literal-klammer set's source of truth is
" LITERAL_KLAMMERS in doc/edit/shared/klammertext_edit.py; a static
" syntax file cannot read it, so when you add a literal klammer 'foo',
" copy the klammertextVerbatim region below with code -> foo (and mirror
" it in the Emacs, Sublime, and VS Code artifacts; all are seeded with
" just 'code').
" 'code' and 'c').
"
" How open vs. close is decided (the same rule as every other integration):
" a delimiter whose NAME follows the @-run (@name) is an OPENING; a bare
@@ -76,12 +76,16 @@ syn match klammertextAppOpen /@\@1<!@\w\+/
syn match klammertextAppClose /@\@1<!@\%(\w\|@\)\@!/
syn match klammertextAppClose /@\@1<!\w\+@\%(\w\|@\)\@!/
" --- literal klammer: interior verbatim (seeded default: @code) -----------
" --- literal klammers: interior verbatim (seeded: @code and @c) -----------
" Defined AFTER the @-tier matches: in Vim, when several items match at the
" same position the LAST defined wins, and this region must beat the plain
" klammertextAppOpen match at '@code'. (Sublime's tokenizer picks the FIRST
" listed rule — the opposite convention; don't copy that ordering here.)
syn region klammertextVerbatim matchgroup=klammertextAppOpen start=/@\@1<!@code\%(\w\)\@!/ matchgroup=klammertextAppClose end=/code@/
" @c is the inline form of @code (literal since 2026-08-16). The \w\@1<!
" guard on the close keeps a word ending in c ("basic@") from ending the
" region — essential for a single-letter name.
syn region klammertextVerbatim matchgroup=klammertextAppOpen start=/@\@1<!@c\%(\w\)\@!/ matchgroup=klammertextAppClose end=/\w\@1<!c@/
" --- colors ---------------------------------------------------------------
" The shared palette, dark and light values (see the header). cterm values

View File

@@ -8,7 +8,7 @@
"whitespace operators left unscoped), the three @-tiers — application",
"(@), definition (@@), system (@@@) — each as an opening (@name, one",
"unit) or a close (name@, bare @), ^-escapes (consumed, unscoped),",
"and verbatim @code ... code@ interiors.",
"and verbatim @code ... code@ / @c ... c@ interiors.",
"",
"How open vs. close is decided (the same rule as every integration):",
"a delimiter whose NAME follows the @-run is an OPENING; a bare",
@@ -19,7 +19,7 @@
"in doc/edit/shared/klammertext_edit.py. A static grammar cannot",
"read it: to add a literal klammer 'foo', copy the @code begin/end",
"rule below with code -> foo (and mirror it in the Emacs, Sublime,",
"and Vim artifacts; all are seeded with just 'code').",
"and Vim artifacts; all are seeded with 'code' and 'c').",
"",
"Delimiter matching, indentation, alignment, and diagnostics are not",
"tokenizer concerns — they come from the Klammertext language server",
@@ -77,6 +77,16 @@
"0": { "name": "entity.name.function.end.klammertext" }
}
},
{
"begin": "@c(?![A-Za-z0-9_])",
"beginCaptures": {
"0": { "name": "entity.name.function.begin.klammertext" }
},
"end": "(?<![A-Za-z0-9_])c@",
"endCaptures": {
"0": { "name": "entity.name.function.end.klammertext" }
}
},
{
"match": "@@@[A-Za-z0-9_]+",
"name": "keyword.control.begin.klammertext"