Aller au contenu principal
Recette numéro 101

Recettes · Chapitre 10 · Sortie et intégration

Un nuancier, chaque feuillet sur son papier

Chaque feuillet d’un catalogue de papetier est un bloc :::paper ; postext-folio le dessine avec la teinte, le grammage, la surface et la rigidité de son papier.

Sur cette page
  • Exemple en anglais : pas encore d’édition française
  • Format 120 × 176 mm
  • 1 colonne
  • Crimson Pro 10,2/13,4
  • IBM Plex Sans Condensed
  • Libre Caslon Display
  • 12 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 89 ms
  • 161 lignes de code

En bref

Un petit catalogue d’échantillons de papetier. Chaque page dit sur quel papier elle est imprimée, et dans la visionneuse 3D chaque page a vraiment l’aspect de ce papier et se tourne comme lui.

Ce que vous allez composer

The Stock Book de Harwell & Daughters, un marchand de papier fictif de Bristol : douze pages au format 120 × 176 mm, une couverture en carte et quatre feuillets qui décrivent chacun le papier sur lequel ils sont imprimés. Le recto d’un feuillet est la fiche du papier, avec grammage, main, fini, teinte et opacité ; le verso est une page de texte composée comme on utilise ce papier : un roman, une lettre, un cartel de galerie, un glossaire. À plat à l’écran, les douze pages s’impriment de la même façon. Dans Folio, chaque feuillet a son papier : le vélin crème, le vergé à vergeures, le couché brillant qui tourne à plat et raide, et le papier bible de 40 g qui retombe aussitôt et laisse voir son verso par transparence.

Cette recette répond à

  • Comment imprimer une couverture, un encart ou un cahier de planches sur un autre papier que le reste du livre ?
  • Comment afficher mon document comme un livre en 3D dont le lecteur tourne les pages à la main ?

La réponse courte

script.js · lignes 349–362dans le code complet
const stocks = doc.pages.map((page) => page.paper?.type ?? 'the book’s paper');
const stage = document.createElement('section');
stage.id = 'folio';
stage.ariaLabel = 'The stock book in 3D';
stage.style.cssText = 'height: min(78vh, 720px); margin: 0 auto; max-width: 1280px';
document.getElementById('pages').before(stage);
createFolioFromDocument(stage, doc, {
  appearance: { textureBaseUrl: 'https://postext.dev/folio/textures' },
  alt: (i) => `Page ${i + 1}, printed on ${stocks[i]}`,
  // page.paper is the fence's attributes as written: { type: 'bible', grammage: 40 }.
  onChange: ({ pages }) => kitStatus(pages.map((i) => `p. ${i + 1}: ${stocks[i]}`
    + (doc.pages[i].paper?.grammage ? ` ${doc.pages[i].paper.grammage} g/m²` : ''))
    .join(' · ')),
});

Ingrédients

Typographie
Crimson Pro, Libre Caslon Display, IBM Plex Sans Condensed (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Un bloc par feuillet

script.js · lignes 137–334dans le code complet
// :::paper{type=bookWove grammage=80}
// ## Book Wove, Natural {kicker="Stock 1 of 4"}
// … the stock card, a :::pagebreak, the specimen page …
// :::
// A fence starts and ends on a new page; each run here fills one leaf, recto and verso
// (gotcha: paper-run-whole-leaves). Canvas, HTML and PDF ignore the stock; Folio draws it.
const markdown = String.raw`---
title: "The Stock Book"
subtitle: "Four papers, each printed on itself"
author: "Harwell & Daughters, Paper Merchants"
---

:::paper{type=cardStock grammage=300 shade=#f7f3ea}

# The Stock Book {style="cover" kicker="Harwell & Daughters · Paper Merchants" season="Autumn 2026 · Four stocks on four leaves"}

# How to use this book {kicker="Before you begin"}

Every leaf in this book is printed on the paper it describes. The front of each leaf names the stock and gives its numbers; the back carries a page of type set the way that paper is most often used, so you can judge it as a reader will meet it.

Turn the leaves slowly. A heavy sheet turns in a wide, stiff curve, and a light one falls over almost at once. Hold a page up to the window and you will see how much of the reverse shows through. Run a thumb along the edge and you will feel the coating, or the lack of it.

The numbers on each card are the ones a printer asks for. Weight is in grams per square metre. Bulk, in cubic centimetres per gram, says how much thickness a gram of the paper gives you: multiply the two and you have the caliper of a single sheet in microns, and from that the spine of a book.

The covers are our house board, a 300 g uncoated card that takes a crease without cracking. We use it for the covers of this book and for most of the menus and programmes our customers print with us.

:::

:::paper{type=bookWove grammage=80}

# Book Wove, Natural {kicker="Stock 1 of 4"}

:::paragraphs{style="spec"}
**Weight** 80 g/m²

**Bulk** 1.6 cm³/g · a sheet of 128 µm

**Finish** uncoated, with a soft wove surface

**Shade** natural, a warm cream

**Opacity** 94 %
:::

A bulky, lightweight sheet made for novels. It is thicker than its weight suggests, so a short book still feels substantial in the hand, and its cream shade is kinder to the eye over a long evening than a bright white.

The wove surface comes from the woven wire the sheet forms on: an even, faint texture you see only at a low angle. Ink sits into it rather than on it, and type keeps a little softness at its edges.

**Best for** fiction, memoir, poetry, any book that is read rather than consulted.

:::pagebreak

## Specimen

The ferry left the north bank at six every evening, whatever the weather, and for thirty-one years Tobias Wren had written its passengers into a ledger bound in green cloth. He wrote the date, the hour and the state of the river, and then a line for every soul who came aboard: a name if they gave one, a description if they did not.

The river at Hallam is four hundred yards wide and never still. It comes down from the moors brown with peat, carrying branches and once, in the flood of the year his daughter was born, the roof of a barn with a goat standing on it. Tobias took the goat across for nothing and wrote it down as a passenger.

On the evening of the last crossing the light was going early. October had turned cold all at once, and the willows on the south bank had dropped their leaves in a single night, so that the landing stage looked bare and new.

:::

:::paper{type=uncoated grammage=100 texture=laid textureStrength=1.5 shade=#fbf8f1}

# Laid Writing, White {kicker="Stock 2 of 4"}

:::paragraphs{style="spec"}
**Weight** 100 g/m²

**Bulk** 1.25 cm³/g · a sheet of 125 µm

**Finish** uncoated, with laid and chain lines

**Shade** a soft, unbleached white

**Opacity** 96 %
:::

A writing paper with a ribbed surface. Close laid lines run across the sheet and wider chain lines cross them, the pattern a dandy roll presses into the wet web. Tilt the page under a lamp and the ribs catch the light.

It is a paper for letters and announcements more than for books: firm enough to stand in an envelope, rough enough to take a fountain pen without feathering, and quiet enough to print a single paragraph on and leave the rest of the sheet empty.

**Best for** letterheads, invitations, certificates, the endpapers of a fine edition.

:::pagebreak

## Specimen

*Harwell & Daughters, 14 Rope Walk, Bristol*

Dear Mrs Okafor,

Thank you for your letter and the proofs of the wedding stationery, which arrived this morning in perfect order. We agree that the laid sheet suits the invitation far better than the smooth one; the lines give the lettering something to rest on.

We have set aside two reams from the same making, so that the reply cards and the order of service will match the invitation exactly. Papers from different makings can differ by a shade, and on a table set side by side the difference shows.

The envelopes will follow on Thursday. If you would like the flaps lined, we keep a tissue in four colours and would be glad to send samples.

With our best wishes for the day,

*Ruth Harwell*

:::

:::paper{type=coatedGloss grammage=150 showThrough=false}

# Art Gloss, Bright White {kicker="Stock 3 of 4"}

:::paragraphs{style="spec"}
**Weight** 150 g/m²

**Bulk** 0.8 cm³/g · a sheet of 120 µm

**Finish** coated both sides, calendered to a gloss

**Shade** bright white

**Opacity** 98 %
:::

A heavy coated sheet, polished between steel rollers until it shines. The clay coating fills the hollows between the fibres, so ink stays on the surface and dries as a sharp, saturated dot. Photographs come up with deep blacks and clean highlights.

Because the coating is calendered hard, the sheet is thinner for its weight than an uncoated one, and noticeably stiffer. It turns as a single flat plane and lands with a sound. Nothing shows through from the reverse.

**Best for** art books, exhibition catalogues, posters, the plate section of a history.

:::pagebreak

## Specimen

*Plate 14.* Evening light on the estuary at Pill, looking west towards the two bridges. The photograph was taken from the old pilots’ steps an hour before high water, when the mud is still uncovered and holds the colour of the sky.

*Plate 15.* A barge loaded with Norwegian timber passes under the suspension bridge in 1931. Glass negative, the Harwell family collection; printed here at the size of the original plate.

On this paper the four process inks print as they will in the book: :swatch{color="cyan"} cyan, :swatch{color="magenta"} magenta, :swatch{color="yellow"} yellow and :swatch{color="key"} black, side by side and never muddied by the sheet.

Ask for the plate section of a book to be printed on gloss and bound in among the uncoated pages of the text: the reader will feel the change of paper at every turn.

:::

:::paper{type=bible grammage=40}

# India Bible, Opaque {kicker="Stock 4 of 4"}

:::paragraphs{style="spec"}
**Weight** 40 g/m²

**Bulk** 1.1 cm³/g · a sheet of 44 µm

**Finish** uncoated, with a fine vellum tooth

**Shade** an ivory white

**Opacity** 82 %
:::

The thinnest sheet we stock that will still go through a book press. A thousand pages of it make a spine of under five centimetres, which is why dictionaries, prayer books and the collected works of the long-winded are printed on it.

It is strong for its weight, but it is not opaque. Hold this leaf up and you will read the type on its back through the sheet. Good setting on a thin paper prints the lines of both sides in register, so that the show-through falls between the lines and not across them.

**Best for** dictionaries, scriptures, reference works, timetables, pocket editions.

:::pagebreak

## Specimen

:::paragraphs{style="entry"}
**caliper** *n.* The thickness of a single sheet of paper or board, measured in microns. From the instrument used to measure it.

**chain lines** *n. pl.* The widely spaced lines of a laid paper, left by the wires that hold the laid wires of the mould in place.

**deckle** *n.* The rough, feathered edge of a handmade sheet, where the pulp ran under the frame of the mould.

**grain** *n.* The direction in which most fibres in a sheet lie. A book folds and opens well when the grain runs parallel to its spine.

**opacity** *n.* The degree to which a sheet hides what is printed on its reverse.

**ream** *n.* Five hundred sheets of paper, once four hundred and eighty.
:::

:::

:::paper{type=cardStock grammage=300 shade=#f7f3ea}

# Ordering {kicker="Harwell & Daughters"}

Every stock in this book is held in our warehouse in full reams and in the sizes most printers use. Samples of any paper are free; send us the job and we will send the sheets. Orders placed before noon leave the same day.

When you write, give the weight and the name as they appear on the cards. If you are matching an earlier job, send us a sheet of it: we will find the making it came from.

:::paragraphs{style="colophon"}
Set in Crimson Pro, Libre Caslon Display and IBM Plex Sans Condensed (SIL Open Font License) · Text: original, CC BY 4.0 · Harwell & Daughters is a fictional merchant.
:::

# Harwell & Daughters {style="back" address="14 Rope Walk · Bristol · since 1897"}

:::
`; // content.<lang>.md, inlined by the Cookbook

Un bloc :::paper commence sur une nouvelle page, et le texte qui le suit aussi : chaque passage remplit ici un feuillet, la fiche au recto, un :::pagebreak, le spécimen au verso. Un feuillet a un seul papier pour ses deux faces, celui de son recto : un passage commencé sur un verso imprimerait cette page sur le papier du feuillet qu’elle partage (voir Erreurs fréquentes).

#2 · Le papier du livre et ses couvertures

script.js · lignes 41–51dans le code complet
const folio = {
  tilt: 24,
  // The paper of any page outside a :::paper fence (there are none here). Each fence's
  // attributes override these field by field, and past them the stock's defaults apply.
  paper: { type: 'uncoated' },
  // 'pages': page 1 turns as the front board and page 12, a verso, as the back board, so no
  // case is drawn round the block (gotcha: folio-cover-pages-even).
  binding: { type: 'paperback', cover: 'pages', coverMaterial: 'paper' },
  surface: { type: 'linen' },
  lighting: { environment: 'daylight', intensity: 1 },
};

folio.paper est le papier de toutes les pages hors d’un bloc ; les attributs d’un bloc le remplacent champ par champ, et les valeurs par défaut du papier complètent le reste. cover: 'pages' fait de la page 1 le plat avant et de la page 12 le plat arrière : le livre reste fermé jusqu’à ce que vous l’ouvriez ; il faut pour cela un nombre pair de pages, que garantit le saut parity: 'even' de la quatrième de couverture.

#3 · Des couvertures à fond perdu

script.js · lignes 55–84dans le code complet
const field = { kind: 'box', id: 'field', style: { backgroundColor: col('oxblood') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(TRIM_H) } } };
const front = { enabled: true, minHeight: mm(TRIM_H), slot: { elements: [field,
  text('kicker', '{attr.kicker}', { ...label, fontSize: pt(7.5), color: col('cream') },
    at('page', 'top-left', INNER, 22)),
  text('title', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(52),
    lineHeight: 0.95, color: col('cream'), align: 'left' },
  { ...at('#kicker', 'below', 0, 30), size: { width: mm(TRIM_W - INNER - OUTER) } }),
  text('subtitle', '{subtitle}', { fontFamily: 'Crimson Pro', italic: true,
    fontSize: pt(12), lineHeight: 1.3, color: col('cream'), align: 'left' },
  { ...at('#title', 'below', 0, 6), size: { width: mm(80) } }),
  text('season', '{attr.season}', { ...label, fontSize: pt(7), color: col('cream') },
    at('page', 'bottom-left', INNER, -18)),
] } };
const back = { enabled: true, minHeight: mm(TRIM_H), slot: { elements: [field,
  text('name', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(20),
    lineHeight: 1, color: col('cream'), align: 'center' },
  { ...at('page', 'center', 0, -4), size: { width: mm(90) } }),
  text('address', '{attr.address}', { ...label, fontSize: pt(6.8), color: col('cream'),
    align: 'center' }, { ...at('#name', 'below', 0, 4), size: { width: mm(90) } }),
] } };
// The covers are the book's two openers; the folio skips them (pages: 'body', below).
// No margins: the design's column is the whole page, so the field reaches every edge, and the
// style's margins last only as long as its section, the cover page.
const flush = { span: 'page', margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) } };
const headingStyles = [
  { id: 'cover', numbered: false, toc: false, advancedDesign: front, ...flush },
  { id: 'back', numbered: false, toc: false, advancedDesign: back, ...flush,
    breakBefore: { enabled: true, parity: 'even' } }, // the last page, a verso
];

Chaque couverture est un style de titre dont la maquette contient un aplat de couleur aussi grand que le format. Le style ramène ses marges à zéro le temps de sa section, la seule page de couverture : l’aplat atteint chaque bord, et les pages suivantes gardent leurs marges.

#4 · La fiche du papier

script.js · lignes 88–99dans le code complet
const card = { enabled: true, minHeight: mm(25), slot: { elements: [
  text('kicker', '{attr.kicker}', { ...label, fontSize: pt(7), color: col('oxblood') },
    at('container', 'top-left', 0, 2)),
  text('name', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(24),
    lineHeight: 1.05, color: col('ink'), align: 'left' },
  { ...at('#kicker', 'below', 0, 2.5), size: { width: 'fill' } }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: { ...at('#name', 'below', 0, 3.5), size: { width: 'fill' } } },
] } };
const spec = { id: 'spec', fontFamily: 'IBM Plex Sans Condensed', fontSize: pt(8),
  lineHeight: pt(LEAD * 0.85), textAlign: 'left', firstLineIndent: pt(0), color: col('ink'),
  boldColor: col('oxblood') };

Chaque feuillet s’ouvre sur un titre de niveau 1 dessiné à partir d’un emplacement : le surtitre tiré des attributs du titre, le nom du papier et un filet fin. Les lignes de caractéristiques sont un style de paragraphe dans le caractère étroit des étiquettes, avec leurs intitulés en gras dans la couleur d’accent.

#5 · Relire le papier dans la mise en page

Le code est la réponse courte ci-dessus. Chaque page mise en page dans un bloc porte les attributs du bloc dans page.paper, tels qu’ils sont écrits : { type: 'bible', grammage: 40 }. createFolioFromDocument les transmet à la visionneuse, qui dessine chaque feuillet avec son papier ; le pen les lit aussi pour la ligne d’état et pour le texte alternatif de chaque page.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 101 · A swatch book, every leaf on its own paper ═══════════
// https://postext.dev/en/cookbook/paper-swatch-book
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Crimson Pro, Libre Caslon Display, IBM Plex Sans Cond. (OFL) · Needs postext ≥ 1.25.0
//
// A paper merchant's stock book: each leaf sits inside a :::paper fence that names its stock,
// and postext-folio draws that leaf with the stock's shade, surface, stiffness and opacity.
// Turn the leaves and compare: the gloss sheet turns flat and stiff, the bible paper falls
// over at once and shows its back through, the card covers turn as boards.
import { buildDocumentWithFonts, renderPageToCanvas } from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'paper-swatch-book';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the house colours, and the four process inks the gloss page shows
const palette = {
  ink: '#22201d', // text
  oxblood: '#5e2128', // the covers' colour field, kickers and folios
  cream: '#f3ead6', // type on the covers
  rule: '#cbc2b3', // hairlines
  muted: '#6f675e', // labels, folios, the colophon
  cyan: '#00a0e0', magenta: '#e0007a', yellow: '#ffe100', key: '#1b1b1b', // process inks
};
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' } })),
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
// #endregion
const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER] = [120, 176, 18, 20, 16, 14]; // mm
const LEAD = 13.4; // pt
const label = { fontFamily: 'IBM Plex Sans Condensed', fontWeight: 600, letterSpacing: pt(1.2),
  textTransform: 'uppercase', align: 'left' };
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, look, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  placement, ...look });

// #region folio: the book's own paper and binding; the leaves' papers come from the text
const folio = {
  tilt: 24,
  // The paper of any page outside a :::paper fence (there are none here). Each fence's
  // attributes override these field by field, and past them the stock's defaults apply.
  paper: { type: 'uncoated' },
  // 'pages': page 1 turns as the front board and page 12, a verso, as the back board, so no
  // case is drawn round the block (gotcha: folio-cover-pages-even).
  binding: { type: 'paperback', cover: 'pages', coverMaterial: 'paper' },
  surface: { type: 'linen' },
  lighting: { environment: 'daylight', intensity: 1 },
};
// #endregion

// #region covers: a colour field to the bleed, for the front and the back of the book
const field = { kind: 'box', id: 'field', style: { backgroundColor: col('oxblood') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(TRIM_H) } } };
const front = { enabled: true, minHeight: mm(TRIM_H), slot: { elements: [field,
  text('kicker', '{attr.kicker}', { ...label, fontSize: pt(7.5), color: col('cream') },
    at('page', 'top-left', INNER, 22)),
  text('title', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(52),
    lineHeight: 0.95, color: col('cream'), align: 'left' },
  { ...at('#kicker', 'below', 0, 30), size: { width: mm(TRIM_W - INNER - OUTER) } }),
  text('subtitle', '{subtitle}', { fontFamily: 'Crimson Pro', italic: true,
    fontSize: pt(12), lineHeight: 1.3, color: col('cream'), align: 'left' },
  { ...at('#title', 'below', 0, 6), size: { width: mm(80) } }),
  text('season', '{attr.season}', { ...label, fontSize: pt(7), color: col('cream') },
    at('page', 'bottom-left', INNER, -18)),
] } };
const back = { enabled: true, minHeight: mm(TRIM_H), slot: { elements: [field,
  text('name', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(20),
    lineHeight: 1, color: col('cream'), align: 'center' },
  { ...at('page', 'center', 0, -4), size: { width: mm(90) } }),
  text('address', '{attr.address}', { ...label, fontSize: pt(6.8), color: col('cream'),
    align: 'center' }, { ...at('#name', 'below', 0, 4), size: { width: mm(90) } }),
] } };
// The covers are the book's two openers; the folio skips them (pages: 'body', below).
// No margins: the design's column is the whole page, so the field reaches every edge, and the
// style's margins last only as long as its section, the cover page.
const flush = { span: 'page', margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) } };
const headingStyles = [
  { id: 'cover', numbered: false, toc: false, advancedDesign: front, ...flush },
  { id: 'back', numbered: false, toc: false, advancedDesign: back, ...flush,
    breakBefore: { enabled: true, parity: 'even' } }, // the last page, a verso
];
// #endregion

// #region stock-card: the kicker over each stock's name, and the specification lines
const card = { enabled: true, minHeight: mm(25), slot: { elements: [
  text('kicker', '{attr.kicker}', { ...label, fontSize: pt(7), color: col('oxblood') },
    at('container', 'top-left', 0, 2)),
  text('name', '{titleText}', { fontFamily: 'Libre Caslon Display', fontSize: pt(24),
    lineHeight: 1.05, color: col('ink'), align: 'left' },
  { ...at('#kicker', 'below', 0, 2.5), size: { width: 'fill' } }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: { ...at('#name', 'below', 0, 3.5), size: { width: 'fill' } } },
] } };
const spec = { id: 'spec', fontFamily: 'IBM Plex Sans Condensed', fontSize: pt(8),
  lineHeight: pt(LEAD * 0.85), textAlign: 'left', firstLineIndent: pt(0), color: col('ink'),
  boldColor: col('oxblood') };
// #endregion

const config = () => ({
  locale: 'en-us',
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Crimson Pro', fontSize: pt(10.2), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4),
    indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: 'Libre Caslon Display', color: col('ink'), fontWeight: 400, levels: [
    // A :::paper fence already starts a new page, so the stock name breaks to any side.
    { level: 1, fontSize: pt(24), breakBefore: { enabled: true, parity: 'any' },
      marginTop: pt(0), marginBottom: pt(LEAD / 2), advancedDesign: card },
    { level: 2, fontFamily: 'IBM Plex Sans Condensed', fontSize: pt(7.5), fontWeight: 600,
      letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
      lineHeight: pt(LEAD), marginTop: pt(0), marginBottom: pt(LEAD / 2) },
  ] },
  headingStyles,
  paragraphStyles: [spec,
    { id: 'entry', fontSize: pt(8.4), lineHeight: pt(11.5), textAlign: 'left',
      firstLineIndent: pt(0), hangingIndent: mm(4), marginTop: pt(3) },
    { id: 'colophon', fontFamily: 'IBM Plex Sans Condensed', fontSize: pt(6.8),
      lineHeight: pt(9.4), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
      marginTop: pt(2 * LEAD) }],
  header: { elements: [] },
  footer: { elements: [text('folio', '{pageNumber} · {title}', { ...label, fontSize: pt(6.5),
    color: col('muted'), align: 'center', pages: 'body' }, at('page', 'bottom', 0, -10))] },
  folio,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region paper-runs: each leaf's stock is a :::paper fence around its two pages
// :::paper{type=bookWove grammage=80}
// ## Book Wove, Natural {kicker="Stock 1 of 4"}
// … the stock card, a :::pagebreak, the specimen page …
// :::
// A fence starts and ends on a new page; each run here fills one leaf, recto and verso
// (gotcha: paper-run-whole-leaves). Canvas, HTML and PDF ignore the stock; Folio draws it.
const markdown = String.raw`---
Exemple Markdown · 190 lignes · content.en.mdtitle: "The Stock Book" subtitle: "Four papers, each printed on itself" author: "Harwell & Daughters, Paper Merchants" --- :::paper{type=cardStock grammage=300 shade=#f7f3ea} # The Stock Book {style="cover" kicker="Harwell & Daughters · Paper Merchants" season="Autumn 2026 · Four stocks on four leaves"} # How to use this book {kicker="Before you begin"} Every leaf in this book is printed on the paper it describes. The front of each leaf names the stock and gives its numbers; the back carries a page of type set the way that paper is most often used, so you can judge it as a reader will meet it. Turn the leaves slowly. A heavy sheet turns in a wide, stiff curve, and a light one falls over almost at once. Hold a page up to the window and you will see how much of the reverse shows through. Run a thumb along the edge and you will feel the coating, or the lack of it. The numbers on each card are the ones a printer asks for. Weight is in grams per square metre. Bulk, in cubic centimetres per gram, says how much thickness a gram of the paper gives you: multiply the two and you have the caliper of a single sheet in microns, and from that the spine of a book. The covers are our house board, a 300 g uncoated card that takes a crease without cracking. We use it for the covers of this book and for most of the menus and programmes our customers print with us. ::: :::paper{type=bookWove grammage=80} # Book Wove, Natural {kicker="Stock 1 of 4"} :::paragraphs{style="spec"} **Weight** 80 g/m² **Bulk** 1.6 cm³/g · a sheet of 128 µm **Finish** uncoated, with a soft wove surface **Shade** natural, a warm cream **Opacity** 94 % ::: A bulky, lightweight sheet made for novels. It is thicker than its weight suggests, so a short book still feels substantial in the hand, and its cream shade is kinder to the eye over a long evening than a bright white. The wove surface comes from the woven wire the sheet forms on: an even, faint texture you see only at a low angle. Ink sits into it rather than on it, and type keeps a little softness at its edges. **Best for** fiction, memoir, poetry, any book that is read rather than consulted. :::pagebreak ## Specimen The ferry left the north bank at six every evening, whatever the weather, and for thirty-one years Tobias Wren had written its passengers into a ledger bound in green cloth. He wrote the date, the hour and the state of the river, and then a line for every soul who came aboard: a name if they gave one, a description if they did not. The river at Hallam is four hundred yards wide and never still. It comes down from the moors brown with peat, carrying branches and once, in the flood of the year his daughter was born, the roof of a barn with a goat standing on it. Tobias took the goat across for nothing and wrote it down as a passenger. On the evening of the last crossing the light was going early. October had turned cold all at once, and the willows on the south bank had dropped their leaves in a single night, so that the landing stage looked bare and new. ::: :::paper{type=uncoated grammage=100 texture=laid textureStrength=1.5 shade=#fbf8f1} # Laid Writing, White {kicker="Stock 2 of 4"} :::paragraphs{style="spec"} **Weight** 100 g/m² **Bulk** 1.25 cm³/g · a sheet of 125 µm **Finish** uncoated, with laid and chain lines **Shade** a soft, unbleached white **Opacity** 96 % ::: A writing paper with a ribbed surface. Close laid lines run across the sheet and wider chain lines cross them, the pattern a dandy roll presses into the wet web. Tilt the page under a lamp and the ribs catch the light. It is a paper for letters and announcements more than for books: firm enough to stand in an envelope, rough enough to take a fountain pen without feathering, and quiet enough to print a single paragraph on and leave the rest of the sheet empty. **Best for** letterheads, invitations, certificates, the endpapers of a fine edition. :::pagebreak ## Specimen *Harwell & Daughters, 14 Rope Walk, Bristol* Dear Mrs Okafor, Thank you for your letter and the proofs of the wedding stationery, which arrived this morning in perfect order. We agree that the laid sheet suits the invitation far better than the smooth one; the lines give the lettering something to rest on. We have set aside two reams from the same making, so that the reply cards and the order of service will match the invitation exactly. Papers from different makings can differ by a shade, and on a table set side by side the difference shows. The envelopes will follow on Thursday. If you would like the flaps lined, we keep a tissue in four colours and would be glad to send samples. With our best wishes for the day, *Ruth Harwell* ::: :::paper{type=coatedGloss grammage=150 showThrough=false} # Art Gloss, Bright White {kicker="Stock 3 of 4"} :::paragraphs{style="spec"} **Weight** 150 g/m² **Bulk** 0.8 cm³/g · a sheet of 120 µm **Finish** coated both sides, calendered to a gloss **Shade** bright white **Opacity** 98 % ::: A heavy coated sheet, polished between steel rollers until it shines. The clay coating fills the hollows between the fibres, so ink stays on the surface and dries as a sharp, saturated dot. Photographs come up with deep blacks and clean highlights. Because the coating is calendered hard, the sheet is thinner for its weight than an uncoated one, and noticeably stiffer. It turns as a single flat plane and lands with a sound. Nothing shows through from the reverse. **Best for** art books, exhibition catalogues, posters, the plate section of a history. :::pagebreak ## Specimen *Plate 14.* Evening light on the estuary at Pill, looking west towards the two bridges. The photograph was taken from the old pilots’ steps an hour before high water, when the mud is still uncovered and holds the colour of the sky. *Plate 15.* A barge loaded with Norwegian timber passes under the suspension bridge in 1931. Glass negative, the Harwell family collection; printed here at the size of the original plate. On this paper the four process inks print as they will in the book: :swatch{color="cyan"} cyan, :swatch{color="magenta"} magenta, :swatch{color="yellow"} yellow and :swatch{color="key"} black, side by side and never muddied by the sheet. Ask for the plate section of a book to be printed on gloss and bound in among the uncoated pages of the text: the reader will feel the change of paper at every turn. ::: :::paper{type=bible grammage=40} # India Bible, Opaque {kicker="Stock 4 of 4"} :::paragraphs{style="spec"} **Weight** 40 g/m² **Bulk** 1.1 cm³/g · a sheet of 44 µm **Finish** uncoated, with a fine vellum tooth **Shade** an ivory white **Opacity** 82 % ::: The thinnest sheet we stock that will still go through a book press. A thousand pages of it make a spine of under five centimetres, which is why dictionaries, prayer books and the collected works of the long-winded are printed on it. It is strong for its weight, but it is not opaque. Hold this leaf up and you will read the type on its back through the sheet. Good setting on a thin paper prints the lines of both sides in register, so that the show-through falls between the lines and not across them. **Best for** dictionaries, scriptures, reference works, timetables, pocket editions. :::pagebreak ## Specimen :::paragraphs{style="entry"} **caliper** *n.* The thickness of a single sheet of paper or board, measured in microns. From the instrument used to measure it. **chain lines** *n. pl.* The widely spaced lines of a laid paper, left by the wires that hold the laid wires of the mould in place. **deckle** *n.* The rough, feathered edge of a handmade sheet, where the pulp ran under the frame of the mould. **grain** *n.* The direction in which most fibres in a sheet lie. A book folds and opens well when the grain runs parallel to its spine. **opacity** *n.* The degree to which a sheet hides what is printed on its reverse. **ream** *n.* Five hundred sheets of paper, once four hundred and eighty. ::: ::: :::paper{type=cardStock grammage=300 shade=#f7f3ea} # Ordering {kicker="Harwell & Daughters"} Every stock in this book is held in our warehouse in full reams and in the sizes most printers use. Samples of any paper are free; send us the job and we will send the sheets. Orders placed before noon leave the same day. When you write, give the weight and the name as they appear on the cards. If you are matching an earlier job, send us a sheet of it: we will find the making it came from. :::paragraphs{style="colophon"} Set in Crimson Pro, Libre Caslon Display and IBM Plex Sans Condensed (SIL Open Font License) · Text: original, CC BY 4.0 · Harwell & Daughters is a fictional merchant. ::: # Harwell & Daughters {style="back" address="14 Rope Walk · Bristol · since 1897"} :::
`; // content.<lang>.md, inlined by the Cookbook // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Crimson Pro': ['400', '400i', '700'], 'Libre Caslon Display': ['400'], 'IBM Plex Sans Condensed': ['400', '600', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown }, config(), kitFonts(FONTS)); showPages(doc, { title: 'The Stock Book · four papers in Folio' }); // #region answer: every page carries its stock; the viewer draws each leaf with it const stocks = doc.pages.map((page) => page.paper?.type ?? 'the book’s paper'); const stage = document.createElement('section'); stage.id = 'folio'; stage.ariaLabel = 'The stock book in 3D'; stage.style.cssText = 'height: min(78vh, 720px); margin: 0 auto; max-width: 1280px'; document.getElementById('pages').before(stage); createFolioFromDocument(stage, doc, { appearance: { textureBaseUrl: 'https://postext.dev/folio/textures' }, alt: (i) => `Page ${i + 1}, printed on ${stocks[i]}`, // page.paper is the fence's attributes as written: { type: 'bible', grammage: 40 }. onChange: ({ pages }) => kitStatus(pages.map((i) => `p. ${i + 1}: ${stocks[i]}` + (doc.pages[i].paper?.grammage ? ` ${doc.pages[i].paper.grammage} g/m²` : '')) .join(' · ')), }); // #endregion
Kit · core, fonts, viewer : identique dans toutes les recettes · 224 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 ───────────────────────────────────────────────────────────────────────

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

#Ajouter un cahier hors-texte sur un couché brillant plus épais

Un bloc placé dans le texte de n’importe quel livre imprime ces pages sur leur propre papier ; les figures appelées à l’intérieur sont placées avant qu’il se ferme.

+:::paper{type=coatedGloss grammage=170 showThrough=false}
+
+# Plates {kicker="Between pages 64 and 65"}
+
+::resource{id="plate-1"}
+
+:::

#Une brochure piquée avec une couverture en carte

La recette du zine enveloppe des pages de papier journal dans une couverture en carte avec deux blocs et une piqûre à cheval.

Erreurs fréquentes

Erreur fréquente

Une suite de papier commence et finit sur une nouvelle page, et un feuillet prend le papier de son recto

Le texte placé dans :::paper commence sur une nouvelle page, et le texte qui le suit aussi. Un feuillet a deux pages et un seul papier, celui de son recto : une suite qui commence sur un verso imprime cette page sur le papier du feuillet qu’elle partage. Insérez un saut de page là où une suite doit commencer sur un recto. Un autre papier pour une suite de pages →

Erreur fréquente

cover: 'pages' exige un nombre pair de pages pour le plat verso

Avec binding.cover réglé sur 'pages', la première page se tourne comme le plat recto, et la dernière comme le plat verso seulement si elle tombe sur un verso. Un nombre impair de pages laisse au livre une couverture avant sans dos : complétez-le jusqu’à un nombre pair ou gardez la reliure cartonnée. Papier, reliure, bureau et lumière (Folio) →

Erreur fréquente

La visionneuse Folio prend la taille de son conteneur

createFolioFromDocument ajuste le livre à l’élément que vous lui donnez, avec les boutons et le nombre de pages dans ses marges. Un élément sans hauteur (un div vide dans le flux normal) n’affiche rien : donnez-lui une hauteur en CSS. Visionneuse de livre en 3D (Folio) →

Crédits

Texte
Texte original, CC BY 4.0
Polices
Crimson Pro (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1)
Sandbox