En bref
Un petit livre de quatre poèmes sur la nature de Gerard Manley Hopkins. Il montre comment garder chaque vers sur sa propre ligne, avec les retraits voulus par le poète et une ligne blanche entre les strophes.
Ce que vous allez composer
Une plaquette de quatre poèmes sur la nature de Gerard Manley Hopkins, en 140 × 216 mm, dans le texte de la première édition de 1918. Après le faux-titre, un photogramme de fougère et de sorbier en frontispice fait face à une page de titre composée en Italiana. La table des matières liste chaque poème par son chiffre romain et son titre en italique, avec des points espacés jusqu’au numéro de page. Chaque poème ouvre une page sous un chiffre romain vert sauge et son titre en italique. Chaque poème est un bloc :::verse, avec les retraits de 1918 : les vers de Pied Beauty rentrent selon leur rime, et les vers longs de The Windhover sont renvoyés à la ligne avec un retrait de 4 em, deux fois plus profond que ses retraits. Les strophes sont séparées d’une ligne, et aucun mot n’est coupé, sauf là où Hopkins l’a coupé. La date se place sous chaque poème, alignée à droite, et un rameau de sorbier suit le dernier. Les pages des poèmes portent un folio centré en pied.
Cette recette répond à
- Comment composer de la poésie : un vers par ligne, blancs entre les strophes, retraits suspendus pour les vers renvoyés, sans césure ?
- Comment ajouter de l’espace vertical entre deux blocs, quand les lignes vides n’y font rien ?
- Comment ajouter une table des matières qui se met à jour seule (points de conduite, numéros de page, auteurs, lignes de partie) ?
- Comment composer une épigraphe, une dédicace, une signature ou une citation en exergue avec un grand guillemet ?
- Comment composer des illustrations non numérotées : ornements, vignettes, logos ?
La réponse courte
// A poem is one :::verse block written as it is printed: a line of verse a line, a blank line
// between stanzas (one line of the grid), and spaces before an indented line, two to an em:
// :::verse{style="verse" align=start}
// The world is charged with the grandeur of God.
// It will flame out, like shining from shook foil;
//
// And for all this, nature is never spent;
// :::
// align=start sets the poem flush with the margin (the default centres it on its longest line).
const verse = {
id: 'verse',
hangingIndent: em(2), // a turnover hangs 2 em past its line's start: 4 em under a 2 em indent
// Verse keeps its words whole, even where bodyText.hyphenation.ragged is switched on.
hyphenation: false,
};
// Hook-up: paragraphStyles: [verse, …]; the Markdown goes to buildDocument as it is written.
Ingrédients
- Fonctionnalités
- Vers ligne à ligneStyles de paragrapheTable des matièresEspace vertical expliciteRetraits, alignement et espacement des paragraphesÉchappements et caractères littérauxTitres numérotésOuvertures composéesStyles de titreChapitres non numérotésAttributs de titreImages dans les maquettes de pageCouvertures, pages de titre et colophonsMétadonnées du documentTitres courants par sectionTitres courants et foliosTypes de ressource personnalisésDes figures exactement iciPalette de couleurs sémantiqueTitres courants selon le rôle de la page
- Typographie
- Sorts Mill Goudy, Italiana, Marcellus SC (SIL OFL 1.1)
- Ressources
plate-1120.jpg- The frontispiece photogram of fern and rowan (Generated With Diffusion Models, original)
- The rowan ornament, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)
Préparation
#1 · Un poème est un bloc :::verse
Un bloc :::verse garde chaque ligne du Markdown comme un vers : le poème s’écrit tel qu’il s’imprime (:::verse). Ses espaces en tête de ligne sont ses retraits, deux par cadratin : les vers qui riment entre eux commencent au même retrait, comme en 1918. Une ligne blanche entre les strophes laisse une ligne de la grille, et disparaît en haut de page. Un vers trop long pour la colonne est renvoyé à la ligne à une espace entre deux mots, et le renvoi pend du hangingIndent du style de vers, compté depuis le début du vers lui-même : 2 em plus loin pour un vers sans retrait, 4 em sous un vers en retrait de 2 em, si bien qu’un renvoi ne se lit jamais comme un vers à part. align=start aligne chaque poème sur la marge ; sans lui, un poème est centré sur son vers le plus long. Les vers ne sont pas coupés par la césure, et un vers ne se sépare jamais de son renvoi à un saut de page.

#2 · Les lignes autour d’un poème ont aussi leurs styles
const lineStyles = [
{ id: 'dedication', fontSize: pt(9.5), marginBottom: pt(LEAD) }, // under The Windhover
{ id: 'date', fontFamily: 'Marcellus SC', fontSize: pt(8), color: col('muted'),
textAlign: 'right', marginTop: pt(LEAD) }, // a line of space above, flush right
];
La dédicace au-dessus de The Windhover et le lieu et la date sous chaque poème ne sont pas des vers : chacun est donc un conteneur :::paragraphs d’un seul paragraphe, dans un style à lui. Aucun des deux styles ne fixe d’interlignage, et leurs marges valent une ligne (15 pt) : les vers restent sur la grille du texte. La date se place une ligne sous le poème, alignée à droite, en Marcellus SC de 8 pt, le caractère des folios.
#3 · Chaque poème ouvre une page sous son numéro
// '{1:I}' numbers the poems I to IV: {number} prints the numeral here, :::toc in the contents.
// The head is HEAD_LINES grid lines (minHeight, no bottom margin), so each poem starts on the same
// line; numeral and title fill 4, or 5 if the title wraps.
const HEAD_LINES = 6;
const poemHead = { enabled: true, minHeight: pt(LEAD * HEAD_LINES), slot: { elements: [
{ kind: 'text', id: 'numeral', content: '{number}', fontFamily: 'Italiana', fontSize: pt(22),
color: col('sage'), align: 'left',
placement: { anchor: { to: 'container', edge: 'top-left' } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Sorts Mill Goudy',
italic: true, fontSize: pt(17), color: col('ink'), align: 'left',
placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(2) },
size: { width: 'fill' } } },
] } };
// Parity 'any': the next page, recto or verso, with no blank page between two poems.
const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
Au lieu de son texte, le titre imprime une maquette dans la colonne. Dans cette maquette, {number} est le compteur tel que le met en forme numberingTemplate: '{1:I}', de I à IV, et {titleText} est le titre (étendue et maquette avancée). Le niveau n’a pas de marge inférieure, et minHeight donne à chaque tête de poème une hauteur de six lignes de la grille (31,75 mm). Le chiffre et un titre d’une ligne en occupent quatre, un titre sur deux lignes cinq : chaque poème commence sur la même ligne de sa page. En page 6, cette ligne porte la dédicace. La parité 'any' passe à la page suivante, recto ou verso : il n’y a pas de page blanche entre les poèmes.
#4 · Les pages liminaires sont faites de titres
// Headings too, each on a page of its own, with no number, contents entry or folio.
// A style restates the break, or inherits the poems' (gotcha: style-inherits-break).
const leaf = { numbered: false, toc: false, span: 'page', footer: { elements: [] },
breakBefore: { enabled: true, parity: 'any' } };
const onPage = (y, width) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
...(width && { size: { width: mm(width) } }) }); // centred, y mm below the trim
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center',
placement: onPage(y), ...extra });
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const design = (...elements) => ({ enabled: true, slot: { elements } });
const front = [
{ id: 'half-title', ...leaf,
advancedDesign: design(face('title', '{titleText}', 'Italiana', 20, 60)) },
{ id: 'plate', ...leaf, advancedDesign: design( // span 'page': a column's foot would cut it
image('plate', { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } }),
face('caption', '{attr.caption}', 'Sorts Mill Goudy', 8.5, 203,
{ italic: true, color: col('muted') })) },
{ id: 'title-page', ...leaf, advancedDesign: design(
face('author', '{author}', 'Marcellus SC', 10, 46, // {author}, {title}: the frontmatter
{ letterSpacing: pt(2.4), textTransform: 'uppercase' }),
face('title', '{title}', 'Italiana', 54, 56, { lineHeight: 1 }),
face('subtitle', '{subtitle}', 'Sorts Mill Goudy', 13, 80,
{ italic: true, color: col('sage') }),
image('sprig', onPage(96, 30)),
face('press', 'The Herbarium Press', 'Marcellus SC', 8.5, 182,
{ letterSpacing: pt(1.8), color: col('muted') })) },
{ id: 'contents', ...leaf, span: 'column', advancedDesign: { enabled: false },
fontFamily: 'Italiana', fontSize: pt(24), lineHeight: pt(LEAD * 2), marginBottom: pt(LEAD) },
];
Un style de titre peut passer à une nouvelle page et y dessiner une maquette : le faux-titre, le frontispice et la page de titre sont donc chacun un titre, avec un style à lui. numbered: false les tient hors du compteur, et le premier poème reste I. toc: false les tient hors de la table des matières, et un pied de page vide laisse leurs pages sans folio (styles de titre). Les styles s’étendent sur toute la largeur de la page parce que le pied du bloc de texte coupe une maquette gardée dans la colonne, ce qui rognerait à cet endroit la planche à fond perdu et ferait perdre sa légende. La légende vient de l’attribut caption du titre ; l’auteur, le titre et le sous-titre de la page de titre viennent du frontmatter.
#5 · La table des matières se remplit d’elle-même
// The numerals stand on the titles' baseline, in Italiana and sage, at the titles' size.
const contents = {
levels: [{ level: 1, italic: true, marginBottom: pt(LEAD), numberFontFamily: 'Italiana',
numberFontWeight: 400, numberColor: col('sage'), numberWidth: mm(6), numberGap: mm(3) }],
pageNumber: { fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), width: mm(6) },
leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
};
:::toc liste chaque titre dont le style l’autorise, avec le chiffre tiré du même gabarit et l’étiquette de la page où tombe le titre. La composition remet le livre en page jusqu’à ce que ces étiquettes de page ne changent plus (table des matières). Le titre de la page elle-même, Contents, a un style avec numbered: false ; sans lui, Contents serait le poème I. Les points de conduite '. ' mettent une espace après chaque point. Les points sont alignés à droite, si bien qu’ils s’alignent d’une entrée à l’autre, et ils s’impriment dans le caractère et la couleur des numéros de page, parce que toc.leader n’a pas de réglage de couleur.
#6 · Un ornement est un type de ressource sans étiquette
// No caption prefix and no caption: the sprig prints bare, where ::resource{id="sprig"} puts
// it (double quotes: gotcha resource-double-quotes), a fifth of the measure wide, centred.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
defaultPlacement: { position: 'here', width: 0.2, align: 'center' } };
Une image dans le texte est une ressource, et toute ressource a un type. Le type ornement a un préfixe de légende vide et le rameau n’a pas de légende : aucune étiquette comme « Figure 1 » ne s’imprime dessous (types de ressources). Le defaultPlacement du type place le rameau là où se trouve ::resource{id="sprig"}, centré et large d’un cinquième de la colonne : le cul-de-lampe sous Inversnaid tient en une ligne de Markdown. resourceTypes remplace la liste par défaut : ce livre n’a donc pas de type Figure ni Table. Pour les garder, étalez defaultResourceTypes() devant l’ornement.
La recette complète
// ═══ Postext Cookbook · Nº 015 · Poems set line by line ═════════════════════════════ // https://postext.dev/en/cookbook/poetry-collection // Code: MIT · Text: G. M. Hopkins, Poems, 1918 (PD) · Plate: diffusion models · Sprig: code // Fonts: Sorts Mill Goudy, Italiana, Marcellus SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'poetry-collection'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#26221f', // the text: a warm near-black sage: '#56673f', // the one accent: numerals, the subtitle, the ornament's leaves sepia: '#8c5f3a', // used for the rowan berries only muted: '#746a60', // dates, folios, leaders and the colophon paper: '#fbf8f2', // the page }; // A colour is its hex and the palette entry it follows. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.sage, model: 'hex' } }, ]; const TRIM_W = 140, TRIM_H = 216; // mm: a poetry trim, tall enough for a sonnet's turnovers const LEAD = 15; // pt: the body's leading, the grid every line of verse sits on // #region answer: verse: a :::verse block keeps each line, its indent, the stanza breaks // A poem is one :::verse block written as it is printed: a line of verse a line, a blank line // between stanzas (one line of the grid), and spaces before an indented line, two to an em: // :::verse{style="verse" align=start} // The world is charged with the grandeur of God. // It will flame out, like shining from shook foil; // // And for all this, nature is never spent; // ::: // align=start sets the poem flush with the margin (the default centres it on its longest line). const verse = { id: 'verse', hangingIndent: em(2), // a turnover hangs 2 em past its line's start: 4 em under a 2 em indent // Verse keeps its words whole, even where bodyText.hyphenation.ragged is switched on. hyphenation: false, }; // Hook-up: paragraphStyles: [verse, …]; the Markdown goes to buildDocument as it is written. // #endregion // #region lines: the lines around a poem: a dedication above it, a place and a date below const lineStyles = [ { id: 'dedication', fontSize: pt(9.5), marginBottom: pt(LEAD) }, // under The Windhover { id: 'date', fontFamily: 'Marcellus SC', fontSize: pt(8), color: col('muted'), textAlign: 'right', marginTop: pt(LEAD) }, // a line of space above, flush right ]; // #endregion // #region poem-head: every poem opens a page under its numeral and its title // '{1:I}' numbers the poems I to IV: {number} prints the numeral here, :::toc in the contents. // The head is HEAD_LINES grid lines (minHeight, no bottom margin), so each poem starts on the same // line; numeral and title fill 4, or 5 if the title wraps. const HEAD_LINES = 6; const poemHead = { enabled: true, minHeight: pt(LEAD * HEAD_LINES), slot: { elements: [ { kind: 'text', id: 'numeral', content: '{number}', fontFamily: 'Italiana', fontSize: pt(22), color: col('sage'), align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Sorts Mill Goudy', italic: true, fontSize: pt(17), color: col('ink'), align: 'left', placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(2) }, size: { width: 'fill' } } }, ] } }; // Parity 'any': the next page, recto or verso, with no blank page between two poems. const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead, marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } }; // #endregion // #region front: the half-title, the frontispiece, the title page and the contents // Headings too, each on a page of its own, with no number, contents entry or folio. // A style restates the break, or inherits the poems' (gotcha: style-inherits-break). const leaf = { numbered: false, toc: false, span: 'page', footer: { elements: [] }, breakBefore: { enabled: true, parity: 'any' } }; const onPage = (y, width) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); // centred, y mm below the trim const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content, fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', placement: onPage(y), ...extra }); const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement }); const design = (...elements) => ({ enabled: true, slot: { elements } }); const front = [ { id: 'half-title', ...leaf, advancedDesign: design(face('title', '{titleText}', 'Italiana', 20, 60)) }, { id: 'plate', ...leaf, advancedDesign: design( // span 'page': a column's foot would cut it image('plate', { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } }), face('caption', '{attr.caption}', 'Sorts Mill Goudy', 8.5, 203, { italic: true, color: col('muted') })) }, { id: 'title-page', ...leaf, advancedDesign: design( face('author', '{author}', 'Marcellus SC', 10, 46, // {author}, {title}: the frontmatter { letterSpacing: pt(2.4), textTransform: 'uppercase' }), face('title', '{title}', 'Italiana', 54, 56, { lineHeight: 1 }), face('subtitle', '{subtitle}', 'Sorts Mill Goudy', 13, 80, { italic: true, color: col('sage') }), image('sprig', onPage(96, 30)), face('press', 'The Herbarium Press', 'Marcellus SC', 8.5, 182, { letterSpacing: pt(1.8), color: col('muted') })) }, { id: 'contents', ...leaf, span: 'column', advancedDesign: { enabled: false }, fontFamily: 'Italiana', fontSize: pt(24), lineHeight: pt(LEAD * 2), marginBottom: pt(LEAD) }, ]; // #endregion // #region contents: what :::toc prints for each poem: numeral, italic title, leader, page // The numerals stand on the titles' baseline, in Italiana and sage, at the titles' size. const contents = { levels: [{ level: 1, italic: true, marginBottom: pt(LEAD), numberFontFamily: 'Italiana', numberFontWeight: 400, numberColor: col('sage'), numberWidth: mm(6), numberGap: mm(3) }], pageNumber: { fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), width: mm(6) }, leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up }; // #endregion // #region ornament: a resource type for artwork that prints no caption and no label // No caption prefix and no caption: the sprig prints bare, where ::resource{id="sprig"} puts // it (double quotes: gotcha resource-double-quotes), a fifth of the measure wide, centred. const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'here', width: 0.2, align: 'center' } }; // #endregion const proseStyles = [ // the note on the text and the colophon, under the contents { id: 'note', fontSize: pt(9.5), lineHeight: pt(13) }, { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ]; const folio = (pages) => ({ kind: 'text', id: `folio-${pages}`, content: '{pageNumber}', pages, fontFamily: 'Marcellus SC', fontSize: pt(9), color: col('muted'), align: 'center', placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } }); const config = () => ({ locale: 'en-us', // the sample is in English colorPalette, // The list replaces Figure and Table; a book with figures spreads defaultResourceTypes() in. resourceTypes: [ornament], page: { // mirror: left is the inner margin; 150 dpi is for the screen sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(22), bottom: mm(24), left: mm(22), right: mm(18), mirror: true }, }, layout: { layoutType: 'single' }, bodyText: { // the note's prose: justified, hyphenated (en-us), spaces 0.8–1.6 of normal fontFamily: 'Sorts Mill Goudy', fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6, }, headings: { fontFamily: 'Sorts Mill Goudy', fontWeight: 400, color: col('ink'), levels: [poems, { level: 2, fontFamily: 'Marcellus SC', fontSize: pt(9), lineHeight: pt(LEAD), color: col('sage'), marginTop: pt(0), marginBottom: pt(0) }], }, headingStyles: front, toc: contents, paragraphStyles: [verse, ...lineStyles, ...proseStyles], header: { elements: [] }, // no running heads: each poem starts a page under its own title footer: { elements: [folio('opener'), folio('body')] }, // centred; never on a blank page }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 130 lignes · content.en.md
title: "Pied Beauty" subtitle: "Four poems of the natural world" author: "Gerard Manley Hopkins" --- # Pied Beauty {style="half-title"} # Fern and Rowan {style="plate" caption="Fern and rowan: the ‘flitches of fern’ and the ‘beadbonny ash’ of Inversnaid"} # Pied Beauty {style="title-page"} # Contents {style="contents"} :::toc :::space{lines=2} ## A note on the text :::paragraphs{style="note"} This selection follows the first edition of the poems, edited by Robert Bridges in 1918, nearly thirty years after Hopkins’s death. Its spelling, accents and indents are kept, and lines that rhyme with one another start the same distance in. A line too long for the page is turned over, and the turnover is set deeper than the lines around it, so that it reads as part of the line above. No word is broken unless Hopkins broke it, as he did at the end of the first line of *The Windhover*. Under each poem is the date from Bridges’s notes, with the place when he gives one. ::: :::paragraphs{style="colophon"} Set in Sorts Mill Goudy, Italiana and Marcellus SC (SIL Open Font License). Text from Project Gutenberg eBook 22403, accents and indents from Wikisource. Plate made with diffusion models; ornament drawn for this edition. ::: # God’s Grandeur :::verse{style="verse" align=start} The world is charged with the grandeur of God. It will flame out, like shining from shook foil; It gathers to a greatness, like the ooze of oil Crushed. Why do men then now not reck his rod? Generations have trod, have trod, have trod; And all is seared with trade; bleared, smeared with toil; And wears man’s smudge and shares man’s smell: the soil Is bare now, nor can foot feel, being shod. And for all this, nature is never spent; There lives the dearest freshness deep down things; And though the last lights off the black West went Oh, morning, at the brown brink eastward, springs— Because the Holy Ghost over the bent World broods with warm breast and with ah! bright wings. ::: :::paragraphs{style="date"} 23 February 1877 ::: # The Windhover :::paragraphs{style="dedication"} *To Christ our Lord* ::: :::verse{style="verse" align=start} I caught this morning morning’s minion, king- dom of daylight’s dauphin, dapple-dawn-drawn Falcon, in his riding Of the rolling level underneath him steady air, and striding High there, how he rung upon the rein of a wimpling wing In his ecstacy! then off, off forth on swing, As a skate’s heel sweeps smooth on a bow-bend: the hurl and gliding Rebuffed the big wind. My heart in hiding Stirred for a bird,—the achieve of, the mastery of the thing! Brute beauty and valour and act, oh, air, pride, plume, here Buckle! AND the fire that breaks from thee then, a billion Times told lovelier, more dangerous, O my chevalier! No wonder of it: shéer plód makes plough down sillion Shine, and blue-bleak embers, ah my dear, Fall, gall themselves, and gash gold-vermillion. ::: :::paragraphs{style="date"} St Beuno’s, 30 May 1877 ::: # Pied Beauty :::verse{style="verse" align=start} Glory be to God for dappled things— For skies of couple-colour as a brinded cow; For rose-moles all in stipple upon trout that swim: Fresh-firecoal chestnut-falls; finches’ wings; Landscape plotted and pieced—fold, fallow, and plough; And áll trádes, their gear and tackle and trim. All things counter, original, spare, strange; Whatever is fickle, freckled (who knows how?) With swift, slow; sweet, sour; adazzle, dim; He fathers-forth whose beauty is past change: Praise him. ::: :::paragraphs{style="date"} St Beuno’s, Tremeirchion, summer 1877 ::: # Inversnaid :::verse{style="verse" align=start} This darksome burn, horseback brown, His rollrock highroad roaring down, In coop and in comb the fleece of his foam Flutes and low to the lake falls home. A windpuff-bonnet of fáwn-fróth Turns and twindles over the broth Of a pool so pitchblack, féll-frówning, It rounds and rounds Despair to drowning. Degged with dew, dappled with dew Are the groins of the braes that the brook treads through, Wiry heathpacks, flitches of fern, And the beadbonny ash that sits over the burn. What would the world be, once bereft Of wet and of wildness? Let them be left, O let them be left, wildness and wet; Long live the weeds and the wilderness yet. ::: :::paragraphs{style="date"} 28 September 1881 ::: ::resource{id="sprig"}`; // content.<lang>.md, inlined by the Cookbook // #region art: a rowan sprig for the ornament, drawn in code; the plate is a JPEG in assets/ const f = (n) => n.toFixed(1); const path = (d, fill, a = 1) => `<path d="${d}" fill="${fill}" fill-opacity="${a}"/>`; const ring = (pts) => `M${pts.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z`; const disk = (x, y, r, fill, a = 1) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" ` + `fill="${fill}" fill-opacity="${a}"/>`; const PX = 10; // a drawing w × h mm has a viewBox in tenths of a millimetre, w·PX × h·PX const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" ` + `height="${h * PX}" viewBox="0 0 ${w * PX} ${h * PX}">${body}</svg>`; // A cubic Bézier and its unit normal at t. const bez = ([p0, p1, p2, p3], t) => { const u = 1 - t; const coord = (i) => u * u * u * p0[i] + 3 * u * u * t * p1[i] + 3 * u * t * t * p2[i] + t * t * t * p3[i]; const dt = (i) => 3 * u * u * (p1[i] - p0[i]) + 6 * u * t * (p2[i] - p1[i]) + 3 * t * t * (p3[i] - p2[i]); const [dx, dy] = [dt(0), dt(1)]; const len = Math.hypot(dx, dy) || 1; return { x: coord(0), y: coord(1), a: Math.atan2(dy, dx), nx: -dy / len, ny: dx / len }; }; // A stalk: the curve drawn as a filled band tapering from w0 to w1. const stalk = (curve, w0, w1) => { const left = []; const right = []; for (let i = 0; i <= 40; i++) { const p = bez(curve, i / 40); const w = (w0 + (w1 - w0) * (i / 40)) / 2; left.push([p.x + p.nx * w, p.y + p.ny * w]); right.unshift([p.x - p.nx * w, p.y - p.ny * w]); } return ring([...left, ...right]); }; // A leaf blade from its base (x, y) along angle a: length l, half-width w, `teeth` serrations. const blade = (x, y, a, l, w, teeth = 0) => { const [c, s] = [Math.cos(a), Math.sin(a)]; const at = (u, v) => [x + u * c - v * s, y + u * s + v * c]; const side = (sign) => Array.from({ length: 25 }, (_, i) => { const t = i / 24; let h = w * Math.sin(Math.PI * t ** 0.85) ** 0.9; if (teeth && t > 0.25) h *= 1 - 0.18 * ((t * teeth) % 1); return at(l * t, sign * h); }); return ring([...side(1), ...side(-1).reverse()]); }; // The ornament: a rowan leaf laid flat, with a bunch of berries at its tip. function sprig() { const [w, h] = [36, 12]; const rib = [[16, 66], [90, 56], [170, 56], [236, 60]]; const d = [stalk(rib, 6, 3)]; for (let j = 0; j < 5; j++) { const q = bez(rib, 0.12 + j * 0.19); const size = 50 - j * 3; for (const sgn of [1, -1]) d.push(blade(q.x, q.y, q.a + sgn * 1.1, size, size * 0.22, 8)); } const tip = bez(rib, 1); d.push(blade(tip.x, tip.y, tip.a, 44, 10.5, 8)); const out = [path(d.join(''), palette.sage)]; for (const [dx, dy, r] of [[34, -16, 9], [44, 2, 10], [30, 14, 9], [52, -12, 8.5], [58, 10, 8], [22, -2, 8]]) { const [bx, by] = [262 + dx, 60 + dy]; out.push(path(stalk([[240, 64], [252, 66], [bx - 10, by + 2], [bx, by]], 2.6, 1.8), palette.sage), disk(bx, by, r, palette.sepia)); } return svgOf(w, h, out.join('')); } await loadSvg('sprig.svg', sprig()); // The frontispiece is a photogram: the whole 140 × 216 mm page, its torn coating and the paper // under it in one JPEG, declared at its pixels. await loadImage('plate-1120.jpg', asset('plate-1120.jpg')); // Nothing cites them: designs show the plate and the sprig, ::resource sets the tailpiece. const svgResource = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText, createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } }); const resources = [ { id: 'plate', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'plate-1120.jpg', format: 'jpeg', width: 1120, height: 1728 }, altText: 'A photogram of a fern frond and a sprig of rowan in berry, left white on a ' + 'brushed sage ground.' }, svgResource('sprig', 36, 12, 'Ornament: a rowan leaf with a bunch of berries.'), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded before the first build. None of the three ships a bold. const FONTS = { 'Sorts Mill Goudy': ['400', '400i'], Italiana: ['400'], 'Marcellus SC': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // :::toc lists the page each poem lands on: the build lays out again until those settle. const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: 'Pied Beauty · four poems by Gerard Manley Hopkins' });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
#Mettre les renvois plus en retrait
Un retrait plus profond place le reste d’un vers renvoyé plus loin de la marge, pour des poèmes dont les vers sont souvent renvoyés à la ligne.
- hangingIndent: em(2), // a turnover hangs 2 em past its line's start: 4 em under a 2 em indent
+ hangingIndent: em(4), // a turnover hangs 2 em past its line's start: 4 em under a 2 em indent#Aligner les renvois à droite
Avec turnover: 'right' dans bodyText.verse, le reste d’un vers trop long pour la colonne passe à droite sur la ligne suivante, derrière un crochet ouvrant, comme le composent beaucoup d’éditions anglaises de poésie.
indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6,
+ verse: { turnover: 'right' }, // a turnover flush right, behind [
},#Ouvrir chaque poème sur une page de droite
Avec la parité 'odd', chaque poème s’ouvre sur le recto suivant. Le livre passe de huit pages à onze, et les trois versos blancs n’impriment pas de folio, parce que les folios sont filtrés selon le rôle de la page.
const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
- marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } };
+ marginBottom: pt(0), breakBefore: { enabled: true, parity: 'odd' } };#Numéroter les poèmes en chiffres arabes
Le style de compteur du gabarit change d’un coup le chiffre sur la page du poème et dans la table des matières.
-const poems = { level: 1, numberingTemplate: '{1:I}', advancedDesign: poemHead,
+const poems = { level: 1, numberingTemplate: '{1}', advancedDesign: poemHead,Erreurs fréquentes
Erreur fréquente
'1998. ' ou '- ' en début de paragraphe ouvre une liste
Un paragraphe qui commence par un nombre, un point et une espace, ou par un trait d’union et une espace, devient un élément de liste. Mettez un word joiner (U+2060) devant le nombre, et écrivez les dialogues avec un tiret cadratin. Échappements et caractères littéraux →
Erreur fréquente
Un style de titre hérite du saut de page de son niveau
Une entrée headingStyles reprend de son niveau de titre chaque champ qu’elle omet, breakBefore compris. Une page de table des matières ou un colophon mis en forme sur un H1 après un :::pagebreak hérite de la parité 'odd' et atterrit derrière une page blanche. Donnez à un tel style breakBefore: { enabled: false }. Styles de titre →
Erreur fréquente
::resource{id="…"} n’accepte que les guillemets doubles
Une intégration en bloc n’est reconnue que sous la forme ::resource{id="…"} avec des guillemets doubles ; toute autre forme reste dans le texte comme une ligne visible. Des figures exactement ici →
- Gardez ensemble les vers d’une strophe : une ligne blanche dans un bloc
:::versecommence une nouvelle strophe, quel que soit le nombre de lignes blanches. - Une ligne qui commence par un signe plus et une espace est un vers en escalier, placé là où le vers précédent s’arrêtait ; écrivez
\+pour imprimer le signe plus. - Sans
align=start, chaque poème est centré sur son vers le plus long, et chaque poème se trouve alors à une distance différente de la marge. - Pour renvoyer deux mots ensemble, liez-les par une espace insécable (U+00A0), comme the soil et bright wings. en page 5.
- Un élément de pied de page dont
pagesreste à'all's’imprime aussi sur les pages blanches. Le folio est ici fait de deux éléments, l’un pour les ouvertures et l’autre pour les pages de texte : un verso blanc reste blanc.
Crédits
- Recette
- Ignacio Ferro
- Texte
- “God’s Grandeur”, “The Windhover”, “Pied Beauty” and “Inversnaid”, with the places and dates from the editor’s notes, in the first edition of Poems of Gerard Manley Hopkins (1918); accents and indents checked against the proofread Wikisource transcription of that edition · Gerard Manley Hopkins; Robert Bridges (editor) · domaine public
- The note on the text, the plate’s caption and the colophon · Ignacio Ferro · CC BY 4.0
- Images
- The frontispiece photogram of fern and rowan · Generated With Diffusion Models · original
- The rowan ornament, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
- Polices
- Sorts Mill Goudy (SIL OFL 1.1) · Italiana (SIL OFL 1.1) · Marcellus SC (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


