Skip to content

0.130 → 0.131 — font family in richtext, a host font library, and tables as pipes or HTML

A writer sets a run of richtext in a font family, spelled in markdown as a qm-span pair, and an app hands the engine a font library of its own to choose families from. A table writes as a GFM pipe table or as HTML, which spells merged cells, and its layout leaves the qm-* carrier: qm-table and qm-cell no longer spell a table's layout, so markdown 0.130 exported with either loses the table's widths, placement, header row setting and cell alignment when it is read back. The sections up to Tables are the changes no type checker reports; the table closing the guide lists every break.

Stored rows

A row holding a font_family mark, an element named table, a merged table cell or a table head 0.130 cannot draw saves under quillmark/document@0.131.0, the first tag that can hold them: 0.130 refuses the element as corrupt and draws the rest wrong. Every other row saves under @0.129.0 as before, so a 0.130 build reads it, byte for byte but for the one-time settles below. A 0.130 build refuses a row under the new tag, as a build too old for it, so deploy every reader before a writer saves one, and a writer rolled back to 0.130 cannot open the rows 0.131 saved under it.

A body mark spanning a line break loads as one mark per line. Normalization cuts a formatting mark at each \n, the form markdown export writes, so **a\⏎b** imports as two marks, a link over a break as two links with one url, and a MarkOp::Add across lines reads back split. A stored row holding such a mark saves to new bytes on its first load, so a hash over it recomputes once. A table cell's or footnote's marks still span its <br>.

A character holds at most one link. link is keyed like font_family: stored overlapping links settle to one url per character, the later-starting one winning, and a MarkOp::Add of a link over a link replaces its url there. Markdown could already write only one, so exported markdown is unchanged.

A table's header rows are head, a count, and headless: true sits beside head: 0, so a 0.130 build, which reads headless alone, draws no header row there. A stored headless: true normalizes to head: 0 beside it: the row loads as before and rewrites once when saved, so a hash over its stored bytes recomputes. A richtext field value holding such a table reads as content, exporting as a table and revising from markdown as one.

A table whose grid pads past MAX_TABLE_CELLS slots is refused as Invariant::TableTooLarge, on load, on overwrite and on an island op, before the mint pads it.

A cell's colspan and rowspan span it, where 0.130 carried them as keys it did not name. Normalization settles each to what it can cover: empty cells no other span covers, inside the grid and its head or body section. A span past that stores shorter, and a covered cell's colspan and rowspan drop, so a host that stored either key under another meaning reads it rewritten, and the row rewrites once when saved, under @0.131.0 where a span still covers more than one slot. A covered cell keeps its align and valign, which it draws again once no span covers it, though markdown, which spells no covered cell, drops them. A cell holding a mark, a comment's anchor included, is no placeholder a span covers.

Document.currentStorageVersion() (WASM) and Document.current_storage_version() (Python) return the newest tag, @0.131.0, though most rows still save as @0.129.0. A check that treats any other tag as a version mismatch now flags rows this build wrote: let fromStored decide, and read the tag only once it throws.

Markdown

<qm-span family="Carlito">text</qm-span> inside a line sets its text's family, where 0.130 dropped the tags and warned parse::dropped_construct (qm-span). Markdown that spelled one now imports with a font mark and renders in that family. Pairs nest, and the outer family resumes past an inner pair. A character holds one family, so export writes nested pairs back side by side, and a MarkOp::Add of a family replaces the family under its range. An attribute other than family drops alone and warns qm-span[<name>]. A tag setting no family, or a pair left open, drops and warns qm-span as before, so a family value no family takes, empty, spaced at an edge or holding a control or bidi character, warns twice: qm-span[family] and qm-span. A qm-span tag line, which 0.130 read as an element named span around the blocks below it, drops and warns qm-span, and the blocks stay.

Markdown Syntax has the full rules.

Rendering

A Typst render sets a font run's family ahead of the font list in force, so a character the family lacks falls through to the surrounding font. A family the host library lacks keeps the surrounding font and warns typst::unknown_font_family once per family and field, at the field's path, on every compile. Code spans and equations keep their own font. An acroform render ignores the mark.

An app adds fonts through the engine: Engine::with_fonts(files), the fonts option of new Engine(..) in JavaScript, Engine(fonts=..) in Python, and --font on quillmark render and quillmark workspace. The embedded Roboto still loads only for a quill shipping no fonts. A file other than .ttf, .otf, .ttc or .otc fails the engine with engine::font_unsupported, and one holding no face Typst reads with engine::font_unreadable. A font run sets only a family the library holds: a quill's own fonts and the embedded Roboto set the plate, never a run. A library face sets only marked text: a plate naming its family, a missing bold or italic, and glyph fallback never reach it, so a document without font marks renders as it did on 0.130. engine.fontFaces(quill) (WASM), Engine.font_faces(quill) (Python) and Engine::font_faces (Rust) list the library's faces, with their bytes. A Rust app on the quillmark facade reaches the library as quillmark::FontLibrary and the workspace writer as quillmark::typst_workspace::workspace, both behind the typst feature.

Tables

qm-table and qm-cell

<qm-table widths="2 1"> around a pipe table is an ordinary element named table around a bare table, which a quill renders as if the tags were absent, warning typst::unregistered_element; <qm-cell align="right">42</qm-cell> in a cell imports 42 and warns qm-cell. A host that keeps documents as markdown converts each table holding either: import it on 0.130, which still reads the carrier, and export the content on 0.131, which writes HTML, or rewrite it by hand. 0.130 drops an HTML table, so the rewritten markdown needs 0.131:

<table widths="2 1" align="center">
<thead>
<tr><th>Item</th><th>Qty</th></tr>
</thead>
<tbody>
<tr><td>Total</td><td align="right">42</td></tr>
</tbody>
</table>

A headless wrapper is a table with no <thead>, its rows <td> cells in a <tbody>. A pipe table's delimiter row spells its column alignment, which HTML spells aligns="none right" on <table>. Loading a stored row on 0.131 and exporting it writes this spelling for every table that needs it.

Export

to_markdown writes an HTML table for a table holding widths, a placement, a cell's align or valign, a span, or more or fewer than one header row, where 0.130 wrote a pipe table inside a qm-table wrapper with qm-cell pairs. Every other table writes as pipes, as before, and a <table> holding nothing pipes cannot write re-exports as pipes. A consumer reading exported markdown with its own parser reads an HTML block for such a table: GitHub draws its grid and spans and shows the markdown in its cells as written.

Import

An HTML block opening <table> imports as a table, where 0.130 dropped it and warned each of its tags, table, tr and td among them. Markdown holding a raw HTML table renders it now. What the subset does not hold warns parse::dropped_construct:

  • table for a table its block ends before its </table>: a blank line inside a table ends it, and the rows after it drop as an HTML block of their own, warning their tags;
  • table:row for a row too short for the table, which is as wide as its widest row;
  • table:text for text outside every cell, and a tag's name, such as caption, for any other tag;
  • table:size for the rows past MAX_TABLE_CELLS (100 000) slots, rows times columns, which drop, a pipe table's as an HTML table's;
  • <tag>[<attr>], such as td[nowrap] or table[widths], for an attribute the subset does not read or a value outside its spelling; border, class, scope and style drop without a warning.

What follows a </table> in its block, on its line or the next, reads as markdown of its own, so prose written tight under a table imports. qm-table[<attr>] and qm-cell[<attr>] no longer occur.

Fixes that change output

An acroform render binds a richtext array element holding no text as an empty line, where 0.130 skipped it, so a blank line between groups reaches the widget. A list item with nothing after its marker ends at the blank line below it, so a code fence after that blank line opens outside the item, and a card fence inside that code block stays code, where 0.130 registered the card.

Every break

Break Surface Action
A row holding vocabulary @0.129.0 lacks (§ Stored rows) saves as quillmark/document@0.131.0, which a 0.130 build refuses stored documents Ship readers before writers
currentStorageVersion() / current_storage_version() return quillmark/document@0.131.0, the tag a row holding vocabulary @0.129.0 lacks saves under WASM, Python Stop comparing a row's tag to it; catch fromStored instead
An inline qm-span pair imports as a font_family mark, where it dropped and warned qm-span imported content, rendered output, Parsed.warnings None
A qm-span tag line drops and warns qm-span, where it opened an element named span imported content, Parsed.warnings Set the family with an inline pair inside each line
parse::dropped_construct names qm-span[<attr>] for an attribute a qm-span pair cannot read Parsed.warnings, doc.warnings, revise warnings Drop the attribute, or let the warning stand
A font run in a family the host library lacks warns typst::unknown_font_family Typst session warnings, RenderResult.warnings Supply the family, or treat the code as an error where a hard failure is wanted
MarkKind gains FontFamily { name }; StoredDocument gains V0_131_0, and STORAGE_V0_131_0 is the tag a row holding vocabulary @0.129.0 lacks saves under Rust An exhaustive match adds the arm
ContentMark gains a font_family member, and the content dicts Reader.get_content / get_content_at return hold font_family marks WASM, Python A switch over type handles it
TypstBackend is a struct holding a font library: the unit value TypstBackend is TypstBackend::default(), or TypstBackend::new(Arc<FontLibrary>) Rust Construct it
quillmark_typst::workspace::workspace, re-exported as quillmark::typst_workspace::workspace, takes a &FontLibrary third Rust Pass &FontLibrary::default() for no library
A refused font in new Engine({ fonts }) rejects every verb that loads the render build, acroform renders included, with the same error each time; the fonts are parsed once WASM Catch the rejection on each verb, or build a new engine without the font
quillmark workspace --font writes fonts/host-<n>.<ext> beside the quill's fonts CLI None
qm-table is an ordinary element and a qm-cell pair drops, warning qm-cell, so the table imports without their layout imported content, Parsed.warnings, doc.warnings, revise warnings Rewrite the table as HTML
A table holding layout, alignment, a span or other than one header row exports as an HTML table exported markdown None; a consumer parsing the markdown reads an HTML block
An HTML block opening <table> imports as a table, where it dropped rendered output, imported content None
An HTML table warns table, table:row, table:text and <tag>[<attr>], and a table past the cell bound table:size; qm-table[<attr>] and qm-cell[<attr>] are gone Parsed.warnings, doc.warnings, revise warnings Match the new constructs
A stored table past MAX_TABLE_CELLS slots is refused as Invariant::TableTooLarge fromStored, overwrite, island ops Split the table
A stored headless: true gains head: 0 beside it stored documents None to load; a hash over stored bytes recomputes
A body formatting mark spanning a \n normalizes to one mark per line, a link to one link per line stored documents, imported content, Content.marks None to load; a hash over stored bytes recomputes, and a reader expecting one mark over a break reads several
link is keyed: overlapping links settle to one url per character, and a MarkOp::Add of a link replaces the link under its range; MarkKind::is_keyed holds for Link stored documents, Content.marks, mark ops, Rust None to load; a hash over stored bytes recomputes
A cell's colspan and rowspan span it and normalize to what they can cover; a covered cell's span keys drop stored documents, rendered output Store a span only where the editor merges cells; a hash over stored bytes recomputes
The Typst backend draws every header row in table.header and a span through table.cell(colspan:, rowspan:), leaving covered cells out Typst rendered output A plate addressing cells by x and y addresses a merged cell by its first slot
TableLayout.headless is head: usize; TABLE_KEYS names head; carrier::CELL and carrier::TABLE are gone and RESERVED holds anchor alone; Dropped gains Table, TableRow, TableText, TableSize and TableAttr; Invariant gains TableTooLarge; MAX_TABLE_CELLS is new Rust Read head == 0; an exhaustive match adds the arms
TableProps.headless is head?: number, and TableCell gains colspan? and rowspan?; the content dicts Reader.get_content / get_content_at return carry the same keys WASM, Python Read head === 0, or head == 0 in Python