Skip to main content
Recipe number 25

Cookbook · Chapter 10 · Output & integration

A real PDF with the same fonts embedded

A recital programme exported to PDF. Each face is fetched once, for FontFace and for the PDF, and each heading becomes a bookmark.

On this page
Output
Canvas · PDF
Postext
Tested with Postext 1.4.1
Needs ≥ 1.4.1 · postext-pdf ≥ 1.4.1
Licence
Updated 25 Sept 2026
Code MIT · Text CC BY 4.0
  • Trim 148 × 210 mm
  • 1 column
  • Crimson Text 10/13.3
  • Fraunces
  • Tenor Sans
  • 4 pages
  • Level
  • Postext 1.4.1
  • Laid out in 39 ms
  • 180 lines of code

What you'll build

The programme for a twilight recital, Home from Sea: four A5 pages for a consort of soprano, cello and harp. The cover is night teal, with the title in gilt Fraunces italic and rows of gilt wave scales rising from the foot. Inside, the order of performance is a table with a teal head in Tenor Sans capitals and a tinted total row. The notes are justified Crimson Text. The words of the three songs are poems by Longfellow, Stevenson and Tennyson, set with the indents of their printed editions. The Build the PDF button makes the file you would email to the audience or put on the venue's website. Its four pages match the screen line for line. It embeds only the seven font files the browser loaded, and each heading is a bookmark. For commercial printing, see the print-ready PDF.

This recipe answers

  • How do I export a real PDF in the browser with the fonts embedded?
  • Why do my line breaks change, or PDF words overlap, and how do I load fonts correctly?

The short answer

script.js · lines 15–51in full code
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first).
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}

One download per face: the layout measures it, the PDF embeds it

Ingredients

Type
Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1)
Assets
  • The seigaiha waves on the cover, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)

Method

#1 · One download per face, for the page and for the PDF

The code is the short answer above. Postext measures every word with the faces the browser has loaded when the build runs, and the PDF draws each line where that layout put it. If the PDF embeds a different file, such as a variable font's default instance or a system fallback, each word still starts at its measured position, but its letters have other widths, so words overlap or leave gaps. The recipe therefore fetches each face once. Its bytes become a FontFace before the first build, and the font provider passes the same bytes through decompressWoff2 for the PDF, so the export downloads no fonts of its own.

#2 · Fonts first, then the layout

script.js · lines 363–367in full code
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds.
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Home from Sea · a recital programme' });

registerFaces() resolves only when every face in FONTS has loaded, so the first build measures with the faces the PDF will embed. It is also the only build, because FONTS lists every bold and italic a block of Crimson Text or Fraunces can ask for, and the kit's buildWithFonts finds nothing to add (Measurement cache). That check only helps the screen. If FONTS misses a face, buildWithFonts loads it and runs the layout again, with a console warning when a block is set in that face and none when it is a bold or an italic, but the PDF still gets the closest face the provider has.

#3 · The export, and a list of what it embedded

script.js · lines 371–386in full code
const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 });
const list = (faces) => [...faces].join(', ') || 'none';
offerPdf(() => {
  document.getElementById('pt-actions').prepend(bar);
  return renderToPdf(doc, {
    fontProvider, // the answer: the page's own font files
    resourceBytes: imageBytes, // the cover drawing, as vector paths
    outlines: true, // the default, spelled out: each heading becomes a bookmark
    onProgress: ({ phase, pages, totalPages }) => {
      bar.value = pages / totalPages;
      const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`,
        save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` };
      kitStatus(`PDF · ${says[phase]}`);
    },
  });
}, `${RECIPE}.pdf`);

renderToPdf calls onProgress in three phases: prepare once the fonts and the cover are embedded, pages after each page, and save before it writes the file. Here it asks the provider for ten faces. The status line lists the seven files the provider served, exactly the ones in FONTS, and three stand-ins, all for Tenor Sans, which ships only a regular 400 and is never set in bold or italic here. Any other stand-in points to a face missing from FONTS. If you remove Crimson Text 600, the line reports Crimson Text 600 → 400 and the bold The Harbour Consort on page 4 prints at regular weight. CodePen's preview cannot show a PDF, so the kit's offerPdf replaces the button with two links, one that opens the PDF in a new tab and one that downloads it (Generating PDFs).

#4 · The heading tree is the bookmark tree

script.js · lines 108–116in full code
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };

In the PDF, the bookmarks panel shows the cover's title, Programme with About the music under it, The texts with its three songs, and The performers. The outline follows the heading levels, so choose them with the bookmarks in mind: here, an H1 per section and an H2 per song. The performers has a heading style of its own, an H1 with no page break and no opener, so it shares page 4 with Tennyson's poem and still gets a top-level bookmark. The \\ that breaks the cover title in two becomes a space in the bookmark.

#5 · Title and author from the frontmatter

script.js · lines 80–93in full code
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};

Every frontmatter value is quoted. The cover prints two of them through {author} and {subtitle}, and the PDF's document properties take the title Home from Sea and the author The Harbour Consort from the same block. The date and the venue are attributes of the cover heading. The drawing behind them is one SVG the width of the page, and the PDF keeps it as vector paths.

The whole recipe

// ═══ Postext Cookbook · Nº 025 · A real PDF with the same fonts embedded ═══════════
// https://postext.dev/en/cookbook/pdf-with-embedded-fonts
// Code: MIT · Text: notes original (CC BY 4.0), poems in the public domain · Cover: drawn in code
// Fonts: Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'pdf-with-embedded-fonts';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: one download per face: the layout measures it, the PDF embeds it
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first).
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
// #endregion

const palette = { // eight named colours; every colour in the config links to one of them
  ink: '#1a2326', band: '#0f2a33', // text, a sea-green near-black; night teal: cover and titles
  gilt: '#c9a227', bronze: '#806414', // the accent; deepened to 5.4:1 for small type on paper
  foam: '#e3ebe8', rule: '#b9c6c2', // cover small type and the table's total; hairlines
  muted: '#5c6b70', paper: '#fbfaf6' }; // feet and colophon; the page
// The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.band]] // the defaults'
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // id: teal, never blue

const PAGE = { width: 148, height: 210 }; // mm: an A5 programme
const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 28 }; // mm, mirrored: a 102 mm measure
const LEAD = 13.3; // pt: the leading of text and verse, 1.33 × the 10 pt body
const LABEL = 7.5, TRACK = 0.2; // pt: kickers, feet, table head, date; em: capitals' tracking
const H2 = { italic: true, fontSize: pt(13.5), lineHeight: pt(2 * LEAD) }; // two lines of text

const label = (size, ink) => ({ fontFamily: 'Tenor Sans', fontSize: pt(size),
  letterSpacing: pt(size * TRACK), textTransform: 'uppercase', color: col(ink) });
const title = (size) => ({ fontFamily: 'Fraunces', fontWeight: 300, italic: true,
  fontSize: pt(size), lineHeight: 1 }); // a multiple (gotcha: design-lineheight-multiple)
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default)
const at = (to, edge, y, width) => ({ anchor: { to, edge }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) });

// #region cover: the drawing fills the page; the frontmatter and the heading set the type
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};
// #endregion

const opener = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // kicker, title, rule
  text('kicker', '{attr.kicker}', at('container', 'top-left', 0), label(LABEL, 'bronze')),
  text('title', '{titleText}', at('#kicker', 'below', 1.5), { ...title(26), color: col('band') }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('gilt'),
    placement: { ...at('#title', 'below', 2.5), size: { width: mm(14) } } },
] } };

const foot = (parity, edge, x, content) => ({ kind: 'text', id: parity, content, parity,
  ...label(LABEL, 'muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x),
    y: mm(-MARGIN.bottom / 2) } } });

// #region headings: the heading tree is the bookmark tree
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
// #endregion

const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  colorPalette, resourceTypes: [plain],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  bodyText: { fontFamily: 'Crimson Text', fontSize: pt(10), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), boldFontWeight: 600,
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 },
  headings, headingStyles: [cover, aside], layout: { layoutType: 'single' },
  paragraphStyles: [ // verse: a paragraph per line, never stretched if a line ever turns over
    { id: 'verse', textAlign: 'left', firstLineIndent: pt(0) },
    { id: 'verse-in', textAlign: 'left' }, // a line the poet indented: the body's 4.5 mm
    { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(6.5), lineHeight: pt(9.3),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('band'), headerColor: col('paper'), headerFontFamily: 'Tenor Sans',
    headerFontSize: pt(LABEL), headerBold: false, bodyFontSize: pt(9), cellPadding: mm(1.2) },
  header: { elements: [] }, footer: { elements: [ // no running heads: folios in the feet, 10 mm up
    foot('even', 'bottom-left', MARGIN.outer, '{pageNumber} · {title}'), // verso: the programme
    foot('odd', 'bottom-right', -MARGIN.outer, '{chapterTitle} · {pageNumber}')] }, // recto
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown sample · 162 lines · content.en.mdtitle: "Home from Sea" subtitle: "Three new songs and older water music for soprano, cello and harp" author: "The Harbour Consort" --- # Home \\ from Sea {style="cover" when="Saturday 17 October 2026 · 6 pm" where="The Sail Loft, Kellan Harbour"} # Programme {kicker="Twilight recital · 17 October 2026"} ::resource{id="order"} ## About the music Hester Vane wrote *Home from Sea* for the Harbour Consort last winter, and tonight is its first performance. Its three songs set poems written within ten years of one another, and between them the consort plays older water music in its own arrangements, so that each new song follows a piece the room may already know. The poems follow on the next pages, in the order in which they are sung. Mendelssohn’s boat song rocks in six-eight; then, in Longfellow’s *The Tide Rises, the Tide Falls*, the harp keeps the tide turning and the cello takes the curlew’s call. Fauré wrote his *Élégie* in 1880 as the slow movement of a cello sonata he never finished; its lament leads into Stevenson’s *Requiem*, set almost as a folk song. Debussy’s *La cathédrale engloutie*, a piano prelude that Lior Bensaid has arranged for harp, follows the Breton legend of a church that rises from the sea on clear mornings and sinks again. Last comes Tennyson’s *Crossing the Bar*, which the poet asked to have placed at the end of every collection of his poems. Vane gives it the same place in her cycle. # The texts {kicker="Home from Sea · three songs"} ## The Tide Rises, the Tide Falls :::paragraphs{style="verse"} The tide rises, the tide falls, The twilight darkens, the curlew calls; Along the sea-sands damp and brown The traveller hastens toward the town, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space Darkness settles on roofs and walls, But the sea in the darkness calls and calls; The little waves, with their soft, white hands, Efface the footprints in the sands, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space The morning breaks; the steeds in their stalls Stamp and neigh, as the hostler calls; The day returns, but nevermore Returns the traveller to the shore, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: ::: :::space ## Requiem :::paragraphs{style="verse"} Under the wide and starry sky, Dig the grave and let me lie. Glad did I live and gladly die, :::paragraphs{style="verse-in"} And I laid me down with a will. ::: :::space This be the verse you grave for me: *Here he lies where he longed to be*; *Home is the sailor*, *home from sea*, :::paragraphs{style="verse-in"} *And the hunter home from the hill*. ::: ::: :::pagebreak ## Crossing the Bar :::paragraphs{style="verse"} Sunset and evening star, :::paragraphs{style="verse-in"} And one clear call for me! ::: And may there be no moaning of the bar, :::paragraphs{style="verse-in"} When I put out to sea, ::: :::space But such a tide as moving seems asleep, :::paragraphs{style="verse-in"} Too full for sound and foam, ::: When that which drew from out the boundless deep :::paragraphs{style="verse-in"} Turns again home. ::: :::space Twilight and evening bell, :::paragraphs{style="verse-in"} And after that the dark! ::: And may there be no sadness of farewell, :::paragraphs{style="verse-in"} When I embark; ::: :::space For tho’ from out our bourne of Time and Place :::paragraphs{style="verse-in"} The flood may bear me far, ::: I hope to see my Pilot face to face :::paragraphs{style="verse-in"} When I have crost the bar. ::: ::: # The performers {style="aside"} **The Harbour Consort** was formed in 2019 by three musicians who had played together at the town’s lifeboat-day concerts for years: Morwenna Hale, soprano, Ada Pryor, cello, and Lior Bensaid, harp. Its twilight recitals in the Sail Loft run from October to March. Hester Vane, the consort’s composer this season, writes mostly for voices and small ensembles, and *Home from Sea* is her second song cycle. :::paragraphs{style="colophon"} Set in Crimson Text, Fraunces and Tenor Sans (SIL Open Font License), from the same font files on screen and in this PDF. Poems by Longfellow (1880), Stevenson (1887) and Tennyson (1889), public domain; notes CC BY 4.0. Town, consort and composer are imagined. :::
`; // content.<lang>.md, inlined by the Cookbook const plain = { id: 'plain', name: 'Programme', shortLabel: '', captionPrefix: '', // no "Table 1" numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const row = (who, what, time, more) => [who, what, time].map((content, i) => ({ content, align: i === 2 ? 'right' : 'left', ...more })); // durations flush right const resources = [ { id: 'order', typeId: 'plain', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, // where ::resource sets it, not floated to the foot table: { model: { headerRowCount: 1, columnWidths: [28, 55, 17], rows: [ row('COMPOSER', 'WORK', 'DURATION', { isHeader: true }), // capitals: the head is a label row('Felix Mendelssohn', '*Venetian Boat Song*, op. 30 no. 6', '3′05″'), row('Hester Vane', '*The Tide Rises, the Tide Falls* · Longfellow', '4′20″'), row('Gabriel Fauré', '*Élégie*, op. 24', '6′50″'), row('Hester Vane', '*Requiem* · Stevenson', '3′10″'), row('Claude Debussy', '*La cathédrale engloutie*', '6′15″'), row('Hester Vane', '*Crossing the Bar* · Tennyson', '5′40″'), row('', 'About half an hour, without an interval', '29′20″', { background: col('foam') }), ] } } }, { id: 'cover', typeId: 'plain', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'cover.svg', width: PAGE.width * 10, height: PAGE.height * 10 }, altText: 'Night-teal cover whose lower half is rows of gilt wave scales, fading upward.' }, ]; // #region art: seigaiha, the blue-sea-wave pattern, as gilt rings on night teal const WAVES = 115; // mm from the top edge: where the waves begin, under the venue function coverArt(w, h, top) { // mm: the page, and where the waves begin const R = 12.5; // mm: the radius of one scale const f = (n) => +n.toFixed(2); const rows = Math.ceil((h - top) / (R / 2)) + 1; let out = `<rect width="${w}" height="${h}" fill="${palette.band}"/>`; for (let i = 0; i <= rows; i++) { // top row first: each row hides the lower half of the last const y = top + (i * R) / 2; const glow = f(0.5 + 0.5 * (i / rows) ** 1.3); // half-lit at the top, full gilt at the foot for (let x = (i % 2) * R; x <= w + R; x += 2 * R) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${R}" fill="${palette.band}"/>`; for (const k of [0.9, 0.64, 0.38]) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(k * R)}" fill="none" ` + `stroke="${palette.gilt}" stroke-width="${f(0.09 * R)}" stroke-opacity="${glow}"/>`; } out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(0.12 * R)}" fill="${palette.gilt}" ` + `fill-opacity="${glow}"/>`; } } return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" ` + `viewBox="0 0 ${w} ${h}"><clipPath id="page"><rect width="${w}" height="${h}"/></clipPath>` + `<g clip-path="url(#page)">${out}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Each bold and italic a block may ask for. Tenor Sans has 400 only (gotcha: faked-font-styles). const FONTS = { 'Crimson Text': ['400', '400i', '600', '600i'], Fraunces: ['300', '300i'], 'Tenor Sans': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: the faces first, then the layout, then a check that nothing was missed await registerFaces(); // the answer: every face in FONTS, from its own bytes await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES)); // buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds. const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Home from Sea · a recital programme' }); // #endregion // #region pdf: the export: bookmarks from the headings, a progress bar, the faces it embedded const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 }); const list = (faces) => [...faces].join(', ') || 'none'; offerPdf(() => { document.getElementById('pt-actions').prepend(bar); return renderToPdf(doc, { fontProvider, // the answer: the page's own font files resourceBytes: imageBytes, // the cover drawing, as vector paths outlines: true, // the default, spelled out: each heading becomes a bookmark onProgress: ({ phase, pages, totalPages }) => { bar.value = pages / totalPages; const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`, save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` }; kitStatus(`PDF · ${says[phase]}`); }, }); }, `${RECIPE}.pdf`); // #endregion
Kit · core, fonts, viewer, pdf, images: the same in every recipe · 310 lines// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗

Variations

#Leave the bookmarks out

A one-page flyer or a poster has nothing to bookmark. With outlines: false the PDF has no outline, and it opens with the sidebar closed.

-    outlines: true, // the default, spelled out: each heading becomes a bookmark
+    outlines: false,

#Serve the fonts yourself

Point the fetch at your own copies of the same static WOFF2 files, one per weight and style, under Fontsource's file names. The page and the PDF still share each download.

-    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
+    files.set(file, fetch(`/fonts/${file}.woff2`)
       .then((res) => {
-        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
+        if (!res.ok) throw new Error(`No font file ${file}.woff2`);

Pitfalls

Pitfall

Load every face before layout

Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →

Pitfall

The PDF asks for every weight and style of every family

renderToPdf asks the font provider for the bold, italic and bold-italic faces of every family a block could use, even ones never printed, and a single rejection stops the export. The provider must snap to the nearest weight the family ships and fall back to upright when there is no italic. Fonts embedded in the PDF →

Pitfall

A bold or italic the family lacks is faked on screen, not in the PDF

When text asks for a weight or style its family does not ship (a bold table header in a single-face label font, italic in a sans with no italics), the browser synthesises it on the canvas and in HTML, thickening or slanting the upright face on the same widths. A PDF embeds real faces only, so there the provider's closest face prints plain. Put only faces the family ships in your font list and set every style to match, such as tableStyle.headerBold: false. Fonts embedded in the PDF →

Pitfall

Fontsource latin files drop glyphs outside Latin

The PDF provider embeds Fontsource's latin files, which cover Spanish and Western European text but not →, ≈, ✓, ★, Greek or Central European letters; those glyphs go missing in the PDF. Keep PDF text inside the latin range. Fonts embedded in the PDF →

Pitfall

Quote every frontmatter value

YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984". Document metadata →

Pitfall

Any headings object switches off the H1 page break

By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →

Pitfall

A heading style inherits its level's page break

A headingStyles entry takes every field it leaves out from its heading level, breakBefore included. A contents page or a colophon styled on an H1 after a :::pagebreak inherits parity 'odd' and lands behind a blank page. Give such a style breakBefore: { enabled: false }. Heading styles →

Pitfall

A swapped palette misses design elements and the reference colour

postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →

Pitfall

A design text's lineHeight is a multiple, never a dimension

In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →

Pitfall

Design text overflow defaults to 'ellipsis-end'

A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines. Text, rules and boxes in page designs →

Pitfall

A config is cached by identity: build a fresh object

The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config(). Pages on a canvas →

List in FONTS every bold and italic your text can use. If one is missing, the screen still looks right, because the kit loads the face without a warning, but the PDF prints the provider's nearest face, and only the stand-ins on the status line reveal the swap.

Use static files, one per weight and style. From a variable WOFF2 the PDF embeds only the default instance, so a bold run would print at regular weight (Why a font provider?).

Credits

Text
  • “The Tide Rises, the Tide Falls”, with its indents, as printed in The Complete Poetical Works of Henry Wadsworth Longfellow · Henry Wadsworth Longfellow · public domain
  • “Requiem”, with the indents and italics of the first edition of Underwoods (1887) · Robert Louis Stevenson · public domain
  • “Crossing the Bar”, with its indented short lines, from the first edition of Demeter and Other Poems (1889), in the proofread Wikisource transcription · Alfred Tennyson · public domain
  • The programme, the notes on the music, the performers and the colophon · Ignacio Ferro · CC BY 4.0
Images
  • The seigaiha waves on the cover, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
Fonts
Crimson Text (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)
PDF