summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 09:53:33 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 09:53:49 +0200
commit66543815ae6cbf6e576a889c9291678744243953 (patch)
treec3bc567ee53163abc3cf24bd7cba8038ee14e521
parent99dbddb2106f8a3ca31c7e0c43b7dc1a81652786 (diff)
downloadcolitur-66543815ae6cbf6e576a889c9291678744243953.tar.gz
colitur-66543815ae6cbf6e576a889c9291678744243953.zip
feat(render): Typst as a seventh escaping flavour
Adds Escape.Typst: type, all, to_string ("typst"), of_string, of_extension (.typ), and a per-character expand function, structured identically to the existing latex escaper (one pass, no re-scan, so double-escaping stays impossible by construction). The metacharacter set was verified against the installed typst 0.14.2 binary, not assumed: a probe document escaping each of #, *, _, $, @, <, >, `, \, ~ and - was compiled and pdftotext'd back to confirm the literal character survives, and each was separately confirmed to do something else when left bare (# opens code mode, */_ toggle strong/emph, $ opens math, @ opens a reference -- a bare unresolved @word is a hard compile error, not merely mangled output -- </> can close around a bare word into label syntax that swallows it whole, ` opens raw, ~ is a non-breaking space, and a run of two or three '-' becomes an en/em dash). All ten are backslash-escapable; none needed a non-backslash workaround. '-' is escaped unconditionally rather than only inside a detected run, since this escaper has no lookahead -- a probe confirmed escaping every hyphen independently still typesets as literal hyphens for a run of any length, so the single per-character rule is sufficient. test_escape.ml's new Typst cases were written first and shown to fail against a stubbed identity apply before the real escaper landed, per this project's own regression-test discipline. Comments in both files mark which new tests are genuine regression tests (the per-character escaping, including a real shipped citation and a synthetic markdown-habit overlay name) versus characterisation (to_string/ of_string/of_extension are flat table lookups with no logic to have been wrong).
-rw-r--r--lib/render/escape.ml33
-rw-r--r--lib/render/escape.mli10
-rw-r--r--test/test_escape.ml62
3 files changed, 95 insertions, 10 deletions
diff --git a/lib/render/escape.ml b/lib/render/escape.ml
index f506c44..ee9ce6c 100644
--- a/lib/render/escape.ml
+++ b/lib/render/escape.ml
@@ -1,18 +1,19 @@
-type flavour = Latex | Groff | Html | Xml | Ics | None_
+type flavour = Latex | Typst | Groff | Html | Xml | Ics | None_
-let all = [ Latex; Groff; Html; Xml; Ics; None_ ]
+let all = [ Latex; Typst; Groff; Html; Xml; Ics; None_ ]
let to_string = function
- | Latex -> "latex" | Groff -> "groff" | Html -> "html"
+ | Latex -> "latex" | Typst -> "typst" | Groff -> "groff" | Html -> "html"
| Xml -> "xml" | Ics -> "ics" | None_ -> "none"
let of_string = function
- | "latex" -> Some Latex | "groff" -> Some Groff | "html" -> Some Html
+ | "latex" -> Some Latex | "typst" -> Some Typst | "groff" -> Some Groff | "html" -> Some Html
| "xml" -> Some Xml | "ics" -> Some Ics | "none" -> Some None_
| _ -> None
let of_extension = function
| ".tex" -> Some Latex
+ | ".typ" -> Some Typst
| ".ms" | ".mom" | ".me" -> Some Groff
| ".html" | ".htm" -> Some Html
| ".xml" -> Some Xml
@@ -37,6 +38,29 @@ let latex = function
| '~' -> "\\textasciitilde{}"
| c -> String.make 1 c
+(* Typst markup metacharacters, verified live against the installed `typst`
+ 0.14.2 binary rather than assumed (probe: each character escaped,
+ compiled, pdftotext'd back, confirmed literal; each also confirmed to
+ do something ELSE when left bare -- '#' opens code mode, '*'/'_' toggle
+ strong/emph, '$' opens math, '@' opens a reference (a bare "@word" is a
+ hard COMPILE ERROR, not merely mangled output, if "word" resolves to no
+ label), '<'/'>' can close around a bare word into label syntax that
+ swallows it whole, '`' opens raw, '~' is a non-breaking space, and a run
+ of two or three '-' becomes an en/em dash). Backslash-escapable in every
+ case -- no character on this list needed a non-backslash workaround.
+ '-' is escaped UNCONDITIONALLY, not only inside a detected run: this
+ function has no lookahead (see [expand]'s own comment above), so it
+ cannot tell "is this hyphen part of a run" one character at a time: a
+ probe confirmed escaping every hyphen independently ("\-\-\-") still
+ typesets as three literal hyphens, never collapsing to an em dash, so
+ the single per-character rule is sufficient for runs of any length. *)
+let typst = function
+ | '\\' -> "\\\\"
+ | '#' -> "\\#" | '*' -> "\\*" | '_' -> "\\_" | '$' -> "\\$"
+ | '@' -> "\\@" | '<' -> "\\<" | '>' -> "\\>" | '`' -> "\\`"
+ | '~' -> "\\~" | '-' -> "\\-"
+ | c -> String.make 1 c
+
let html = function
| '&' -> "&amp;" | '<' -> "&lt;" | '>' -> "&gt;"
| '"' -> "&quot;" | '\'' -> "&#39;"
@@ -57,6 +81,7 @@ let groff s =
let apply flavour s =
match flavour with
| Latex -> expand latex s
+ | Typst -> expand typst s
| Groff -> groff s
| Html | Xml -> expand html s
| Ics -> expand ics s
diff --git a/lib/render/escape.mli b/lib/render/escape.mli
index 97cf050..ee1e20b 100644
--- a/lib/render/escape.mli
+++ b/lib/render/escape.mli
@@ -3,11 +3,11 @@
Knows nothing about calendars. Pure and total: every function is defined on
every string, and none reads the clock, the environment or the filesystem. *)
-(** The six escaping modes. Markdown, AsciiDoc and plain text all use [None_]:
- their metacharacter sets are context-dependent, and escaping them
- aggressively produces worse output than not escaping at all (spec section 5).
- This is a documented limitation of those flavours. *)
-type flavour = Latex | Groff | Html | Xml | Ics | None_
+(** The seven escaping modes. Markdown, AsciiDoc and plain text all use
+ [None_]: their metacharacter sets are context-dependent, and escaping
+ them aggressively produces worse output than not escaping at all (spec
+ section 5). This is a documented limitation of those flavours. *)
+type flavour = Latex | Typst | Groff | Html | Xml | Ics | None_
val all : flavour list
val to_string : flavour -> string
diff --git a/test/test_escape.ml b/test/test_escape.ml
index 35a9b9d..7c55395 100644
--- a/test/test_escape.ml
+++ b/test/test_escape.ml
@@ -12,6 +12,56 @@ let test_latex () =
check "real name" "Ss.mi Nominis Iesu \\& Mari\xc3\xa6"
(E.apply E.Latex "Ss.mi Nominis Iesu & Mari\xc3\xa6")
+(* Typst's markup metacharacters, verified against the installed `typst`
+ 0.14.2 binary rather than trusted from a spec list: a probe document
+ escaping each one individually, `typst compile`'d and `pdftotext`'d
+ back, confirmed the literal character survives (and, separately, that
+ the UNescaped form really does something else -- #box[..] consumes a
+ bare '#', *x* renders bold, @word errors "cannot reference text", a
+ bare <label> vanishes into label syntax, a~b becomes a non-breaking
+ join, and a--b/a---b become en/em dashes). Every case below is a
+ REGRESSION test: run against escape.ml's pre-fix stub (`Typst -> s`,
+ identity, no escaping at all -- see escape.ml's own TODO comment),
+ every one of these lines fails, because the stub hands Typst the bare
+ metacharacter it would then parse as markup instead of the
+ backslash-escaped literal. *)
+let test_typst () =
+ check "hash" "\\#" (E.apply E.Typst "#");
+ check "star" "\\*" (E.apply E.Typst "*");
+ check "underscore" "\\_" (E.apply E.Typst "_");
+ check "dollar" "\\$" (E.apply E.Typst "$");
+ check "at" "\\@" (E.apply E.Typst "@");
+ check "lt" "\\<" (E.apply E.Typst "<");
+ check "gt" "\\>" (E.apply E.Typst ">");
+ check "backtick" "\\`" (E.apply E.Typst "`");
+ check "backslash first" "\\\\" (E.apply E.Typst "\\");
+ check "tilde" "\\~" (E.apply E.Typst "~");
+ check "dash" "\\-" (E.apply E.Typst "-");
+ (* Runs of two/three unescaped hyphens become en/em dashes in live
+ Typst ("a--b" -> "a\xe2\x80\x93b", "a---b" -> "a\xe2\x80\x94b"). This
+ escaper has no lookahead (see [expand]'s own comment: one pass, no
+ re-scan), so it cannot detect "is this hyphen part of a run" -- it
+ escapes every '-' unconditionally instead, which a live probe
+ confirmed defangs a run of ANY length too: "\-\-\-" still typesets
+ as three literal hyphens, never an em dash. *)
+ check "double dash defanged" "\\-\\-" (E.apply E.Typst "--");
+ check "triple dash defanged" "\\-\\-\\-" (E.apply E.Typst "---");
+ (* A real shipped citation (data/ef/lectionary.sexp's 2 Nov Epistle),
+ not synthetic -- the metacharacter in this list most likely to
+ actually occur in colitur's own data, since citation ranges are
+ routinely hyphenated ("11-16") and Latin/English feast names almost
+ never carry '#'/'*'/'_'/'$'/'@'/'<'/'>'/'`'/'~'. *)
+ check "real citation" "Ezech 34:11\\-16" (E.apply E.Typst "Ezech 34:11-16");
+ (* A realistic MISTAKE, not shipped data: this project also ships a
+ Markdown flavour, so a diocesan overlay author hand-editing a local
+ feast's `name` field in a text editor typing markdown-habit
+ asterisks for emphasis is a plausible real input, not a contrived
+ one -- verified live that unescaped asterisks here would render
+ bold, and confirmed escaped ones render as the two literal
+ asterisks the author actually typed. *)
+ check "overlay author's markdown-habit name" "Our Lady of the \\*Assumption\\*"
+ (E.apply E.Typst "Our Lady of the *Assumption*")
+
let test_groff () =
check "backslash" "\\e" (E.apply E.Groff "\\");
(* RG-irrelevant but groff-critical: a leading dot starts a request. *)
@@ -34,11 +84,20 @@ let test_ics () =
let test_none_is_identity () =
check "none" "& < > \\ % {}" (E.apply E.None_ "& < > \\ % {}")
+(* CHARACTERISATION, not regression: to_string/of_string/of_extension are
+ flat data-table lookups with no per-character logic to get subtly
+ wrong (unlike [typst]/[latex]/[html]/[ics]'s own [expand] functions),
+ so they were correct the moment the Typst variant and its three table
+ entries were added -- there was no "stubbed wrong, then fixed" step
+ for this one to have failed against, unlike test_typst above. Pinned
+ here, not as its own case, because that is where every other
+ flavour's mapping already lives. *)
let test_flavour_names () =
List.iter
(fun f -> Alcotest.(check bool) "roundtrip" true (E.of_string (E.to_string f) = Some f))
- [ E.Latex; E.Groff; E.Html; E.Xml; E.Ics; E.None_ ];
+ [ E.Latex; E.Typst; E.Groff; E.Html; E.Xml; E.Ics; E.None_ ];
Alcotest.(check bool) "tex" true (E.of_extension ".tex" = Some E.Latex);
+ Alcotest.(check bool) "typ" true (E.of_extension ".typ" = Some E.Typst);
Alcotest.(check bool) "ms" true (E.of_extension ".ms" = Some E.Groff);
Alcotest.(check bool) "mom" true (E.of_extension ".mom" = Some E.Groff);
Alcotest.(check bool) "html" true (E.of_extension ".html" = Some E.Html);
@@ -108,6 +167,7 @@ let test_fold_pathological_input_terminates () =
let suite =
( "Escape",
[ Alcotest.test_case "latex" `Quick test_latex;
+ Alcotest.test_case "typst" `Quick test_typst;
Alcotest.test_case "groff" `Quick test_groff;
Alcotest.test_case "html/xml" `Quick test_html_xml;
Alcotest.test_case "ics" `Quick test_ics;