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
normalizehas run. It does not state thatvalidatepasses, 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_markdownnow walks an explicit frame stack, andemit_contentchecks the depth and returnsEmitError::NestingTooDeep. Neither trusts a bound onlyvalidateenforces.
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_plaintext → Content |
→ Normalized |
from_canonical_json / serial::from_canonical_value / serial::from_authored_value → Content |
→ 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. abeside a list starting at3keeps thatstartacross 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,ordinalandinstanceaside",startincluded. 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, sostartcannot carry a boundary there andorderedalone 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 carriesinstancewherever 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/*coversassets/logo.pngand no longerassets/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::traverse—runs,items,segment, and theSpanthey 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 readingContent.linesgroups them the way both projections and the quill census do.Container::instance()andContainer::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-crateengine_errhelpers. No code, message or shape changes.Invariant::ContinuesAcrossContainersandApplyError::ContinuesAcrossContainers. A within-block break lives inside one container, so acontinuesline whose container path differs from the line above is claiming to continue a block it is not in.normalizeclears it,validatecatches a hand-built content that skipped normalize, andLineOp::SetContinuesrefuses the deliberate crossing up front.