Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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"