Aller au contenu principal
Recette numéro 12

Recettes · Chapitre 10 · Sortie et intégration

D’une chaîne Markdown à une page mise en forme

Une chaîne Markdown avec frontmatter, composée en pages de magazine sur deux colonnes et peinte sur canvas, avec six couleurs nommées au lieu du bleu d’origine.

Sur cette page
  • Format 180 × 240 mm
  • 2 colonnes, Gouttière de 6 mm
  • Newsreader 9,5/13,5
  • Inter Tight
  • Young Serif
  • 2 pages
  • Niveau
  • Postext 1.33.4
  • Mis en page en 79 ms
  • 172 lignes de code

En bref

Les deux premières pages d’un article de voyage dans un petit magazine, avec un bandeau coloré et une image. La recette montre comment un texte simple avec quelques titres devient une page mise en forme dans vos couleurs.

Ce que vous allez composer

Les premières pages d’un reportage de voyage dans Carnet de terrain, un petit magazine. La page 1 est une ouverture sur un recto : une photographie de marais salants et d’une dune au crépuscule sur les 58 % supérieurs de la page, le titre en Young Serif de 54 pt, et dessous deux colonnes justifiées qui commencent le texte par une attaque en gras. La page 2, au verso, continue sous un titre courant en capitales espacées et se termine sur une marque de fin et un colophon. La maquette est définie par une seule fabrique de configuration et une palette de six couleurs. Le titre vient du titre de niveau 1, et le chapeau, l’auteur et la date viennent du frontmatter de la chaîne Markdown : le même pen compose donc tout article dont le titre tient en deux lignes ; seule la photographie appartient à celui-ci.

Cette recette répond à

  • Comment empêcher les titres, les mots en gras et les puces de sortir en bleu ?
  • Comment régler une page de livre : format, marges intérieure et extérieure en miroir, deux colonnes et une gouttière ?
  • Comment transformer une chaîne Markdown en pages composées et en dessiner une sur un canvas ?

La réponse courte

script.js · lignes 125–162dans le code complet
const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr' }), // each edition's hyphenation patterns
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name: there is no fallback chain
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. Parity 'odd' puts the
      // next article pasted in on a recto, with a blank verso only when one is needed.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});

Ingrédients

Typographie
Newsreader, Young Serif, Inter Tight (SIL OFL 1.1)
Ressources
  • salt-road-1440.jpg
  • Les salines et la dune au crépuscule de l’ouverture, une photographie (Generated With Diffusion Models, original)

Préparation

#1 · Toutes les polices, avant la première construction

script.js · lignes 222–226dans le code complet
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};

Postext mesure le texte avec les polices dont dispose le navigateur au moment de la mise en page : une construction lancée avant l’arrivée des polices garderait les sauts de ligne d’une police de secours. buildDocumentWithFonts charge toutes les polices que la configuration nomme, ainsi que celles de FONTS, avant la première construction ; kitFonts(FONTS) lui fournit les fichiers de Fontsource, les mêmes qu’un PDF intégrerait. Il parcourt ensuite les polices utilisées par les pages, charge celles qui manquent encore et reconstruit. Une police que Fontsource ne fournit pas reste en police de secours et figure dans doc.contentWarnings comme fontFallback. Le gras et l’italique d’une police de texte ne se chargent que si la famille les possède.

#2 · Nommer les couleurs, puis renvoyer aux noms

script.js · lignes 23–41dans le code complet
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex. The design objects below are functions
// that config() calls, so col() copies the hex out of `palette` on every build: one edit
// to `palette` reaches the text, the folios and the drop folio alike.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, bold, italic, bullets, the default running head) link
  // to 'main-color': point it at the accent, so nothing left unset prints blue.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];

Chaque couleur de la configuration est un col('id'), un id de palette avec sa valeur hexadécimale à côté. bodyText.boldColor, italicColor, referenceColor et unorderedLists.color nomment ink, et headings.color nomme l’accent. Les valeurs par défaut du moteur pour les titres, le gras, l’italique et les puces renvoient à main-color, et cette palette fait pointer cette entrée vers l’accent : tout ce que vous omettez sort donc en indigo plutôt qu’en bleu. Les objets de maquette sont des fonctions que config() appelle à chaque construction, et col() recopie chaque fois la valeur hexadécimale actuelle depuis palette : une seule modification de palette atteint le texte, les folios et l’ouverture. La marque de fin est une pastille de couleur en ligne qui nomme band, si bien qu’un changement de teinte l’atteint aussi. Le titre courant par défaut renvoie lui aussi à main-color : il s’imprimerait dans l’accent tant que vous ne le remplacez pas (étape 5).

#3 · Une photographie recadrée à son cadre

script.js · lignes 45–51dans le code complet
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };

Les marais salants, la dune et le cairn sont une photographie générée par des modèles de diffusion, prise au crépuscule pour que le titre clair se lise sur un ciel sombre. Le JPEG est recadré au cadre du bandeau, PAGE.width × BAND millimètres, et la ressource déclare ses pixels, 1440 × 1120 : en pleine largeur, l’image remplit exactement le bandeau. Un bandeau plus haut demande une image recadrée au nouveau cadre. L’ouverture la montre au moyen d’un élément image qui nomme la ressource par son id.

#4 · Le frontmatter écrit l’ouverture

script.js · lignes 55–91dans le code complet
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo',
  fr: 'Carnet de terrain' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A bare
      // lineHeight multiplies the text's size: 1 sets the title solid. Both texts wrap.
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}',
          fr: 'Par {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});

Le titre de niveau 1 s’étend sur les deux colonnes, et son ouverture est un emplacement d’éléments : la photographie jusqu’au fond perdu et quatre textes enchaînés l’un sous l’autre. Un titre plus long pousse le chapeau et la signature vers le bas au lieu de les chevaucher, mais au-delà de deux lignes la signature tombe sur les marais salants clairs. {titleText} est le texte du titre ; {subtitle}, {author} et {publishDate} sont des champs du frontmatter. Le \\ dans le titre coupe la ligne là où vous le placez (sauts de ligne dans les titres). minHeight se compte depuis la marge du haut : la hauteur réservée se termine donc 5 mm sous le bandeau.

#5 · Des titres courants à la place du bleu par défaut

script.js · lignes 95–114dans le code complet
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});

Omettez header et la valeur par défaut du moteur revient : de l’Open Sans au-dessus d’un filet sur toute la largeur, sur chaque page. Ici, chaque folio est ancré à la page physique et filtré par parité, pour qu’il se trouve toujours au bord extérieur. Son libellé est ancré au folio, à GAP millimètres avec right-of ou left-of, et un folio de trois ou quatre chiffres pousse le libellé au lieu de le heurter. pages: 'body' écarte les quatre éléments de l’ouverture. Le pied de page contient un seul folio en pied pour l’ouverture et, puisque footer est défini, les pages courantes perdent le folio centré par défaut.

#6 · Construire avec une configuration neuve, puis peindre

script.js · lignes 231–239dans le code complet
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada',
    pt: 'De uma string Markdown a uma página diagramada',
    fr: 'D’une chaîne Markdown à une page mise en forme' }),
});

config() est une fonction pour que chaque construction lise la palette telle qu’elle est : elle rappelle les fonctions de maquette de l’étape 2. Un objet de configuration unique modifié sur place marcherait aussi : depuis postext 1.25, le moteur voit le changement à la construction suivante. buildDocumentWithFonts prend le contenu, cette configuration et les options de polices du kit, et se résout avec le document. showPages dispose les pages comme dans un livre relié, la page 1 seule à droite, et peint chacune avec renderPageToCanvas.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════
// https://postext.dev/en/cookbook/first-page-from-markdown
// Code: MIT · Text: original (CC BY 4.0) · Photo: diffusion models
// Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.25.0
//
// This pen sets a Markdown string with a frontmatter block on two magazine pages and paints
// them on canvases. The whole design is in config(); swap config() for {} in the build
// below to see the engine's defaults.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'fr'; // @lang: the language of the sample document ('en' | 'es' | 'pt' | 'fr')
const RECIPE = 'first-page-from-markdown';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every setting that shapes the pages is in this part, from PAGE down to config().
const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page
const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side
const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on

// #region palette: six named colours; every colour in the config links to one of them
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex. The design objects below are functions
// that config() calls, so col() copies the hex out of `palette` on every build: one edit
// to `palette` reaches the text, the folios and the drop folio alike.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, bold, italic, bullets, the default running head) link
  // to 'main-color': point it at the accent, so nothing left unset prints blue.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
// #endregion

// #region art: a photograph of the salt pans and the dunes at Sorra, cut for a page × band frame
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
// #endregion

// #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo',
  fr: 'Carnet de terrain' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A bare
      // lineHeight multiplies the text's size: 1 sets the title solid. Both texts wrap.
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}',
          fr: 'Par {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
// #endregion

// #region running-heads: folio and title on body pages, a drop folio under the opener
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
// #endregion

// The colophon: small sans under a 0.5 pt hairline, with no box around it.
const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
  padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } });

// #region answer: one config factory in place of the default skin: page, type, colour, slots
const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr' }), // each edition's hyphenation patterns
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name: there is no fallback chain
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. Parity 'odd' puts the
      // next article pasted in on a recto, with a blank verso only when one is needed.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// content.<lang>.md, inlined by the Cookbook. Its frontmatter feeds the opener and the
// running heads. The end mark is an inline swatch that names `band`, so it follows the
// palette too.
const markdown = String.raw`---
Exemple Markdown · 46 lignes · content.fr.mdtitle: "La route du sel" subtitle: "Quatre jours à pied sur le chemin des porteurs, des salines d’Arvela au marché de Castrel." author: "Lena Varga" publishDate: "mai 2026" --- # La route \\ du sel **Les porteurs partaient de la digue d’Arvela.** À marée basse, l’estran s’étire à ses pieds sur trois kilomètres, une plaine grise striée de chenaux, et du haut de la digue on aperçoit les salines avant la mer : quarante bassins peu profonds remplis de saumure, alignés comme les cases d’un échiquier, chacun d’un blanc plus franc que le précédent. Pendant six siècles, le sel d’Arvela a quitté la côte à dos d’homme et de femme. On chargeait à l’aube, trente kilos par panier d’osier, et l’on partait vers l’intérieur par un chemin que personne n’a jamais pavé : à travers l’estran, par-dessus les dunes de Sorra, par les pinèdes et jusqu’à la vieille ville de foire de Castrel, à quatre jours de marche. Le chemin de fer a mis fin au métier en 1911, mais les randonneurs entretiennent le sentier depuis. Je l’ai parcouru en avril avec Tomás Reis, dont la grand-mère portait le sel quand elle était enfant, et qui a passé vingt étés à baliser l’itinéraire de cairns de pierres blanches. « Cherchez le *sel* par terre, m’a-t-il dit le premier matin. Là où il en est tombé, rien ne pousse. » Le lendemain, dans les dunes, le sentier s’efface pendant des heures, puis une veine pâle se dessine dans le sable, large de quelques centimètres, comme tracée à la craie. C’est le sel tombé des paniers pendant six siècles, une croûte que le vent découvre à chaque printemps. Tomás marche à côté et n’y pose jamais le pied. Cette nuit-là, nous avons dormi dans un creux des dunes que les porteurs appelaient la Cuisine, parce que le vent n’y entre jamais. Tomás n’a pas allumé de feu. Il a mangé du pain et du poisson séché en parlant de sa grand-mère, qui goûtait un seul grain et savait dire s’il venait d’Arvela ou des salines du Sud, et qui ne s’est jamais, assure-t-il, égarée dans le brouillard. ## Le chargement du retour Les paniers revenaient pleins vers la côte. Un registre de porteur de 1887, conservé aux archives paroissiales de Sorra, énumère les chargements du retour de son premier printemps : - **Du grain**, surtout seigle et orge, des fermes d’altitude au-dessus de Castrel. - **De la résine de pin** et du goudron, pour calfater chaque hiver les bateaux de pêche d’Arvela. - **De la laine et des peaux** de la foire de mai à Castrel. - **Des clous et des hameçons** de la forge d’Orsa. Jusqu’à l’arrivée du train, un panier de sel valait un panier de grain. Un bon porteur faisait onze allers-retours par an, quatre-vingt-huit jours sur le chemin, et entre deux voyages il récoltait le sel au râteau dans les bassins. Trois mains ont tenu le registre. La première, une anglaise soignée, note chaque charge au demi-kilo près ; la deuxième, dès 1893, n’inscrit que des chiffres ; la troisième, une écriture d’enfant, surgit certains hivers et écrit le nom de la ville avec deux l : Castrell. La dernière entrée du registre, au milieu d’une page de l’automne 1910, est un chargement d’orge jamais marqué comme livré, et personne à Sorra ne sait pourquoi. Tomás croit que le porteur est allé poser des rails pour la compagnie du chemin de fer, qui payait en espèces. ## Dans les pins Le troisième jour est le plus long. Après Orsa, le sentier monte huit heures durant dans les pinèdes qui sentent la résine, en passant devant des relais en ruine où les porteurs dormaient sur la paille et payaient leur lit en sel. Chaque maison a un cairn près de sa porte, coiffé d’une pierre blanche que Tomás y a posée. La mieux conservée se trouve aux Neuf-Puits, à une heure sous le col. Son linteau est entaillé de rangées de courtes encoches, une pour chaque nuit qu’un porteur y a passée, et par endroits elles sont si serrées qu’elles ne forment plus qu’un seul sillon. Tomás a essayé de les compter un hiver et a renoncé à quatre mille. Le quatrième après-midi, le sentier quitte les pins pour une crête nue, d’où l’on voit Castrel en contrebas, une ville grise bâtie autour d’une place qu’on appelle encore la place du Sel, bien qu’on n’y vende plus de sel depuis un siècle. Comme les porteurs, Tomás dépose une poignée de sel sur la margelle de la fontaine avant de boire. Puis il fait demi-tour et reprend le chemin de la mer. Selon lui, les porteurs ne dormaient jamais à Castrel, parce que les auberges se payaient en espèces et qu’un porteur était payé en grain. :swatch{color="band"} :::callout{type="colophon"} Œuvre de fiction : Arvela, Sorra, Orsa et Castrel sont des lieux imaginaires. Composé en Newsreader, Young Serif et Inter Tight (SIL Open Font License) · Texte : original, CC BY 4.0 · Photo : modèles de diffusion. :::
`; // The opener's image element points at the photograph by id. const resources = [landscape]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // #region fonts: every face the design uses, loaded before the first build const FONTS = { Newsreader: ['400', '400i', '700'], // text 'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400 'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon }; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: the photograph, one buildDocumentWithFonts call with a fresh config, then paint await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId)); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); // showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }). showPages(doc, { title: t({ en: 'From a Markdown string to a designed page', es: 'De una cadena Markdown a una página diseñada', pt: 'De uma string Markdown a uma página diagramada', fr: 'D’une chaîne Markdown à une page mise en forme' }), }); // #endregion
Kit · 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 ───────────────────────────────────────────────────────────────────────

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

#Voir ce que la configuration remplace

Construisez avec une configuration vide : le même Markdown sort sur une page de 17 × 24 cm en EB Garamond de 8 pt, avec des titres en Open Sans bleu et un titre courant bleu au-dessus d’un filet ; le moteur charge aussi ces deux familles, puisque la configuration par défaut les nomme.

-const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
+const doc = await buildDocumentWithFonts({ markdown, resources }, {}, kitFonts(FONTS));

#Changer la teinte de toute la page

Changez l’accent et l’exécution suivante peint les folios, les intertitres et la marque de fin dans la nouvelle couleur, parce que col() recopie la nouvelle valeur hexadécimale à côté de l’id à chaque appel de config(). La photographie garde ses propres couleurs.

-  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
+  band: '#3f5b3a', // the one accent (a pine green): the folios, the subheads, the end mark

#Peindre une page sur votre propre canvas

renderPageToCanvas dimensionne le canvas à la page en pixels, à la résolution du document (300 dpi par défaut) multipliée par scale : scale: 0.5 peint donc à environ 150 dpi, et le CSS décide de sa taille à l’écran.

-showPages(doc, {
-  title: t({ en: 'From a Markdown string to a designed page',
-    es: 'De una cadena Markdown a una página diseñada',
-    pt: 'De uma string Markdown a uma página diagramada',
-    fr: 'D’une chaîne Markdown à une page mise en forme' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });

Erreurs fréquentes

  • Young Serif n’existe qu’en une seule graisse. Les titres sont en gras par défaut : headings.fontWeight: 400 est donc obligatoire ici. Sans lui, les intertitres demandent un Young Serif gras que Fontsource n’a pas, le navigateur épaissit le romain, et doc.contentWarnings liste la police comme fontFallback.

Crédits

Texte
Texte original, CC BY 4.0
Images
  • Les salines et la dune au crépuscule de l’ouverture, une photographie · Generated With Diffusion Models · original
Polices
Newsreader (SIL OFL 1.1) · Young Serif (SIL OFL 1.1) · Inter Tight (SIL OFL 1.1)
Sandbox