Quickstart¶
Installation¶
Basic Usage¶
from quillmark import Document, Quill, Quillmark, OutputFormat
engine = Quillmark() # backend registry + render dispatcher
quill = Quill.from_path("path/to/quill") # portable, declarative config data
markdown = """~~~
$quill: my_quill
$kind: main
title: Example Document
~~~
# Hello World
"""
doc = Document.from_markdown(markdown)
result = engine.render(quill, doc, OutputFormat.PDF)
with open("output.pdf", "wb") as f:
f.write(result.artifacts[0].bytes)
Installation¶
Basic Usage¶
// The single root import is the canonical API: Quill/Document (re-exported
// from the internal Typst-less core build) plus the Engine render
// dispatcher. An editor that only validates uses Quill/Document and loads
// no backend: Typst loads lazily on the first render.
import { Document, Quill, Engine } from "@quillmark/wasm";
const enc = new TextEncoder();
// A Quill is portable, declarative data: no engine needed to load it.
const quill = Quill.fromTree(new Map([
["Quill.yaml", enc.encode("quill:\n name: my_quill\n backend: typst\n version: 1.0.0\n description: Demo\n\ntypst:\n plate_file: plate.typ\n")],
["plate.typ", enc.encode("#import \"@local/quillmark-helper:0.1.0\": data\n#data.at(\"$body\")\n")],
]));
const markdown = `~~~
$quill: my_quill
$kind: main
title: Example Document
~~~
# Hello World`;
const doc = Document.fromMarkdown(markdown);
// Rendering goes through the Engine. Its methods are async: the first call
// lazily loads the Typst backend binary; the canonical quill crosses into
// backend memory internally (no manual fromTree/fromJson needed).
const engine = new Engine();
const result = await engine.render(quill, doc, { format: "pdf" });
const pdfBytes = result.artifacts[0].bytes;
Live Preview (Canvas)¶
For editor-style previews, paint pages directly into a <canvas> instead
of round-tripping through PNG/SVG. paint is WASM-only: both the Typst
and pdfform backends support it; probe with engine.supportsCanvas(quill)
before mounting canvas UI, and shares the cached compile with the
byte-output render path.
const session = await engine.open(quill, doc); // compile once (async)
// Surface session-level diagnostics from compile time.
for (const w of session.warnings) console.warn(w.message);
function renderPage(canvas, page, userZoom = 1) {
const densityScale = (window.devicePixelRatio || 1) * userZoom;
const result = session.paint(canvas.getContext("2d"), page, {
layoutScale: 1,
densityScale,
});
canvas.style.width = `${result.layoutWidth}px`;
canvas.style.height = `${result.layoutHeight}px`;
}
for (let p = 0; p < session.pageCount; p++) renderPage(canvases[p], p);
session.free(); // when doc changes
Key contract points:
- The painter owns
canvas.width/canvas.heightand rewrites them on every call (so eachpaintis a full repaint: noclearRectneeded). The consumer ownscanvas.style.*and readsresult.layoutWidth/layoutHeightto size the display box. - Fold
devicePixelRatioand in-app zoom intodensityScale;layoutScalecontrols display size. - If
layoutScale * densityScalewould push either dimension past 16384 px,densityScaleis clamped to fit;result.clampedreports it andresult.effectiveDensityScaleis the density actually applied (a clamped page renders soft at the samecanvas.stylesize). pageCountandpageSize(page)reflect the session's current compile: stable between edits, but re-read them after a committedupdate(doc), which recompiles in place and can change the page count (ChangeSet.pageCount).- In an edit loop, repaint
dirtyPages ∩ visiblerather than every page:
function onEdit(editedDoc) {
const { pageCount, dirtyPages } = session.update(editedDoc);
for (const p of dirtyPages) if (isVisible(p)) renderPage(canvases[p], p);
}
update is transactional: if it throws, the canvas still shows the
last-good compile, so keep it up and surface the diagnostics.
Full design rationale: PREVIEW.md.