crates/nornir-docbook/Cargo.toml
[package]
name = "nornir-docbook"
# 0.1.5 — ONE theme: the render stops making copies of its own output.
#
# `examples/bench.rs` has said since 2026-09-09 that "memory is the real ceiling
# on a big document: 3.2 GB of peak RSS for 3 MB of markdown". One number could
# not be acted on, because it did not say whether the memory was typst laying
# the document out or this crate copying the result afterwards.
#
# * `examples/mem.rs` + `examples/common/mod.rs` — the split. `gunnar-stage`
# (this workspace, zero dependencies, REUSED not rewritten) does the
# per-stage attribution; the example owns only the counting
# `#[global_allocator]`, which a global allocator has to be. The typst half
# of the pipeline is rebuilt to get a boundary inside `export_svg_column`
# and is proved byte-identical to the shipped function before any number is
# taken. ANSWER, measured 2026-09-12 on oden: typst's compile owns
# **2249.6 of 2397.9 MB — 93.8%** — of the peak growth and still holds
# 1449 MB of laid-out frames when it returns. `md_to_typst` is 1.7%.
# * **The layout arena is dropped before the output grows.** The
# `PagedDocument` is dead once the page is serialised and the text runs are
# collected; it used to live to the end of the function with every string
# the function built stacked on top of it.
# * **`svgz::optimize_svg_owned`** — the lossless pass, in the page's own
# buffer. Both its rewrites are strictly shrinking, so the write cursor is
# never ahead of the read cursor. `optimize_svg(&str)` is now one copy plus
# this, so there is one transform and not two.
# * **`dress_svg` / `dress_svg_with_text` take the page by value** and edit it
# in place: the layers go on the end, the new root element goes on the front
# in one move of the body, and no second copy of the page exists.
# * **`merge_svg_pages` consumes an iterator** instead of borrowing a slice, so
# `export(.., Svg, ..)` serialises and drops one page at a time rather than
# holding every page AND the concatenation. `export_svg_pages` and the Svg
# arm now share one compile (`paged_document`).
# * **`SvgzBook::write` runs the optimizer once per page, not twice.** It
# called `to_svgz(page)` and then `optimize_svg(page).len()` for the stats
# line — a second full pass and a second full copy of every page.
# MEASURED, 40 pages of a real 72 KB typst page, two interleaved rounds:
# 234/233 ms -> 166/155 ms, **-32%**, same bytes out.
#
# **RESULT**, one render in a fresh process, interleaved 0.1.4/0.1.5 runs on
# oden 2026-09-12 — and the SVG is identical to the byte in all four rows:
#
# input VmHWM 0.1.4 -> 0.1.5 peak live 0.1.4 -> 0.1.5
# 3 KB 70 -> 70 MB 0% 11.9 -> 10.9 MB -8%
# 29 KB 70 -> 70 MB 0% 37.1 -> 32.6 MB -12%
# 293 KB 357 -> 305 MB -15% 291.9 -> 252.5 MB -13%
# 2929 KB 3195 -> 2685 MB -16% 2915.6 -> 2331.5 MB -20%
#
# **NOT output-affecting**, and proved so from outside the crate as well as
# inside it: the 0.1.4 and 0.1.5 binaries each rendered the 3 KB / 29 KB /
# 293 KB / 2929 KB corpus to a file and `cmp` found no difference — 294 535 423
# bytes identical on the largest. Inside, every changed writer is checked
# against 0.1.4's own spelling of it, kept verbatim in the tests as the
# oracle, and each of the three new guards was seen RED before it was trusted
# (the dressing one was the ONLY test in the suite that caught its planted
# fault; the other 70 passed).
#
# **What is left is typst's.** 2331 MB of peak live against the compile's own
# 2249 MB is 3.5% of headroom — this crate no longer raises the high-water
# mark on a big document at all. Moving the rest means changing how
# `typst-layout` arenas a document or how `typst-svg` grows the `String` it
# serialises one into. That is a fork or an upstream change and is not taken
# here.
#
# 0.1.4 — ONE addition: the PDF gets the memory door the column and HTML had.
#
# * `export_pdf(markdown, meta, &ImageSet)` — `report.typ`, `Target::Paged`,
# typst-pdf, through `build_engine_ext` with the caller's `ImageSet`, no root
# mounted, no fetch. Until now a caller with bytes and no directory (a forge
# reading blobs) could typeset a document to SVG with its pictures and to PDF
# without them; measured 2026-09-12 as gunnar.rs's "View PDF" drawing alt
# text for every badge. `export(.., Pdf, ..)` is unchanged and shares the one
# PDF writer with the new door.
#
# 0.1.3 — ONE repair, and it takes back the trade 0.1.2 wrote on its type.
#
# * The text layer swallowed every link under it. 0.1.2 called that "prose over
# links" and moved on; on gunnar.rs (2026-09-11) it was a README's link to
# its own PDF that did nothing when clicked. The frame walk that places the
# words now also collects every `FrameItem::Link` to a URL, and each one is
# emitted again, after the text, as a transparent box in a
# `<g class="link-layer" aria-hidden="true">` at the same absolute placement
# — so it is on top and takes the click, with `cursor:pointer` in the page's
# own style. Only `http`, `https`, `mailto` and scheme-less targets are
# lifted; a `javascript:` link stays under the text where no click reaches
# it. **Output-affecting** for every `TextLayer::On` page (one more CSS rule
# in its `<style>`, and the layer when it has links); every `TextLayer::Off`
# page is byte-identical to 0.1.2.
#
# 0.1.2 — ONE addition, and it repeals a sentence 0.1.1 wrote about itself.
#
# 0.1.1 said of `export_svg_column`: "typst-svg outlines every glyph to a
# `<path>` — there is no text in the markup for a screen reader to find … a
# caller that needs selectable text wants the HTML export". True of the
# exporter, and it made the typeset page a thing you could look at and not a
# thing you could read: no selection, no copy, no Ctrl-F, nothing for a screen
# reader past the `<title>`.
#
# * `TextLayer` — the trick every PDF viewer has used for thirty years. After
# the compile, the page's `Frame` is walked for `FrameItem::Text` runs and
# each one is emitted ONCE as an invisible `<text>` at its baseline, in its
# size, stretched by `textLength`/`lengthAdjust` onto the advance width the
# typesetter measured — so the selection highlight lands on the ink even
# though the browser is laying out a substitute font. The layer is last (on
# top, which is what makes a drag select), the glyphs are wrapped
# `aria-hidden="true"`, `role` becomes `"document"`, and a `<style>` inside
# the SVG carries the transparency and the selection colour so the page
# works wherever it is dropped.
# **Measured**: +4% of the render time, +23% of the bytes (`examples/bench.rs`,
# `text_layer_cost`). It is a second walk of the frames, not a second compile.
# The trade it does make is hit-testing: an `<a>` typst drew under the layer
# stops taking clicks. Prose over links, and it is written on the type.
# * `export_svg_column` takes the grade as a fifth argument. `TextLayer::On` is
# the default grade and what a browser wants; `TextLayer::Off` is 0.1.1's
# output byte for byte, proved by re-running 0.1.1's pipeline in the test
# rather than by resemblance.
# * `svgz::optimize_svg` grew a precondition it always had: it deletes
# whitespace between `>` and `<` because a typst page has no `<text>` node.
# The layer is therefore added AFTER that pass and never through it — a page
# with text in it that went through would lose the spaces between its words.
#
# 0.1.1 — three additions and one repair, all driven by the first consumer that
# is a SERVER rather than a build step (gunnar-ui, rendering a repository's
# README on request):
# * `ImageSet` — images held in memory, so a caller that has the bytes and no
# directory (a forge reading blobs out of a git tree) can place a picture
# without a filesystem root and without this crate making a network request.
# Its slot table is FIXED at 64, because every distinct virtual path leaks a
# `typst_syntax::FileId` for the life of the process and typst panics past
# 2^16 of them — invisible to a one-shot xtask, fatal to a long-running one.
# The existing remote path still mints one id per distinct URL; that is
# documented on the type rather than changed, because changing it would move
# a cache key that is on disk in every repo.
# * `export_svg_column` — the whole document as ONE tall page at a caller-set
# column width (`column.typ`: no cover, no outline, no page furniture,
# `height: auto`), dressed with `width="100%"` + `<title>` + `role="img"` so
# a browser can scale it and a screen reader can name it. `report.typ` is a
# book; a web page is not.
# * `export_html` — the same compile `export(.., Html, ..)` does, but taking an
# `ImageSet` and answering a `String`.
# * `md_to_typst` now writes every run of text as a typst STRING LITERAL
# (`#"…"`) instead of escaping typst's active characters. The escape list had
# grown to `\ # $ * _ ` < > @ [ ]` plus two separate rules for `/`, each
# added after a real document lost a whole render to "unclosed delimiter",
# and a list cannot close over "inert here, active there". Two characters can
# end a string literal, so the table is complete by construction. It also
# fixes `MAX_BLOB_BYTES` rendering as `MAX \_BLOB \_BYTES`.
# **Output-affecting**: consumers see the same document, spelled differently.
# * An image that nothing can resolve is now its ALT TEXT rather than an
# `#image("/…")` that fails the compile — but only where there is no root
# mounted to resolve it against. A build step with a root still gets the
# failure, because there a missing asset is a broken repository. Found from
# the other side: gunnar-ui rendering this repository's README with an
# `ImageSet` lost the whole page to one unreachable badge.
version = "0.1.6"
edition = "2021"
# GUARDRAIL: the pure, warehouse-free doc RENDER engine lifted verbatim out of
# nornir's `docs/export.rs` — markdown → typst → PDF/HTML/SVG, no shellout, no JS,
# 100% Rust, template embedded via rust-embed. Consumed by nornir (which keeps a
# byte-stable `docs::export` re-export shim) and the doc-book xtasks
# (dwarves/korp/korp-rustfs-s3), which import exactly
# `export_svg_pages`/`export_svg_pages_per_chapter`/`ChapterInput`/`ExportMeta`.
# The assembly/history half (sections/warehouse/release_book/search) stays in
# nornir — it reads the Iceberg warehouse and is genuinely Mímir.
# Publishable as of 2026-08-23: the release cascade reached it. It was held
# back only until something needed it on crates.io, and nornir 0.5.5 does —
# nornir's own publish fails on this crate's absence from the index.
publish = true
description = "The pure, warehouse-free documentation RENDER engine of the nordisk constellation: assembled markdown → typst → PDF / HTML / per-page SVG via typst-as-lib + pulldown-cmark, with the report template embedded at compile time (rust-embed). No shellout, no JavaScript, 100% Rust, deterministic. Lifted verbatim out of nornir's docs subsystem so the render primitive releases on its own cadence and is shared by nornir + the doc-book xtasks; the assembly/history half that reads the Iceberg warehouse stays behind in nornir."
license = "MIT OR Apache-2.0"
repository = "https://git.gunnar.rs/nordisk/edda"
keywords = ["typst", "markdown", "pdf", "svg", "documentation"]
categories = ["development-tools", "template-engine"]
exclude = ["/.nornir/"]
[dependencies]
anyhow = "1"
# A rendered page is COMMITTED as `.svgz` (gzip-compressed SVG) — see `src/svgz.rs`
# for the 7.8x measurement on korp's real book. flate2's default backend is the
# pure-Rust `miniz_oxide`: no C, no zlib-sys, nothing added to the constellation's
# zero-shell / all-Rust build.
flate2 = "1"
serde = { version = "1", features = ["derive"] }
typst = "0.15"
typst-as-lib = { version = "0.16", features = ["typst-kit-fonts", "typst-kit-embed-fonts", "typst-html"] }
# Direct, and only for the font search — which is run ONCE here rather than on
# every `TypstEngine::build()`. Measured on a one-word document, 40 runs:
# 16.30 ms of fixed cost per render before, 14.60 ms after (-1.7 ms, -10%).
# Already in the graph through typst-as-lib's `typst-kit-fonts` feature, so it
# adds nothing to the tree.
typst-kit = { version = "0.15", default-features = false, features = ["embedded-fonts"] }
typst-pdf = "0.15"
typst-svg = "0.15"
typst-html = "0.15"
# typst 0.15 (2026-10-02) moved `PagedDocument`/`Page` out of `typst::layout` into
# typst-layout, which `typst` does not re-export — so it is named directly.
typst-layout = "0.15"
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
rust-embed = "8"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
toml = "1"
chrono = { version = "0.4", features = ["serde"] }
# Pure-Rust (rustls) HTTP client — fetch a remote cover image referenced by an
# absolute URL in the export metadata.
ureq = { version = "3", default-features = false, features = ["rustls"] }
[dev-dependencies]
tempfile = "3"
# ROOT-LAW #0: all nordisk parallelism originates in gatling, never rayon.
# Dev-only, and it is here for a MEASUREMENT rather than for a feature — the
# `parallel` arm of `examples/bench.rs` compares typst's own internal layout
# threading against fanning whole documents through
# `gatling_forkjoin::gatling_map_balanced`. Published as `rotaryengine`; the
# library identity stays `gatling`.
gatling = { path = "../../../znippy-zoomies/gatling", version = "0.1.9", package = "rotaryengine" }
# The per-stage attributor `examples/mem.rs` splits the peak RSS with. Path-only
# and dev-only: it is stripped from the published manifest, so it adds nothing
# to this crate's registry graph. REUSED rather than rewritten — it already
# exists in this workspace (`crates/gunnar-stage`, zero dependencies), it
# already charges nested stages exclusively so the fractions sum to the whole,
# and it already has the per-thread proof that the counting allocator this
# repository wrote before did not. The ~20 lines `mem.rs` does own are the
# `#[global_allocator]` shim, which gunnar-stage deliberately refuses to ship
# because a global allocator is a property of the whole executable.
gunnar-stage = { path = "../gunnar-stage" }
# The compression proof is a PIXEL proof: `svgz::tests` rasterizes a real typst page
# before and after the lossless pass and compares the RGBA buffers. resvg is the SAME
# engine facett-docview renders these pages with in every shipped Manual tab — not a
# second renderer that could agree with itself while both are wrong. Dev-only: the
# render engine itself never rasterizes.
comemo = "0.5"
# resvg/usvg follow the version typst-pdf itself links (0.15.1 -> krilla-svg 0.8 ->
# resvg/usvg 0.47, tiny-skia 0.12), so the graph holds ONE of each; 0.48 is out
# but would be a second copy beside typst's. Move with typst. (2026-10-02)
resvg = "0.47"
usvg = "0.47"
tiny-skia = "0.12"
# Measurement, not decoration. An EXAMPLE rather than `benches/` so that nothing
# pulls a benchmark harness into the crate's graph and the crate stays liftable
# (D21, `gunnar-bitmap`'s rule). Timings are persisted to nornir's `bench_runs`,
# never to criterion.
[[example]]
name = "bench"
# The memory half of the same measurement: which stage owns the peak RSS. A
# separate example rather than a mode of `bench` because it installs a counting
# `#[global_allocator]`, and a global allocator is not something to put in front
# of every allocation of a timing table.
[[example]]
name = "mem"