Exporters
Two export packages turn a Folio document into files. They follow the same pattern: a plain async function you can call anywhere (browser, worker, Node), plus a small plugin that adds an entry under a shared Export toolbar menu.
in progress@nextgensoftwares/folio-export-pdf | @nextgensoftwares/folio-export-docx | |
|---|---|---|
| Input | the layout (DocumentLayout) | the document (FolioDocument) |
| Pagination | Folio's: the PDF has exactly the pages you edit | Folio's with layout (fixed, default): page breaks, line heights and gaps written out; or Word's (pagination: 'flow') |
| Text | HarfBuzz-shaped glyphs in embedded subset fonts | Word styles (Normal, Heading 1–6) from the theme, the same fonts embedded |
| Plugin mapping | PdfPainter per custom fragment type or media:<type> | NodeExporter per node type or media:<type> |
| Toolbar entry | Export ▸ PDF | Export ▸ Word (.docx) |
PDF
import { exportPdf, fontFiles } from '@nextgensoftwares/folio-export-pdf';
const bytes = await exportPdf(editor.layout, {
fonts: fontFiles([
{ family: 'Geist', src: '/fonts/Geist-Variable.ttf', weight: [100, 900] },
{ family: 'Noto Sans Arabic', src: '/fonts/NotoSansArabic-Regular.ttf' },
]),
shaper: fontEngine, // recommended: the FontEngine the layout was measured with
title: 'Physics I',
});What it does:
- Draws every fragment at its laid-out position: lines with HarfBuzz-shaped glyphs (Arabic joins, ligatures and kerning match the screen), rects (fills, borders, radii), list markers, images, math and headers/footers.
- Embeds each face as a subset (one instance per variable-font weight) with a ToUnicode map, so text is selectable and searchable.
- Streams pages: reads each page's lazy fragments, draws, compresses and drops them before the next, and yields every
yieldEverypages (default 25).signalcancels;onProgress(done, total)reports. - Writes link annotations for
http(s),mailto,telandftplinks; withdocument, also bookmarks (the outline) and named destinations from the top-level headings (h<block index>and the heading'sid), and#namelinks jump to them.langsets the document language. - Equations from
mathare drawn as vectors when it returns aPdfMathDrawing:mathFromHtml(katexRender)lays KaTeX's HTML out off-screen and records its glyphs (embedded from KaTeX's own font files, selectable), rules and SVG paths, placed on the math box's baseline. - Images honour
objectFit(containletterboxes,covercrops) andborderRadius;resolveSrcmay be async (plugin-media's IndexedDBidb:sources). - Never throws on content: missing images, painters or fonts become warnings (
onWarning), and the fragment is skipped or drawn as a placeholder.
Host hooks: fonts(font) returns the bytes of one face (fontFiles builds this from URLs), images(src) returns image bytes (default: data: URLs; loadImageInBrowser in the browser), rasterizeSvg turns SVG into PNG (rasterizeSvgInBrowser), math(latex, display, size) typesets equations (mathFromHtml(render) in the browser; without it, math is drawn as its LaTeX source), colors overrides text, code, link, marker and highlight colours.
import katex from 'katex';
import { mathFromHtml } from '@nextgensoftwares/folio-export-pdf';
const math = mathFromHtml((tex, display) => katex.renderToString(tex, { displayMode: display, throwOnError: false }));The KaTeX CSS must be on the page (its @font-face rules are where the font files are found; .ttf sources are preferred).
Toolbar entry
import { pdfExportPlugin } from '@nextgensoftwares/folio-export-pdf';
const pdf = pdfExportPlugin({ faces: FACES, exporters: () => composed.exporters.pdf });pdfExportPlugin returns a minimal plugin object (name + ui.toolbar) that exports editor.layout and downloads <first heading>.pdf (or filename). Options: everything in PdfExportOptions, plus faces (used when fonts isn't given), filename, exporters, onDone(bytes, ms) and onError.
exporters takes other plugins' mappings (composePlugins(...).exporters.pdf), merged over painters. It can be the map itself or a getter, which is handy because the export plugin is usually part of the same composePlugins call that produces the map.
Painters for plugin fragments
type PdfPainter = (
attrs: Attrs,
ctx: PdfPaintContext & { fragment: CustomFragment | MediaFragment },
) => MaybePromise<void | PrintFallback | null | undefined>;
interface PdfPaintContext {
page: PageLayout;
ops(raw: string): void; // raw content-stream operators, already in px / y-down space
rect(r: { x; y; width; height; fill?; stroke?; strokeWidth?; radius? }): void;
text(text: string, at: { x; y; font: FontSpec; color?; rtl? }): Promise<void>; // y = baseline
image(src: string, box: { x; y; width; height }): Promise<void>;
}
interface PrintFallback { image?: string; title: string; detail: string; link?: string }A painter is looked up by key: the custom fragment's (or inline atom's) type, or media:<mediaType> for non-image media (media:video, media:audio, media:iframe). It is called with the node's attrs and a paint context that also carries the fragment (its box, in page px, y down). It can:
- draw itself through
ctxand return nothing, - return a
PrintFallback(optional poster image, title, detail line, link): the exporter draws its standard card in the fragment's box, or - return
nullto fall back to the default drawing.
Each painter runs in its own graphics state; a throwing painter is reported as a warning and the default is drawn. This is exactly the shape of @nextgensoftwares/folio-plugin-media's mediaPrintFallback / filePrintFallback, so its map works as-is. See the custom block tutorial for a painter that draws.
DOCX
import { exportDocx } from '@nextgensoftwares/folio-export-docx';
import { fontFiles } from '@nextgensoftwares/folio-fonts';
import { mathImageFromHtml } from '@nextgensoftwares/folio-export-pdf';
const bytes = await exportDocx(editor.folioDoc, {
theme, headerFooter, variables, // the layout's: {{page}} / {{total}} become Word fields
layout: editor.layout, // fixed pagination: reproduce these pages
fonts: fontFiles(FACES), // embedded: Word and LibreOffice draw the same fonts
mathImage: mathImageFromHtml(render), // equations as pictures (default math: 'image' then)
exporters: { callout: calloutToDocx },
});What it produces: real Word styles (Normal and Heading 1–6 derived from the theme, plus code, inline code, quote and caption styles), numbering for lists (a list's listLevel becomes its numbering level: format, text with enclosing counters written as their values, marker font/colour/size/bold, alignment and indents; picture bullets become Word picture bullets, w:numPicBullet with the image and w:lvlPicBulletId on the level, • where the image can't be loaded; lower-greek is written as literal text), tables, images (fetched up to imageConcurrency at a time, SVG with a PNG fallback), equations (OMML, pictures or source text), headers/footers with page fields. Like the PDF writer it never throws on content (onWarning) and reports progress per phase (images, convert, pack).
Fixed pagination (pages like the screen)
Word re-wraps and re-paginates every file it opens. With layout (and pagination left at 'fixed') the export leaves it nothing to decide:
- Fonts:
fonts(font)bytes are embedded (obfuscated, ECMA-376 §17.8.1). Variable fonts become static instances of each weight layout used (HarfBuzz's instancer,@nextgensoftwares/folio-fonts): 400/700 as the family's Regular/Bold, other weights as their own family ("Geist SemiBold"), since Word only knows regular/bold. Glyph advances therefore match layout's, and so do line breaks. - Exact vertical metrics: every line is an exact line of the layout's height (paragraphs whose lines differ get a paragraph per line), the gap above each block is the layout's (no space after, no collapsing surprises), runs are lowered so the reader's baseline (measured: LibreOffice puts it 71.4% down what's left after the ascent and Windows descent) lands on layout's CSS baseline. Indents come from where layout put the lines, tables get the layout's column widths, row heights and cell margins, pictures and display equations are anchored at their boxes.
- Breaks: the first block of each page gets
pageBreakBefore; paragraphs split across pages are cut where layout cut them (a justified part's last line isdistributed so it stays justified), widow control is off, keep rules are dropped (layout already applied them). Tables break between the same rows. - Compatibility:
compatibilityMode14 (Word 2010 layout: Word 2013+ "smart justify" shrinks spaces up to ~20% to fit more words, and LibreOffice emulates it),doNotExpandShiftReturn, no automatic hyphenation, kerning on. Word shows the file in "Compatibility Mode".
pagination: 'flow' keeps the editing-friendly output: styles, keep rules, Word 2013+ layout, Word paginates. The layout must come from the same theme and header/footer (docxExportPlugin passes editor.layout; give it theme and headerFooter getters); a layout with another page setup falls back to flow with a warning.
Measured with tools/fidelity (LibreOffice 24 as the reader): page counts match on every fixture, line breaks match apart from Arabic justification (LibreOffice uses kashida). Word's exact-line baseline rule and its handling of embedded fonts can't be verified on Linux.
Plugin nodes map through NodeExporters, keyed by node type or media:<mediaType>:
type NodeExporter = (attrs: Attrs, ctx: DocxContext) =>
MaybePromise<(FileChild | ParagraphChild)[] | PrintFallback | null | undefined>;Return docx objects, a PrintFallback (rendered as a card paragraph with the title, detail and link), or null / undefined for the default handling. DocxContext gives the node being exported (ctx.node), the theme, inherited direction, current indent and width, blocks(nodes) and inline(nodes) to convert children, prefetched image(src) and warn. Node types without an exporter export their content, with a warning. A PrintFallback.image that the document never referenced (a data: thumbnail, an idb: poster) is resolved and embedded when the card is written (25 MB cap).
docxExportPlugin(opts) adds Export ▸ Word (.docx). Like the PDF plugin it accepts exporters as a map or a getter, theme and headerFooter as values or getters, and faces (font URLs) for embedding.
Wiring plugin mappings
Plugins publish their mappings in FolioPlugin.exporters, keyed by exporter name and then type; composePlugins merges them per exporter. Both export plugins accept the merged map directly:
const composed = composePlugins(schema, [
mediaPlugin({ uploader }),
pdfExportPlugin({ faces, exporters: () => composed.exporters.pdf }),
docxExportPlugin({ theme: () => currentTheme, exporters: () => composed.exporters.docx }),
]);Calling the exporters yourself, use splitExporters: it separates the painter or exporter functions from the resolveSrc helper that @nextgensoftwares/folio-plugin-media ships in the same map (turning stored idb: srcs into fetchable URLs):
import { exportPdf, splitExporters } from '@nextgensoftwares/folio-export-pdf';
import { exportDocx, splitExporters as splitDocx } from '@nextgensoftwares/folio-export-docx';
await exportPdf(layout, { fonts, ...splitExporters(composed.exporters.pdf) }); // { painters, resolveSrc? }
await exportDocx(doc, { theme, ...splitDocx(composed.exporters.docx) }); // { exporters, resolveSrc? }| Export | Signature |
|---|---|
splitExporters (export-pdf) | (map, painters?, resolveSrc?) => { painters: Record<string, PdfPainter>; resolveSrc? } |
splitExporters (export-docx) | (map, base?, resolveSrc?) => { exporters: Record<string, NodeExporter>; resolveSrc? } |
PrintFallback (both) | { image?: string; title: string; detail: string; link?: string } |
Both options objects also take resolveSrc directly.