En poques paraules
Unes pàgines d'Alícia al país de les meravelles amb notes de l'editor al marge. Cada nota va al costat de les línies que explica, marcada amb una lletra de color.
Què compondràs
El començament del capítol VII d'Alice’s Adventures in Wonderland, el berenar de bojos, en tres pàgines d'una edició anotada de 156 × 234 mm. El text de Carroll va en una columna justificada d'Unna, d'uns 66 caràcters per línia. Un canal de 33 mm al costat del tall exterior recull les vuit glosses de l'editor, cadascuna al costat del paràgraf que porta la seva crida, a la dreta a les pàgines senars i a l'esquerra a les parelles. Les de paraules i acudits van en verd i les d'història en vermell, igual que les lletres que les criden. El capítol s'obre sota una aquarel·la de la taula del te vista des de dalt, amb el número, el títol en Rozha One i una entradeta en cursiva amb caplletra verda, i al marge, al costat de l'entradeta, va la nota sobre aquesta edició.
Aquesta recepta respon a
- Com poso notes al marge o glosses al costat del paràgraf que expliquen?
- Com faig notes a peu de pàgina?
- Com compono en columna i mitja, amb una columna de text ampla i una de lateral estreta?
- Com afegeixo a una obertura una línia d'autor, una entradeta o un primer paràgraf amb caplletra?
La resposta curta
const layout = {
layoutType: 'oneAndHalf', // one text column and a narrower side column
sideColumnPercent: 26, // of the 127 mm between the margins: a 33 mm channel
sideColumnRole: 'floats', // no text runs in it: it holds side boxes and figures
sideColumnSide: 'outer', // at the fore-edge, which mirrored margins move page by page
gutterWidth: mm(4.5), // the text column keeps 127 − 33 − 4.5 = 89.5 mm
};
// A gloss is :::callout{type="note" span="side" title="a · …"} in the Markdown.
// span="side" moves it out of the flow into the channel, level with the block after
// the fence, so the fence goes just before the paragraph that carries its letter.
// Glosses never float. One that meets the gloss above stacks under it. One that would
// run past the column's foot slides up, as far as the gloss above allows, until its
// foot sits on the foot; if it still does not fit, it waits for the next page
// (gotcha: side-box-starts-at-fence).
const gloss = (id, hue) => ({ id,
backgroundEnabled: false, // one device: a hairline over the gloss, in its colour
stripe: { enabled: true, side: 'top', width: pt(0.5), color: col(hue) },
padding: { top: mm(1.5), right: pt(0), bottom: pt(0), left: pt(0) },
// Cormorant SC draws lower case as small capitals: the title needs no textTransform.
titleStyle: { fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5),
letterSpacing: pt(0.3), color: col(hue), gap: mm(0.8) },
// Face and colours come from bodyText. A box body also inherits its 4 mm first-line
// indent, which a gloss sets back to 0.
body: { fontSize: pt(7.8), lineHeight: pt(10), textAlign: 'left',
firstLineIndent: pt(0) } });
const calloutStyles = [gloss('note', 'lawn'), gloss('context', 'jam')]; // words, history
Ingredients
- Funcions
- Notes al margeColumna al marge per a flotantsColumna i mitjaMarges simètricsRequadresSuperíndexs i subíndexsEstils de paràgrafObertures dissenyadesBanda de capítol d'amplada completaTextos, filets i caixes als dissenys de pàginaImatges als dissenys de pàginaAncoratge d'elements de dissenyCaplletres a les oberturesAtributs de títolCapçaleres i folisCapçaleres segons el tipus de pàginaFigures i taules com a recursosPaleta de color semànticaTall de línies òptim (Knuth–Plass)Chips en línia
- També fa servir
- Notes a peu de pàginaColor del paper
- Tipografia
- Unna, Rozha One, Cormorant SC (SIL OFL 1.1)
- Recursos
tea-table-1560.jpg- The tea-table seen from above, a watercolour (Generated With Diffusion Models, original)
Elaboració
#1 · Deixa el tall exterior per a les glosses
El codi és la resposta curta de dalt. En una disposició en columna i mitja, sideColumnPercent: 26 dona a la columna lateral 33 dels 127 mm que hi ha entre marges, i al text li queden 89,5 mm després de l'espai entre columnes de 4,5 mm. Amb sideColumnRole: 'floats' el text de Carroll no entra a la columna lateral, que només rep el que s'hi col·loca amb span: 'side'. Una nota a peu de pàgina espera al peu de la columna, sota un filet; al marge, cada glossa queda a l'altura de la línia que explica i les glosses no treuen línies a la columna de text. Els dos estils de glossa només es diferencien en el color del filet i del títol, així que el lector distingeix una glossa sobre una paraula d'una altra sobre història abans de llegir-la.
#2 · Reflecteix la pàgina perquè el canal segueixi el tall exterior
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [156, 234]; // mm
const [TOP, BOTTOM, INNER, OUTER] = [22, 22, 15, 14]; // mm: 38 lines of text
const page = { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
backgroundColor: col('paper'),
// left is a recto's inner margin; mirror swaps the sides on a verso, and 'outer' moves
// the channel with them: right on a recto, left on a verso.
margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
mirror: true } };
const CONTENT_W = TRIM_W - INNER - OUTER; // 127 mm
const SIDE_W = CONTENT_W * layout.sideColumnPercent / 100; // 33 mm: the glosses' measure
const TEXT_W = CONTENT_W - SIDE_W - layout.gutterWidth.value;
Amb mirror: true els marges s'intercanvien a cada pàgina parella, i sideColumnSide: 'outer' hi porta el canal, així que les glosses baixen per la dreta de les pàgines 81 i 83 i per l'esquerra de la 82. Els marges de 22 mm a dalt i a baix deixen 190 mm, lloc per a 38 línies de 14 pt, i cada glossa comença en una línia d'aquesta mateixa retícula.
#3 · Posa cada glossa abans del paràgraf que explica
const bodyText = { // justified and hyphenated (en-us) by default
// Copy-fitted to the 89.5 mm column: at 9.5 pt '“Have some wine,” the March Hare said in
// an encouraging tone.' fits one line. At 10.4 pt every measure from 77 to 93 mm left
// it a short last line: 'couraging tone.', 'aging tone.', 'ing tone.' or 'tone.'.
fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
// Unna's space is narrow, 0.22 em. At the default 0.6 the line breaker set 'Alice felt
// dreadfully puzzled. The Hatter’s remark seemed to have' with its spaces at 0.69.
minWordSpacing: 0.75 };
const paragraphStyles = [
// The chapter's first paragraph, set flush. indentAfterHeading: false would do it
// without glosses, but the gloss fences between the heading and this paragraph
// count as the block after the heading, so the default stays and the paragraph takes
// this style (gotcha: side-box-after-heading).
{ id: 'opening', firstLineIndent: pt(0) },
];
Un requadre amb span="side" a la seva tanca :::callout surt del flux i es col·loca al canal a l'altura del bloc que segueix la tanca. Per això la tanca de cada glossa va just abans del paràgraf que porta la seva crida, com a l'exemple de sota. Les glosses a i b acompanyen el primer paràgraf, de manera que les seves tanques queden entre el títol i aquest paràgraf. A la 1.4.1 els dos requadres compten llavors com el bloc que segueix el títol, i indentAfterHeading: false no arribaria al paràgraf. La configuració deixa aquesta opció en el seu valor per defecte i és l'estil opening el que treu el sagnat al paràgraf. El cos està ajustat a la columna: a 9,5 pt, el paràgraf que comença «“Have some wine,”» cap en una línia de 89,5 mm, i a 10,4 pt totes les mesures entre 77 i 93 mm li deixaven una última línia curta, com «ing tone.» o «tone.». L'espai entre paraules d'Unna és estret, 0,22 em, i amb el minWordSpacing per defecte, 0,6, una línia de la pàgina 83 el reduïa al 0,69 de la seva amplada; amb minWordSpacing: 0.75 aquesta línia ja no és possible.
:::callout{type="context" span="side" title="c · Hair"}
Tenniel drew Alice with long, loose hair. …
:::
“Your hair wants cutting,”:chip[^c^]{style="context"} said the Hatter. …#4 · Dona a cada lletra el color de la seva glossa
// A letter in the text is a chip named after its gloss's type, :chip[^a^]{style="note"}:
// the Markdown has no mark for coloured text, and a chip style sets a colour. With no
// fill, outline, padding or gap the chip adds no width, so the lines break as they would
// with a plain ^a^.
const letter = (id, hue) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
paddingX: pt(0), gap: pt(0), color: col(hue), bold: true });
const chipStyles = [letter('note', 'lawn'), letter('context', 'jam')];
El Markdown no té marca per al text en color, però un estil de chip sí que fixa un color de text, així que cada crida és un chip l'estil del qual es diu igual que el tipus de la seva glossa. :chip[^c^]{style="context"} imprimeix una c vermella, el mateix vermell del filet i del títol de la glossa c. Sense fons, contorn, farciment ni separació, el chip no afegeix amplada i cada línia es talla on es tallava amb un ^c^ sense res més.
#5 · Obre el capítol sota la taula del te
const HEADPIECE = 72; // mm: the depth of the painting at the head of the page
const HEADNOTE = { size: 9.8, lead: LEAD }; // pt: the headnote keeps the body's leading
const CAPS = { text: 0.597, initial: 0.56 }; // cap heights, em: Unna and Rozha One
// The initial's size: its capital runs from the first line's cap height down to the
// last baseline it spans. The default size is as tall as both line boxes, so its top
// rises above the first line's capitals.
const dropSize = (lines) => pt(((lines - 1) * HEADNOTE.lead + CAPS.text * HEADNOTE.size)
/ CAPS.initial);
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
offset: { y: mm(y) }, size: { width } });
// The opener reserves the height of its lowest text, rounded up to the grid, and the
// heading's default bottom margin adds one blank line: with a four-line headnote the
// text starts on line 23. The painting reserves nothing
// (gotcha: opener-image-no-reserve), so the kicker, the title and the headnote hang
// below it and carry the reserve past it.
const opener = { enabled: true, slot: { elements: [
// An image element draws a resource without number or caption. It hangs from the
// trim's corner over the margin, which 1.4.1 paints only when level 1 spans the page.
{ kind: 'image', id: 'headpiece', resourceId: 'tea-table',
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: mm(TRIM_W) } } },
// The numeral comes from the heading line,
// # A Mad Tea-Party {num="VII" headnote="…" …}: the excerpt stands alone, so
// {chapterNumber} would print 1 (gotcha: heading-number-placeholders).
{ kind: 'text', id: 'kicker', content: 'Chapter {attr.num}', fontFamily: LABEL,
fontWeight: 600, fontSize: pt(9), letterSpacing: pt(2), textTransform: 'uppercase',
color: col('jam'), align: 'left',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { y: mm(HEADPIECE - TOP + 8) } } }, // 8 mm under the painting's foot
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY,
fontSize: pt(40), lineHeight: 1.05, color: col('ink'), align: 'left',
// A longer title wraps instead of ending in '…' (gotcha: overflow-ellipsis-default).
overflow: 'wrap',
placement: below('kicker', 2, 'fill') },
// Drop caps exist only in design text, which is set ragged
// (gotcha: design-text-ragged): the headnote is the editor's voice, italic and ragged;
// Carroll's text opens in the flow.
{ kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT,
italic: true, fontSize: pt(HEADNOTE.size),
// A design text's lineHeight multiplies its size
// (gotcha: design-lineheight-multiple).
lineHeight: HEADNOTE.lead / HEADNOTE.size, color: col('ink'),
align: 'left', overflow: 'wrap', // a drop cap needs wrapping text
dropCap: { lines: 2, fontFamily: DISPLAY, fontSize: dropSize(2), color: col('lawn'),
gap: mm(1.5) },
placement: below('title', 5, mm(TEXT_W)) },
// The note on this edition stands in the channel, level with the headnote's top.
{ kind: 'text', id: 'edition-label', content: 'This edition', fontFamily: LABEL,
fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(0.3), color: col('muted'),
align: 'left', placement: { anchor: { to: '#headnote', edge: 'right-of' },
offset: { x: layout.gutterWidth }, size: { width: mm(SIDE_W) } } },
{ kind: 'text', id: 'edition', content: '{attr.source}', fontFamily: TEXT,
fontSize: pt(7.5), lineHeight: 10 / 7.5, color: col('muted'), align: 'left',
overflow: 'wrap',
placement: { anchor: { to: '#edition-label', edge: 'below' }, offset: { y: mm(0.8) },
size: { width: mm(SIDE_W) } } },
] } };
La ranura de disseny del títol ocupa tota la pàgina perquè l'aquarel·la pugui arribar al tall. Un element d'imatge no reserva alçada, així que l'avanttítol, el títol i l'entradeta pengen sota l'aquarel·la i són ells els que fixen la reserva, que acaba al text més baix, arrodonida a la retícula; el marge inferior que el títol porta per defecte afegeix una línia en blanc. Amb una entradeta de quatre línies el text comença a la línia 23, i una cinquena el baixaria a la 24. El número, l'entradeta i la nota sobre aquesta edició surten d'atributs del títol, {num="VII" headnote="…" source="…"}. El número es pren d'aquí perquè {chapterNumber} imprimiria 1 en un fragment que és un document a part. El text de disseny no es justifica mai, així que la caplletra obre l'entradeta de l'editor, en cursiva i en bandera, i el text de Carroll comença justificat a sota, ja al flux. dropSize() calcula el cos de la caplletra perquè la seva part superior quedi a l'altura de les majúscules de la primera línia de l'entradeta.
#6 · Dona a cada glossa el color del que explica
const palette = {
ink: '#1f1b1a', // text: a warm near-black
lawn: '#1e6f6b', // glosses on words and jokes, the drop cap
jam: '#b23a48', // glosses on history, the kicker
muted: '#6e645b', // running heads, the note on this edition
paper: '#f8f3e6', // the page, and the tablecloth
};
// col(id): a colour linked to its entry. It carries the hex too, because 1.4.1 paints
// design elements from the hex alone (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const entry = (id, hex, name = id) => ({ id, name, value: { hex, model: 'hex' } });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
// The engine's defaults link to 'main-color': aimed at the green, nothing prints blue.
entry('main-color', palette.lawn, 'lawn (defaults)'),
];
El verd gespa marca les glosses sobre paraules, dites i acudits, i el vermell melmelada les de persones, dates i història del text. N'hi ha prou amb un valor nou a jam, al mateix objecte palette, per tornar a pintar alhora les glosses d'història, les seves crides i l'avanttítol; l'aquarel·la de dalt es va triar per aquest mateix verd gespa i aquest vermell melmelada. Cada color enllaçat porta a més el seu hex, perquè la 1.4.1 pinta l'obertura i les capçaleres amb l'hex (ho explica «Errors freqüents»).
La recepta completa
// ═══ Postext Cookbook · Nº 032 · Annotated classic with margin glosses ════════════ // https://postext.dev/en/cookbook/annotated-classic-glosses // Code: MIT · Text: Lewis Carroll (PD), glosses (CC BY 4.0) · Headpiece: diffusion models // Fonts: Unna, Rozha One, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // The mad tea-party as an annotated edition: the text keeps to one column, and its glosses stand // in the outer margin beside the lines they explain, changing sides with the spread. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en') const RECIPE = 'annotated-classic-glosses'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a lawn green and a jam red on cream paper const palette = { ink: '#1f1b1a', // text: a warm near-black lawn: '#1e6f6b', // glosses on words and jokes, the drop cap jam: '#b23a48', // glosses on history, the kicker muted: '#6e645b', // running heads, the note on this edition paper: '#f8f3e6', // the page, and the tablecloth }; // col(id): a colour linked to its entry. It carries the hex too, because 1.4.1 paints // design elements from the hex alone (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const entry = (id, hex, name = id) => ({ id, name, value: { hex, model: 'hex' } }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => entry(id, hex)), // The engine's defaults link to 'main-color': aimed at the green, nothing prints blue. entry('main-color', palette.lawn, 'lawn (defaults)'), ]; // #endregion const [TEXT, DISPLAY, LABEL] = ['Unna', 'Rozha One', 'Cormorant SC']; const LEAD = 14; // body leading in pt: the grid of every page // #region answer: a float-only channel at the fore-edge, and two gloss styles for it const layout = { layoutType: 'oneAndHalf', // one text column and a narrower side column sideColumnPercent: 26, // of the 127 mm between the margins: a 33 mm channel sideColumnRole: 'floats', // no text runs in it: it holds side boxes and figures sideColumnSide: 'outer', // at the fore-edge, which mirrored margins move page by page gutterWidth: mm(4.5), // the text column keeps 127 − 33 − 4.5 = 89.5 mm }; // A gloss is :::callout{type="note" span="side" title="a · …"} in the Markdown. // span="side" moves it out of the flow into the channel, level with the block after // the fence, so the fence goes just before the paragraph that carries its letter. // Glosses never float. One that meets the gloss above stacks under it. One that would // run past the column's foot slides up, as far as the gloss above allows, until its // foot sits on the foot; if it still does not fit, it waits for the next page // (gotcha: side-box-starts-at-fence). const gloss = (id, hue) => ({ id, backgroundEnabled: false, // one device: a hairline over the gloss, in its colour stripe: { enabled: true, side: 'top', width: pt(0.5), color: col(hue) }, padding: { top: mm(1.5), right: pt(0), bottom: pt(0), left: pt(0) }, // Cormorant SC draws lower case as small capitals: the title needs no textTransform. titleStyle: { fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(0.3), color: col(hue), gap: mm(0.8) }, // Face and colours come from bodyText. A box body also inherits its 4 mm first-line // indent, which a gloss sets back to 0. body: { fontSize: pt(7.8), lineHeight: pt(10), textAlign: 'left', firstLineIndent: pt(0) } }); const calloutStyles = [gloss('note', 'lawn'), gloss('context', 'jam')]; // words, history // #endregion // #region letters: the letter that calls each gloss, in the gloss's colour // A letter in the text is a chip named after its gloss's type, :chip[^a^]{style="note"}: // the Markdown has no mark for coloured text, and a chip style sets a colour. With no // fill, outline, padding or gap the chip adds no width, so the lines break as they would // with a plain ^a^. const letter = (id, hue) => ({ id, backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: pt(0), color: col(hue), bold: true }); const chipStyles = [letter('note', 'lawn'), letter('context', 'jam')]; // #endregion // #region page: a trade page with mirrored margins, so the channel is always at the fore-edge const PT = 25.4 / 72; // mm in a point const [TRIM_W, TRIM_H] = [156, 234]; // mm const [TOP, BOTTOM, INNER, OUTER] = [22, 22, 15, 14]; // mm: 38 lines of text const page = { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, backgroundColor: col('paper'), // left is a recto's inner margin; mirror swaps the sides on a verso, and 'outer' moves // the channel with them: right on a recto, left on a verso. margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }; const CONTENT_W = TRIM_W - INNER - OUTER; // 127 mm const SIDE_W = CONTENT_W * layout.sideColumnPercent / 100; // 33 mm: the glosses' measure const TEXT_W = CONTENT_W - SIDE_W - layout.gutterWidth.value; // #endregion // #region text: book texture and a flush opening paragraph const bodyText = { // justified and hyphenated (en-us) by default // Copy-fitted to the 89.5 mm column: at 9.5 pt '“Have some wine,” the March Hare said in // an encouraging tone.' fits one line. At 10.4 pt every measure from 77 to 93 mm left // it a short last line: 'couraging tone.', 'aging tone.', 'ing tone.' or 'tone.'. fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4), // Unna's space is narrow, 0.22 em. At the default 0.6 the line breaker set 'Alice felt // dreadfully puzzled. The Hatter’s remark seemed to have' with its spaces at 0.69. minWordSpacing: 0.75 }; const paragraphStyles = [ // The chapter's first paragraph, set flush. indentAfterHeading: false would do it // without glosses, but the gloss fences between the heading and this paragraph // count as the block after the heading, so the default stays and the paragraph takes // this style (gotcha: side-box-after-heading). { id: 'opening', firstLineIndent: pt(0) }, ]; // #endregion // #region opener: a headpiece, the chapter's numeral and title, a headnote with a drop cap const HEADPIECE = 72; // mm: the depth of the painting at the head of the page const HEADNOTE = { size: 9.8, lead: LEAD }; // pt: the headnote keeps the body's leading const CAPS = { text: 0.597, initial: 0.56 }; // cap heights, em: Unna and Rozha One // The initial's size: its capital runs from the first line's cap height down to the // last baseline it spans. The default size is as tall as both line boxes, so its top // rises above the first line's capitals. const dropSize = (lines) => pt(((lines - 1) * HEADNOTE.lead + CAPS.text * HEADNOTE.size) / CAPS.initial); const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) }, size: { width } }); // The opener reserves the height of its lowest text, rounded up to the grid, and the // heading's default bottom margin adds one blank line: with a four-line headnote the // text starts on line 23. The painting reserves nothing // (gotcha: opener-image-no-reserve), so the kicker, the title and the headnote hang // below it and carry the reserve past it. const opener = { enabled: true, slot: { elements: [ // An image element draws a resource without number or caption. It hangs from the // trim's corner over the margin, which 1.4.1 paints only when level 1 spans the page. { kind: 'image', id: 'headpiece', resourceId: 'tea-table', placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: mm(TRIM_W) } } }, // The numeral comes from the heading line, // # A Mad Tea-Party {num="VII" headnote="…" …}: the excerpt stands alone, so // {chapterNumber} would print 1 (gotcha: heading-number-placeholders). { kind: 'text', id: 'kicker', content: 'Chapter {attr.num}', fontFamily: LABEL, fontWeight: 600, fontSize: pt(9), letterSpacing: pt(2), textTransform: 'uppercase', color: col('jam'), align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(HEADPIECE - TOP + 8) } } }, // 8 mm under the painting's foot { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(40), lineHeight: 1.05, color: col('ink'), align: 'left', // A longer title wraps instead of ending in '…' (gotcha: overflow-ellipsis-default). overflow: 'wrap', placement: below('kicker', 2, 'fill') }, // Drop caps exist only in design text, which is set ragged // (gotcha: design-text-ragged): the headnote is the editor's voice, italic and ragged; // Carroll's text opens in the flow. { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT, italic: true, fontSize: pt(HEADNOTE.size), // A design text's lineHeight multiplies its size // (gotcha: design-lineheight-multiple). lineHeight: HEADNOTE.lead / HEADNOTE.size, color: col('ink'), align: 'left', overflow: 'wrap', // a drop cap needs wrapping text dropCap: { lines: 2, fontFamily: DISPLAY, fontSize: dropSize(2), color: col('lawn'), gap: mm(1.5) }, placement: below('title', 5, mm(TEXT_W)) }, // The note on this edition stands in the channel, level with the headnote's top. { kind: 'text', id: 'edition-label', content: 'This edition', fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(0.3), color: col('muted'), align: 'left', placement: { anchor: { to: '#headnote', edge: 'right-of' }, offset: { x: layout.gutterWidth }, size: { width: mm(SIDE_W) } } }, { kind: 'text', id: 'edition', content: '{attr.source}', fontFamily: TEXT, fontSize: pt(7.5), lineHeight: 10 / 7.5, color: col('muted'), align: 'left', overflow: 'wrap', placement: { anchor: { to: '#edition-label', edge: 'below' }, offset: { y: mm(0.8) }, size: { width: mm(SIDE_W) } } }, ] } }; // #endregion // The running heads: the book on the verso, the chapter on the recto, folios at the fore-edge. const HEAD = 14; // mm from the top trim to the heads' baseline const INSET = 8; // mm from the folio's outer edge to the running head's const DROP = 13; // mm from the bottom trim up to the foot of the drop folio's box // In 1.4.1 a design text's first baseline sits 0.96 em below the top of its box: the default // lineHeight, 1.2, times 0.8, where the baseline falls in the line box. const BASELINE = 1.2 * 0.8; const baseline = (size) => mm(HEAD - BASELINE * size * PT); const head = (id, parity, content, x, extra = {}) => ({ kind: 'text', id, parity, content, pages: 'body', fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'), ...extra, placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' }, offset: { x: mm(x), y: baseline(extra.fontSize?.value ?? 8.5) } } }); const folio = { fontFamily: TEXT, fontWeight: 700, fontSize: pt(9), letterSpacing: pt(0), color: col('ink') }; const header = { elements: [ head('verso-folio', 'even', '{pageNumber}', OUTER, folio), head('verso-title', 'even', '{title}', OUTER + INSET), head('recto-title', 'odd', '{chapterTitle}', -(OUTER + INSET)), head('recto-folio', 'odd', '{pageNumber}', -OUTER, folio), ] }; // The opener, a recto, carries a drop folio at the foot of its channel instead. const footer = { elements: [{ ...head('drop-folio', 'odd', '{pageNumber}', -OUTER, folio), pages: 'opener', placement: { anchor: { to: 'page', edge: 'bottom-right' }, offset: { x: mm(-OUTER), y: mm(-DROP) } } }] }; const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) colorPalette, page, layout, bodyText, paragraphStyles, calloutStyles, chipStyles, header, footer, headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), // Rozha One has one weight levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). // 'odd' puts the opener on a recto; span 'page' lets its design cross the channel. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener }, ], }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 121 línies · content.en.md
title: "Alice’s Adventures in Wonderland" author: "Lewis Carroll" --- # A Mad Tea-Party {num="VII" headnote="The tea-party is not in the book Carroll wrote out by hand for Alice Liddell in 1864; he added it, with the Cheshire Cat, for the book of 1865. Letters in the text point to the glosses beside it: green for words and jokes, red for history." source="Text after Project Gutenberg eBook 11; notes CC BY 4.0. Set in Unna, Rozha One and Cormorant SC (SIL OFL)."} :::callout{type="note" span="side" title="a · The March Hare"} ‘Mad as a March hare’ is an old saying. Hares are shy, but in early spring they race about the fields and rear up on their hind legs to box. ::: :::callout{type="context" span="side" title="b · The Hatter"} ‘Mad as a hatter’ was a saying before Carroll used it. Hatters made felt from fur treated with mercury, and the fumes gave many of them tremors and fits of shyness and temper. Carroll never calls him the Mad Hatter. ::: :::paragraphs{style="opening"} There was a table set out under a tree in front of the house, and the March Hare:chip[^a^]{style="note"} and the Hatter:chip[^b^]{style="context"} were having tea at it: a Dormouse was sitting between them, fast asleep, and the other two were using it as a cushion, resting their elbows on it, and talking over its head. “Very uncomfortable for the Dormouse,” thought Alice; “only, as it’s asleep, I suppose it doesn’t mind.” ::: The table was a large one, but the three were all crowded together at one corner of it: “No room! No room!” they cried out when they saw Alice coming. “There’s *plenty* of room!” said Alice indignantly, and she sat down in a large arm-chair at one end of the table. “Have some wine,” the March Hare said in an encouraging tone. Alice looked all round the table, but there was nothing on it but tea. “I don’t see any wine,” she remarked. “There isn’t any,” said the March Hare. “Then it wasn’t very civil of you to offer it,” said Alice angrily. “It wasn’t very civil of you to sit down without being invited,” said the March Hare. “I didn’t know it was *your* table,” said Alice; “it’s laid for a great many more than three.” :::callout{type="context" span="side" title="c · Hair"} Tenniel drew Alice with long, loose hair. Alice Liddell, in Carroll’s photographs of her, wears hers short and dark, with a fringe. ::: “Your hair wants cutting,”:chip[^c^]{style="context"} said the Hatter. He had been looking at Alice for some time with great curiosity, and this was his first speech. “You should learn not to make personal remarks,” Alice said with some severity; “it’s very rude.” :::callout{type="context" span="side" title="d · The riddle"} Carroll made up the riddle without an answer. So many readers asked for one that in a preface of 1896 he offered this: ‘Because it can produce a few notes, tho they are very flat; and it is nevar put with the wrong end in front!’ Later printings changed *nevar*, raven spelt backwards, to *never*, and the joke was lost. ::: The Hatter opened his eyes very wide on hearing this; but all he *said* was, “Why is a raven like a writing-desk?”:chip[^d^]{style="context"} “Come, we shall have some fun now!” thought Alice. “I’m glad they’ve begun asking riddles.—I believe I can guess that,” she added aloud. “Do you mean that you think you can find out the answer to it?” said the March Hare. “Exactly so,” said Alice. “Then you should say what you mean,” the March Hare went on. “I do,” Alice hastily replied; “at least—at least I mean what I say—that’s the same thing, you know.” :::callout{type="note" span="side" title="e · I see what I eat"} The Hatter has the logic right. Turn a statement round and you get its converse, which can be false when the statement is true. Carroll, as Charles Dodgson, taught mathematics at Christ Church, Oxford, and wrote two books on logic. ::: “Not the same thing a bit!” said the Hatter. “You might just as well say that ‘I see what I eat’ is the same thing as ‘I eat what I see’!”:chip[^e^]{style="note"} “You might just as well say,” added the March Hare, “that ‘I like what I get’ is the same thing as ‘I get what I like’!” “You might just as well say,” added the Dormouse, who seemed to be talking in his sleep, “that ‘I breathe when I sleep’ is the same thing as ‘I sleep when I breathe’!” “It *is* the same thing with you,” said the Hatter, and here the conversation dropped, and the party sat silent for a minute, while Alice thought over all she could remember about ravens and writing-desks, which wasn’t much. The Hatter was the first to break the silence. “What day of the month is it?” he said, turning to Alice: he had taken his watch out of his pocket, and was looking at it uneasily, shaking it every now and then, and holding it to his ear. :::callout{type="context" span="side" title="f · The fourth"} Of May: Alice Liddell was born on 4 May 1852. ::: Alice considered a little, and then said “The fourth.”:chip[^f^]{style="context"} “Two days wrong!” sighed the Hatter. “I told you butter wouldn’t suit the works!” he added looking angrily at the March Hare. :::callout{type="note" span="side" title="g · The best butter"} Grocers sold butter by grade, and ‘best’ fetched the highest price. The March Hare defends its quality, which was never the trouble. ::: “It was the *best* butter,”:chip[^g^]{style="note"} the March Hare meekly replied. “Yes, but some crumbs must have got in as well,” the Hatter grumbled: “you shouldn’t have put it in with the bread-knife.” The March Hare took the watch and looked at it gloomily: then he dipped it into his cup of tea, and looked at it again: but he could think of nothing better to say than his first remark, “It was the *best* butter, you know.” Alice had been looking over his shoulder with some curiosity. “What a funny watch!” she remarked. “It tells the day of the month, and doesn’t tell what o’clock it is!” “Why should it?” muttered the Hatter. “Does *your* watch tell you what year it is?” “Of course not,” Alice replied very readily: “but that’s because it stays the same year for such a long time together.” “Which is just the case with *mine*,” said the Hatter. Alice felt dreadfully puzzled. The Hatter’s remark seemed to have no sort of meaning in it, and yet it was certainly English. “I don’t quite understand you,” she said, as politely as she could. “The Dormouse is asleep again,” said the Hatter, and he poured a little hot tea upon its nose. :::callout{type="note" span="side" title="h · The Dormouse"} Hazel dormice sleep through the day and hibernate for half the year, from autumn to spring. The name is often traced to the French *dormir*, to sleep. ::: The Dormouse:chip[^h^]{style="note"} shook its head impatiently, and said, without opening its eyes, “Of course, of course; just what I was going to remark myself.” “Have you guessed the riddle yet?” the Hatter said, turning to Alice again. “No, I give it up,” Alice replied: “what’s the answer?” “I haven’t the slightest idea,” said the Hatter. “Nor I,” said the March Hare. Alice sighed wearily. “I think you might do something better with the time,” she said, “than waste it in asking riddles that have no answers.” “If you knew Time as well as I do,” said the Hatter, “you wouldn’t talk about wasting *it*. It’s *him*.” “I don’t know what you mean,” said Alice. “Of course you don’t!” the Hatter said, tossing his head contemptuously. “I dare say you never even spoke to Time!”`; // content.<lang>.md, inlined by the Cookbook // The headpiece is a resource that only the opener's design draws: never cited, never placed. // It is a watercolour, a JPEG in assets/ cut to the trim's 156 × 72 mm, declared at its pixels. const resources = [{ id: 'tea-table', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'tea-table-1560.jpg', format: 'jpeg', width: 1560, height: 720 }, altText: 'The tea-table under a tree, seen from above: a long white cloth laid with cups, a red ' + 'teapot, bread and butter and a watch on its chain; the Dormouse asleep in a teapot at one ' + 'end and a red arm-chair at the other.' }]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Unna: ['400', '400i', '700'], // 700: the folios and the gloss letters 'Rozha One': ['400'], 'Cormorant SC': ['600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadImage('tea-table-1560.jpg', asset('tea-table-1560.jpg')); const continuation = { pageNumbering: { startAt: 81 } }; // chapter VII of a book: an odd folio const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Annotated classic with margin glosses' });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 les glosses a la dreta a totes les pàgines
Per a una edició que es llegeix pàgina a pàgina en pantalla, desactiva els marges simètrics: 'outer' passa a ser la vora dreta de totes les pàgines, i les capçaleres no es mouen, perquè van ancorades a les vores de la pàgina.
- mirror: true } };
+ mirror: false } };#Porta les notes llargues al peu de la pàgina
Escriu com a nota a peu de pàgina la que no càpiga al marge, amb [^1] al text i la seva definició [^1]: sota el paràgraf: Postext la compon al peu de la columna de text, a 89,5 mm d'amplada i sota un filet curt, i les glosses conserven el canal. Notes al peu de la columna que les cita mostra com donar estil a aquestes notes.
Errors freqüents
Error freqüent
Un requadre lateral comença a l'altura del bloc que segueix la seva tanca
A postext 1.4.1, un requadre amb span: 'side' es col·loca a la columna lateral a l'altura on ha arribat el text a la seva tanca, a la línia següent de la retícula i sota els requadres que ja hi hagi. Posa la tanca d'una glossa just abans del paràgraf que explica: si va després, la glossa comença al costat del paràgraf següent. Un requadre que passaria del peu de la columna puja fins que el seu peu coincideix amb el de la columna, si el requadre de sobre li deixa lloc; si tot i així no hi cap, espera la columna lateral de la pàgina següent. Notes al marge →
Error freqüent
Un requadre lateral després d'un títol sagna el paràgraf següent
A postext 1.4.1, un requadre amb span: 'side' entre un títol i el seu primer paràgraf dona a aquest paràgraf sagnat de primera línia, encara que indentAfterHeading sigui false: el requadre surt del flux, però els seus blocs continuen comptant com el bloc que segueix el títol. Col·loca el requadre després del primer paràgraf. Notes al marge →
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
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
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 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
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
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 →
- Les glosses s'apilen en l'ordre del Markdown, l'una sota l'altra, així que una glossa llarga empeny cap avall la següent. A la pàgina 81 les dues crides cauen a la segona línia del primer paràgraf: la glossa a comença a l'altura del paràgraf i la b a continuació, cinc línies per sota de la seva crida. On les crides quedin a prop, escriu glosses breus.
- L'error «Un requadre lateral després d'un títol sagna el paràgraf següent», a dalt, aconsella posar el requadre després del primer paràgraf. Una glossa amb la tanca allà comença al costat del segon paràgraf (ho explica «Un requadre lateral comença a l'altura del bloc que segueix la seva tanca»), així que aquesta edició deixa les dues tanques abans del primer paràgraf i li treu el sagnat amb l'estil
opening.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Alice’s Adventures in Wonderland (1865), the opening of chapter VII, “A Mad Tea-Party” · Lewis Carroll · domini públic
- The headnote, the eight glosses and the note on this edition · Ignacio Ferro · CC BY 4.0
- Imatges
- The tea-table seen from above, a watercolour · Generated With Diffusion Models · original
- Fonts
- Unna (SIL OFL 1.1) · Rozha One (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)


