En poques paraules
Cinc pàgines d'una novel·la clàssica brasilera en format de butxaca. Cada capítol curt comença just després de l'anterior, i cap pàgina no comença ni acaba amb una línia solta.
Què compondràs
Cinc pàgines del final de Dom Casmurro (Machado de Assis, 1899), en el portuguès original, compostes com a llibre de butxaca de 110 × 178 mm per a una col·lecció inventada, la Coleção Casuarina. Les precedeixen una coberta en bandes pruna, crema i pruna, amb el títol en Abril Fatface i el segell safrà de la col·lecció, i una làmina pintada del mar esvalotat de Flamengo, generada amb models de difusió, davant de la 213. El text va del final del CXVIII al del CXXIII, i cap capítol no obre pàgina. Cadascun comença dues línies per sota del final de l'anterior (tres en el CXIX, per omplir la 213), amb un numeral romà en pruna, el títol en versals espaiades i un filet curt safrà. A la 214 en comencen dos; a la 215 i la 216, un. Totes les pàgines de text tret de l'última acaben a la línia 33, i cap no comença amb una línia sola.
Aquesta recepta respon a
- Com evito vídues, òrfenes i últimes línies d'una sola paraula, i mantinc cada títol amb el seu text?
- Com aconsegueixo columnes arran del peu i una última pàgina equilibrada (justificació vertical)?
- Com numero els títols (1, 1.1, 1.1.1) i dono a cada nivell un estil diferent?
- 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?
La resposta curta
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
{ kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
lineHeight: 1, color: col('plum'), align: 'center',
placement: { ...at('container', 'top'), size: { width: 'fill' } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
Ingredients
- Funcions
- Vídues, òrfenes i línies curtesObertures dissenyadesCapítols que obren en pàgina senarTítols numeratsEquilibri de columnesPartició de mots i llengua del documentSagnats, alineació i separació de paràgrafsCapçaleres i folisCapçaleres segons el tipus de pàginaEstils de títolCapítols sense númeroCobertes, portades i colofonsSalts de pàgina i de columnaImatges als dissenys de pàginaTextos, filets i caixes als dissenys de pàginaAtributs de títolMarges simètricsRequadres flotantsMetadades del document
- També fa servir
- Icones i distintius de cantonadaRequadresColor del paperFigures i taules com a recursosPreliminars en romansSalts de línia als títols
- Tipografia
- Tinos (Apache 2.0) · Abril Fatface, League Spartan (SIL OFL 1.1)
- Recursos
plate-1100.jpg- The plate of the sea off Flamengo under the Sugarloaf, a painting (Generated With Diffusion Models, original)
- The casuarina roundel, drawn in code in the book’s palette (Ignacio Ferro, MIT)
Elaboració
#1 · Encadena els capítols, cada títol amb el seu text
// Machado's chapters run a page or two, so none opens a page. The level's break is off
// (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1
// default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
{ kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
lineHeight: 1, color: col('plum'), align: 'center',
placement: { ...at('container', 'top'), size: { width: 'fill' } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…'
placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits
offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
El nivell de capítol porta el salt desactivat, i marginTop col·loca cada títol dues línies per sota de l'última del capítol anterior. El seu disseny no porta span, així que el títol es dibuixa dins de la columna, en un bloc de tres línies d'alçada, i el text que va a sota cau a la retícula de base (saltar abans, span i disseny avançat). Les regles de cohesió conserven els valors per defecte. Els paràgrafs de quatre línies que segueixen el CXIX i el CXXI deixen dues línies al peu de la seva pàgina i en passen dues a la següent; amb avoidOrphans: false es partirien en tres i una, i les pàgines 214 i 215 començarien amb una línia sola (òrfenes, vídues i runts). L'equilibratge de columnes afegeix sobre el CXIX una tercera línia de blanc, i amb ella la pàgina 213 acaba a la línia 33 (equilibratge de columnes).

#2 · Compon una pàgina de 33 línies, amb partició de mots portuguesa
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
color: col('ink'), referenceColor: col('ink'), // for a :ref added later: main-color does
// not reach it in 1.4.1, and it would print blue
firstLineIndent: mm(5), // every paragraph indented, the first after a head too
maxRuntTracking: 0 }; // 1.4.1 measures a runt fix's tracking but never paints it
// (gotcha: runt-tracking-unpainted); the fix keeps its word spacing
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 * PT), // 16.3 mm
left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese patterns, by their exact code (gotcha: hyphenation-locales)
El marge inferior fa 16,3 mm, el que queda dels 178 mm d'alçada després del marge superior de 15 mm i 33 línies de 12,6 pt, així que totes les pàgines plenes acaben a la mateixa línia. Tinos, que té les mateixes amplades que la Times New Roman, compon uns 59 caràcters per línia en la mesura de 84 mm a 9,5 pt. locale: 'pt' divideix les paraules amb els patrons portuguesos: at-tracção, af-fluencia, genealogica-mente (llengües admeses). Tots els paràgrafs porten sagnat, també el primer després d'un títol, com és costum a les edicions portugueses i brasileres, i per a això n'hi ha prou amb el valor per defecte d'indentAfterHeading.
#3 · Posa el títol del llibre a la pàgina parella i el capítol a la senar
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
pages: 'body', // never on the cover or the plate, which are opener pages
...caps(7.5, TRACK), color: col('muted'), align: 'center',
placement: at('page', 'top', x + (TRACK / 2) * PT, HEAD_Y) }); // tracking, as in the answer
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
{ ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };
{title} imprimeix el títol de les metadades del document, i {chapterTitle}, el de l'últim capítol que comença a la pàgina o abans, així que la 217 anomena el CXXIII, que va començar a la 216 (elements de text). La 213 anomena el CXIX, que comença al peu, tot i que les seves primeres 25 línies tanquen el CXVIII: la 1.4.1 no té marcador per al capítol amb què obre una pàgina. SHIFT porta les capçaleres al centre de la caixa de text, a 1 mm del centre de la pàgina, perquè el marge interior fa 2 mm més que l'exterior.
#4 · Dibuixa la coberta i la làmina com a títols
// span: 'page', in one column too, paints their art whole and keeps the \\ in the title
// (gotcha: opener-clipped-at-top). A :::pagebreak follows each in the text (gotcha:
// cover-pagebreak): 1.4.1 drops the cover's reserved room, since its foot band runs past the
// column (gotcha: opener-taller-than-column), and the plate reserves room down to its caption
// only, since pictures do not count (gotcha: opener-image-no-reserve).
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
{ kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
align: 'center', placement: at('page', 'top', 1.2 * PT, 44) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
placement: onPage(72, { width: 'fill' }) },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
{ kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
{ kind: 'image', id: 'roundel', resourceId: 'roundel',
placement: onPage(133, { width: mm(20) }) },
{ kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
color: col('saffron'), align: 'center', placement: at('page', 'top', 0.9 * PT, 159) },
] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'sea', resourceId: 'sea',
placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
{ kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
align: 'center', placement: at('page', 'top', 0.8 * PT, 146) },
{ kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
placement: onPage(152, { width: mm(78) }) },
] } } };
const resources = [
{ id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'roundel.svg', width: 60, height: 60 },
altText: 'The series mark: a casuarina tree in a ring.' },
// The plate is a painting, a JPEG in assets/ cut to the trim, declared at its pixels.
{ id: 'sea', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
bitmap: { fileId: 'plate-1100.jpg', format: 'jpeg', width: 1100, height: 1780 },
altText: 'A heavy morning sea under the Sugarloaf, a low sun, and a lone swimmer among '
+ 'the breakers.' },
];
Totes dues són estils de títol amb numbered: false, així que el primer capítol numerat continua sent el CXIX. span: 'page' les converteix en pàgines d'obertura. Sense això, les bandes de la coberta es tallen a les vores superior i inferior de la caixa de text, la capçalera i el foli 1 s'imprimeixen sobre la coberta i el títol surt en una sola línia. El peu de la làmina surt del seu títol: CAPÍTULO CXXIII és el text del títol, i la cita en cursiva, el seu atribut quote (atributs d'encapçalament). El :::pagebreak que segueix cadascuna aparta el text de les il·lustracions, perquè la 1.4.1 no reserva lloc per a la banda del peu de la coberta ni per a la imatge de la làmina.
#5 · Digues al fragment en quin punt del llibre és
const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } };
continuation.headings dona els comptadors de títols amb què comença el fragment. Amb h1: 118, el primer títol numerat és el capítol CXIX; sense això, els cinc capítols surten numerats del I al V. numberingTemplate: '{1:I}', al nivell de capítol, posa aquests números en romans, i el disseny els imprimeix amb {number}. :::numbering{startAt=212}, després del salt de pàgina de la coberta, numera la làmina com a 212 i les pàgines de text de la 213 a la 217 (:::numbering).
La recepta completa
// ═══ Postext Cookbook · Nº 065 · Pocket classic: short chapters that run on ═══════════ // https://postext.dev/en/cookbook/short-chapters-run-on // Code: MIT · Text: Machado de Assis, Dom Casmurro, 1899 (PD, Gutenberg #55752) · Plate: painted // Fonts: Tinos (Apache 2.0), Abril Fatface, League Spartan (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the viewer's title; the sample is Portuguese const RECIPE = 'short-chapters-run-on'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#24202a', // the text: a violet near-black paper: '#f6f1e6', // the pocket book's paper plum: '#4f2a49', // the series colour: cover bands, the chapter numerals saffron: '#d9a03c', // the second ink: rules and drawings, never text on paper (2.1:1) muted: '#6b616e', // the running heads and the colophon (5.2:1 on paper) }; // Design slots read the hex, not the id, in 1.4.1 (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 default bold and italic colours link to main-color, set here to the ink, not blue. { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } }, ]; const [TEXT, DISPLAY, LABEL] = ['Tinos', 'Abril Fatface', 'League Spartan']; const TRIM = { width: 110, height: 178 }; // mm: a pocket book const [TOP, INNER, OUTER] = [15, 14, 12]; // mm; mirrored, so the inner margin is at the spine const [BODY, LEAD, LINES] = [9.5, 12.6, 33]; // pt, pt, and the lines of a full page const PT = 25.4 / 72; // mm per point const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const caps = (size, track) => ({ fontFamily: LABEL, fontWeight: 600, fontSize: pt(size), letterSpacing: pt(track), textTransform: 'uppercase' }); // #region answer: chapters that run on, each under a centred head drawn in the column // Machado's chapters run a page or two, so none opens a page. The level's break is off // (1.4.1 drops it anyway, gotcha: headings-drop-h1-break, but a release that keeps the H1 // default would open each chapter on a recto) and a chapter starts two lines under the last. const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL), lineHeight: 1, color: col('plum'), align: 'center', placement: { ...at('container', 'top'), size: { width: 'fill' } } }, { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'), align: 'center', overflow: 'wrap', // a long title wraps instead of ending in '…' placement: { anchor: { to: '#numeral', edge: 'below' }, // centred tracked text sits offset: { x: pt(TRACK / 2), y: mm(GAP) }, size: { width: 'fill' } } }, // left by TRACK / 2 { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'), placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } }, ] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX… fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead }; // The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph // at the foot of a page, and headings.keepWithNext takes the head along when the paragraph // moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a // last line shorter than about 20 characters as a fault. Column balancing adds lines above // a head so that the page ends on line 33. // #endregion // #region text: a pocket page of 33 lines, in Portuguese const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), referenceColor: col('ink'), // for a :ref added later: main-color does // not reach it in 1.4.1, and it would print blue firstLineIndent: mm(5), // every paragraph indented, the first after a head too maxRuntTracking: 0 }; // 1.4.1 measures a runt fix's tracking but never paints it // (gotcha: runt-tracking-unpainted); the fix keeps its word spacing 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 * PT), // 16.3 mm left: mm(INNER), right: mm(OUTER), mirror: true } }; const LOCALE = 'pt'; // the Portuguese patterns, by their exact code (gotcha: hyphenation-locales) // #endregion // #region heads: the book's title over the verso, the chapter over the recto, folios at the foot const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity, pages: 'body', // never on the cover or the plate, which are opener pages ...caps(7.5, TRACK), color: col('muted'), align: 'center', placement: at('page', 'top', x + (TRACK / 2) * PT, HEAD_Y) }); // tracking, as in the answer const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity, pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) }); const header = { elements: [ head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it ] }; const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER), { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] }; // #endregion // #region cover: the cover and the plate, heading styles that fill a page each // span: 'page', in one column too, paints their art whole and keeps the \\ in the title // (gotcha: opener-clipped-at-top). A :::pagebreak follows each in the text (gotcha: // cover-pagebreak): 1.4.1 drops the cover's reserved room, since its foot band runs past the // column (gotcha: opener-taller-than-column), and the plate reserves room down to its caption // only, since pictures do not count (gotcha: opener-image-no-reserve). const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) }); const cover = { id: 'cover', numbered: false, span: 'page', advancedDesign: { enabled: true, slot: { elements: [ { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') }, placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } }, { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'), align: 'center', placement: at('page', 'top', 1.2 * PT, 44) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44), lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap', placement: onPage(72, { width: 'fill' }) }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5), color: col('saffron'), placement: onPage(111, { width: mm(12) }) }, { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') }, placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } }, { kind: 'image', id: 'roundel', resourceId: 'roundel', placement: onPage(133, { width: mm(20) }) }, { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8), color: col('saffron'), align: 'center', placement: at('page', 'top', 0.9 * PT, 159) }, ] } } }; // The plate faces the first page of text: the morning sea off Flamengo, captioned with the // line of chapter CXXIII it illustrates, which the heading carries in its quote attribute. const plate = { id: 'plate', numbered: false, span: 'page', advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'sea', resourceId: 'sea', placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } }, { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'), align: 'center', placement: at('page', 'top', 0.8 * PT, 146) }, { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true, fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap', placement: onPage(152, { width: mm(78) }) }, ] } } }; const resources = [ { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'roundel.svg', width: 60, height: 60 }, altText: 'The series mark: a casuarina tree in a ring.' }, // The plate is a painting, a JPEG in assets/ cut to the trim, declared at its pixels. { id: 'sea', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'plate-1100.jpg', format: 'jpeg', width: 1100, height: 1780 }, altText: 'A heavy morning sea under the Sugarloaf, a low sun, and a lone swimmer among ' + 'the breakers.' }, ]; // #endregion // The colophon floats to the foot of the last page, beside the series roundel. const colophon = { id: 'colophon', placement: 'bottom', backgroundEnabled: false, padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginBottom: pt(0), icon: { kind: 'resource', resourceId: 'roundel', size: mm(10) }, titleStyle: { gap: mm(3) }, body: { fontFamily: TEXT, fontSize: pt(7.6), lineHeight: pt(LEAD * 0.8), color: col('muted'), italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0) } }; const config = () => ({ // a factory: the engine caches resolved configs per object locale: LOCALE, colorPalette, page, layout: { layoutType: 'single' }, bodyText, // The heading's own line is hidden under its design but still measured, in this face. headings: { fontFamily: LABEL, fontWeight: 600, levels: [chapters] }, headingStyles: [cover, plate], calloutStyles: [colophon], header, footer, }); // #region art: the series roundel, drawn in code in the book's two inks let seed = 1871; // Mulberry32, seeded: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const n = (v) => v.toFixed(2); const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}mm" ` + `height="${h}mm" viewBox="0 0 ${w} ${h}">${body}</svg>`; const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${n(width)}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; // The casuarina of Bento's garden (chapter II): a leaning trunk, and branches that arch out // on alternate sides and let their needles hang. function roundelSvg() { const { plum, saffron } = palette; const out = [`<circle cx="30" cy="30" r="29" fill="${saffron}"/>`, line('M30 30m-25.5 0a25.5 25.5 0 1 0 51 0a25.5 25.5 0 1 0 -51 0', plum, 0.9), line('M28.6 50Q30.6 33 30.2 10.5', plum, 1.6), line('M18.5 50.2H41.5', plum, 1.3)]; for (let k = 0; k < 9; k++) { const side = k % 2 ? 1 : -1; const y0 = 12.5 + k * 3.7; const reach = (3 + k * 1.55) * (0.8 + rand() * 0.35); const x1 = 30.2 + side * reach; const y1 = y0 + 1.2 + rand() * 1.4; out.push(line(`M30.2 ${n(y0)}Q${n(30.2 + side * reach * 0.5)} ${n(y0 - 2.2)} ${n(x1)} ${n(y1)}`, plum, 0.95)); const strands = 3 + Math.round(reach / 1.6); for (let j = 1; j <= strands; j++) { // needles hang from the arch const u = j / (strands + 0.5); const x = 30.2 + side * reach * u; const y = y0 + (y1 - y0) * u ** 2 - 2.2 * 2 * u * (1 - u) + 0.3; out.push(line(`M${n(x)} ${n(y)}q${n(side * 0.4)} ${n(2)} ${n(side * 0.1)} ` + `${n(3 + rand() * 2.4)}`, plum, 0.7)); } } return svg(60, 60, out.join('')); } // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── // Dom Casmurro in Portuguese, in the first edition's spelling: the cover, the plate, then // pages 213 to 217 (:::numbering in the text), from the end of chapter CXVIII. const markdown = String.raw`---Mostra en Markdown · 63 línies · content.es.md
title: "Dom Casmurro" author: "Machado de Assis" --- # Dom \\ Casmurro {style="cover"} :::pagebreak :::numbering{startAt=212} # Capítulo CXXIII {style="plate" quote="«…grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã.»"} :::pagebreak O retrato de Escobar, que eu tinha alli, ao pé do de minha mãe, falou-me como se fosse a propria pessoa. Combati sinceramente os impulsos que trazia do Flamengo; rejeitei a figura da mulher do meu amigo, e chamei-me desleal. Demais, quem me affirmava que houvesse alguma intenção daquella especie no gesto da despedida e nos anteriores? Tudo podia ligar-se ao interesse da nossa viagem. Sancha e Capitú eram tão amigas que seria um prazer mais para ellas irem juntas. Quando houvesse alguma intenção sexual, quem me provaria que não era mais que uma sensação fulgurante, destinada a morrer com a noite e o somno? Ha remorsos que não nascem de outro peccado, nem tem maior duração. Agarrei-me a esta hypothese que se conciliava com a mão de Sancha, que eu sentia de memoria dentro da minha mão, quente e demorada, apertada e apertando… Sinceramente, eu achava-me mal entre um amigo e a attracção. A timidez póde ser que fosse outra causa daquella crise; não é só o ceu que dá as nossas virtudes, a timidez tambem, não contando o acaso, mas o acaso é um méro accidente; a melhor origem dellas é o ceu. Entretanto, como a timidez vem do ceu, que nos dá a compleição, a virtude, filha della é, genealogicamente, o mesmo sangue celestial. Assim reflectiria, se pudesse; mas a principio vaguei á tôa. Paixão não era nem inclinação. Capricho seria ou quê? Ao fim de vinte minutos era nada, inteiramente nada. O retrato de Escobar pareceu falar-me; vi-lhe a altitude franca e simples, sacudi a cabeça e fui deitar-me. # Não faça isso, querida A leitora, que é minha amiga e abriu este livro com o fim de descançar da cavatina de hontem para a valsa de hoje, quer fechal-o ás pressas, ao ver que beiramos um abysmo. Não faça isso, querida; eu mudo de rumo. # Os autos Na manhã seguinte accordei livre das abominações da vespera; chamei-lhes allucinações, tomei café, percorri os jornaes e fui estudar uns autos. Capitú e prima Justina sairam para a missa das nove, na Lapa. A figura de Sancha desappareceu inteiramente no meio das allegações da parte adversa, que eu ia lendo nos autos, allegações falsas, inadmissiveis, sem apoio na lei nem nas praxes. Vi que era facil ganhar a demanda; consultei Dalloz, Pereira e Souza… Uma só vez olhei para o retrato de Escobar. Era uma bella photographia tirada um anno antes. Estava de pé, sobrecasaca abotoada, a mão esquerda no dorso de uma cadeira, a direita mettida ao peito, o olhar ao longe para a esquerda do espectador. Tinha garbo e naturalidade. A moldura que lhe mandei pôr não encobria a dedicatoria, escripta embaixo, não nas costas do cartão: «Ao meu querido Bentinho o seu querido Escobar 20-4-70.» Estas palavras fortaleceram-me os pensamentos daquella manhã, e espancaram de todo as recordações da vespera. Naquelle tempo a minha vista era boa; eu podia lel-as do logar em que estava. Tornei aos autos. # A catastrophe No melhor delles, ouvi passos precipitados na escada, a campainha soou, soaram palmas, golpes na cancella, vozes, acudiram todos, acudi eu mesmo. Era um escravo da casa de Sancha que me chamava: —Para ir lá… sinhô nadando, sinhô morrendo. Não disse mais nada, ou eu não lhe ouvi o resto. Vesti-me, deixei recado a Capitú e corri ao Flamengo. Em caminho, fui adivinhando a verdade. Escobar metteu-se a nadar, como usava fazer, arriscou-se um pouco mais fóra que de costume, apesar do mar bravio, foi enrolado e morreu. As canoas que acudiram mal puderam trazer-lhe o cadaver. # O enterro A viuva… Poupo-vos as lagrimas da viuva, as minhas, as da outra gente. Sai de lá cerca de onze horas; Capitú e prima Justina esperavam-me, uma com o parecer abatido e estupido, outra enfastiada apenas. —Vão fazer companhia a pobre Sanchinha; eu vou cuidar do enterro. Assim fizemos. Quiz que o enterro fosse pomposo, e a affluencia dos amigos foi numerosa. Praia, ruas, praça da Gloria, tudo eram carros, muitos delles particulares. A casa, não sendo grande, não podiam lá caber todos; muitos estavam na praia, falando do desastre, apontando o logar em que Escobar fallecèra, ouvindo referir a chegada do morto. José Dias ouviu tambem falar dos negocios do finado, divergindo alguns na avaliação dos bens, mas havendo accordo em que o passivo devia ser pequeno. Elogiavam as qualidades de Escobar. Um ou outro discutia o recente gabinete Rio Branco; estavamos em Março de 1871. Nunca me esqueceu o mez nem o anno. Como eu houvesse resolvido falar no cemiterio, escrevi algumas linhas e mostrei-as em casa a José Dias, que as achou realmente dignas do morto e de mim. Pediu-me o papel, recitou lentamente o discurso, pesando as palavras, e confirmou a primeira opinião; no Flamengo espalhou a noticia. Alguns conhecidos vieram interrogar-me: —Então, vamos ouvil-o? —Quatro palavras. Poucas mais seriam. Tinha-as escripto com receio de que a emoção me impedisse de improvisar. No tilbury em que andei uma ou duas horas, não fizera mais que recordar o tempo do seminario, as relações de Escobar, as nossas sympathias, a nossa amizade, começada, continuada e nunca interrompida, até que um lance da fortuna fez separar para sempre duas creaturas que promettiam ficar por muito tempo unidas. De quando em quando enxugava os olhos. O cocheiro aventurou duas ou tres perguntas sobre a minha situação moral; não me arrancando nada, continuou o seu officio. Chegando a casa, deitei aquellas emoções ao papel; tal seria o discurso. # Olhos de ressaca Emfim, chegou a hora da encommendação e da partida. Sancha quiz despedir-se do marido, e o desespero daquelle lance consternou a todos. Muitos homens choravam tambem, as mulheres todas. Só Capitú, amparando a viuva, parecia vencer-se a si mesma. Consolava a outra, queria arrancal-a dalli. A confusão era geral. No meio della, Capitú olhou alguns instantes para o cadaver tão fixa, tão apaixonadamente fixa, que não admira lhe saltassem algumas lagrimas poucas e caladas… As minhas cessaram logo. Fiquei a ver as della; Capitú enxugou-as depressa, olhando a furto para a gente que estava na sala. Redobrou de caricias para a amiga, e quiz leval-a; mas o cadaver parece que a retinha tambem. Momento houve em que os olhos de Capitú fitaram o defuncto, quaes os da viuva, sem o pranto nem palavras desta, mas grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã. :::callout{type="colophon"} Do fim do capítulo CXVIII ao fim do CXXIII de *Dom Casmurro* (1899), com a ortografia da edição Garnier transcrita pelo Project Gutenberg (n.º 55752); corrigiu-se um erro de transcrição no capítulo CXXIII. Ilustração pintada e marca da coleção feitas para esta edição. Composto em Tinos (Apache 2.0), Abril Fatface e League Spartan (SIL OFL). :::`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) Tinos: ['400', '400i'], // text, colophon; italic: the plate's quote, the colophon's title 'Abril Fatface': ['400'], // chapter numerals and the cover title 'League Spartan': ['600'], // chapter titles, running heads, folios, the cover's capitals }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('roundel.svg', roundelSvg()); await loadImage('plate-1100.jpg', asset('plate-1100.jpg')); // #region excerpt: these pages continue a book: chapter CXVIII is under way, the next is CXIX const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // #endregion const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Dom Casmurro, a pocket edition', es: 'Dom Casmurro, edición de bolsillo' }) });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
#Obre cada capítol en una pàgina nova
Amb un salt abans de cada capítol, les cinc pàgines de text passen a ser set: la 214 porta només el CXIX, el seu títol i quatre línies, i la 213, on no comença cap capítol, pren com a capçalera el títol de la làmina, CAPÍTULO CXXIII.
- breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
+ breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(2 * LEAD),
+ advancedDesign: chapterHead };#Posa el número del capítol a la pàgina senar
{chapterNumber} imprimeix el numeral del mateix capítol del qual surt el títol: CXIX · NÃO FAÇA ISSO, QUERIDA.
- head('recto-chapter', '{chapterTitle}', 'odd', SHIFT),
+ head('recto-chapter', '{chapterNumber} · {chapterTitle}', 'odd', SHIFT),Errors freqüents
Error freqüent
avoidWidows vigila el peu de la columna, i avoidOrphans, el capdamunt
Postext dona nom propi a les dues línies soles: avoidWidows (widowMinLines, widowPenalty) evita que la primera línia d'un paràgraf quedi sola al peu d'una columna, i avoidOrphans (orphanMinLines, orphanPenalty), que l'última quedi sola al capdamunt de la següent. Molts manuals d'estil fan servir els dos noms al revés, així que tria l'opció pel lloc on actua. Totes dues vénen activades i funcionen com a penalitzacions: la composició compara cadascuna amb les línies buides que deixaria respectar-la. Vídues, òrfenes i línies curtes →
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
{number}/{chapterNumber} donen el número de l'H1; {numberRoman} només en parts
{number} i {chapterNumber} imprimeixen el número ja formatat del títol, però {numberRoman}, {numberDecimal} i les altres variants numèriques només s'emplenen a les pàgines de part. Dona format al número de capítol al seu numberingTemplate ({1:I}) o passa'l com a atribut. Títols numerats →
Error freqüent
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →
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
Posa :::pagebreak després d'una coberta a pàgina completa
Amb diverses columnes, una obertura a pàgina completa, com una coberta, deixa que el bloc següent comenci a la columna 2 de la mateixa pàgina, damunt de la il·lustració. Un :::pagebreak just després del títol de la coberta tanca la pàgina sense deixar-ne cap en blanc. Cobertes, portades i colofons →
Error freqüent
Una obertura més alta que la seva columna perd tota la reserva
A postext 1.4.1, quan l'alçada que reserva un títol amb disseny avançat (el seu minHeight, o el seu element de disseny més baix) supera la de la columna que obre, el títol es queda només amb l'alçada de la seva pròpia línia, sense cap avís, i el text es compon a sobre del disseny. Una làmina a pàgina completa necessita una columna tan alta com la pàgina: dona marges zero al seu estil de títol i fixa minHeight a l'alçada de la pàgina. Obertures dissenyades →
Error freqüent
Les imatges d'una obertura no compten per a l'alçada que reserva
A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →
Error freqüent
Una obertura que es queda a la seva columna es talla al capdamunt de la caixa de text
A postext 1.4.1, un títol amb disseny avançat que es queda a la seva columna es retalla per la vora superior de la columna: una caixa o una imatge ancorades a la pàgina o a la sang es pinten als marges laterals, però no al superior, i cap avís no ho diu. Dona span: 'page' a aquest títol, encara que el llibre tingui una sola columna: llavors el seu disseny es pinta sencer, com a banda d'obertura de la pàgina. Obertures dissenyades →
Error freqüent
:::numbering s'aplica a la pàgina següent
:::numbering canvia el compte a partir de la pàgina següent que comença, no de l'actual. Col·loca'l just després d'un :::pagebreak (amb paritat odd abans del primer capítol) perquè la numeració nova comenci on vols. Preliminars en romans →
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
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
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 →
- Si puges
widowMinLinesa 3, a la 1.4.1 el CXIX i el CXXI es queden al peu amb tres línies a sota, i els seus paràgrafs de quatre línies es parteixen en tres i una: les pàgines 214 i 215 comencen amb una línia sola, sigui quin sigui el valor d'orphanPenalty. - Si el CXVIII creix unes quantes línies, al CXIX només li queda lloc per a una línia sota el seu títol.
keepWithNextporta llavors el títol a la pàgina 214, i la 213 acaba quatre o cinc línies abans del peu; ambkeepWithNext: false, el títol tanca la 213 sense text a sota. - La làmina és un H1, així que
{chapterTitle}hi recorre: una pàgina de text en què no ha començat cap capítol des de la làmina, com la 213 quan el CXIX passa a la següent, porta com a capçalera CAPÍTULO CXXIII. - Amb
avoidRuntsdesactivat, l'última línia del llibre porta manhã. sola en lloc de nadador da manhã. - Sense el
:::pagebreakque segueix la coberta, i sense la làmina, el capítol CXVIII comença a 19,5 mm de la vora superior de la coberta, sota la seva línia de títol de 3 mm. Sense el que segueix la làmina, onze línies de text s'imprimeixen sobre el mar si el peu de la làmina puja a 94 mm. - En aquestes pàgines cap línia no es talla pel guionet d'un pronom enclític (Vesti-me, metteu-se). Posa en cursiva Emfim, la primera paraula del CXXIII, i la 1.4.1 compon aquell paràgraf amb el tall de línies del text amb marques, que acaba una línia en vencer- i comença la següent amb se a si mesma, sense el guionet que l'ortografia portuguesa repeteix al principi de la línia.
- Aquests salts de pàgina valen només per a aquest text a 9,5 sobre 12,6 pt en una mesura de 84 mm. Si canvies el text, el cos o la mesura, torna a mirar el peu de cada pàgina i el blanc sobre cada títol.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Dom Casmurro (1899), from the end of chapter CXVIII to the end of chapter CXXIII, in the spelling of the Garnier edition, with one transcription slip corrected (homens, chapter CXXIII) · Machado de Assis · domini públic
- The colophon, and the series name Coleção Casuarina (invented for this recipe) · Postext Cookbook · original
- Imatges
- The plate of the sea off Flamengo under the Sugarloaf, a painting · Generated With Diffusion Models · original
- The casuarina roundel, drawn in code in the book’s palette · Ignacio Ferro · MIT
- Fonts
- Tinos (Apache 2.0) · Abril Fatface (SIL OFL 1.1) · League Spartan (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)


