Skip to content

0.108 → 0.109 — a container run is a thing you can name, and a projection says so in its signature

Documents are untouched and stored blobs re-encode byte for byte. Every break a type checker reports is Rust, and they come from one place: the content model had two holes where a projection trusted something its types did not carry.

The bindings change additively, which is not the same as changing nothing. Container gains a field a host writing container paths now owes, and omitting it compiles. That is the one break here no checker reports: instance is a writer's obligation.

The first is canonical form. to_markdown and emit_content both walked a container tree assuming normalize had run, and Content says nothing about that. Normalized is that assumption made into a type — which moves the signature of every codec, since a codec is exactly where the assumption is established.

The second is container identity. Two adjacent lines sit in the same container iff their whole container path is equal, so two adjacent runs of one shape had no way to stay apart: [Quote], [Quote] was one two-paragraph quote, and two one-item lists were one item whose second line came back with its marker gone. Container::instance is the field that breaks the tie, and it lands on every arm.

Break Surface Action
The codecs return Normalized; to_markdown / emit_content take it Rust Reads deref through; a mutation takes into_content()into_normalized()
Card::body() returns &Normalized Rust Same
TypedReader / CardReader get_content{,_at} return Option<Normalized> Rust Same
Content::to_canonical_json moves to Normalized Rust Mint first
Container::Quote becomes a struct variant; ListItem / Unknown gain instance Rust .. in the pattern; Container::instance() to read it
instance is a writer's obligation TypeScript, and any producer of canonical content JSON — behavior, not types Stamp adjacent same-shape sibling runs, or they arrive welded
mark_op_to_value / line_op_to_value / island_op_to_value removed Rust None — no caller could have existed; the decoders are unchanged
PdfUpdate::begin / resolve_pages take an &ObjectIndex Rust Build one over the base and pass it to both
reader::find_object_bytes / reader::object_dict Rust (#[doc(hidden)]) ObjectIndex::object_bytes / ObjectIndex::dict
.quillignore reads * as gitignore does Quill bundles A slashed pattern that relied on * crossing / needs ** or a dir/ line
pdfform::svg_parse_failed / png_parse_failed Diagnostic codes pdfform::flat_parse_failed, raised at open instead of at render

Normalized is the precondition, stated

Content::normalize repairs; Content::validate rejects. They are not the same question, and the gap between them is where the crash lived: nothing about canonicalization brings a container path under MAX_NESTING_DEPTH, so a hand-built content could be canonical and still refuse to validate. export::emit_block recursed one call frame per container level and overflowed the stack a few thousand levels down — a SIGABRT no Result catches, against a Typst emitter that checked the depth up front and returned an error for the same input.

Normalized is the mint that closes the trust half, and it is deliberately the weaker of the two claims:

  • It states that normalize has run. It does not state that validate passes, and the mint stays infallible for that reason — canonicalizing is total, checking is a separate question the codecs ask separately.
  • So a projection taking one must be total over any token. to_markdown now walks an explicit frame stack, and emit_content checks the depth and returns EmitError::NestingTooDeep. Neither trusts a bound only validate enforces.

The token derefs to &Content, so a read-only consumer changes nothing:

let rt = from_markdown(md)?;
rt.text;  rt.lines;  rt.marks;  rt.islands;   // all reach through
rt.validate()?;  rt.is_inline();

What moves is a caller that names the type or mutates the value:

// 0.108
let mut rt = from_markdown(md)?;
rt.marks.push(mark);
rt.normalize();
let md2 = to_markdown(&rt);

// 0.109
let mut rt = from_markdown(md)?.into_content();
rt.marks.push(mark);
let rt = rt.into_normalized();          // or: let rt: Normalized = rt.into();
let md2 = to_markdown(&rt);

into_normalized runs normalize — the same call the old code made by hand, now the only way to obtain the token. The full signature list:

Crate 0.108 0.109
quillmark-content from_markdown / from_plaintextContent Normalized
from_canonical_json / serial::from_canonical_value / serial::from_authored_valueContent Normalized
to_markdown(&Content) to_markdown(&Normalized)
Content::to_canonical_json() Normalized::to_canonical_json()
serial::to_canonical_value(&Content) (&Normalized)
quillmark-typst emit::emit_content(&Content) (&Normalized)
quillmark-core Card::body() -> &Content -> &Normalized
Card::overwrite_body(Content) / overwrite_field(_, Content) impl Into<Normalized> — a Content still passes unchanged
TypedReader::get_content{,_at} / CardReader::get_content{,_at}Option<Content> Option<Normalized>

Two things deliberately did not move. to_plaintext still takes &Content: it projects text and drops slot chars, with no walk to make total, and a token reaches it through the deref. And the op channel needs no round trip at all — apply_text_delta, apply_mark_ops, apply_line_ops, apply_island_ops and apply_field_change are forwarded on Normalized and re-establish the invariant on the error path as well as the success one, so an editor loop is unchanged.

A body is checked on the way out too

CanonicalContent's Serialize now validates, failing the write with a serializer error rather than letting a store accept bytes it could not read back. This follows from the token being about canonical form and not validity: Card::overwrite_body takes a caller's content on that token alone, so an invalid shape could reach the DTO. serde_json::to_string(&doc) can therefore fail where it previously could not — reachable only from a hand-built Content, never from a parse or a decode.

Container gains instance

Four defects close with the discriminator, and the first three are visible in the Markdown projection:

  • from_markdown("- a\n\n<!-- -->\n\n- b") — the CommonMark idiom for spelling two lists apart — no longer destroys the second list's marker.
  • Two adjacent ordered lists typeset with their own numbering instead of running 1 2 3 4.
  • 1. a beside a list starting at 3 keeps that start across the round trip.

Adjacent lists now alternate their marker (-/+ for bullets, ./) for ordered), which is how CommonMark itself spells two lists apart, so an authored file needs no comment separator. Adjacent quotes were already separable — the blank line ends one — and an Unknown container has no Markdown syntax at all, so its boundary lives in storage, the lane it has.

For Rust, this is a pattern break on an enum that is #[non_exhaustive] at the enum level (which does not extend to a variant's fields):

// 0.108
match c {
    Container::Quote => ,
    Container::ListItem { ordered, start, ordinal } => ,
    Container::Unknown { tag, attrs } => ,
    _ => ,
}

// 0.109
match c {
    Container::Quote { .. } => ,
    Container::ListItem { ordered, start, ordinal, .. } => ,
    Container::Unknown { tag, attrs, .. } => ,
    _ => ,
}

Container::instance() reads the field off any arm. Constructing one needs it named; any distinct values work, since normalize canonicalizes instance to 0 and flips it to 1 only where the two runs would otherwise weld, so a document needing no discriminator carries none. ordinal is canonicalized beside it, to a gapless 0-based index within its run — [5, 9] and [0, 1] stop being two spellings of the same two items.

Two rules, and a pair that falls between them

  • Container::same_run — "same shape, ordinal and instance aside", start included. The identity rule: what the run walks apply, and what a producer separates its runs against.
  • Container::same_weld — whether the Markdown projection would read two adjacent runs as one, so the canonical form has to spend a discriminator. Coarser for lists: CommonMark reads only a list's first number, so start cannot carry a boundary there and ordered alone decides.

A pair falls between them where start differs. 1. a beside a list starting at 3 is two shapes, so same_run separates the runs and a producer writes no discriminator — and normalize mints one regardless, because Markdown would weld them. Reading instance back on a pair you never spelled it for is that rule, not a repair.

instance is a writer's obligation

On the TypeScript surface instance is optional on all three ContentContainer arms — the wire omits a zero, so a read shape cannot require it — and nothing stops compiling. What moves is what a writer owes:

Consumer Action
Reads content: renders, projects, searches None
Reads, edits, writes back: a ProseMirror↔content codec, any editor host Stamp instance on adjacent sibling runs of one shape
Rust The pattern break above

The middle row is the one to read twice, because a codec flattening a tree writes adjacent same-shape siblings constantly — two bullet_list nodes in a row, two blockquotes — and the flat containers form cannot tell a boundary the writer meant from one it did not. A codec that drops the field:

  • keeps producing what it produced on 0.108, where the boundary was unrepresentable. The four defects above stay open for that host until its codec stamps the discriminator; upgrading does not close them on its own.
  • loses a boundary a 0.109 producer wrote. A row reaching the store from from_markdown, the CLI or any Rust caller carries instance wherever the document holds one. A codec re-deriving paths from its own tree drops it on write-back, and the two runs weld for good — the mixed-version loss below, one version skew short of it.

Nothing reports either, and nothing can. Two adjacent lines with equal paths are one container, which is also how a two-paragraph quote is spelled, so "welded" and "meant it" are the same value. assignInstances in @quillmark/wasm/runtime does the stamping, given one entry per container run in document order.

What an older reader does with it

instance is written to the wire only when non-zero, so every blob written before this release re-encodes byte for byte and its content hash does not move. That is why the key is additive within quillmark/document@0.93.0 rather than a schema-version event.

The cost is in the forward direction, and only for a document that spends the key: a build predating the field ignores it and reads the two runs welded, so a row written on 0.109 and re-saved on 0.108 loses the boundary. If a deployment runs mixed versions against one store, that is the case to know about.

.quillignore reads gitignore's *

A pattern holding more than one * used to match nothing at all — **/*.tmp and *.sublime-* were dead lines. Patterns now compile through glob::Pattern and are matched against both the whole path and the basename, which fixes those and tightens two readings to gitignore's:

  • * stops at /. assets/* covers assets/logo.png and no longer assets/icons/logo.png.
  • A pattern spelling out a / anchors at the bundle root, rather than matching any path that opens and closes with its halves.

Both narrow what a line ignores, so a bundle can gain back a file it used to drop. If you relied on the old reading, assets/** or the directory line assets/ covers the subtree:

# 0.108: dropped assets/icons/logo.png as a side effect of a loose `*`
assets/*

# 0.109: say what you mean
assets/**

No in-tree quill spells either shape. A line also always ignores the name it literally spells out, since [ is both a character-class opener and an ordinary character in a filename: Cinzel[wght].ttf ignores the variable font of that name as well as the class it describes.

quillmark-pdf reads objects through an index

Reading one object from the base walks every byte of it — the live copy is the last revision, so a scan cannot stop early — and nothing memoized that, so a stamp or flatten pass paid O(pages) whole-file scans. Collecting every object header in one pass makes each read a lookup: a 20-page 300 KB form stamps in 0.7 ms rather than 37 ms, flat in page count.

// 0.108
let mut up = PdfUpdate::begin(&pdf, producer)?;
let page_ids = up.resolve_pages(&pdf, fields)?;

// 0.109
let idx = ObjectIndex::new(&pdf);
let mut up = PdfUpdate::begin(&idx, producer)?;
let page_ids = up.resolve_pages(&idx, fields)?;

ObjectIndex is re-exported at the crate root beside PdfUpdate, which needs it to be constructed at all. reader::find_object_bytes and reader::object_dict become ObjectIndex::object_bytes and ObjectIndex::dict; that module is #[doc(hidden)] and outside the crate's semver, so this is a rename for workspace callers rather than a published break.

The op wire is a reading direction

mark_op_to_value, line_op_to_value and island_op_to_value are removed. An op bundle is authored on the JS or Python side and reaches Rust through change_bundle_from_value, so nothing in the workspace ever emitted one and every wire change had to be made twice — the second time in code no product path executes. The decoders are unchanged, and their round-trip tests become decoder tests over literal JSON, which is what the wire actually is.

Quieter consequences

Typst markup for ordered lists. Every ordered run's first item now lowers as N. where a run starting at 1 lowered as +. Stating the number is what resets Typst's running counter so an adjacent list numbers from its own start. The page is identical; the generated markup is not, so anything diffing or golden-comparing Typst source sees it.

Block census counts. The census counts what the projections see, so two adjacent runs of one shape count two where they counted one. A quill declining list or quote reports the construct at a document that has two of them where it reported one, and plate::unsupported_construct moves with it.

A pdfform session opens stricter. Flatten and parse now happen together at open and update, so a malformed flatten surfaces from the call that produced it under one code, pdfform::flat_parse_failed, replacing the per-format pdfform::svg_parse_failed and pdfform::png_parse_failed raised at render. Opening a session fails on that bug now, including for a caller that only ever renders the AcroForm PDF. Both replaced codes were reachable only through a bug in this crate's own flatten.

A variant's container cell expands per property. An object or array<object> cell under variants: rendered as controlled_by: !must_fill # object — a null where the schema wants a mapping, with every property's description, default: and type annotation dropped. A blueprint for such a quill changes shape; no other blueprint moves.

Additive

  • quillmark_content::traverseruns, items, segment, and the Span they yield. The container walks five call sites across three crates had each spelled by hand, each with its own idea of when a run ends. A consumer reading Content.lines groups them the way both projections and the quill census do.
  • Container::instance() and Container::same_run().
  • Content::into_normalized(), Normalized::empty(), Normalized::into_content().
  • RenderError::coded(code, message) — the one constructor for a single-error-diagnostic failure, replacing nine hand-spelled sites and two per-crate engine_err helpers. No code, message or shape changes.
  • Invariant::ContinuesAcrossContainers and ApplyError::ContinuesAcrossContainers. A within-block break lives inside one container, so a continues line whose container path differs from the line above is claiming to continue a block it is not in. normalize clears it, validate catches a hand-built content that skipped normalize, and LineOp::SetContinues refuses the deliberate crossing up front.