En bref
La rubrique météo d’un magazine, composée deux fois avec le même texte. On y voit comment un seul jeu de réglages donne une version imprimée en colonnes et une version sombre sur une colonne, à lire à l’écran.
Ce que vous allez composer
Notes on Weather, la rubrique d’automne d’un petit magazine, composée deux fois à partir du même Markdown. Sur papier, c’est un numéro de 225 × 297 mm : une page de partie bleu pluie, trois notes qui s’ouvrent sous un bandeau clair couleur de brume, deux colonnes justifiées, les dessins d’un pluviomètre et d’une rose des vents, et la photographie d’une vallée dans la brume. À l’écran, c’est une seule colonne en drapeau en Newsreader, gris pâle sur un fond presque noir, en texte HTML que le lecteur peut sélectionner et copier, sans page de partie, sans bandeaux ni folios. Le panneau s’ouvre sur le pluviomètre, réduit à la hauteur du panneau et dessiné aux couleurs de nuit, à côté de la page imprimée qui le porte. Remontez pour voir l’ouverture de « Rain », ou tirez le coin du panneau : le texte est remis en page à la nouvelle taille. La maquette écran tient en neuf clés sous htmlViewer.overrides, à l’intérieur de la configuration papier.
Cette recette répond à
- Comment afficher une vue de lecture HTML adaptative, à défilement, du même document ?
- Comment donner à l’édition pour écran une maquette plus simple que celle de l’édition imprimée, avec une seule configuration ?
La réponse courte
// 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
// 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 { ...merged, page: { ...merged.page, width: px(width), height: px(height),
margins: { ...merged.page.margins, left: side, right: side } } };
}
Ingrédients
- Fonctionnalités
- Une maquette plus simple pour l’écranÉdition de lecture HTMLFigures ajustées à la pageOuvertures composéesPages de partieBandeau de chapitre sur toute la largeurAttributs de titrePlacement des figuresPalette de couleurs sémantiqueTitres courants selon le rôle de la pageUne ou deux colonnesCouleur du papierPages sur un canvasFigures et tableaux comme ressourcesFigure et Tableau dans votre langueTypes de ressource personnalisésDes citations qui placent les figuresStyles de paragraphe
- Typographie
- Newsreader, Gloock, Reddit Sans (SIL OFL 1.1)
- Ressources
valley-1610.jpg- Radiation fog in a valley at dawn (Figure 2) (Generated With Diffusion Models, original)
- The rain gauge, the wind rose and the part page's rain, drawn in code in both palettes (Ignacio Ferro, CC BY 4.0)
Préparation
#1 · Un jeu de noms de couleurs, deux jeux de valeurs
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' } }));
// Every colour names its palette entry: text, captions, the openers' design elements and the
// reference colour follow whichever palette the config carries. The hex is the day value.
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id });
Chaque couleur de la configuration désigne un id de la palette, et l’hexadécimal écrit à côté n’est que la valeur de secours de jour. L’édition écran passe donc aux couleurs de nuit en remplaçant un seul tableau. Les overrides de la réponse courte fusionnent les objets clé par clé et les niveaux de titre sur level, et remplacent en entier tout autre tableau, colorPalette compris. main-color fait partie de la palette, si bien que les valeurs par défaut du moteur qui s’en servent changent aussi. La nouvelle palette atteint toutes les couleurs qui lui sont liées : le texte, les légendes, les éléments de maquette des ouvertures et la couleur des renvois.
#2 · Ouvertures : un bandeau sur papier, les mots à l’écran
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
color: col('ink'), align: 'left', ...style,
overflow: 'wrap' }); // titles break onto more lines
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') },
];
Les éléments de surtitre, de titre et de chapeau sont définis une fois et repris dans les deux maquettes, si bien que les deux éditions affichent les mêmes attributs de titre : {attr.kicker}, {attr.lead} et le {partTitle} de la partie. Sur papier, un titre de niveau 1 passe à une nouvelle page et s’étend sur les deux colonnes, et sa maquette suspend un bandeau de brume depuis le fond perdu. minHeight réserve la hauteur du bandeau sous la marge de tête, plus AIR, les 9 mm entre le bandeau et la première ligne. Les titres imprimés sont composés en 54 pt parce que chacun tient en un mot court et que c’est le seul gros texte de sa page. À l’écran, le même niveau devient un titre dans la colonne, sans bandeau ni blanc. L’override fusionne sur level : tout réglage du titre qu’il ne reprend pas reste celui du papier.
#3 · Une page de partie qui n’existe que sur papier
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)) }),
] },
};
À l’impression, :::part ouvre la page de partie bleu pluie : un bloc à fond perdu sur tous les bords, un élément image de traits de pluie qui laissent le texte dégagé, le chiffre et le titre tirés du bloc, et la liste des notes en blanc. Les overrides écran fixent parts.page: false : aucune page ne s’ouvre et la liste n’est pas composée. La partie applique toujours son numéro et son titre aux notes qui la suivent, et le surtitre écran affiche « Autumn · Note 1 » comme sur papier.
#4 · Dessiner chaque figure deux fois
// The valley is a photograph, one JPEG in assets/ that both editions share, at its pixels.
const photo = { kind: 'bitmap', svg: undefined,
bitmap: { fileId: 'valley-1610.jpg', format: 'jpeg', width: 1610, height: 690 } };
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, [1610, 690], { 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 wooded hills lies under layers of fog at sunrise, with a church '
+ 'tower, trees and a river showing through it, seen from a fenced bank.',
es: 'Un valle entre lomas boscosas yace bajo la niebla al amanecer; asoman la torre de '
+ 'una iglesia, árboles y un río, vistos desde un ribazo con una cerca.' }), ...photo },
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
];
Changer de palette ne recolore pas un SVG : chaque dessin est donc fait une fois par palette. La vallée est une photographie, un seul JPEG que partagent les deux éditions. figures(edition) construit les mêmes ressources pour les deux éditions, chacune pointant vers le fichier de son édition : gauge-day.svg sur papier, gauge-night.svg à l’écran. Les ids, les légendes, les textes alternatifs et les placements ne changent pas, si bien que chaque :ref du Markdown renvoie à la même figure et au même numéro dans l’une ou l’autre édition. Dans l’édition écran, le texte alternatif va dans chaque <img alt>, où un lecteur d’écran le trouve.
#5 · Héberger l’édition écran
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 an opener can take a whole page and push its text to the next
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, and
// the edition's root resets the text properties it would inherit (letter-spacing…) by itself
const inShadow = `<style>::selection{background:${night.rain}55}</style>`; // 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 + renderToHtml(screenDoc,
{ mode: 'single', padding: 0, resourceImageUrl: imageUrl });
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);
Chaque page écran a la hauteur du panneau : fitFiguresToPage réduit donc le pluviomètre jusqu’à ce qu’il tienne sur une page avec sa légende (mise en page). À 96 dpi, la résolution que suppose CSS, les millimètres et les points hérités du papier s’affichent à leur taille nominale (intégrer la visionneuse HTML). Le pen écrit les lignes en position absolue de renderToHtml dans un Shadow DOM, hors de portée des sélecteurs de la page. Les propriétés héritées comme letter-spacing franchissent pourtant cette frontière, et la racine de l’édition les réinitialise elle-même, parce que le moteur a mesuré les lignes sans elles. Les pages HTML sont transparentes tant qu’on ne les peint pas : page.backgroundColor prend donc le papier de nuit dans la palette. Le ResizeObserver attend 150 ms et ne recompose, à partir d’un config() neuf, que si la largeur ou la hauteur du panneau a changé ; après la recomposition, le panneau revient à la même fraction de sa hauteur.
La recette complète
// ═══ 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: code (CC BY 4.0) · Photo: diffusion models // Fonts: Newsreader, Gloock, Reddit Sans (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocument, prepareFonts, withLoadedFonts, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides, 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' } })); // Every colour names its palette entry: text, captions, the openers' design elements and the // reference colour follow whichever palette the config carries. The hex is the day value. const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // #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 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 // 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 { ...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) }; const config = () => ({ locale: t({ en: 'en-us', es: 'es' }), // hyphenation, and "Figura" in the Spanish captions // The built-in types, retuned: 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'), levels: [ // Each note opens on the next page, recto or verso. { 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`---Exemple Markdown · 50 lignes · content.en.md
title: "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. The photograph is generated. :::`; // content.<lang>.md, inlined by the Cookbook // #region figures: the same resources for both editions, each pointing at its edition's drawing // The valley is a photograph, one JPEG in assets/ that both editions share, at its pixels. const photo = { kind: 'bitmap', svg: undefined, bitmap: { fileId: 'valley-1610.jpg', format: 'jpeg', width: 1610, height: 690 } }; 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, [1610, 690], { 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 wooded hills lies under layers of fog at sunrise, with a church ' + 'tower, trees and a river showing through it, seen from a fenced bank.', es: 'Un valle entre lomas boscosas yace bajo la niebla al amanecer; asoman la torre de ' + 'una iglesia, árboles y un río, vistos desde un ribazo con una cerca.' }), ...photo }, 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 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 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() { // the drawings in both palettes, the part page's rain, the photo 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] of [['day', day], ['night', night]]) { await loadSvg(`gauge-${edition}.svg`, gauge(p)); await loadSvg(`rose-${edition}.svg`, rose(p)); } await loadImage('valley-1610.jpg', asset('valley-1610.jpg')); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build Newsreader: ['400', '400i', '700'], Gloock: ['400'], 'Reddit Sans': ['400', '400i', '600', '700'] }; // 400: captions and the colophon // ─── 4 · Build & show ─────────────────────────────────────────────────────── await prepareFonts(markdown, config(), kitFonts(FONTS)); await drawFigures(); const doc = await withLoadedFonts( // print: every page on the kit's desk, and one beside the screen () => buildDocument({ markdown, resources: figures('day') }, config()), { ...kitFonts(FONTS), text: 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 an opener can take a whole page and push its text to the next 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, and // the edition's root resets the text properties it would inherit (letter-spacing…) by itself const inShadow = `<style>::selection{background:${night.rain}55}</style>`; // 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 + renderToHtml(screenDoc, { mode: 'single', padding: 0, resourceImageUrl: imageUrl }); 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); // #endregionKit · core, fonts, viewer, images : identique dans toutes les recettes · 270 lignes
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
<section id="editions">
<figure class="edition"><canvas id="proof" role="img"></canvas><figcaption></figcaption></figure>
<figure class="edition screen"><div id="screen" role="region" tabindex="0"></div><figcaption></figcaption></figure>
</section>
<main id="pages"></main>
/* The reading room: the printed page and the screen edition side by side, on a fog-grey desk. */
#editions {
display: flex; justify-content: center; align-items: center; gap: 32px;
box-sizing: border-box; min-height: min(75vw, 100vh); padding: 28px;
background: #dde3e9;
}
.edition { margin: 0; display: flex; flex-direction: column; gap: 14px; }
.edition figcaption {
font: 600 11px/1 system-ui, sans-serif; letter-spacing: .16em; text-transform: uppercase;
color: #4f5a66;
}
#proof {
aspect-ratio: 225 / 297;
display: block; height: min(580px, calc(100vh - 188px)); background: #fff;
box-shadow: 0 1px 2px rgb(20 30 40 / .25), 0 20px 40px -18px rgb(20 30 40 / .5);
}
.screen { flex: 0 1 580px; min-width: 0; }
/* The pane the edition fills: its size is the page size, so resizing it re-lays the text.
Drag its corner to try. Its background is the night paper, set by the script. At least
400 px tall: in a shorter pane an opener can take a whole page and push its text to
the next. */
#screen {
height: min(580px, calc(100vh - 188px)); overflow-y: auto; scrollbar-gutter: stable;
resize: both; min-width: 240px; min-height: 400px; max-width: 100%;
border-radius: 10px; box-shadow: 0 0 0 7px #1c2129, 0 26px 48px -20px rgb(10 14 20 / .7);
scrollbar-width: thin; scrollbar-color: #2e3742 transparent;
}
@media (max-width: 760px) {
#editions { flex-direction: column; min-height: 0; padding: 24px 16px; }
#proof { height: auto; width: min(420px, 100%); }
.screen { flex: none; width: 100%; }
#screen { height: 72vh; }
}
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Lire de jour
Retirez la palette de nuit et utilisez les valeurs de jour pour le panneau et les figures ; l’édition écran affiche alors des pages blanches, une encre sombre et les dessins de jour.
- 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') },#Donner aux ouvertures imprimées un numéro de chapitre sur un bandeau de couleur
Pour une ouverture imprimée plus appuyée, avec un bandeau saturé et un grand numéro de chapitre, voyez Ouverture de chapitre sur un bandeau à fond perdu ; les overrides écran restent les mêmes.
Erreurs fréquentes
Erreur fréquente
La sortie HTML n’a ni filet de colonne, ni grille, ni traits de coupe, et elle est transparente
renderToHtml ne dessine ni filet de colonne, ni grille de ligne de base, ni traits de coupe, et ses pages sont transparentes à moins que vous ne passiez un fond, ce qui sur une page sombre donne du texte sombre sur fond sombre. Édition de lecture HTML →
Erreur fréquente
Certains réglages htmlViewer n’existent que dans le Sandbox
htmlViewer.maxCharsPerLine, columnGap et optimalLineBreaking sont lus par la visionneuse du Sandbox, pas par renderToHtml. Dans votre propre page, dimensionnez la page vous-même et passez mode et columnGap à renderToHtml ; les overrides s’appliquent avec applyHtmlViewerOverrides. Édition de lecture HTML →
Erreur fréquente
Une ouverture réserve la hauteur jusqu’à son élément ancré à la page le plus bas
Une ouverture en maquette avancée réserve la hauteur de son élément le plus bas, et les éléments ancrés à la page ou au fond perdu sous le titre comptent aussi : un décor au pied de la page repousse donc le texte à la page suivante. Donnez à un tel élément reserve: false (depuis postext 1.5) : il est peint là où il est ancré et ne réserve rien. Les éléments qui suivent le bandeau lui-même (ancrés à son pied, ou de hauteur 'fill') ne fixent jamais sa hauteur. Ouvertures composées →
- Gardez au panneau au moins 400 px de hauteur (le
min-heightdu pen). Dans un panneau étroit de moins de 350 px de haut environ, l’ouverture écran dépasse la zone de contenu de la page ; elle occupe alors toute la page, et le premier paragraphe de la note commence sur la suivante. - Donnez une taille au panneau avant de le mettre en page. Un panneau sans largeur, masqué ou écrasé dans une rangée flex, donnerait une page de largeur nulle : c’est pourquoi
showScreen()ignore un panneau plus étroit ou plus bas queFOLDED, 200 px.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Images
- Radiation fog in a valley at dawn (Figure 2) · Generated With Diffusion Models · original
- The rain gauge, the wind rose and the part page's rain, drawn in code in both palettes · Ignacio Ferro · CC BY 4.0
- Polices
- Newsreader (SIL OFL 1.1) · Gloock (SIL OFL 1.1) · Reddit Sans (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


