Aller au contenu principal
Recette numéro 68

Recettes · Chapitre 7 · Figures et images

Un essai photographique à planches à fond perdu

Des planches en styles de titre construits depuis une liste, chacune remplissant sa page jusqu’au format ; l’orage traverse le petit fond d’une double page.

p. 4–5 sur 9

  • Exemple en anglais : pas encore d’édition française
  • Format 280 × 210 mm
  • 1 colonne
  • Andada Pro 10,5/15
  • Syne
  • Syne Mono
  • 9 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 72 ms
  • 176 lignes de code

En bref

Un livre de photos de paysage qui montre une journée en montagne en six images. Il explique comment remplir des pages entières de photos jusqu’au bord, et étirer une image sur deux pages en regard.

Ce que vous allez composer

Sierra, un livre de photos de paysage sur une page de 280 × 210 mm : une journée dans un massif de granit en six planches, des premières lueurs au matin qui suit la première neige. Chaque planche remplit sa page jusqu’au format et ne porte qu’un petit chiffre et l’heure dans son coin inférieur gauche ; la première porte aussi le titre, SIERRA en Syne de 72 pt, en réserve dans le ciel de l’aube. L’orage traverse le petit fond des pages 4 et 5 en une seule image. Trois textes courts, composés dans une colonne de 100 mm près du petit fond, font face aux planches qui les suivent. Le texte de l’orage se termine sur une planche du crépuscule plus petite qui flotte en dessous, avec son chiffre dans la légende. La dernière planche montre le cirque de la deuxième depuis le même bloc, sous la neige. Les planches sont des photographies générées par des modèles de diffusion ; la planche de la neige a été obtenue en retouchant celle de midi, pour que les deux vues concordent.

Cette recette répond à

  • Comment donner à chaque chapitre sa propre photo, sa propre couleur ou sa propre variante d’ouverture ?
  • Comment insérer des pages blanches voulues, ou commencer une section sur une nouvelle double page ?
  • Comment masquer les titres courants sur les ouvertures et les pages blanches, ou peindre un verso blanc dans la couleur de la partie ?
  • Comment composer des illustrations non numérotées : ornements, vignettes, logos ?

La réponse courte

script.js · lignes 35–57dans le code complet
// '{attr.art}' is filled in per heading, so a plain plate names its picture on its line:
// # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras.
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own
  { id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread:
  { id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // The picture is as deep as the page, more than the column holds: the plate claims the
  // whole page, and what follows starts on the next one.
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});

Ingrédients

Typographie
Andada Pro, Syne, Syne Mono (SIL OFL 1.1)
Ressources
  • alba-1600.jpg
  • atardecer-2000.jpg
  • mediodia-1600.jpg
  • nieve-1600.jpg
  • noche-1600.jpg
  • tormenta-2400.jpg
  • Plate I, dawn: ridges fading into a peach haze under a dark blue sky (Generated With Diffusion Models, original)
  • Plate II, noon: a granite cirque and its lake (Generated With Diffusion Models, original)
  • Plate III, storm: rain, lightning and a break of sun across a spread (Generated With Diffusion Models, original)
  • Plate IV, dusk: the crest lit orange under a violet sky (Generated With Diffusion Models, original)
  • Plate V, night: stars over the ridges and a lit refuge (Generated With Diffusion Models, original)
  • Plate VI, snow: the cirque of plate II the morning after (Generated With Diffusion Models, original)

Préparation

#1 · Un style de titre pour chaque planche, construit depuis une liste

Le code est la réponse courte ci-dessus. plate() construit un style de titre à partir de chaque ligne de PLATES, et le Markdown fixe l’ordre de l’essai, une ligne par planche : # Night {style="lamina" art="noche" n="V" hora="22:10"}. Le resourceId de l’élément image est '{attr.art}', un espace réservé comme celui d’un texte : chaque planche simple partage le style lamina et nomme son image sur sa ligne de titre ; seules la couverture et la dernière planche, qui dessinent des éléments en plus, gardent un style à elles (éléments image). span: 'page' fait commencer chaque planche sur une page à elle. L’image fait 210 mm de haut, la hauteur de la page, et une maquette réserve l’espace jusqu’au pied de ses images. C’est plus que la colonne de 158 mm des pages de texte : la planche prend donc toute la page et ce qui la suit commence sur la page suivante, sans minHeight ni marges propres.

Les deux styles de la double page, pliego et pliego-recto, dessinent la même image de 560 mm, ici art="tormenta" : la moitié verso au bord gauche de la page, la moitié recto 280 mm plus à gauche, si bien que chaque page montre sa moitié et que le canvas rogne le reste au format. parity: 'even' garde les deux moitiés en regard. Sans lui, retirer la planche de midi du Markdown mettrait la moitié gauche en page 3, un recto, et la moitié droite au verso, en page 4 ; avec lui, la page 3 reste blanche et l’orage remplit toujours les pages 4 et 5.

#2 · Le chiffre et l’heure viennent de la ligne de titre

script.js · lignes 61–72dans le code complet
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];

Le chiffre et l’heure sont des attributs du titre : le contenu reste dans le Markdown et une seule maquette sert à toutes les planches. L’heure est accrochée right-of le chiffre, ce qui aligne leurs hauts. La ligne de base d’un texte de maquette se trouve à 0,8 de sa boîte de ligne sous le haut de l’élément, et la boîte de ligne vaut par défaut 1,2 fois le corps : dropTo(13, 8) abaisse donc l’heure de 0,8 × 1,2 × (13 − 8) pt et pose l’heure de 8 pt sur la ligne de base du chiffre de 13 pt, à 193,4 mm du haut de la page. L’heure commence par un point médian, comme la légende de la planche IV, parce que le I de Syne est une simple barre qui, seule à côté de l’heure, se lit comme un filet. La moitié recto de l’orage ne dessine pas de légende, et la planche de la neige compose la sienne à l’encre, parce que son premier plan est blanc.

#3 · Un titre et un colophon sur les planches elles-mêmes

script.js · lignes 76–90dans le code complet
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));

La première planche est la couverture. {title} et {subtitle} lisent le frontmatter, et extra dans PLATES ajoute les deux éléments à la maquette de cette planche, dans la couleur de sa légende. Le colophon se place de la même façon sur la dernière planche, à l’encre sur la neige : deux éléments de 7,5 pt, un par ligne, tirés des attributs colofon et tipos, alignés à droite, le second sur la ligne de base du chiffre. Un seul élément sur plusieurs lignes se couperait là où sa largeur s’épuise ; avec un élément par ligne, la coupure tombe après la licence.

#4 · Les textes suivent leurs planches sans saut

script.js · lignes 94–114dans le code complet
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple of the size
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };

La planche qui précède un texte remplit sa page : le texte commence donc la page suivante sans saut à lui. Une page dont le premier bloc est un titre avec saut est une page d’ouverture, et le pages: 'body' du folio l’écarte. Sans saut, les pages de texte gardent leur folio ; les planches, ouvertes par des titres sur toute la largeur de la page, n’en ont pas. breakBefore: { enabled: false } désactive le saut au recto qu’a par défaut un titre de niveau 1 ; avec ce saut, la page de chaque texte serait une page d’ouverture, sans folio. FOOT place le folio sur la ligne de base des chiffres des planches, à 193,4 mm du haut de la page : le folio d’un texte et le chiffre de la planche en regard sont sur une même ligne.

#5 · Déclarer chaque planche, n’appeler que celle qui flotte

script.js · lignes 194–220dans le code complet
// Plate IV is the one plate that floats, so a counter of its type would number it 1. The type
// prints no number (no caption prefix, an empty template) and the caption carries the numeral.
// The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else.
const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }),
  shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
// JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi).
const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap',
  bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
  createdAt: 0, updatedAt: 0, altText: t(alt), ...extra });
const resources = [ // the five the heading styles draw, never cited, and plate IV
  picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.',
    es: 'Ocho crestas que se pierden en la bruma del alba.' }),
  picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.',
    es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }),
  picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning '
    + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la '
    + 'izquierda; un rayo y un claro de sol a la derecha.' }),
  picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.',
    es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }),
  picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.',
    es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }),
  // 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure.
  picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.',
    es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' },
  { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }),
];

L’élément image d’une maquette dessine une ressource déclarée : les six images sont donc dans resources, des JPEG du dossier assets/ de la recette, chacun découpé à la forme de la page ou de la double page qu’il remplit et déclaré à sa taille en pixels, seule donnée que lit la mise en page. loadImage() enregistre chaque fichier avant la composition. La planche IV est la seule appelée avec :ref, et donc la seule qui flotte. Elle prend le premier emplacement libre après l’appel, sur la page de son texte, une ligne sous le dernier paragraphe. Un type numéroté lui donnerait la légende Plate 1, puisque c’est la seule ressource de son type qui flotte. lamina a plutôt un numberingTemplate vide, et la légende elle-même commence par IV. Dans la phrase, le text du :ref nomme la planche, parce qu’un :ref nu n’imprime que le mot plate.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 068 · Photo essay with full-bleed plates ═══════════════
// https://postext.dev/en/cookbook/photo-essay-full-bleed
// Code: MIT · Text: original (CC BY 4.0) · Plates: diffusion models
// Fonts: Andada Pro, Syne, Syne Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// Sierra, a landscape photobook: one day in a mountain range in six plates, each on a page of
// its own and one across the gutter of a spread, with three short texts between them.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'photo-essay-full-bleed';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the paper between the plates, the ink and one rust for the labels
const palette = {
  ink: '#1c1f27', // text: the blue-black of the night plate
  paper: '#f3f0ea', // every page's ground: a pale stone, seen only between the plates
  accent: '#94462e', // the times over each text: the dusk plate's rust, dark enough for 8 pt
  muted: '#5f646d', // the running foot and the caption of the small plate
  white: '#fbfaf7', // type set on the plates
};
// col() links a colour to its palette entry; the hex is the value the entry holds.
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' } }));
// #endregion
const PAGE = { width: 280, height: 210 }; // a landscape photobook, in mm
const MARGIN = { top: 28, bottom: 24, inner: 40, outer: 140 }; // text pages: a 100 mm measure
const LEAD = 15; // body leading in pt
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });

// #region answer: the plates' heading styles, generated from a list
// '{attr.art}' is filled in per heading, so a plain plate names its picture on its line:
// # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras.
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own
  { id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread:
  { id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // The picture is as deep as the page, more than the column holds: the plate claims the
  // whole page, and what follows starts on the next one.
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});
// #endregion

// #region caption: the plate's numeral and hour, lower left, from the heading's attributes
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];
// #endregion

// #region cover: the book's title reversed out of the dawn sky of the first plate
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));
// #endregion

// #region texts: a text between plates opens on the next page with its hours above it
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple of the size
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };
// #endregion

const config = () => ({
  // The English sample is British English: every English tag hyphenates with the US patterns.
  locale: t({ en: 'en-us', es: 'es' }),
  colorPalette,
  resourceTypes: [lamina],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150,
    backgroundColor: col('paper'), // the ground of every page; the plates cover it
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Andada Pro', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // both default to blue
    referenceColor: col('ink'), referenceBold: false, // 'lámina IV' reads as a word of the text
    firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
    minWordSpacing: 0.7, maxWordSpacing: 1.6 }, // a narrower range than the defaults, 0.6 to 2
  // A heading's own line is measured even where its design paints the title: set it in a face
  // the page loads, or the kit fetches Open Sans for it.
  headings: { fontFamily: 'Syne', levels: [
    // A text follows its plate with no break of its own, so the default H1 break is switched
    // off: the plate fills its page, the text still starts a page, and that page stays a body
    // page, with its running foot.
    { level: 1, breakBefore: { enabled: false }, advancedDesign: textOpener },
  ] },
  headingStyles: PLATES.map(plate),
  captionStyle: { fontFamily: 'Syne Mono', fontSize: pt(7.5), color: col('muted') },
  header: { elements: [] },
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Exemple Markdown · 43 lignes · content.en.mdtitle: "Sierra" subtitle: "An essay in six lights" --- # Dawn {style="alba" n="I" hora="08:12"} # The climb {hora="06:40–14:06 · 1770–2180 m"} We left the car park at 6:40 with head torches, on the paved track that climbs between the broom. At that hour the granite still holds the night’s cold, and our boots sound louder than they should. From the first bend we can see the lights of the village below, eleven of them, counted twice; from the second we can’t. At 8:12 the sun touches the highest point of the crest. It is a pink edge that lasts as long as it takes to get the camera out; then the light works its way down the gullies, slowly, and takes almost an hour to reach the path. The meadow grass is white with frost and crunches under our boots. An ibex watches us from a boulder and does not move, so we are the ones who go round. We stop at the pass to eat. There is a spring that is not on the map, an iron spout driven into the rock, and the water is cold enough to make our teeth ache. Andrés notes the time and the altitude of every photograph in a notebook; at one o’clock the thermometer hanging from his rucksack reads eleven degrees in the sun. Solar noon falls after two here, on summer time. By then the shadows have gone under the stones, and the lake, seen from the pass, looks like a sheet of tin someone has left behind between the walls. # Noon {style="lamina" art="mediodia" n="II" hora="14:06"} # Storm {style="pliego" art="tormenta" n="III" hora="16:40"} # Storm {style="pliego-recto" art="tormenta"} # Eleven seconds {hora="15:00–19:50 · 2180–1950 m"} At three o’clock the air grows heavy. A cloud that at first we take for another mountain is building in the west. We pack in a hurry. The first thunder comes eleven seconds after the flash: nearly four kilometres away, Andrés reckons, because he always counts. The next one comes after four. We take shelter under the overhang of a boulder as big as a house. First small hail, then rain; the wall opposite fades behind the curtain and comes back darker, washed. Nobody speaks for a good hour. Around half past seven the storm drifts away down the valley and leaves the sky broken into strips. For a moment the sun comes out below the cloud, low in the west, and turns the whole crest a colour that lasts less than three minutes (:ref{id="atardecer" text="plate IV"}). We go down to the refuge soaked through, beside the stream, which is running full. # Night {style="lamina" art="noche" n="V" hora="22:10"} # First snow {hora="22:10–09:00 · 1950 m"} At ten the sky is clear, and the refuge window cannot hold all its stars. At midnight somebody opens the door and says it is snowing. We go out in our socks. The snow falls straight down, with no wind, in large flakes that are slow to melt on our sleeves, and the torch lights only a cone of white points coming towards us. In the morning the thermometer at the window reads two below. There is a hand’s depth of snow at the door and a rim of thin ice along the lake. The ridges that were grey yesterday are white with black streaks, the ribs where the snow will not stick, and the sky is the same colour as the ground. Andrés takes the last photograph at 8:31, from the same boulder as at noon yesterday. It takes him some time to find it, because the stones he used as markers are now identical white mounds. In the end he places it by matching the crest to yesterday’s picture on the camera screen. It is the first snow of the autumn, two weeks early, the warden says. We set off at nine. The paved track is under the snow, so we go down from one cairn to the next. # Snow {style="nieve" n="VI" hora="08:31" colofon="Sierra. An essay in six lights · Plates generated with diffusion models · Text: CC BY 4.0" tipos="Set in Andada Pro, Syne and Syne Mono (SIL OFL)"}
`; // content.<lang>.md, inlined by the Cookbook // #region plates: every picture is a resource; only plate IV is cited, so only it floats // Plate IV is the one plate that floats, so a counter of its type would number it 1. The type // prints no number (no caption prefix, an empty template) and the caption carries the numeral. // The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else. const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }), shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; // JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi). const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap', bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0, altText: t(alt), ...extra }); const resources = [ // the five the heading styles draw, never cited, and plate IV picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.', es: 'Ocho crestas que se pierden en la bruma del alba.' }), picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.', es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }), picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning ' + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la ' + 'izquierda; un rayo y un claro de sol a la derecha.' }), picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.', es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }), picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.', es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }), // 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure. picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.', es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' }, { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build 'Andada Pro': ['400'], Syne: ['700', '800'], 'Syne Mono': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const files = resources.map((r) => r.bitmap.fileId); // a resource names a file: load each await Promise.all(files.map((f) => loadImage(f, asset(f)))); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Sierra: a photo essay in landscape', es: 'Sierra: un ensayo fotográfico apaisado' }) });
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

#Faire flotter les planches en regard de leurs notices

Pour des planches qui flottent sur le recto en regard d’une notice de catalogue, avec la largeur de la figure calculée d’après ses proportions, voyez Des notices de catalogue face à leurs planches.

#Ouvrir un article sur une photo à fond perdu

Pour une photographie à fond perdu en tête d’un article, avec le titre et le chapeau par-dessus, voyez l’article de magazine.

Erreurs fréquentes

Erreur fréquente

La page 1 est un recto : planifiez les pages avec les numéros physiques

La page 1 est une page de droite et la page 2 le premier verso : planifiez donc les doubles pages avec les numéros de page physiques. Une ouverture sur une page paire fait face à la page impaire qui la suit. Sauts de page et de colonne →

Erreur fréquente

Pas de <marker> ni de filtres dans les dessins SVG (repli en matriciel)

Une figure SVG ne reste vectorielle dans le PDF que sans <marker>, filtres ni masques ; sinon elle se replie sur une image matricielle, et des filtres profondément imbriqués peuvent la rendre blanche dans Chrome. Dessinez les pointes de flèche comme des tracés. Figures et tableaux comme ressources →

  • N’appelez jamais une planche qu’une maquette dessine. Appelée, elle flotte aussi comme figure : un :ref vers la planche de midi dans le premier texte en place une seconde copie en page 3, et l’orage recule de deux pages, après une page blanche.
  • La planche du crépuscule se place une ligne sous son texte, pas au pied de la page. Sur la dernière page d’un texte, un flottant situé sous le texte remonte jusqu’à lui (layout.hugClosingFloats, activé par défaut), et position: 'bottom' n’y change rien.
  • Chaque texte est ajusté pour tenir sur une page dans les deux éditions. Quand le premier déborde sur la page 3, la planche de midi passe en page 4 et l’orage en pages 6 et 7, après une page 5 blanche.
  • La moitié droite de l’orage est un second titre # Storm. La page n’imprime pas de titre pour elle, mais un :::toc construit à partir des titres liste l’orage deux fois, en pages 4 et 5.
  • Les planches s’arrêtent au format, qui est le bord du canvas. Avec page.cutLines activé, le canvas s’agrandit autour du format, et le fond perdu autour de chaque planche montre la couleur du papier. Pour l’impression, ancrez les planches à 'bleed' et agrandissez chacune de la valeur du fond perdu de chaque côté.

Crédits

Texte
  • The three texts, the captions and the colophon, in Spanish and English, and the six plates, drawn in code · Postext Cookbook · CC BY 4.0
Images
  • Plate I, dawn: ridges fading into a peach haze under a dark blue sky · Generated With Diffusion Models · original
  • Plate II, noon: a granite cirque and its lake · Generated With Diffusion Models · original
  • Plate III, storm: rain, lightning and a break of sun across a spread · Generated With Diffusion Models · original
  • Plate IV, dusk: the crest lit orange under a violet sky · Generated With Diffusion Models · original
  • Plate V, night: stars over the ridges and a lit refuge · Generated With Diffusion Models · original
  • Plate VI, snow: the cirque of plate II the morning after · Generated With Diffusion Models · original
Polices
Andada Pro (SIL OFL 1.1) · Syne (SIL OFL 1.1) · Syne Mono (SIL OFL 1.1)
Sandbox