En poques paraules
Els dos primers capítols d'una novel·la nord-americana en una edició en rústica. Cada capítol comença en una pàgina de la dreta, més avall, sota un número gran.
Què compondràs
Els dos primers capítols de The Awakening (1899), de Kate Chopin, ocupen vuit pàgines de rústica de 140 × 216 mm. La coberta, una pintura generada amb models de difusió i carregada com a JPEG, mostra el golf des de Grand Isle: un lugre a l'horitzó entre els roures i l'ombrel·la blanca que puja entre la camamilla. El colofó va al peu del seu dors. Cada capítol comença en pàgina senar, amb el text a 81 mm de la vora superior, sota un número romà en cursiva de 48 pt, un filet vermell òxid de 18 mm i la primera frase en versaletes, partida en dues línies centrades. El capítol I acaba a la 5 amb un floró de camamilla, de manera que la 6 queda en blanc, sense capçalera ni foli, i el II comença a la 7. Les capçaleres porten el nom de l'autora a les parelles i el títol en cursiva a les senars.
Aquesta recepta respon a
- Com forço un salt de pàgina o de columna, i faig que cada capítol comenci en pàgina senar?
- Com afegeixo a una obertura una línia d'autor, una entradeta o un primer paràgraf amb caplletra?
- Com insereixo pàgines en blanc expressament, o començo una secció en una doble pàgina nova?
- Com poso capçaleres: el títol del llibre a la pàgina esquerra, el del capítol a la dreta i el foli per fora?
- Com faig una coberta, una portadella, una portada i un colofó?
- Com afegeixo espai vertical entre dos blocs, si les línies en blanc no fan res?
- Com componc il·lustracions sense numerar: ornaments, vinyetes, logotips?
La resposta curta
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
enabled: true,
// The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
// starts the text on line BODY_AT (a one-line lead would leave two).
minHeight: line(BODY_AT),
slot: { elements: [
{ kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
align: 'center', placement: centred(mm(NUMERAL_Y)) },
{ kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
marginBottom: pt(0), italic: true, advancedDesign: opener };
Ingredients
- Funcions
- Obertures dissenyadesCapítols que obren en pàgina senarCapçaleres per seccióMarges simètricsEstils de títolCobertes, portades i colofonsAtributs de títolTextos, filets i caixes als dissenys de pàginaImatges als dissenys de pàginaGeometria per seccióCapçaleres segons el tipus de pàginaCapçaleres i folisMetadades del documentEstils de paràgrafEspai vertical explícitFigures i taules com a recursosTipus de recurs propisFigures just aquíColor del paperVídues, òrfenes i línies curtes
- Tipografia
- Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1)
- Recursos
cover-1190.jpg- The cover’s view of the gulf, a painting (Generated With Diffusion Models, original)
- The camomile tailpiece, drawn in code in the page’s palette (Ignacio Ferro, MIT)
Elaboració
#1 · L'obertura surt del títol i del seu atribut
El codi és la resposta curta de més amunt. Els capítols de Chopin només porten número, així que el títol de cadascun en Markdown és aquest número (# I), i {titleText} l'imprimeix. {attr.lead} imprimeix l'atribut lead del títol, que conté la primera frase del capítol; el paràgraf de Markdown que va a sota comença per la frase següent (atributs d'encapçalament). El text d'un disseny no es pot justificar, així que la primera frase va centrada, i el \n de cada atribut la talla pel sentit, després de cage i després de bright;. Aquest \n només talla la línia si paragraphIndent és més gran que zero; amb 0 s'imprimeixen tal qual els dos caràcters. La frase comença nou línies de la retícula per sota de la vora superior de la caixa de text, i un minHeight de 12 línies deixa una línia en blanc a sota; sense minHeight, el text arrenca enganxat a la frase (span i disseny avançat). marginBottom: pt(0) substitueix el marge de mig cos que el nivell porta per defecte i que baixaria el text una línia.
breakBefore amb paritat 'odd' porta cada capítol a una pàgina senar. La configuració ho repeteix perquè qualsevol objecte headings anul·la el salt que el nivell porta per defecte (saltar abans). Per forçar una pàgina senar on no hi ha títol, escriu :::pagebreak{parity="odd"} al Markdown; en una maquetació de dues o més columnes, :::columnbreak passa el text a la columna següent (:::pagebreak).
#2 · Una pàgina de rústica amb línies senceres
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
backgroundColor: col('paper'),
margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
left: mm(INNER), right: mm(OUTER), mirror: true },
};
El marge inferior és el que queda dels 216 mm d'alçada després del marge superior i 35 línies de 14 pt, així que totes les pàgines plenes acaben a la mateixa línia, com la 3 i la 4. Amb mirror: true, el marge interior de 20 mm queda al costat del llom tant a les senars com a les parelles (marges simètrics). La mesura de 104,5 mm admet uns 69 caràcters de Crimson Pro de 10,5 pt. El text base admet espais entre paraules de 0,7 a 1,9 vegades el normal; amb el mínim per defecte, 0,6, una línia d'aquestes pàgines es tanca fins a 0,62. També fixa maxRuntTracking: 0. Amb el valor per defecte, el paràgraf que acaba en nodding good-by to him. es compon amb una línia menys mitjançant un interlletratge negatiu que el llenç no pinta, i els seus espais surten aixafats.
#3 · Capçaleres que se salten les obertures i les pàgines en blanc
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
fontSize: pt(9), color: col('muted'), ...style,
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
parity: 'even' posa el nom de l'autora, en versaletes de Cormorant SC, a les pàgines parelles, i 'odd', el títol en cursiva a les senars; els folis van a la vora exterior de la caixa de text. pages: 'body' aparta els quatre elements de les obertures i de la pàgina 6, la parella en blanc que afegeix el salt amb paritat. Amb pages: 'all', aquesta pàgina imprimiria 6 i Kate Chopin. Les obertures porten en lloc seu un foli al peu, un element del footer que pages: 'opener' reserva per a elles (elements de text).
#4 · La coberta és una secció sense capçaleres
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
id: 'cover',
span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
margins: { top: mm(COLOPHON_TOP) },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'art', resourceId: 'cover',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
{ kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
placement: onPage(155) },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
{ kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
La coberta és un títol, # The Awakening {style="cover"}. El seu estil col·loca la pintura als 134 mm superiors, el títol del frontmatter a 50 pt i el nom de l'autora en versaletes. L'estil regeix a més la secció que obre aquest títol, les pàgines 1 i 2, fins al capítol I. El seu header i el seu footer buits treuen el foli al peu de la coberta i les capçaleres del colofó. El seu marge superior col·loca el colofó a les sis últimes línies de la caixa de text, així que l'última línia del colofó queda a l'altura de l'última de la pàgina 3 (estils d'encapçalament). L'estil necessita span: 'page' encara que el llibre tingui una sola columna. Un disseny que es queda a la columna es retalla per la vora superior, aquí a 165 mm; sense span: 'page', la pintura no s'imprimiria i el títol perdria la part de dalt.

#5 · Un floró tanca cada capítol
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 },
altText: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a white '
+ 'sunshade comes up from the beach through the camomile.' };
const resources = [
painting,
// ::resource places each ornament once, so each chapter end has its own id.
...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
`A camomile flower between two sprigs closes chapter ${n}.`)),
];
El tipus d'ornament no té prefix de peu, així que la flor de camamilla s'imprimeix sense rètol, amb una amplada del 17 % de la mesura, al lloc de ::resource{id="tailpiece-1"} (tipus de recurs). Una inserció en línia deixa una línia de la retícula per sobre, i el :::space que la precedeix n'afegeix una altra entre l'última línia del capítol i la flor (:::space). Els dos florons fan servir el mateix fitxer, fleuron.svg, amb dos identificadors diferents, perquè cada recurs es col·loca una sola vegada.
La recepta completa
// ═══ Postext Cookbook · Nº 038 · Trade paperback: sunk openers and recto chapters ═══ // https://postext.dev/en/cookbook/trade-paperback-novel // Code: MIT · Text: Kate Chopin, The Awakening, 1899 (PD, Gutenberg #160) · Cover: diffusion models // Fonts: Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document (this recipe is English only) const RECIPE = 'trade-paperback-novel'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#231d18', // the text: a warm near-black rubric: '#8e3b22', // the one accent: the opener's hairline, the author, the tailpiece muted: '#6e655b', // running heads, folios on openers, the colophon paper: '#fbf7ef', // a cream book paper }; // Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults (headings, lists, callouts, captions) link to 'main-color', the rubric. { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } }, ]; const TEXT = 'Crimson Pro'; // the text face const DISPLAY = 'Cormorant Garamond'; // italic numerals and the cover's title const LABEL = 'Cormorant SC'; // small capitals: the lead, the author's name const [BODY, LEAD] = [10.5, 14]; // pt: the body size and its leading, the grid's pitch const line = (n) => pt(n * LEAD); // n grid lines // #region page: a 140 × 216 mm trade page whose text block holds 35 whole lines const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto const LINES = 35; // every full page ends on the same line const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro const MM_PER_PT = 25.4 / 72; const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT), left: mm(INNER), right: mm(OUTER), mirror: true }, }; // #endregion // #region answer: a sunk opener: the numeral, a hairline, and the first sentence in small caps // # I {lead="A green and yellow parrot, … kept repeating over and over:"} // The heading's text is the numeral, printed by {titleText}; the chapter's first sentence // travels as the heading's lead attribute, printed by {attr.lead} in small capitals and // centred, because design text is never justified (gotcha: design-text-ragged). const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts const NUMERAL = 48; // pt const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } }); const opener = { enabled: true, // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and // starts the text on line BODY_AT (a one-line lead would leave two). minHeight: line(BODY_AT), slot: { elements: [ { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'), align: 'center', placement: centred(mm(NUMERAL_Y)) }, { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600, fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center', lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple) overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default) paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline) placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } }, ] }, }; // Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on // a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default // 0.5 em would start the text a line lower. const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(0), italic: true, advancedDesign: opener }; // #endregion // #region heads: the author on the verso, the title on the recto, folios outside // {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter). const HEAD_Y = 12; // mm from the top edge to the top of the running heads const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages fontSize: pt(9), color: col('muted'), ...style, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } }, }); const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) }; const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) }; const folio = { fontFamily: TEXT, color: col('ink') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps), head('recto-title', '{title}', 'odd', 'top', SHIFT, italic), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; // Openers carry a drop folio instead, centred under the text block. const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center', placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] }; // #endregion // #region cover: a section of its own: art, title, author, no heads, a colophon on its verso // Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and // chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it. // The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines. const COLOPHON_LINES = 6; const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down const cover = { id: 'cover', span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2 margins: { top: mm(COLOPHON_TOP) }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center', placement: onPage(155) }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6), color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } }, { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12), letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) }, ] } }, }; const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) }; // #endregion // #region tailpiece: an ornament type that prints no caption, where ::resource stands // At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha // resource-double-quotes). The space adds a line to the gap the embed keeps above it. const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'here', width: 0.17, align: 'center' } }; const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg', svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 }); // The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels. const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'cover-1190.jpg', format: 'jpeg', width: 1190, height: 1139 }, altText: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a white ' + 'sunshade comes up from the beach through the camomile.' }; const resources = [ painting, // ::resource places each ornament once, so each chapter end has its own id. ...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12, `A camomile flower between two sprigs closes chapter ${n}.`)), ]; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, resourceTypes: [ornament], page, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.7, maxWordSpacing: 1.9, // at the default 0.6 a line closes to 0.62 maxRuntTracking: 0, // gotcha: runt-tracking-unpainted }, // The designs paint every heading; the level's own face is the numeral's (500 italic), so the // kit has no unused face to load. headings: { fontFamily: DISPLAY, fontWeight: 500, levels: [chapter] }, headingStyles: [cover], paragraphStyles: [colophon], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 79 línies · content.en.md
title: "The Awakening" author: "Kate Chopin" --- # The Awakening {style="cover"} :::paragraphs{style="colophon"} First published by Herbert S. Stone & Company, Chicago, in April 1899. Chapters I and II follow Project Gutenberg eBook #160, with Chopin’s spelling and punctuation. Set in Crimson Pro, Cormorant Garamond and Cormorant SC (SIL Open Font License). The cover, a painting, shows the gulf from the Lebrun cottages on Grand Isle. A Postext Cookbook edition. The text is in the public domain. ::: # I {lead="A green and yellow parrot, which hung in a cage\noutside the door, kept repeating over and over:"} “*Allez vous-en! Allez vous-en! Sapristi!* That’s all right!” He could speak a little Spanish, and also a language which nobody understood, unless it was the mocking-bird that hung on the other side of the door, whistling his fluty notes out upon the breeze with maddening persistence. Mr. Pontellier, unable to read his newspaper with any degree of comfort, arose with an expression and an exclamation of disgust. He walked down the gallery and across the narrow “bridges” which connected the Lebrun cottages one with the other. He had been seated before the door of the main house. The parrot and the mocking-bird were the property of Madame Lebrun, and they had the right to make all the noise they wished. Mr. Pontellier had the privilege of quitting their society when they ceased to be entertaining. He stopped before the door of his own cottage, which was the fourth one from the main building and next to the last. Seating himself in a wicker rocker which was there, he once more applied himself to the task of reading the newspaper. The day was Sunday; the paper was a day old. The Sunday papers had not yet reached Grand Isle. He was already acquainted with the market reports, and he glanced restlessly over the editorials and bits of news which he had not had time to read before quitting New Orleans the day before. Mr. Pontellier wore eye-glasses. He was a man of forty, of medium height and rather slender build; he stooped a little. His hair was brown and straight, parted on one side. His beard was neatly and closely trimmed. Once in a while he withdrew his glance from the newspaper and looked about him. There was more noise than ever over at the house. The main building was called “the house,” to distinguish it from the cottages. The chattering and whistling birds were still at it. Two young girls, the Farival twins, were playing a duet from “Zampa” upon the piano. Madame Lebrun was bustling in and out, giving orders in a high key to a yard-boy whenever she got inside the house, and directions in an equally high voice to a dining-room servant whenever she got outside. She was a fresh, pretty woman, clad always in white with elbow sleeves. Her starched skirts crinkled as she came and went. Farther down, before one of the cottages, a lady in black was walking demurely up and down, telling her beads. A good many persons of the *pension* had gone over to the *Chênière Caminada* in Beaudelet’s lugger to hear mass. Some young people were out under the water-oaks playing croquet. Mr. Pontellier’s two children were there—sturdy little fellows of four and five. A quadroon nurse followed them about with a faraway, meditative air. Mr. Pontellier finally lit a cigar and began to smoke, letting the paper drag idly from his hand. He fixed his gaze upon a white sunshade that was advancing at snail’s pace from the beach. He could see it plainly between the gaunt trunks of the water-oaks and across the stretch of yellow camomile. The gulf looked far away, melting hazily into the blue of the horizon. The sunshade continued to approach slowly. Beneath its pink-lined shelter were his wife, Mrs. Pontellier, and young Robert Lebrun. When they reached the cottage, the two seated themselves with some appearance of fatigue upon the upper step of the porch, facing each other, each leaning against a supporting post. “What folly! to bathe at such an hour in such heat!” exclaimed Mr. Pontellier. He himself had taken a plunge at daylight. That was why the morning seemed long to him. “You are burnt beyond recognition,” he added, looking at his wife as one looks at a valuable piece of personal property which has suffered some damage. She held up her hands, strong, shapely hands, and surveyed them critically, drawing up her fawn sleeves above the wrists. Looking at them reminded her of her rings, which she had given to her husband before leaving for the beach. She silently reached out to him, and he, understanding, took the rings from his vest pocket and dropped them into her open palm. She slipped them upon her fingers; then clasping her knees, she looked across at Robert and began to laugh. The rings sparkled upon her fingers. He sent back an answering smile. “What is it?” asked Pontellier, looking lazily and amused from one to the other. It was some utter nonsense; some adventure out there in the water, and they both tried to relate it at once. It did not seem half so amusing when told. They realized this, and so did Mr. Pontellier. He yawned and stretched himself. Then he got up, saying he had half a mind to go over to Klein’s hotel and play a game of billiards. “Come go along, Lebrun,” he proposed to Robert. But Robert admitted quite frankly that he preferred to stay where he was and talk to Mrs. Pontellier. “Well, send him about his business when he bores you, Edna,” instructed her husband as he prepared to leave. “Here, take the umbrella,” she exclaimed, holding it out to him. He accepted the sunshade, and lifting it over his head descended the steps and walked away. “Coming back to dinner?” his wife called after him. He halted a moment and shrugged his shoulders. He felt in his vest pocket; there was a ten-dollar bill there. He did not know; perhaps he would return for the early dinner and perhaps he would not. It all depended upon the company which he found over at Klein’s and the size of “the game.” He did not say this, but she understood it, and laughed, nodding good-by to him. Both children wanted to follow their father when they saw him starting out. He kissed them and promised to bring them back bonbons and peanuts. :::space ::resource{id="tailpiece-1"} # II {lead="Mrs. Pontellier’s eyes were quick and bright;\nthey were a yellowish brown, about the color of her hair."} She had a way of turning them swiftly upon an object and holding them there as if lost in some inward maze of contemplation or thought. Her eyebrows were a shade darker than her hair. They were thick and almost horizontal, emphasizing the depth of her eyes. She was rather handsome than beautiful. Her face was captivating by reason of a certain frankness of expression and a contradictory subtle play of features. Her manner was engaging. Robert rolled a cigarette. He smoked cigarettes because he could not afford cigars, he said. He had a cigar in his pocket which Mr. Pontellier had presented him with, and he was saving it for his after-dinner smoke. This seemed quite proper and natural on his part. In coloring he was not unlike his companion. A clean-shaved face made the resemblance more pronounced than it would otherwise have been. There rested no shadow of care upon his open countenance. His eyes gathered in and reflected the light and languor of the summer day. Mrs. Pontellier reached over for a palm-leaf fan that lay on the porch and began to fan herself, while Robert sent between his lips light puffs from his cigarette. They chatted incessantly: about the things around them; their amusing adventure out in the water—it had again assumed its entertaining aspect; about the wind, the trees, the people who had gone to the *Chênière;* about the children playing croquet under the oaks, and the Farival twins, who were now performing the overture to “The Poet and the Peasant.” Robert talked a good deal about himself. He was very young, and did not know any better. Mrs. Pontellier talked a little about herself for the same reason. Each was interested in what the other said. Robert spoke of his intention to go to Mexico in the autumn, where fortune awaited him. He was always intending to go to Mexico, but some way never got there. Meanwhile he held on to his modest position in a mercantile house in New Orleans, where an equal familiarity with English, French and Spanish gave him no small value as a clerk and correspondent. He was spending his summer vacation, as he always did, with his mother at Grand Isle. In former times, before Robert could remember, “the house” had been a summer luxury of the Lebruns. Now, flanked by its dozen or more cottages, which were always filled with exclusive visitors from the “*Quartier Français*,” it enabled Madame Lebrun to maintain the easy and comfortable existence which appeared to be her birthright. Mrs. Pontellier talked about her father’s Mississippi plantation and her girlhood home in the old Kentucky blue-grass country. She was an American woman, with a small infusion of French which seemed to have been lost in dilution. She read a letter from her sister, who was away in the East, and who had engaged herself to be married. Robert was interested, and wanted to know what manner of girls the sisters were, what the father was like, and how long the mother had been dead. When Mrs. Pontellier folded the letter it was time for her to dress for the early dinner. “I see Léonce isn’t coming back,” she said, with a glance in the direction whence her husband had disappeared. Robert supposed he was not, as there were a good many New Orleans club men over at Klein’s. When Mrs. Pontellier left him to enter her room, the young man descended the steps and strolled over toward the croquet players, where, during the half-hour before dinner, he amused himself with the little Pontellier children, who were very fond of him. :::space ::resource{id="tailpiece-2"}`; // content.en.md, inlined by the Cookbook // #region art: the fleuron, drawn in millimetres in the page's colour function drawFleuron(color) { // a camomile head between two leafy sprigs const petals = Array.from({ length: 10 }, (_, i) => `<ellipse cx="22.1" cy="6" rx="1.55" ` + `ry="0.62" transform="rotate(${i * 36} 20 6)" fill="${color}"/>`).join(''); const sprig = `<path d="M17.2 6.3C14 7.4 10 7.2 6.4 5.6C5.2 5.1 4.2 5.2 3.4 5.9" fill="none" ` + `stroke="${color}" stroke-width="0.55" stroke-linecap="round"/>` + `<path d="M13.4 6.9C12.6 4.9 10.8 4 9 4.2C10 5.6 11.4 6.6 13.4 6.9Z" fill="${color}"/>` + `<path d="M9.2 6.5C8.7 7.9 7.4 8.7 5.9 8.8C6.6 7.5 7.7 6.7 9.2 6.5Z" fill="${color}"/>` + `<circle cx="2.8" cy="6.3" r="0.7" fill="${color}"/>`; return '<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="12mm" ' + `viewBox="0 0 40 12">${petals}<circle cx="20" cy="6" r="1.05" fill="${color}"/>${sprig}` + `<g transform="translate(40 0) scale(-1 1)">${sprig}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Crimson Pro': ['400', '400i'], // text, colophon, folios 'Cormorant Garamond': ['500i'], // numerals, cover title, the recto's running head 'Cormorant SC': ['600'], // the leads, the author on the cover and the verso }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadImage(painting.bitmap.fileId, asset(painting.bitmap.fileId)); await loadSvg('fleuron.svg', drawFleuron(palette.rubric)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Trade paperback: sunk openers and recto chapters' });Kit · core, fonts, viewer, images: igual a totes les receptes · 270 línies
// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 v1 ── 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 (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Deixa que el capítol II continuï a la pàgina següent
Amb la paritat 'any', el capítol II comença a la pàgina 6, que és parella, i el llibre acaba a la 7; el foli al peu es centra a la caixa de text, no a la pàgina, així que en aquesta parella continua quedant sota el centre del text.
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },#Marca un canvi d'escena dins d'un capítol
Entre dues línies :::space, la mateixa flor marca un canvi d'escena, i el paràgraf següent comença sense sagnat perquè indentAfterHeading està desactivat. Després de seemed long to him., la flor cau al peu de la pàgina 4; el segon espai ja no hi cap i desapareix. El canvi afegeix quatre línies, que empenyen el floró del capítol I, sol, a la pàgina 6, sota una capçalera, així que hauràs de tornar a ajustar el capítol quan n'hi afegeixis un.
const resources = [
+ svg('break-1', 'fleuron.svg', 40, 12, 'A scene break.'),That was why the morning seemed long to him.
:::space
::resource{id="break-1"}
:::space
“You are burnt beyond recognition,” he added, …Errors freqüents
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
La pàgina 1 és senar: planifica amb números físics
La pàgina 1 queda a la dreta i la 2 és la primera pàgina parella, així que planifica els plecs amb números de pàgina físics: una obertura en pàgina parella queda davant de la senar que la segueix. Salts de pàgina i de columna →
Error freqüent
\n en un atribut només talla línies amb paragraphIndent > 0
En un element de text de disseny, un \n escrit al valor d'un atribut només obre una línia nova si paragraphIndent és més gran que zero o hi ha caplletra; si no, el text continua en una línia. Posa a paragraphIndent un valor mínim (0,01 pt) o fes servir un atribut per línia. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
El lineHeight d'un text de disseny és un múltiple, mai una mesura
En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
El text de disseny no es justifica mai, així que l'entradeta d'una caplletra va en bandera
A postext 1.4.1, un element de text de disseny s'alinea a l'esquerra, al centre o a la dreta i talla paraula per paraula: no hi ha alineació justificada, i hyphenate: true només parteix una paraula que no cap en una línia sencera. Les línies d'una entradeta al costat d'una dropCap acaben, per tant, en bandera al costat del text justificat. Limita l'entradeta a les línies que acompanyen la caplletra i ajusta-les a mà: amb lines: 1 (una caplletra alçada) l'entradeta és una sola línia, que el gap de la dropCap pot deixar arran; el paràgraf continua al Markdown. Caplletres a les obertures →
Error freqüent
El desbordament del text de disseny és 'ellipsis-end' per defecte
Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
La correcció de les línies curtes pot estrènyer un espaiat entre lletres que no es pinta mai
A postext 1.4.1, quan un paràgraf acaba en una línia curta, el motor el compon amb una línia menys: primer estreny l'espai entre paraules i després aplica fins a maxRuntTracking mil·lèsimes d'em d'espaiat negatiu entre lletres. Els renderitzadors de canvas i PDF només pinten l'espaiat entre lletres més gran que zero, així que el paràgraf s'imprimeix sense aquest espaiat: les seves línies justificades perden aquesta diferència en els espais entre paraules, que surten aixafats, i l'última línia pot passar-se de la mesura i quedar tallada a la vora de la columna. Posa bodyText.maxRuntTracking: 0, que conserva la correcció amb l'espai entre paraules, i reescriu els paràgrafs que tornin a acabar en una línia curta. Vídues, òrfenes i línies curtes →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
::resource{id="…"} només admet cometes dobles
Una inserció de bloc només es reconeix com a ::resource{id="…"} amb cometes dobles; qualsevol altra forma es queda al text com una línia visible. Figures just aquí →
Error freqüent
Posa entre cometes cada valor del frontmatter
YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984". Metadades del document →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
Comprovació del Sandbox · parityCascade
Cascada de paritat
Per què. Els salts amb paritat s'acumulen i produeixen més de dues pàgines en blanc seguides.
Solució. Revisa els salts always-odd i les paritats de breakBefore; en capítols curts, la paritat any sol evitar aquestes pàgines en blanc. Documentació →
- Amb
'always-odd', darrere de cada secció va una pàgina separadora en blanc abans del farciment de paritat, així que el capítol I passaria a la pàgina 5, després de dues pàgines en blanc, i el llibre arribaria a deu pàgines. - Aquestes pàgines estan ajustades a mà. Amb un marge exterior de 16 mm en lloc de 15,5, la pàgina 7 parteix after-din-ner per un segon guionet i acaba una línia abans, la 8 tanca un paràgraf amb el fragment spondent. i el segon floró passa a una novena pàgina. Revisa el peu de totes les pàgines quan canviïs la mesura, el cos o l'enfonsament.
- Mr. i Mrs. van units al nom amb un espai de no separació (U+00A0). A la 1.4.1 es manté en els paràgrafs sense cursives ni negretes, però en un amb cursives es talla com un espai normal. El paràgraf que acaba en seemed long to him. porta un espai corrent, perquè unir-hi el nom obre la seva primera línia fins a 2,2 vegades l'espai normal entre paraules.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- The Awakening (1899), chapters I and II · Kate Chopin · domini públic
- The colophon · Postext Cookbook · original
- Imatges
- The cover’s view of the gulf, a painting · Generated With Diffusion Models · original
- The camomile tailpiece, drawn in code in the page’s palette · Ignacio Ferro · MIT
- Fonts
- Crimson Pro (SIL OFL 1.1) · Cormorant Garamond (SIL OFL 1.1) · Cormorant SC (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


