Skip to content

0.90.0 → 0.91.0 — column-zero card fences, hardened input paths

A correctness/security release driven by a full parsing-and-conversion audit (see VULNERABILITIES.md on the repo root). One syntax rule is tightened and several input boundaries now reject what they previously accepted; each breaking change below states its migration. Round-trip and output-fidelity bug fixes that require no action are summarized at the end.

Markdown syntax: the closing ~~~ must be at column zero

Previously the closing fence of a card-yaml block could carry 1–3 leading spaces (inherited from CommonMark's closing-fence rule). It must now be at column zero, exactly like the opener.

Why: the payload between the fences is YAML, where indentation is structural. Under the old rule, a tilde code fence embedded in a block-scalar value —

~~~
$quill: q@1.0
$kind: main
snippet: |
  ~~~
  let x = 1;
  ~~~
~~~

— was mistaken for the block's closer, silently truncating snippet and swallowing the rest of the payload into the body. Under the new rule the indented ~~~ lines are payload and the block parses intact. A column-zero ~~~ can never be block-scalar content (YAML requires scalar content to be indented past its key), so the closer is unambiguous.

Migration: if a document's only closer was indented, the opener now falls through to CommonMark as an unclosed code block (with the standard parse::unclosed_code_block warning) and parsing fails with MissingQuill instead of silently mis-splitting. De-indent the closing ~~~ to column zero. Documents emitted by toMarkdown are unaffected — the emitter has only ever produced column-zero fences.

Parsing: data-field names are validated everywhere

Spec §3.4/§10 restricts data-field names to [a-z_][a-z0-9_]*. This was previously enforced only by the typed mutators; the markdown parse, storage (Document.fromJson), and wire (pushCard / insertCard) paths accepted any key. All paths now reject a non-conforming name with an error naming the key. Unicode and arbitrary characters remain fully supported in values and in nested map keys — only top-level field names are restricted (they always were, per spec; the parser now agrees).

Parsing: the §8 nesting limit holds on every input path

Values deeper than 100 nesting levels were rejected on the markdown parse path but accepted (until eventual stack exhaustion) via storage DTOs, the card wire shape, the mutators, and the Python value converter. All paths now reject them with a clean error. As part of this, Card.set_ext / set_ext_namespace (Rust) now return Result$ext carries the same depth bound.

Quill loading

  • Symlinks inside a quill directory are skipped, not followed.
  • A single file in a quill directory may not exceed 50 MiB.
  • quillmark validate rejects a plate_file containing .. or absolute components instead of probing the host filesystem.

Python binding

  • float('nan') / float('inf') field values now raise ValueError instead of being silently stored as null.
  • Integers above i64::MAX now convert losslessly through u64; integers outside 64-bit range raise ValueError instead of leaking a raw OverflowError.

Also in this release (no action required)

Bug fixes that change output for the better but break nothing:

  • Trailing-comment detection follows YAML 1.2 — x: it's fine # note no longer loses its comment on round-trip (values were always parsed correctly; only comment preservation changed).
  • ![alt](src) renders as #image("src", alt: "…"), carrying alt text into the PDF as accessibility alternate text.
  • Nested map keys needing YAML quoting (:, #, leading indicators, edge whitespace, n/true/123) now emit quoted instead of producing YAML that failed to re-parse.
  • WASM getters throw a named QuillmarkError if internal serialization fails, instead of silently yielding undefined (the success path is byte-identical).
  • Typst diagnostics originating in helper or vendored packages report their own file/line/column instead of main.typ coordinates.