Skip to main content
Recipe number 11

Cookbook · Chapter 10 · Output & integration

One source, print and screen editions

Both editions come from one config: htmlViewer.overrides holds the dark screen design, and applyHtmlViewerOverrides merges it in before each HTML build.

  • Trim 225 × 297 mm
  • 2 columns, 7 mm gutter
  • Newsreader 10/14
  • Gloock
  • Reddit Sans
  • 4 pages
  • Level
  • Postext 1.4.1
  • Laid out in 61 ms
  • 250 lines of code

What you'll build

Notes on Weather, the autumn section of a small magazine, set twice from the same Markdown. On paper it is a 225 × 297 mm issue: a rain-blue divider page, three notes that open under a pale fog band, two justified columns, and drawings of a rain gauge, a fogged valley and a wind rose. On screen it is a single ragged column of Newsreader, pale grey on near-black, in HTML text a reader can select and copy, with no divider page, fog bands or folios. The pane opens on the gauge, shrunk to the pane's height and drawn in night colours, beside the printed page it sits on. Scroll up for the Rain opener, or drag the pane's corner and the text is laid out again at the new size. The screen design is nine keys under htmlViewer.overrides, inside the print config.

This recipe answers

  • How do I show a responsive, scrollable HTML reading view of the same document?
  • How do I give the screen edition a simpler design than the print edition, from one config?

The short answer

script.js · lines 68–97in full code
// config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it.
const screenOverrides = () => ({
  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
  parts: { page: false }, // no divider page; the part still names the notes after it
  // 96 dpi: the mm and pt inherited from print render at their CSS size.
  page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } },
  layout: { layoutType: 'single', // one column
    fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print
  bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading
    // A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force
    // a paragraph taller than the pane whole into a one-line gap under a figure, and off the page.
    avoidWidows: false },
  // Heading levels merge on `level`: the print level keeps everything not restated here.
  headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
    marginTop: px(40), marginBottom: px(26),
    advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air
  footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already)
  captionStyle: { fontSize: px(13), gap: px(10) },
  paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole
});
// The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only).
// Wide panes get wider margins, so the measure stops at MEASURE.
const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px
const MIN_SIDE = 34; // px: the side margins of a narrow pane
function screenConfig({ width, height }) {
  const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object
  const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2));
  return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height),
    margins: { ...merged.page.margins, left: side, right: side } } });
}

The screen edition lives in the same config, as overrides of the print one

Ingredients

Type
Newsreader, Gloock, Reddit Sans (SIL OFL 1.1)
Assets
None: every picture is drawn in code

Method

#1 · One set of colour names, two sets of values

script.js · lines 14–31in full code
const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1',
  rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' };
const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129',
  rule: '#2e3742', muted: '#98a2ae', paper: '#111418' };
const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue
  Object.entries({ ...values, 'main-color': values.rain })
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback
// Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text,
// table and box styles and the page background, but not into design-slot elements or
// bodyText.referenceColor, so rewrite every linked colour from the palette the config carries.
function relink(config) {
  const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex]));
  const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])));
  return walk(config);
}

Every colour in the config names a palette id, and the hex written beside it is only the day fallback. So the screen edition switches to the night colours by replacing one array. The overrides in the short answer merge objects key by key and heading levels on level, and replace any other array whole, colorPalette included. main-color is part of the palette, so the engine defaults that use it change as well. In postext 1.4.1 the new palette does not reach the design elements or the reference colour, so relink() rewrites every linked colour from the palette of the merged config.

#2 · Openers: a band on paper, the words on screen

script.js · lines 37–64in full code
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
  color: col('ink'), align: 'left', ...style,
  overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default)
const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans',
  { fontWeight: 600, textTransform: 'uppercase', color: col('rain') });
const title = text('title', '{titleText}', 'Gloock');
const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true });
const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } });
const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } });
const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } });
const fromTop = (y, x) => at('container', 'top-left', y, x);
// minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs
// from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored).
const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('fog') },
    placement: band(mm(0), mm(BAND)) },
  { kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'),
    placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot
  { ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) },
  { ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) },
  { ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) },
] } };
const screenOpener = [
  { ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) },
  { ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') },
  { ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'),
    placement: under('title', px(10), 'fill') },
];

The kicker, title and lead elements are defined once and spread into both designs, so both editions show the same heading attributes: {attr.kicker}, {attr.lead} and the part's {partTitle}. On paper a level-1 heading breaks to a new page and spans both columns, and its design hangs a fog band from the bleed. minHeight reserves the band's depth below the top margin plus AIR, the 9 mm between the band and the first line. The printed titles are set at 54 pt because each is one short word and the only large type on its page. On screen the same level is set as an in-column heading with no band and no air. The override merges on level, so every heading setting it does not restate stays as printed.

#3 · A divider page that exists only on paper

script.js · lines 101–117in full code
const onField = { color: col('paper'), lineHeight: 1 };
const parts = {
  margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low
  bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'),
    numberColor: col('paper') },
  design: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('rain') },
      placement: band(mm(0), 'fill') },
    { kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') },
    text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9),
      letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }),
    text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190),
      placement: under('series', mm(10), mm(150)) }),
    text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60),
      placement: under('numeral', mm(-6), mm(150)) }),
  ] },
};

In print, :::part opens the rain-blue divider: a box bled off every edge, an image element of rain strokes that leave the type clear, the numeral and title from the fence, and the list of notes in white. The screen overrides set parts.page: false, so no page opens and the list is not set. The part still applies its number and title to the notes after it, and the screen kicker reads “Autumn · Note 1” as the printed one does.

#4 · Draw every figure twice

script.js · lines 215–245in full code
const figure = (id, edition, [width, height], placement, caption, alt) => ({ id,
  typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement,
  svg: { fileId: `${id}-${edition}.svg`, width, height },
  caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt>
const figures = (edition) => [
  figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, {
    en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.',
    es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.',
  }, {
    en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow '
      + 'tube, half full, beside a graduated measuring stick.',
    es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un '
      + 'tubo estrecho, medio lleno, junto a una regla graduada.' }),
  figure('valley', edition, [1400, 600], { position: 'bottom', span: 'page' }, {
    en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.',
    es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.',
  }, {
    en: 'A valley between two hills lies under layers of fog, with a church spire and a few '
      + 'trees showing above it, seen from a fenced bank under a pale sun.',
    es: 'Un valle entre dos lomas yace bajo capas de niebla; asoman la aguja de una iglesia '
      + 'y unos árboles, vistos desde un ribazo con una cerca bajo un sol pálido.' }),
  figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, {
    en: 'Where a year of morning winds came from at the garden station: west and south-west.',
    es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.',
  }, {
    en: 'A wind rose of sixteen petals on three rings; the longest petals point west and '
      + 'south-west, the shortest east.',
    es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos '
      + 'apuntan al oeste y al suroeste, los más cortos al este.' }),
  figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered
];

Swapping the palette does not recolour an SVG, so each drawing is made once per palette. figures(edition) builds the same resources for both editions, each pointing at that edition's file: gauge-day.svg in print, gauge-night.svg on screen. Ids, captions, alt texts and placements do not change, so every :ref in the Markdown resolves to the same figure and number in either edition. In the screen edition the alt text goes into each <img alt>, where a screen reader finds it.

#5 · Host the screen edition

script.js · lines 392–422in full code
const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it
if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but
  // not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column)
  pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both';
}
pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but
// inherited text properties (letter-spacing, text-transform…) can, and the lines were measured
// without them: `all: initial` on the wrapper stops them at the edition's edge.
const inShadow = `<style>:host>div{all:initial;display:block}`
  + `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue
let size = '';
function showScreen() {
  const [width, height] = [pane.clientWidth, pane.clientHeight];
  if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded
  const first = !size; // the first build opens on Figure 1's page, like the print proof
  size = `${width}×${height}`;
  const screenDoc = buildDocument({ markdown, resources: figures('night') },
    screenConfig({ width, height }));
  const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds
  shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc,
    { mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`;
  const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode
  pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages
  screenLabel.textContent =
    `${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`;
}
showScreen();
let timer = 0; // debounced; its first call, on observe(), finds the size unchanged
new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); })
  .observe(pane);

Each screen page is as tall as the pane, so fitFiguresToPage shrinks the gauge until it fits on one page with its caption (layout). At 96 dpi, the resolution CSS assumes, the millimetres and points inherited from print render at their nominal size (integrating the HTML viewer). The pen writes renderToHtml's absolutely positioned lines into a Shadow DOM, where the page's selectors cannot reach them. The wrapper's all: initial also blocks inherited properties such as letter-spacing: the engine measured the lines without them, and an inherited value would make the text overprint. HTML pages are transparent unless painted, so page.backgroundColor takes the night paper from the palette. The ResizeObserver waits 150 ms and rebuilds from a fresh config() only when the pane's width or height has changed; after the rebuild the pane scrolls back to the same fraction of its height.

The whole recipe

// ═══ Postext Cookbook · Nº 011 · One source, print and screen editions ═══════════
// https://postext.dev/en/cookbook/print-and-screen-editions
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Newsreader, Gloock, Reddit Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides,
  clearMeasurementCache, registerResourceImage, defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'print-and-screen-editions';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one set of colour ids, two sets of values: day for paper, night for screens
const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1',
  rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' };
const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129',
  rule: '#2e3742', muted: '#98a2ae', paper: '#111418' };
const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue
  Object.entries({ ...values, 'main-color': values.rain })
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback
// Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text,
// table and box styles and the page background, but not into design-slot elements or
// bodyText.referenceColor, so rewrite every linked colour from the palette the config carries.
function relink(config) {
  const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex]));
  const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])));
  return walk(config);
}
// #endregion
const px = (value) => ({ value, unit: 'px' }); // screen sizes, written as CSS pixels
const [TOP, BAND, AIR] = [22, 86, 9]; // mm: top margin, the fog band's depth, air under it

// #region opener: print opens each note under a fog band; the screen keeps the words only
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
  color: col('ink'), align: 'left', ...style,
  overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default)
const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans',
  { fontWeight: 600, textTransform: 'uppercase', color: col('rain') });
const title = text('title', '{titleText}', 'Gloock');
const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true });
const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } });
const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } });
const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } });
const fromTop = (y, x) => at('container', 'top-left', y, x);
// minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs
// from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored).
const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('fog') },
    placement: band(mm(0), mm(BAND)) },
  { kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'),
    placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot
  { ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) },
  { ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) },
  { ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) },
] } };
const screenOpener = [
  { ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) },
  { ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') },
  { ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'),
    placement: under('title', px(10), 'fill') },
];
// #endregion

// #region answer: the screen edition lives in the same config, as overrides of the print one
// config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it.
const screenOverrides = () => ({
  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
  parts: { page: false }, // no divider page; the part still names the notes after it
  // 96 dpi: the mm and pt inherited from print render at their CSS size.
  page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } },
  layout: { layoutType: 'single', // one column
    fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print
  bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading
    // A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force
    // a paragraph taller than the pane whole into a one-line gap under a figure, and off the page.
    avoidWidows: false },
  // Heading levels merge on `level`: the print level keeps everything not restated here.
  headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
    marginTop: px(40), marginBottom: px(26),
    advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air
  footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already)
  captionStyle: { fontSize: px(13), gap: px(10) },
  paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole
});
// The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only).
// Wide panes get wider margins, so the measure stops at MEASURE.
const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px
const MIN_SIDE = 34; // px: the side margins of a narrow pane
function screenConfig({ width, height }) {
  const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object
  const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2));
  return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height),
    margins: { ...merged.page.margins, left: side, right: side } } });
}
// #endregion

// #region part: the divider page, a rain field bled off every edge under the part's numeral
const onField = { color: col('paper'), lineHeight: 1 };
const parts = {
  margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low
  bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'),
    numberColor: col('paper') },
  design: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('rain') },
      placement: band(mm(0), 'fill') },
    { kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') },
    text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9),
      letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }),
    text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190),
      placement: under('series', mm(10), mm(150)) }),
    text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60),
      placement: under('numeral', mm(-6), mm(150)) }),
  ] },
};
// #endregion

const foot = text('foot', '{pageNumber}   {title} · {partTitle}', 'Reddit Sans', {
  fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.5), textTransform: 'uppercase',
  color: col('muted'), align: 'center', placement: { ...at('container', 'top', mm(9)),
    size: { width: 'fill' } },
  pages: 'opener' }); // the notes, not the part page: each fits its opening page (a note that ran
// on would need a copy of this element with pages: 'body')
const colophon = { id: 'colophon', fontFamily: 'Reddit Sans', fontSize: pt(7.5),
  lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
  marginTop: pt(14) };

// A factory: the engine caches resolved configs per object (gotcha: config-cache-identity).
const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  // The locale does not name the figures (gotcha: resource-types-locale): "Figura" in Spanish,
  // one count for the whole issue, and a lower-case "fig." in Spanish running text.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    resetOn: 'never', ...(type.id === 'figure' && { shortLabel: t({ en: 'Fig.', es: 'fig.' }) }),
  })),
  colorPalette: paletteOf(day),
  page: { width: mm(225), height: mm(297), dpi: 150, backgroundColor: col('paper'), // dark at night
    margins: { top: mm(TOP), bottom: mm(24), left: mm(20), right: mm(16), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) },
  bodyText: { fontFamily: 'Newsreader', fontSize: pt(10), lineHeight: pt(14), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rain'),
    textAlign: 'justify', // the default, stated for contrast with the screen's ragged 'left'
    firstLineIndent: mm(4.5), indentAfterHeading: false }, // hyphenation, widows: on by default
  headings: { fontFamily: 'Gloock', fontWeight: 400, color: col('ink'),
    // Off: 1.4.1 drops the column under a closing page's column float a line (here English Rain
    // under the gauge, Spanish Wind under the rose; gotcha: float-stretch-closing-page).
    balancing: { stretchAfterFloats: false }, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: printOpener },
  ] },
  captionStyle: { fontFamily: 'Reddit Sans', fontSize: pt(8), color: col('muted'),
    labelColor: col('rain'), gap: mm(2.4) },
  parts, paragraphStyles: [colophon], header: { elements: [] }, footer: { elements: [foot] },
  htmlViewer: { overrides: screenOverrides() }, // canvas and PDF ignore it; an HTML host applies it
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown sample · 50 lines · content.en.mdtitle: "Notes on Weather" subtitle: "Autumn" author: "Postext Cookbook" --- :::part{number="I" title="Autumn"} 1. Rain, and thirty-one years of the garden gauge 2. Fog, where it comes from and when it lifts 3. Wind, from the shed vane to the Beaufort scale ::: # Rain {kicker="Note 1" lead="A copper can at the end of the garden has kept this household’s rain for thirty-one years, and its book holds over eleven thousand readings to a tenth of a millimetre."} Rain is measured as a depth: the height the water would stand if none of it ran off, sank in or dried. A millimetre of rain is a litre on every square metre of ground, so a gardener and a hydrologist can use the same number. The instrument that counts it has hardly changed in a century and a half; :ref{id="gauge"} shows it in section. A funnel of known width catches the rain and leads it into a narrow inner tube with a tenth of the funnel’s area, so every millimetre that falls stands ten millimetres deep inside, deep enough to read to a tenth with a graduated stick. What overflows the tube in a downpour waits in the outer can and is measured afterwards. The reading is made at eight o’clock every morning, whatever the weather, and written down before breakfast. It is dull work. Climatologists describe a place by its averages over thirty years, and our book now covers thirty-one. In that time the gauge has recorded an average of 612 millimetres a year, from 402 in the driest year to 871 in the wettest. No gauge catches all the rain. Wind carries drops past the mouth of the funnel, so a gauge on an exposed post catches less than falls on the lawn around it, and one beside a wall or a tree catches less again. The rule is to stand it at least twice as far from any obstacle as the obstacle is tall, with its rim a foot above the grass. Ours stands alone at the far end of the lawn for that reason. A raindrop’s shape depends on its size, and it is never the teardrop of the weather map. The smallest are perfect spheres, held round by their own surface tension. Above two millimetres or so, the air pushing up from below flattens them into something closer to a bread roll, and past five or six millimetres they are torn into smaller drops as they fall. A large drop reaches the ground at about nine metres a second, hard enough to drum on leaves and roofs, which is why a summer shower can be heard before it is felt. The heaviest rain falls from the tallest clouds, and it seldom lasts. Long autumn rain comes instead from wide sheets of cloud along a warm front, the kind that close in by degrees through a morning: first a haze across the sun, then a grey lid, then the first drops on the window. By evening the gauge may hold ten or fifteen millimetres. # Fog {kicker="Note 2" lead="Fog is a cloud that touches the ground. In autumn it fills the valley below the house after most clear nights, and it is usually gone by eleven."} Meteorologists call it fog when the visibility drops below one kilometre; above that, it is mist. Either way it is made of the same stuff as a cloud: droplets of water about a hundredth of a millimetre across, so small that they hang in the air instead of falling. A cubic metre of thick fog holds less than half a gram of water, a tenth of a teaspoon, and a layer a few dozen metres deep can hide a village up to its church spire, as :ref{id="valley"} shows. The fog of autumn mornings is usually radiation fog. On a clear, still night the ground gives up the day’s warmth to the open sky and chills the layer of air lying on it. When that air cools to its dew point, the vapour it carries condenses into droplets. Cold air is heavy, so it drains downhill and pools in hollows and river valleys, and at dawn the high ground stands in sunshine above a white, level sea. A wind of more than a few metres a second would stir the cold layer into the warmer air above it, and a cloudy night would keep the ground from cooling. As the sun climbs it warms the ground, the ground warms the air, and the fog thins from below, often rising into a low grey ceiling before it breaks up. Sea fog is another matter. It forms when warm, moist air drifts over cold water, and because the wind that brought it keeps feeding it, it can sit on a coast for days. Fog is white for the same reason a cloud is: its droplets scatter every colour of sunlight alike, red as much as blue. People who live in foggy valleys learn its habits. In ours the first fog of the year comes after the first long, clear night of September. It settles earliest by the river and lingers longest behind the mill. If the church tower comes out of it before nine, the afternoon will be fine. # Wind {kicker="Note 3" lead="We read the wind in what it moves: smoke, leaves, the sea, the washing on the line. In 1805 a naval officer gave it numbers."} Wind is air on its way from high pressure to low. It never takes the straight road: the turning of the Earth bends it aside, so that in the northern hemisphere it circles a low anticlockwise, and near the ground friction slows it and tilts it inwards. A wind is named for where it comes from, not where it goes, so a west wind blows from the west, off the ocean, and often brings the rain. In our garden it is the commonest wind of all, as the rose in :ref{id="rose"} makes plain. The rose is a year of mornings read from the vane on the shed roof. A vane turns its broad tail downwind, so its arrow always points into the wind, where the air is coming from. At eight each morning we note the nearest of its sixteen points, and each petal of the rose is as long as the share of mornings the wind blew from that point. Calm mornings, about one in eight, point nowhere and are left out. Checked against the gauge’s book, the vane stood between south-west and west on most wet mornings. Francis Beaufort, an officer of the Royal Navy, wrote his scale for ships’ logs and described each force by what it did to a warship’s sails. It was later rewritten for land, and the land version still works without an anemometer. At force 2 you feel the wind on your face and the leaves rustle. At force 4 it raises dust and scraps of paper and moves small branches. At force 6 large branches swing and an umbrella is hard to hold. At force 8 twigs break off the trees and walking into it is hard work. The scale stops at force 12, hurricane force, a mean wind of 118 kilometres an hour or more. Every valley has its own winds and its own names for them. Sailors and farmers named the winds long before anyone measured them, and the names still say where a wind comes from and what it brings: the mistral that pours down the Rhône valley, cold and dry; the föhn that warms the northern slopes of the Alps; the bora that falls on the Adriatic in winter, in gusts that can overturn a lorry; the sea breeze that sets in on a summer afternoon and dies at dusk. In Provence the mistral is said to blow for three, six or nine days. Weather stations measure the wind ten metres above open ground and average it over ten minutes, because wind is never steady. It comes in gusts and lulls, eddies round buildings and hedges, and blows harder over the sea than over a town; a gust can be half as strong again as the average around it. That is why a forecast gives two figures, a mean speed and a gust speed, and on an open hill you plan for the second. :::paragraphs{style="colophon"} *Notes on Weather*, part I. Set in Newsreader, Gloock and Reddit Sans (SIL Open Font License). Text and drawings: original, CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook // #region figures: the same resources for both editions, each pointing at its edition's drawing const figure = (id, edition, [width, height], placement, caption, alt) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement, svg: { fileId: `${id}-${edition}.svg`, width, height }, caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt> const figures = (edition) => [ figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, { en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.', es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.', }, { en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow ' + 'tube, half full, beside a graduated measuring stick.', es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un ' + 'tubo estrecho, medio lleno, junto a una regla graduada.' }), figure('valley', edition, [1400, 600], { position: 'bottom', span: 'page' }, { en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.', es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.', }, { en: 'A valley between two hills lies under layers of fog, with a church spire and a few ' + 'trees showing above it, seen from a fenced bank under a pale sun.', es: 'Un valle entre dos lomas yace bajo capas de niebla; asoman la aguja de una iglesia ' + 'y unos árboles, vistos desde un ribazo con una cerca bajo un sol pálido.' }), figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, { en: 'Where a year of morning winds came from at the garden station: west and south-west.', es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.', }, { en: 'A wind rose of sixteen petals on three rings; the longest petals point west and ' + 'south-west, the shortest east.', es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos ' + 'apuntan al oeste y al suroeste, los más cortos al este.' }), figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered ]; // #endregion // #region art: a rain gauge in section, a fogged valley, a wind rose, the part page's rain function mulberry(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${body}</svg>`; const f1 = (n) => n.toFixed(1); const between = (random, [a, b]) => a + random() * (b - a); // Slanted rain strokes; `clear` lists boxes [x0, y0, x1, y1] the strokes stay out of. const streaks = (random, n, { x, y, len, width, color, alpha, clear = [] }) => Array.from({ length: n }, () => { const [x0, y0, l] = [between(random, x), between(random, y), between(random, len)]; const [o, w] = [between(random, alpha).toFixed(2), between(random, width).toFixed(2)]; const hits = clear.some(([a, b, c, d]) => x0 > a && x0 - 0.28 * l < c && y0 + l > b && y0 < d); return hits ? '' : `<path d="M${f1(x0)} ${f1(y0)}l${f1(-0.28 * l)} ${f1(l)}" stroke="${color}" ` + `stroke-opacity="${o}" stroke-width="${w}" stroke-linecap="round"/>`; }).join(''); const shape = (tag, attrs) => `<${tag} ${Object.entries(attrs) .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}="${v}"`).join(' ')}/>`; function gauge(p) { // 300 × 420: rain, the funnel, the can sunk in the lawn, the tube, a stick const random = mulberry(7); const line = { stroke: p.ink, strokeWidth: 2 }; const ticks = Array.from({ length: 23 }, (_, i) => `M246 ${386 - i * 9}h${i % 5 === 0 ? 14 : 7}`).join(''); const grass = Array.from({ length: 42 }, (_, i) => `M${f1(3 + i * 7.2 + random() * 4)} 356` + `l${f1((random() - 0.5) * 7)} -${f1(5 + random() * 9)}`).join(''); return svg(300, 420, streaks(random, 44, { x: [40, 292], y: [0, 72], len: [12, 24], width: [1.6, 2.6], color: p.rain, alpha: [0.35, 0.95] }) + shape('path', { d: 'M70 104H230', stroke: p.muted, strokeWidth: 1.4 }) + shape('path', { d: 'M70 104l7 -4v8zM230 104l-7 -4v8z', fill: p.muted }) // arrowheads + shape('rect', { x: 0, y: 355, width: 300, height: 65, fill: p.rule, fillOpacity: 0.55 }) + shape('path', { d: grass, stroke: p.slate, strokeWidth: 1.6, strokeLinecap: 'round' }) + shape('rect', { x: 76, y: 128, width: 148, height: 268, rx: 5, fill: p.fog, ...line, strokeWidth: 2.5 }) + shape('rect', { x: 124, y: 196, width: 52, height: 192, fill: p.paper, ...line }) + shape('rect', { x: 126, y: 290, width: 48, height: 96, fill: p.rain, fillOpacity: 0.85 }) + shape('path', { d: 'M70 118h160v10H70z', fill: p.slate }) + shape('path', { d: 'M72 128h156l-72 60h-12z', fill: p.rule, ...line }) + shape('rect', { x: 144, y: 184, width: 12, height: 16, fill: p.rule, ...line }) + shape('rect', { x: 240, y: 176, width: 26, height: 214, fill: p.paper, ...line, strokeWidth: 1.6 }) + shape('rect', { x: 241, y: 290, width: 24, height: 99, fill: p.rain, fillOpacity: 0.3 }) + shape('path', { d: ticks, stroke: p.ink, strokeWidth: 1.2 })); } function valley(p, mist) { // 700 × 300: dawn over a valley full of fog, seen from a bank const random = mulberry(3); const skyline = (y0, amp, step) => { // a gentle ridge (or fog top) from edge to edge const y = () => f1(y0 + (random() - 0.5) * amp); let d = `M0 300V${y0}`; for (let x = step; x <= 700; x += step) d += `Q${x - step / 2} ${y()} ${x} ${y()}`; return `${d}V300Z`; }; const slopes = 'M0 300V118C80 114 160 150 240 208C280 236 300 262 318 300Z' + 'M700 300V126C630 122 550 158 480 210C446 236 424 262 408 300Z'; const crowns = [[292, 176, 11], [311, 172, 9], [326, 178, 8], [424, 174, 10], [441, 178, 8]] .map(([cx, cy, r]) => shape('circle', { cx, cy, r, fill: p.slate })); const fog = [[168, 0.3], [182, 0.38], [198, 0.45], [214, 0.5]].map(([y, a]) => // stacked shape('path', { d: skyline(y, 7, 70), fill: mist, fillOpacity: a })); const posts = [96, 150, 204, 258, 312].map((x, i) => `M${x} ${254 - i * 1.5}v-22`).join(''); return svg(700, 300, shape('rect', { width: 700, height: 300, fill: p.fog }) // the sky + shape('circle', { cx: 566, cy: 66, r: 24, fill: p.rule }) + shape('path', { d: skyline(128, 26, 100), fill: p.rule }) + shape('path', { d: skyline(156, 22, 70), fill: p.muted, fillOpacity: 0.55 }) + shape('rect', { y: 214, width: 700, height: 86, fill: p.muted }) // the fogged valley floor + shape('path', { d: slopes, fill: p.slate }) + crowns.join('') + shape('path', { d: 'M358 252V162h14V252zM358 162l7 -24l7 24z', fill: p.slate }) // tower + fog.join('') + shape('path', { d: 'M0 300V250C130 238 250 242 380 258C480 270 590 262 700 246V300Z', fill: p.slate }) // the bank we watch from, which gives the drawing its foot + shape('path', { d: `${posts}M96 239L312 233`, stroke: p.slate, strokeWidth: 2.4 })); } function rose(p) { // 300 × 300: where a year of morning winds came from, in sixteen petals const share = [6, 4, 3, 2, 2, 3, 4, 6, 9, 12, 19, 22, 24, 14, 9, 7]; // N first, clockwise const petal = (s, i) => { const [a, r] = [((i * 22.5 - 90) * Math.PI) / 180, s * 5.4]; // length ∝ share const end = (d) => `${f1(150 + r * Math.cos(a + d))} ${f1(150 + r * Math.sin(a + d))}`; return shape('path', { d: `M150 150L${end(-0.17)}A${f1(r)} ${f1(r)} 0 0 1 ${end(0.17)}Z`, fill: s > 12 ? p.rain : p.slate, fillOpacity: s > 12 ? 0.95 : 0.55 }); }; const rings = [40, 80, 120].map((r) => shape('circle', { cx: 150, cy: 150, r, fill: 'none', stroke: p.rule, strokeWidth: 1.2 })); return svg(300, 300, rings.join('') + share.map(petal).join('') + shape('path', { d: 'M150 18V282M18 150H282', stroke: p.rule, strokeWidth: 1 }) + shape('path', { d: 'M144 14V2L156 14V2', fill: 'none', stroke: p.ink, strokeWidth: 2 }) // N + shape('circle', { cx: 150, cy: 150, r: 5, fill: p.ink })); } async function drawFigures() { // every figure in both palettes, and the part page's rain const words = [[20, 26, 80, 36], [20, 100, 112, 128], [20, 206, 140, 238]]; // mm: the type await loadSvg('rain-field.svg', svg(225, 297, streaks(mulberry(19), 320, { x: [-10, 245], y: [-14, 292], len: [6, 18], width: [0.3, 0.8], color: day.paper, alpha: [0.12, 0.45], clear: words }))); for (const [edition, p, mist] of [['day', day, day.paper], ['night', night, night.muted]]) { await loadSvg(`gauge-${edition}.svg`, gauge(p)); await loadSvg(`valley-${edition}.svg`, valley(p, mist)); // night fog: pale, not black await loadSvg(`rose-${edition}.svg`, rose(p)); } } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Newsreader: ['400', '400i', '700'], Gloock: ['400'], 'Reddit Sans': ['400', '400i', '600', '700'] }; // 400: captions and the colophon // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await drawFigures(); const doc = await buildWithFonts( // print: every page on the kit's desk, and one beside the screen () => buildDocument({ markdown, resources: figures('day') }, config()), markdown); showPages(doc, { title: t({ en: 'One source, print and screen editions', es: 'Un solo original, ediciones impresa y de pantalla' }) }); // The two editions side by side, above the desk. index.html and style.css lay them out; pasted // on its own, the script writes the same markup, and the screen region gives the pane a height. if (!document.getElementById('editions')) { document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions"> <figure class="edition"><canvas id="proof" role="img" style="width:300px"></canvas> <figcaption></figcaption></figure><figure class="edition screen"> <div id="screen" role="region" tabindex="0"></div><figcaption></figcaption></figure></section>`); } const [proof, pane] = [document.getElementById('proof'), document.getElementById('screen')]; const [proofLabel, screenLabel] = document.querySelectorAll('#editions figcaption'); const holds = (page, id) => [...(page.floats ?? []), ...page.columns.flatMap((c) => c.blocks)] .some((block) => block.resourceBlock?.resource.id === id); const note = doc.pages.find((page) => holds(page, 'gauge')) ?? doc.pages[0]; // Figure 1's page const density = Math.min(window.devicePixelRatio || 1, 2); renderPageToCanvas(note, doc, proof, { scale: (density * proof.clientWidth) / note.width }); const { width: trimW, height: trimH } = config().page; proofLabel.textContent = `${t({ en: 'Print', es: 'Impresa' })} · canvas · ` + `${trimW.value} × ${trimH.value} mm`; proof.setAttribute('aria-label', `${t({ en: 'Print edition, page', es: 'Edición impresa, página' })} ${note.index + 1}`); pane.setAttribute('aria-label', t({ en: 'Screen edition', es: 'Edición de pantalla' })); // #region screen: the HTML edition in a Shadow DOM, laid out again when its pane resizes const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but // not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column) pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both'; } pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but // inherited text properties (letter-spacing, text-transform…) can, and the lines were measured // without them: `all: initial` on the wrapper stops them at the edition's edge. const inShadow = `<style>:host>div{all:initial;display:block}` + `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue let size = ''; function showScreen() { const [width, height] = [pane.clientWidth, pane.clientHeight]; if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded const first = !size; // the first build opens on Figure 1's page, like the print proof size = `${width}×${height}`; const screenDoc = buildDocument({ markdown, resources: figures('night') }, screenConfig({ width, height })); const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc, { mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`; const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages screenLabel.textContent = `${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`; } showScreen(); let timer = 0; // debounced; its first call, on observe(), finds the size unchanged new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); }) .observe(pane); // #endregion
Kit · core, fonts, viewer, images: the same in every recipe · 270 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 · 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

#Read by day

Remove the night palette and use the day values for the pane and the figures; the screen edition then shows white pages, dark ink and the day drawings.

-  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
   parts: { page: false }, // no divider page; the part still names the notes after it
@@
-pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
+pane.style.background = day.paper; // the pane's own ground, beside the pages and the scrollbar
@@
-  const screenDoc = buildDocument({ markdown, resources: figures('night') },
+  const screenDoc = buildDocument({ markdown, resources: figures('day') },

#Give the print openers a chapter number on a colour band

For a heavier print opener, with a saturated band and a big chapter number, see Chapter opener on a full-bleed band; the screen overrides stay the same.

Pitfalls

Pitfall

HTML output has no column rule, grid or marks, and is transparent

renderToHtml draws no column rule, baseline grid or crop marks, and its pages are transparent unless you pass a background, which on a dark page means dark text on dark. HTML reading edition →

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 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 →

Pitfall

Some htmlViewer settings exist only in the Sandbox

htmlViewer.maxCharsPerLine, columnGap and optimalLineBreaking are read by the Sandbox's viewer, not by renderToHtml. In your own page, size the page yourself and pass mode and columnGap to renderToHtml; overrides are applied with applyHtmlViewerOverrides. HTML reading edition →

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

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

An opener reserves height down to its lowest page-anchored element

An advanced-design opener reserves the height of its lowest element, and page- or bleed-anchored elements below the heading count too, so decoration at the foot of the page pushes the text to the next page. Keep such decoration above the heading, move it to a header or footer slot, or set the reservation with minHeight. Designed openers →

Pitfall

Localise Figure/Table with defaultResourceTypes(locale)

The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes. Figure and Table in your language →

Pitfall

Only 8 locales hyphenate, by exact code

Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English. Hyphenation and document language →

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 top float can push the last column down on a closing page

In postext 1.4.1, when a chapter or story ends on a page that opens with a page-wide top float and its lines split unevenly between the columns, stretchAfterFloats adds a blank line under the float in the shorter column instead of letting it end short, so the two columns no longer start on the same line. Set headings.balancing.stretchAfterFloats to false, or fit the copy to an even number of lines. Column balancing →

Pitfall

Ragged text can strand punctuation next to bold or a :ref

In postext 1.4.1 text that is not justified (box bodies, ragged paragraphs) can break a line between a bold or italic run, or a :ref, and the punctuation touching it: a full stop can open the next line, and the '(' before a reference can end the line above. Justified text never breaks there. Read the boxes of every edition and reword any sentence where it happens, so the run sits mid-line. Bold, italic and their colours →

  • Check the end of every note after an edit. If a note's last paragraph pushes a two-line tail onto a new page (widow control keeps the two lines together), postext 1.4.1 gives that page's column the height of one line and the canvas clips the second. Both samples are copy-fitted so that no note ends that way.
  • In ragged text, 1.4.1 can end a line on the “(” before a reference, and in a pane the reader can resize, some width will produce that break. These notes write “as Fig. 2 shows”, never “(Fig. 2)”.
  • Let a screen paragraph start on a page's last line (avoidWidows: false). With that rule on, 1.4.1 can force a paragraph taller than the page, whole, into the gap under a figure, where it runs off the foot of the page; a pane of 240 × 460 or 260 × 500 px shows it.
  • Keep the pane at least 400 px tall (the pen's min-height). In a narrow pane less than about 350 px tall, the screen opener is taller than the page's content area; postext 1.4.1 then drops its whole reservation and sets the first paragraph over the title.
  • Give the pane a size before you lay it out. A pane with no width, hidden or squeezed out of a flex row, would give a zero-width page, which is why showScreen() skips a pane narrower or shorter than FOLDED, 200 px.

Credits

Text
Original prose, CC BY 4.0
Fonts
Newsreader (SIL OFL 1.1) · Gloock (SIL OFL 1.1) · Reddit Sans (SIL OFL 1.1)