vertext-core 0.2.0

Unicode-aware layout planning for top-to-bottom, right-to-left text
Documentation
  • Coverage
  • 61.36%
    27 out of 44 items documented0 out of 15 items with examples
  • Size
  • Source code size: 78.92 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 903.34 kB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 2s Average build duration of successful builds.
  • all releases: 2s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • bayanasar/vertext
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • bayanasar

Vertext

Unicode-aware top-to-bottom text with columns advancing right-to-left.

Vertical text is its own layout system, not a rotated horizontal one. Glyph orientation, column progression, and Latin handling are decisions the layout model makes explicitly; nothing here is a 90° transform on a horizontal engine.

Design documentation lives in docs/: docs/OVERVIEW.md for the engine and the documents that consume it, and docs/ARCHITECTURE.md for how the layout works end to end. PROGRESS.md records which claims have actually been run.

Run the demo

./examples/render.sh

That builds the binary, copies the extension into examples/_extensions/, and renders examples/quarto-demo.qmd. Both the copied extension directory and the rendered output are generated and git-ignored; examples/render.sh produces them from nothing.

extensions/vertext is the source of the filter. It is not the only copy: extensions/vertext-theme vendors its own, because Quarto requires an extension that uses another to carry it inside its own _extensions/. Two copies of a filter in one repository will drift, and these two have. Refresh the vendored one with cp -R extensions/vertext extensions/vertext-theme/_extensions/ rather than editing it — examples/test-extension.sh asserts the two are byte-identical, and CI's examples/test-column-budget.js reads both copies so a fix applied to one of them fails there.

Use it in a Quarto document after copying the extension to _extensions/vertext (or installing it with quarto add once this repository is published):

---
filters: [vertext]
format:
  vertext-html: default
---

::: {.vertext}
山川异域,风月同天。
:::

The extension invokes the vertext binary at render time, producing static HTML. No browser JavaScript is required.

Orientation

Not everything in a document wants to be vertical. Each block decides:

Content Rendering Decided by
CJK / Mongolian paragraph Vertical columns East Asian Width majority
Latin-majority paragraph Horizontal block, 66ch The same measure, inverted
Fenced or indented code Horizontal block, 80ch, monospace Markdown declared it code
Table Rows become columns Markdown declared it a table
::: {.vertext .vertext-code} Vertical code The author declared it

The majority is measured in ink, not characters: ideographs and Mongolian count double, because 山川异域 is four characters and a whole clause while "It is a truth universally acknowledged" is thirty-eight for a comparable amount of meaning. Punctuation does not vote.

There is exactly one heuristic in the system and it only ever chooses an orientation. Prose is never distinguished from code by guessing — markdown already says which is which, and where it does not, the author can.

Consecutive horizontal blocks stack: a heading sits on top of its paragraph rather than each claiming a slot beside the columns.

Progression

Columns advance right-to-left for CJK (vertical-rl) and left-to-right for traditional Mongolian (vertical-lr). Both are facts of their scripts, and getting it backwards does not look wrong — it reads the document in reverse.

vertext-progression: lr    # traditional Mongolian

It is declared rather than detected because it cannot be inferred: a Chinese document teaching Mongolian and a Mongolian document teaching Chinese contain the same scripts and want opposite answers. Default is right-to-left.

Whole-document mode

vertext: true          # lay the document out vertically, keep the page chrome
vertext-page: true     # additionally make the page itself a vertical surface

vertext: true suits a document inside a site — navbar, sidebar, and table of contents keep working. vertext-page: true takes over the body and suits a standalone document. Either way no ::: {.vertext} fence is needed; explicit fences still work for laying out one region of an otherwise horizontal page.

What markdown survives

The filter flattens each block to characters before handing it to the binary, so structure only crosses the boundary where the wire protocol carries it:

Markdown Result
Headings (#######) Own block, level-scaled, with a section rule
Fenced / indented code Own block, horizontal, indentation preserved
Tables Real <table>; rows become columns, cells never hyphenate
Bullet / ordered lists Each item is its own block, oriented on its own. An ordered item carries its number as text
Paragraphs Own block, oriented by the rule above
Emphasis, links Flattened to their text. The markup is lost
A list nested inside a list item Flattened into that item's text
Mongolian inside a code block Rendered in the monospace face, so its letters stay isolated instead of joining

The last three rows are real limitations, not rounding errors: *emphasis* arrives as the bare word, and bichig inside a fenced block keeps the monospace face deliberately — a family change mid-line would break the column alignment that is the reason code is set in monospace at all. The cost is that those letters do not join, which is the failure this library exists to prevent, appearing in the one place it is chosen rather than suffered. Declared degradation beats pretended fidelity, but only where the reader will see the declaration. Each construct needs its own marker in the protocol before it can be rendered as itself, and until it has one it belongs in this table rather than being silently implied.

Items are classified one at a time on purpose. Flattening a list into a single string welds the items together and classifies them as a lump: six mostly-Chinese items that each carry a Latin term add up to a Latin-majority blob, and the whole list turns horizontal.

Whole-page vertical flow

Add vertext-page: true to the document's YAML and the page itself becomes a vertical surface: writing-mode: vertical-rl on the body, so the title, the headings, and the prose between strips all flow top-to-bottom with columns advancing right-to-left, and the document scrolls horizontally from the right edge. This is the browser's native vertical flow — the strip is not a box embedded in a horizontal page.

The document title is re-rendered through the binary as a level-1 heading and Quarto's own title block is hidden, so the title obeys the same layout rules as the body it heads. Rotating it with text-orientation: sideways would have been a transform wearing the costume of vertical text.

Collapsing the top strip

On a vertical page, depth is the scarce axis: a navigation strip that costs 11rem out of a 100vh column takes a tenth of every line of text, on every page. Horizontally the same strip costs nothing anyone notices.

So the extension ships a control that hands that depth back, and a theme opts in by publishing its top-edge depth as a custom property:

:root {
  --vertext-nav-depth: 11rem;        /* the strip AND the content inset read this */
  --vertext-nav-depth-collapsed: 2.1rem;   /* optional; this is the default */
}

Every rule that would otherwise write the number — the strip's own size, the content region's top inset, any calc() deriving a height from it — must read var(--vertext-nav-depth) instead. That is the whole contract: one value moves, and the geometry follows.

The extension then builds the button itself and appends it to the strip, which it finds by [data-vertext-edge="nav"], falling back to Quarto's #quarto-header. Collapsing sets vertext-nav-collapsed on the body; the choice is remembered in localStorage. The strip shrinks to a bar that still carries the button — never to nothing, because a control you cannot get back to is a one-way door, not a collapse.

A page that does not declare --vertext-nav-depth gets no button at all. That is deliberate. A theme still baking its depth into a build-time constant would otherwise get a control that renders, clicks, flips a class and moves nothing — which is worse than no control, because it looks like it worked. Reading the computed property back is the one check that proves the geometry really is a runtime value.

Two optional attributes on <html> localize the tooltip, which defaults to English: data-vertext-nav-label and data-vertext-nav-label-collapsed.

Layout

Columns run top-to-bottom; a source newline starts the column to the left. Layout::progression carries the advance direction as data — RightToLeft (vertical-rl, CJK) or LeftToRight (vertical-lr, traditional Mongolian) — because progression is a property of the script, not a property of the engine. The renderer stamps it on the root as data-column-advance and the stylesheet follows.

What the engine does not decide

Line breaking, and with it the kinsoku rules, belong to the host. The core places glyphs into slots and hands you columns; it does not decide where a column ends. A vertical column's length is the CSS budget --vertext-column-height, and wrapping inside a horizontal block is the browser's, which is the only party here holding font metrics.

That is a consequence of the core being metric-free, not a postponement. To forbid a column from beginning with or ending with you must first own the break, and to own the break you must measure — which means fonts at layout time, which is the one thing vertext-core cannot have while it stays pure enough to cross wasm32 with no I/O. So today: no kinsoku, no widow and orphan control, no line-adjustment (追い出し / 追い込み). A host that needs them must supply them, and the print path would need the core to grow metrics first.

What the engine does guarantee at a break is narrower and worth stating: a Mongolian run is never split, a suffix separator never becomes a break opportunity, and a Latin word breaks only at a hyphen it already contained or at the declared cap, with the hyphen visible.

Prose Latin slots are capped at 12 characters; long words are hard-wrapped with a visible hyphen. Code blocks use 24 so conventional compound identifiers stay intact. The caps are declared once, in vertext-html, and published to CSS as custom properties — a cap written in two places drifts, and a drifted cap truncates silently instead of wrapping visibly. Dictionary-aware hyphenation is a later opt-in because it needs a language tag and a hyphenation dictionary, and a hyphenation without a language is a guess.

A Mongolian run is kept whole, and U+202F NARROW NO-BREAK SPACE is kept inside it. In bichig that mark is not a space between words but the joint inside one: ᠮᠣᠩᠭᠣᠯ + U+202F + ᠤᠨ is the genitive "Mongolia's". The separator holds the stem's last letter in its final form, opens the suffix in its initial form, and forbids a break between the two — UAX #14 gives it class GL. Unicode nevertheless gives it White_Space=Yes, so an engine that asks only is_whitespace sets every case ending as a separate word: a half-em gap in the column with the suffix stranded a row below it. It joins only where Mongolian holds it on both sides; anywhere else — French before a colon, digit grouping — it is the narrow space its name describes and keeps its own slot.

Workspace

Crate Role
vertext-core Pure layout engine. Text in, positioned slots out. No I/O, no DOM — it must cross wasm32 unchanged.
vertext-html Shared Layout → HTML renderer and the mode protocol. Every HTML host goes through it, so the slot-to-class mapping exists once.
vertext-cli Thin stdin-to-stdout shell over vertext-html.

One host ships. The Markdown/Quarto extension in extensions/vertext is in production. Every other adapter is a design document and no code — they are indexed in docs/ROADMAP.md, with what each is waiting on:

Product Status
Markdown / Quarto extension (extensions/vertext) Done — shipping in production
Quarto theme Partly real — extensions/vertext-theme/ ships the SCSS and the nav collapse; the design describes more
chaji 侘寂 (Flutter) Design only — layout theme over vertext-core, sibling to the wabisabi widget kit
Browser extension Design only — blocked on vertext-wasm
Notes Design only — blocked on vertext-wasm and slot geometry
Neovim plugin Design only — an honest lossy projection onto the terminal grid
Web IDE Design only
Browser Design only, last in the queue

The markdown path is complete: the filter renders real documents end to end and is in production. It is a Pandoc filter with four Quarto-specific calls, so other Pandoc-based generators are a small port; non-Pandoc generators (Hugo's goldmark, remark, python-markdown) each need their own adapter over the same wire protocol.

Two crates are still ahead. vertext-wasm wraps vertext-core and vertext-html so the browser targets render byte-identically to the CLI, and unblocks three products. Slot geometry — retaining slot positions and the map back to a source offset — is the other, and every product that lets a reader place a caret is blocked on it: Notes, the Web IDE, the Neovim cursor mapping, and editable text in chaji.

Tests

cargo test --workspace         # layout engine and renderer
./examples/test-extension.sh   # the Quarto filter, through real `quarto render`
node examples/test-nav-toggle.js   # the collapse control's branches, in a stub DOM

The last one exists because the other two cannot run a script: cargo test stops at the renderer and test-extension.sh greps markup. It covers the opt-in rule, persistence, and the way back — and it explicitly does not cover hit-testing or layout. A control that renders in the right place and is unclickable has shipped from here before, with every check green the whole way; only a real browser driving a real mouse event catches that, and a consuming site's tools/check-nav-toggle.py is that test.

Getting a browser and a pandoc without installing Quarto

Neither of the last two needs Quarto itself, and neither needs root. This is worth writing down because "no browser here" was believed on this project for longer than it was true, and a control shipped unclicked on the strength of it.

uv pip install --target . pypandoc_binary      # pandoc + lua, ./pypandoc/files/pandoc
npx @puppeteer/browsers install chrome@stable  # a real Chrome, into ./chrome
npx sass extensions/vertext-theme/vertext-theme.scss theme.css

With those three, the whole crossing can be driven by hand: pandoc parses the markdown, the real vertext.lua runs over the AST with Quarto's four quarto.* calls shimmed, and the real vertext binary lays it out. For the collapse control, build a page carrying the compiled theme and the NAV_TOGGLE block read straight out of vertext.lua — extracted, never retyped, or the thing under test is a copy of it — serve it, and point check-nav-toggle.py at it with Chrome started as:

chrome --headless=new --remote-debugging-port=9222 --window-size=1400,900

Run the check against the previous commit as well. A rig that cannot reproduce the failure is not evidence that the failure is gone.

Layout invariants and the mode protocol are unit-tested; the README's 山川异域,风月同天 sample is pinned as a golden. Correctness claims for a script require a reference rendering behind them — "it parses" is not "it renders".

The unit of layout is the UAX #29 extended grapheme cluster, so a variation selector stays with the ideograph whose glyph it selects, a combining mark with its base, and a ZWJ emoji sequence with itself. This is the reason vertext-core has its one dependency, unicode-segmentation: cluster boundaries are table-driven, and a hand-rolled approximation renders text that is wrong in exactly the ways a reader notices and a test does not.

The extension test document is deliberately multi-byte. Marker stripping in the filter is byte-oriented, and an ASCII-only fixture cannot catch a pattern that corrupts neighbouring CJK.

License

MIT.