Czech non-breaking spaces for Typst, done by the rules.
Lines never end where Czech typography forbids it, and jump-to-source keeps working.
Quick start · What it binds · Options · Legal texts · Performance · How it works
In Czech typography, „vlna" (literally “wave”) is a non-breaking space,
the tilde ~ in Typst and LaTeX. This package inserts it for you, so a line
never ends after a one-letter preposition, inside a number or a date, between
a number and its unit, inside a compound abbreviation, and in every other
place the guideline of the Institute of the Czech Language
(ÚJČ AV ČR, ?id=880) rules out. The
feature set mirrors luavlna (Michal Hoftich), and every rule group has its
own switch.
Quick start
#import "@preview/vlna:0.4.1": apply-vlna
#show: apply-vlna
That is all: the defaults follow the ÚJČ guideline. To see exactly what the package glued, outline every group:
#show: apply-vlna.with(debug: true)
What it binds
| Rule group | Examples |
|---|---|
| One- and two-letter words | k lesu, V lese, na kopci; acronyms such as EU, AI, OSN stay free |
| Titles before a name | Ing. arch. Novák, pplk. gšt. Svoboda, dr. h. c. Dvořák, viz tabulka, tzv. postup, s. 12, odst. 4, písm. a) |
| Titles after a name | Novák, Ph.D., Malý, CSc. |
| Initials | M. J. Hegel, Ž. Zíbrt, Fr. Daneš, Ch. Novák |
| Numbers | 2 500, 25,325 23, +420 800 123 987, 50 %, § 23, † 2000, 10 kg, 19 °C, 1 000 000 Kč, 250 € |
| Ratios and scales | 1 : 50 000, 5 : 3, 10 : 2 = 5 |
| Dates | 21. 6. and 16. ledna stay together; the year may move to the next line |
| Compound abbreviations and codes | a. s., s. r. o., př. n. l., T. G. M., PS PČR, ČSN 01 6910 |
| Dashes | a spaced dash stays at the line end (slovo –); 1948–1989 never breaks |
| Web and e-mail addresses | link() addresses break only where ÚJČ allows: after /, before . and -, around @ |
| Legal identifiers (opt-in) | sp. zn. 12 Nt 345/2026, I. ÚS 2438/25, VINITIS s.r.o., 89/2012 Sb. |
Groups chain: k s bratrem, o 2 500 Kč, od 21. 6. and
plk. gšt. doc. Mgr. et Mgr. Ing. Libor Kutěj each stay one block. Code in
raw, inline or block, is never touched.
Options
Every rule group can be switched off, or on, independently:
| Parameter | Default | Binds |
|---|---|---|
bind-two-letter-words |
true |
two-letter words in addition to one-letter ones |
short-words |
auto |
which short words are bound (none disables them) |
initials |
true |
M. J. Hegel, Fr. Daneš, Ž. Zíbrt |
compound-initials |
("Ch",) |
multi-letter initials: Ch. Novák |
titles |
true |
titles and abbreviations before and after a name |
numbers |
true |
2 500, 50 %, § 23, 10 kg, +420 800 123 987 |
units |
default-units |
units and currencies bound after a number |
ratios |
true |
1 : 50 000, 10 : 2 = 5 |
dates |
true |
21. 6. | 2024, 16. ledna | 1972 (the year may separate) |
months |
auto (Czech) |
month names recognised in dates |
number-word |
false |
500 lidí, strana 2, 5. pluk, Karel IV. |
abbreviations |
true |
a. s., př. n. l., PS PČR, ČSN 01 6910 |
dashes |
true |
slovo – at the line end; 1948–1989 unbroken |
links |
true |
ÚJČ break points inside link() addresses |
legal |
false |
Czech legal identifiers, see Legal texts |
lang |
auto |
force one language’s set, see per-language sets |
debug |
false |
outline every glued block |
unitsreplaces the default list. Extend it instead:units: default-units + ("µm", "dpt")(default-unitsis exported).number-wordapplies “číslo + název počítaného jevu” literally and removes many break opportunities, which is why it is off by default.- The uppercase branch of
abbreviationsallows 2–4 letters per part, so ALL-CAPS headings are not glued word by word. linksinserts zero-width spaces (U+200B) into the displayed address, the technique the ÚJČ note itself recommends. The link target (dest) is untouched and stays clickable.
Legal texts
#show: apply-vlna.with(legal: true)
Off by default. Keeps the identifiers of Czech court filings and statutes in one piece:
| Kind | Examples |
|---|---|
| Case numbers | 12 Nt 345/2026, 4 Tdo 123/2025, 2 KZV-12/2026, VZV 23/2026-22 |
| Constitutional Court | I. ÚS 2438/25, Pl. ÚS 45/04 |
| Reference numbers, bank accounts | KRPA-12345-67/TČ-2026-000089, 19-1234567890/0100 |
| Statutes | 89/2012 Sb., 2/1993 Sb. m. s., zák. práce, tr. zák. (o. s. ř., tr. ř., o. z. are covered by abbreviations) |
| Legal forms, bound to the company name | VINITIS s.r.o., ABC a.s., XY v.o.s., spaced forms too |
| Bound to what follows | sp. zn., č. j., ev. č., bod, bodu, věta, věty: sp. zn. 12 Nt 345/2026, věty první |
The identifier is a block of its own inside č. j. KRPA-…: when the whole
run does not fit on a line, the break falls after č. j., never inside the
identifier. In legal mode věta and věty also bind in ordinary sentences,
and hyphenated codes such as COVID-19 are glued.
Configuration guide
Which short words are bound
By default, both one- and two-letter words are bound. The strict Czech rule requires this only for one-letter prepositions and conjunctions; binding all two-letter words is a stronger convention that can hurt line breaking in narrow layouts (mobile-friendly pages, multi-column text):
// Bind only one-letter words; `na`, `po`, `je`, `se`, … may end a line:
#show: apply-vlna.with(bind-two-letter-words: false)
// Bind exactly these words and nothing else (case-sensitive).
// A string is a set of single letters: this binds one-letter prepositions,
// their uppercase forms and uppercase A/I, while lowercase `a`/`i` may end
// a line:
#show: apply-vlna.with(short-words: "vksuozVKSUOZAI")
// An array lists whole words, so two-letter words can be included:
#show: apply-vlna.with(short-words: ("v", "k", "s", "z", "a", "i", "na", "Na"))
short-words replaces the built-in short-word pattern entirely and takes
precedence over bind-two-letter-words.
Per-language sets (luavlna \singlechars)
short-words also accepts a dictionary keyed by language code. The set is
chosen by the language of the surrounding text (text.lang), and, as in
luavlna, languages not listed are not processed at all (neither short
words nor title runs; the other rule groups stay language-independent):
#set text(lang: "cs") // required: the default text.lang is "en"
#show: apply-vlna.with(short-words: (
cs: "AIiVvOoUuSsZzKk", // luavlna's default set for Czech
sk: "AIiVvOoUuSsZzKk",
))
To force one language’s set for the whole document regardless of text.lang
(luavlna \preventsinglelang):
#show: apply-vlna.with(short-words: (cs: "AIiVvOoUuSsZzKk"), lang: "cs")
Initials (luavlna \compoundinitials)
Initials and abbreviated given names (an uppercase letter, optionally
followed by a lowercase one, plus a period) are bound to the next word and
chain with titles: M. J. Hegel, Fr. Daneš, Ing. B. Novák. Compound
letters that count as one initial default to Ch (Ch. Novák):
#show: apply-vlna.with(compound-initials: ("Ch",)) // default
#show: apply-vlna.with(initials: false) // turn initials off
Mid-document toggles (luavlna \preventsingleon/off)
A global show rule cannot be cancelled from inside the document, so the
package provides state-based switches:
#import "@preview/vlna:0.4.1": apply-vlna, vlna-on, vlna-off, vlna-debug-on, vlna-debug-off
#show: apply-vlna
Tady se váže. #vlna-off() Tady ne. #vlna-on() A tady zase.
#vlna-debug-on() Orámuje zásahy odsud dál. #vlna-debug-off()
Coming from luavlna
| luavlna | vlna for Typst |
|---|---|
\singlechars{czech}{AIiVvOoUuSsZzKk} |
short-words: (cs: "AIiVvOoUuSsZzKk") |
\compoundinitials{czech}{Ch} |
compound-initials: ("Ch",) |
\preventsinglelang{czech} |
lang: "cs" |
\preventsingleoff / \preventsingleon |
#vlna-off() / #vlna-on() |
\preventsingledebugon / ...off |
#vlna-debug-on() / #vlna-debug-off() (or debug: true) |
Performance
Every match becomes a #box, so the package cannot be free, but it only pays
for what the document needs. Measured on a 63-page Czech document with every rule group
firing: typst 0.15.1, all series compiled in parallel, each pinned to
its own physical core (2 threads), at least 12 runs per series. Harness, chart and raw dataset are in
examples/benchmark.
| p95 | p50 | vs. bare document | |
|---|---|---|---|
| bare document, no package | 0.49 s | 0.47 s | 1.00× |
| v0.3.0 | 2.12 s | 2.01 s | 4.32× |
| v0.4.0 | 1.53 s | 1.43 s | 3.12× |
| v0.4.1 | 1.54 s | 1.43 s | 3.15× |
v0.4.1 with legal: true |
1.97 s | 1.85 s | 4.01× |
plus one raw block, v0.3.0 |
3.71 s | 3.39 s | 7.56× |
plus one raw block, v0.4.0 |
4.06 s | 3.51 s | 8.30× |
plus one raw block, v0.4.1 |
3.94 s | 3.48 s | 8.04× |
v0.4.1 compiles 27% faster than v0.3.0 at p95, matches v0.4.0 (the new citation abbreviations cost nothing measurable), and legal: true adds 0.42 s, 27% on top.
These figures come from the quick parallel harness (about a minute, 12–85
runs per series) and are not comparable to the 501-page serial figures of
earlier releases. The raw rows are the noisiest (up to 3 of 12 runs
stalled), so their ordering is within noise; the serial 0.4.0 measurement
(22.16 s → 20.30 s p50 on 501 pages, see CHANGELOG.md) remains
the reference for that comparison.
The raw rows are the price of two features. #vlna-off(), #vlna-on(),
#vlna-debug-on() and skipping raw all depend on where a match sits in
the document, which forces a state lookup at every match, and a single code
block anywhere is enough to require it. A document that uses neither takes
the cheap path automatically, with no configuration.
How it works
A show rule that rebuilds text (it.text.replace(" ", "~")) produces
new content whose source position points at the rule itself, so
clicking a letter in the PDF jumps into the package code instead of your
document. vlna wraps the original matched content in #box(..)
instead. The box cannot break internally, and the glyphs keep their original
source span, so clicking them jumps to your document.
Limitations
- Inside a box, spaces do not stretch under justification and words are not hyphenated, so a very long word right after a preposition can produce a looser line. A glued group wider than the line wraps at the column edge.
- Matches cannot cross styling boundaries: in
k *lesu*thekis not bound, because the bold word is a separate element. - Initials are a heuristic. A sentence ending with a bare capital
(
… vitamin C. Nový výzkum) looks like an initial and gets bound too; useinitials: falseif that bothers you. - Text copied from a PDF may contain the invisible U+200B characters that
linksinserted, and jump-to-source is not preserved for the rewritten URL text (it is for everything else).
Version history
| Feature | 0.1.1 | 0.2.0 | 0.3.0 | 0.4.0 | 0.4.1 |
|---|---|---|---|---|---|
| One- and two-letter words | via ~ |
box, source-safe | configurable | yes | yes |
| Titles and abbreviations before a name | partial | yes | yes | yes | yes |
Titles after a name (Ph.D., CSc.) |
– | yes | yes | yes | yes |
| Chaining of words and titles | – | yes | yes | yes | yes |
| Debug visualisation | – | yes | + mid-document toggle | yes | yes |
Initials (M. J. Hegel), compound Ch. |
– | – | yes | yes | yes |
Per-language sets, lang forcing, on/off |
– | – | yes | yes | yes |
| Digit groups, units, dates, ratios, phone numbers | no | no | yes | yes | yes |
Number + counted noun (500 lidí, strana 2) |
– | – | opt-in | opt-in | opt-in |
Compound abbreviations (a. s., ČSN 01 6910), dashes |
– | – | yes | yes | yes |
| URL and e-mail break points (ÚJČ, via U+200B) | – | – | yes | yes | yes |
Legal citations (písm. a), čl. 5) |
– | – | – | – | yes |
Legal identifiers (12 Nt 345/2026, s.r.o.) |
– | – | – | – | opt-in |
| Compile time, 63 pages (p50) | 1.17 s | 0.79 s | 2.01 s | 1.43 s | 1.43 s |
Compile times are not like-for-like across versions: 0.1.1 and 0.2.0 implement fewer rules, so they have less work to do and still lose. Full notes are in CHANGELOG.md.
Development
python3 tests/breaks.py # line-break regression test (needs typst, pdftotext)
typst compile --root . examples/stress-test.typ # every rule, with debug frames
tests/breaks.py sweeps the page width so that a line break lands in every
gap of each glued unit, and fails if a unit ever splits. The benchmark and
the typographic equivalence check against the previous release are described
in examples/benchmark.
License
MIT, © 2025–2026 iamanro.