In breve
Un piccolo catalogo di mostra con tre antiche illustrazioni del Don Chisciotte. Ogni descrizione sta su una pagina sinistra, con la sua immagine che riempie la pagina destra di fronte.
Che cosa comporrai
Un piccolo catalogo per una mostra di gabinetto con tre xilografie del Don Quixote di Gustave Doré (Parigi, 1863), su una pagina di 230 × 280 mm. La copertina compone DORÉ in Libre Bodoni da 88 pt su un nero caldo, sotto un dettaglio incorniciato della tavola dei mulini. Ogni scheda apre una pagina sinistra con un numero di catalogo seppia accanto a un titolo in corsivo, la scheda tecnica nella colonna esterna, un attacco con un capolettera su tre righe e il commento. La prima frase del commento cita la tavola, che apre la pagina destra di fronte, centrata e scalata finché tavola, didascalia e riga di crediti riempiono la gabbia, alta 215 mm. L’ultima pagina è un elenco delle opere con una miniatura nella prima cella di ogni riga e la pagina su cui si stampa ogni tavola.
Questa ricetta risponde a
- Come creo un catalogo d’arte: scheda sulla pagina sinistra, tavola sulla pagina destra di fronte?
- Come decido dove va una figura: in testa alla pagina, su entrambe le colonne, esattamente qui o nel margine?
- Come metto immagini o icone dentro le celle di una tabella?
- Come aggiungo a un’apertura una riga d’autore, un sommario o un attacco con capolettera?
La risposta breve
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
breakBefore: { enabled: true, parity: 'even' }, // an entry opens a verso, facing its plate
marginBottom: pt(LEAD), advancedDesign: entryOpener(),
dropCap: { lines: 3, fontFamily: DISPLAY, color: col('sepia'), gap: mm(1.6) }, // the lead's
});
const plateType = {
id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
// A plate taller than the recto's text block is scaled, proportions kept, until plate, caption
// and credit line fit it; the caption takes the plate's width, and both are centred.
defaultPlacement: { position: 'top', span: 'page', align: 'center',
shrink: 'page', minScale: 0.5, captionMeasure: 'body' },
};
// Under the plate (pt): a caption and a credit line, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
// The print master's pixels, about 283 dpi at this size (gotcha: bitmap-print-size).
bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH }, createdAt: 0, updatedAt: 0,
});
Ingredienti
- Funzionalità
- Collocazione delle figureCapitoli che si aprono su un rectoCitazioni che collocano le figureTipi di risorsa personalizzatiDidascalie numerateStile delle didascalieRighe di fonte e creditiFigure e tabelle come risorseImmagini nelle celle delle tabelleAperture progettateFascia di capitolo a tutta larghezzaCapilettera nei paragrafi del testo correnteTesto, filetti e riquadri nei progetti di paginaImmagini nei progetti di paginaAttributi dei titoliStili di titoloMargini speculariPagine su un canvasStili di paragrafo
- Usa anche
- Colonna e mezzaTitoli numeratiFigure proprio quiColore della cartaTestatine per sezioneColonna a margine per gli elementi flottantiA capo nei titoliCapitoli non numeratiSpazio verticale esplicito
- Tipo
- Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed (SIL OFL 1.1)
- Risorse
library-835.jpgvigil-835.jpgwindmills-835.jpgwindmills-detail-540.jpg- Cat. 1, Don Quixote in His Library (Part I, ch. I), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, pubblico dominio)
- Cat. 2, The Vigil of Arms (Part I, ch. III), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, pubblico dominio)
- Cat. 3, The Adventure of the Windmills (Part I, ch. VIII), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, pubblico dominio)
- The cover’s detail of Cat. 3: a 1,100-px square cut from the same scan and reduced to 540 px (Gustave Doré · Héliodore Pisan, pubblico dominio)
Preparazione
#1 · La tavola aspetta la pagina di fronte
Il codice è nella risposta breve qui sopra. Ogni scheda va a capo su una pagina pari, e un elemento flottante top non cade mai sulla pagina che lo cita, quindi la tavola citata nella prima frase del commento salta il verso e apre il recto che ha di fronte. Il defaultPlacement del tipo tavola chiede shrink: 'page': a tutta giustezza una tavola sarebbe alta 242 mm, più di quanto contenga la gabbia del recto, quindi Postext la scala, mantenendone le proporzioni, finché tavola, didascalia e riga di crediti stanno nella fascia lasciata sopra lo spazio dell’elemento flottante (posizionamento). minScale: 0.5 permetterebbe a una tavola di scendere a metà della sua larghezza; qui ognuna ne conserva circa nove decimi. captionMeasure: 'body' compone la didascalia e la riga di crediti alla larghezza della tavola, e align: 'center' le centra entrambe sulla pagina.
#2 · La testa della scheda viene dal suo titolo
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt; a multiple of the size
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
// mm: the lead, the entry's first paragraph, 5 mm under the hairline on the next grid line;
// the opener ends a line above it (the level's marginBottom), and the tombstone faces it.
const LEAD_Y = Math.ceil((HEAD_RULE + 5) / PT / LEAD) * LEAD * PT;
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const BASE = 0.8; // a line's first baseline stands 0.8 down its line box
// em: Libre Bodoni's capitals, and its figures, which stop short of them ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [0.754, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
- inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT)) * PT;
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
{ kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y - LEAD * PT),
slot: { elements: [...head('Cat.', '{number}'),
text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
// Baseline on the lead's; the attribute's \n breaks give its three lines.
text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
{ lineHeight: TOMB.lead / TOMB.size, reserve: false }), // beside the lead: no room of its own
] } });
Ogni titolo # porta il suo capitolo e la scheda tecnica come attributi, e lo slot grafico dell’apertura li disegna. Il numero grande è il {number} del titolo stesso, da numberingTemplate: '{1}'; con una tavola per scheda procede 1, 2, 3 al passo del contatore Cat. delle tavole. L’attacco è il primo paragrafo della scheda, in un gruppo :::paragraphs{style="lead"} a 13,5 pt, e il dropCap del livello lo apre con un’iniziale seppia su tre righe la cui sommità tocca le maiuscole della prima riga e il cui piede poggia sulla terza linea di base, con entrambe le altezze delle maiuscole misurate dai caratteri (capilettera). L’attacco è composto in bandiera: giustificato a questo corpo, le sue poche parole per riga si aprirebbero oltre l’1,5× del commento. minHeight chiude l’apertura una riga sopra LEAD_Y, la riga di griglia dove comincia l’attacco; la scheda tecnica sta di fronte alla prima riga dell’attacco senza occupare spazio proprio (reserve: false), e un :::space sotto l’attacco fa partire il commento a filo una riga più in basso. TITLE_Y compone il titolo 1,2 mm più in basso del numerale, il che allinea le sue maiuscole alle cifre del numerale: in Libre Bodoni le cifre sono alte 0,716 em e le maiuscole 0,754 em.
#3 · Dichiara il master, disegna una copia più piccola
// plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX);
// the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box.
async function scan(file) {
const res = await fetch(asset(file));
if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`);
return createImageBitmap(await res.blob());
}
async function loadPlates() {
const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan));
const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail),
'Detail of Cat. 3: the knight and his horse caught on the sail.')];
for (const [i, p] of PLATES.entries()) {
registerResourceImage(p.file, onPaper(scans[i]));
resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size
picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText));
}
return resources;
}
Postext dimensiona una bitmap dai pixel che dichiara, ai dpi del documento, e non la stampa mai più larga di quanto quei pixel consentano. plate() dichiara il master di ogni tavola, la scansione di Commons ridotta a 2.400 px di altezza: 1.900 × 2.400 px per Cat. 1, che potrebbe arrivare a 322 mm con page.dpi: 150. È quindi la gabbia a fissare la misura, 215 mm di altezza, e a quell’altezza 2.400 px fanno circa 283 dpi. Ai 300 dpi predefiniti la tavola si fermerebbe a 161 mm di larghezza. Il canvas disegna in quel riquadro qualunque immagine sia registrata, quindi il pen scarica le tre copie da 835 px in assets/, 0,97 MB contro i 5,8 MB dei master. Disegnati invece dai master da 2.400 px, i cieli si riempiono di bande di moiré e le immagini della cattura crescono di circa 0,3 MB, oltre il limite di 1,4 MB del Ricettario.
#4 · Una copertina da uno stile di titolo
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more) => text(id, content, family,
size, color, at('page', 'top', 0, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
{ kind: 'box', id: 'night', style: { backgroundColor: col('night') },
placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
{ kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
{ kind: 'image', id: 'detail', resourceId: 'cover-detail',
placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
// Tracked capitals centre on their letters: the tracking after the É is left out.
centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
textTransform: 'uppercase', letterSpacing: pt(NAME.track) }),
centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
{ italic: true }),
centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
] } },
});
La copertina è il primo titolo, # Doré {style="cover" …}, disegnato da uno stile senza folio. Il suo elemento immagine disegna windmills-detail-540.jpg, registrato come risorsa che nessun paragrafo cita, quindi non riceve né numero né didascalia. Il file è un quadrato di 1.100 px ritagliato dalla scansione dei mulini e ridotto a 540 px, la sua larghezza su una pagina di 1.000 px. Il nome ha una spaziatura tra lettere di 8 pt, e la spaziatura dopo l’ultima lettera resta fuori dalla centratura, così le quattro maiuscole stanno sull’asse centrale della pagina senza scarto.
#5 · Miniature nelle celle dell’elenco
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false, dropCap: false, // no initial
advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
...plates.map((p, i) => [
{ content: '', image: { resourceId: `${p.id}-thumb` } },
cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
cell(String(platePage(i + 1)), { align: 'right' }),
]),
] } },
createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
L’image di una cella adatta una risorsa bitmap alla larghezza interna della cella, qui 23 mm. Ogni miniatura è una copia propria da 300 px, dichiarata a quella misura, invece della tavola da 835 px ridotta al momento del disegno. La tabella non ha testo di didascalia e il suo tipo ha un prefisso di didascalia vuoto, quindi non si disegna alcuna didascalia. Lo stile dell’elenco imposta solo numbered: false e la sua grafica, e prende dal livello 1 l’estensione a tutta pagina, l’interruzione su un verso e il margine inferiore.
#6 · Fermati quando una scheda supera la sua pagina
const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length);
if (astray) { // text run past its verso, or the blank page that follows it
const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`);
kitFail(error); // the viewer's bar says why
throw error;
}
Una scheda che va oltre il suo verso manda comunque la sua tavola alla pagina successiva. Il resto del commento segue allora la tavola, e una pagina bianca tiene la scheda seguente su un verso, così ogni tavola successiva si stampa due pagine dopo la pagina che l’elenco le attribuisce. Il controllo ferma la composizione alla prima pagina che non è un’apertura e non contiene una tavola, e mostra il numero di quella pagina nella barra del visualizzatore.
La ricetta completa
// ═══ Postext Cookbook · Nº 034 · Catalogue entries facing their plates ══════════════ // https://postext.dev/en/cookbook/catalogue-facing-plates // Code: MIT · Text: original (CC BY 4.0), Ormsby 1885 (PD) · Plates: Doré and Pisan, 1863 (PD) // Fonts: Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed (OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en') const RECIPE = 'catalogue-facing-plates'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1b1918', paper: '#faf7f1', night: '#2a2724', // text; the page and plates; the cover sepia: '#8a6a45', gilt: '#c9ad86', // the accent on paper (4.6:1) and on the night (6.9:1) rule: '#cfc6b8', muted: '#6c665e', // hairlines; tombstones, credit lines, folios }; // col(id): a colour linked to its palette entry, with the entry's hex beside the id. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } }, ]; const [TEXT, DISPLAY, LABEL] = ['Ibarra Real Nova', 'Libre Bodoni', 'Sofia Sans Condensed']; const PT = 25.4 / 72; // mm per point const [SIZE, LEAD, LINES] = [11.5, 16, 41]; // body pt, leading pt; 41 lines make the text block const TRIM = { w: 230, h: 280 }; // mm: a catalogue trim const [TOP, INNER, OUTER] = [24, 20, 18]; // margins, mm const BLOCK_W = TRIM.w - INNER - OUTER; // 192 mm: the text block, and the widest plate const BLOCK_H = LINES * LEAD * PT; // 231.4 mm const [SIDE_PC, GUTTER] = [34, 7]; // the outer column: 65.3 mm for numeral and tombstone; mm const SIDE = (BLOCK_W * SIDE_PC) / 100; const MAIN_X = SIDE + GUTTER; // mm from the block's outer edge to the text column const MAIN = BLOCK_W - MAIN_X; // 119.7 mm: the measure, about 71 characters // #region answer: a Plate type that floats to the head of the facing recto, sized to fill it // Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top' // float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto. const entryLevel = () => ({ level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3 breakBefore: { enabled: true, parity: 'even' }, // an entry opens a verso, facing its plate marginBottom: pt(LEAD), advancedDesign: entryOpener(), dropCap: { lines: 3, fontFamily: DISPLAY, color: col('sepia'), gap: mm(1.6) }, // the lead's }); const plateType = { id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …' numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', // A plate taller than the recto's text block is scaled, proportions kept, until plate, caption // and credit line fit it; the caption takes the plate's width, and both are centred. defaultPlacement: { position: 'top', span: 'page', align: 'center', shrink: 'page', minScale: 0.5, captionMeasure: 'body' }, }; // Under the plate (pt): a caption and a credit line, and their gaps. const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 }; const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({ id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText, // The print master's pixels, about 283 dpi at this size (gotcha: bitmap-print-size). bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH }, createdAt: 0, updatedAt: 0, }); // #endregion const MASTER_PX = { library: [1900, 2400], vigil: [1921, 2400], windmills: [1923, 2400] }; const CREDIT = 'Public domain; scan from Wikimedia Commons.'; // the artists are in the tombstone // A picture drawn only in a design slot or a table cell: registered, never cited or numbered. function picture(id, image, altText) { registerResourceImage(`${id}.jpg`, image); return { id, typeId: 'plate', kind: 'bitmap', altText, createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}.jpg`, format: 'jpeg', width: image.width, height: image.height } }; } // Design-slot shorthands; every text wraps, the folio lines too. const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(width !== undefined && { size: { width: width === 'fill' ? 'fill' : mm(width) } }) }); const text = (id, content, fontFamily, size, color, placement, more = {}) => ({ kind: 'text', id, content, fontFamily, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap', placement, ...more }); const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(size * 0.2) }); // #region opener: the entry's head, a numeral and tombstone beside the title and the lead const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm const TITLE = { size: 30, lineHeight: 1.08 }; // pt; a multiple of the size const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral // mm: the lead, the entry's first paragraph, 5 mm under the hairline on the next grid line; // the opener ends a line above it (the level's marginBottom), and the tombstone faces it. const LEAD_Y = Math.ceil((HEAD_RULE + 5) / PT / LEAD) * LEAD * PT; const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt const BASE = 0.8; // a line's first baseline stands 0.8 down its line box // em: Libre Bodoni's capitals, and its figures, which stop short of them ('1': 0.716) const [CAP_HEIGHT, FIGURE_HEIGHT] = [0.754, 0.716]; const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink // The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top. const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT) - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT)) * PT; // Entries open on versos, whose outer column is on the left: the side column is at x = 0. const head = (label, numeral) => [ text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)), text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia', at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }), text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X, TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }), { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'), placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') }, ]; const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y - LEAD * PT), slot: { elements: [...head('Cat.', '{number}'), text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia', at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)), // Baseline on the lead's; the attribute's \n breaks give its three lines. text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted', at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6), { lineHeight: TOMB.lead / TOMB.size, reserve: false }), // beside the lead: no room of its own ] } }); // #endregion // #region cover: a heading style on warm black, a framed detail of Cat. 3 and the name const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset const FRAME_X = (TRIM.w - FRAME.w) / 2; const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm const centred = (id, content, family, size, color, y, more) => text(id, content, family, size, color, at('page', 'top', 0, y, 'fill'), { align: 'center', ...more }); const coverStyle = () => ({ id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ // painted in this order { kind: 'box', id: 'night', style: { backgroundColor: col('night') }, placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } }, { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) }, placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad), size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } }, { kind: 'image', id: 'detail', resourceId: 'cover-detail', placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) }, centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)), // Tracked capitals centre on their letters: the tracking after the É is left out. centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1, textTransform: 'uppercase', letterSpacing: pt(NAME.track) }), centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4, { italic: true }), centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)), ] } }, }); // #endregion // #region checklist: a table of the works with a thumbnail in each first cell const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it // The entries' head, label and numeral from attributes; the level gives span, break and margin. const checklistStyle = () => ({ id: 'checklist', numbered: false, dropCap: false, // no initial advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } }); const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more }); const checklist = (plates) => ({ id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' }, altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.', table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [ ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) => cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })), ...plates.map((p, i) => [ { content: '', image: { resourceId: `${p.id}-thumb` } }, cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`), cell(String(platePage(i + 1)), { align: 'right' }), ]), ] } }, createdAt: 0, updatedAt: 0, }); // The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1. const platePage = (n) => 2 * n + 1; const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // #endregion const foot = (id, content, parity, edge, x, more) => text(id, content, LABEL, 7.5, 'muted', at('page', edge, x, TRIM.h - 15), { ...caps(7.5), parity, ...more }); const footer = { elements: [ // folios at the outer foot; entries (versos) add the title foot('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, { color: col('ink') }), foot('verso-title', '{title}', 'even', 'top-left', OUTER + 9), foot('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, { color: col('ink'), align: 'right' }), ] }; const config = () => ({ locale: 'en-us', // the sample is in English colorPalette, resourceTypes: [plateType, listType], // A bitmap is never set wider than its declared pixels at this dpi: at 150 dpi a 1,900-px // scan may reach 322 mm, so the text block decides (at 300 dpi it stops at 161 mm). page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.h - TOP - BLOCK_H), left: mm(INNER), right: mm(OUTER), mirror: true } }, // Body text never enters the outer column: the openers draw in it and the plates span it. layout: { layoutType: 'oneAndHalf', sideColumnPercent: SIDE_PC, sideColumnRole: 'floats', sideColumnSide: 'outer', gutterWidth: mm(GUTTER) }, bodyText: { fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('sepia'), referenceBold: false, // 'Cat. 1' in the accent, regular weight firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.5, }, headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [entryLevel()] }, headingStyles: [coverStyle(), checklistStyle()], captionStyle: { fontFamily: LABEL, fontSize: pt(CAPTION.size), gap: pt(CAPTION.gap), labelColor: col('sepia'), // the label is bold by default note: { fontSize: pt(CAPTION.note), gap: pt(CAPTION.noteGap), color: col('muted') } }, tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.8), headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(8), bodyFontFamily: TEXT, bodyFontSize: pt(9.5) }, paragraphStyles: [ // The entry's lead, a size larger and ragged; a :::space under it starts the commentary flush. { id: 'lead', fontSize: pt(LEADIN.size), lineHeight: pt(LEADIN.lead), textAlign: 'left', firstLineIndent: pt(0) }, { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header: { elements: [] }, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Esempio Markdown · 61 righe · content.en.md
title: "Doré: Three Plates for Don Quixote" author: "The Print Room" --- # Doré {style="cover" kicker="The Print Room · A cabinet exhibition" subtitle="Three plates for Don Quixote, 1863" foot="Wood engravings by Héliodore Pisan after Gustave Doré"} # Don Quixote \\ in His Library {chapter="Part I · Chapter I" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I"} :::paragraphs{style="lead"} Doré opens the book with its hero wide awake. Seated upright in a carved chair, the hidalgo holds up a sword in his right hand and a book in his left, while everything he has read comes loose from the page and fills the room around him. ::: :::space The plate (:ref{id="library"}, opposite) sets out the sentence in which the hidalgo loses his wits. In John Ormsby’s translation his fancy grew full of ‘enchantments, quarrels, battles, challenges, wounds, wooings, loves, agonies, and all sorts of impossible nonsense’, and Doré draws the list almost item by item. Knights ride out from behind the chair, a dragon uncoils above a shield of arms, a knight no taller than a folio stands on a book beside a giant’s severed head, and a lady in chains kneels at the fore-edge of a volume lettered AMADIS. Cervantes says the hidalgo ‘sold many an acre of tillageland to buy books of chivalry to read’, and Doré scatters them about the room: open on the table under the window, fallen open at his feet, and one propped on its edge at the lower left, beside the giant’s head. Daylight comes in at the window on the left and falls on his face and on the open page. The figures crowding the rest of the room have come out of the books, down to the two knights no taller than a hand who joust on the backs of mice among the flagstones. Doré gives these phantoms the same firm outline as the man who imagines them. In wood engraving whatever the tool cuts away prints white, so Pisan had to turn each of Doré’s greys into a pattern of strokes: close parallel cuts for the heavy curtain, finer and more open ones for the light on the wall, broken curls for the smoke of figures behind the chair. Both men signed the block, Doré in script at the lower left and Pisan in capitals at the lower right. # The Vigil \\ of Arms {chapter="Part I · Chapter III" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I"} :::paragraphs{style="lead"} The innkeeper has no chapel, so Don Quixote keeps his vigil of arms in the yard. Doré makes the scene a nocturne: the armour stands on the well-head, and the bareheaded knight lifts his lance towards a full moon breaking through cloud. ::: :::space The plate (:ref{id="vigil"}, opposite) follows the text closely. The innkeeper has told his guest that armour may be watched anywhere, so Don Quixote lays his on a trough beside the well and paces up and down in front of it. In Ormsby’s translation the night closes in ‘with a light from the moon so brilliant that it might vie with his that lent it’. Doré stands the armour upright on the well-head, helmet and breastplate and gauntlets, so that it reads as a second knight facing the thin man in his shirt who will wear it. On the right the inn is a dark mass with an outside stair, and a figure watches from its landing: in the novel the guests ‘flocked to see it from a distance’. Water lies in the ruts of the yard and catches the light, and the knight’s shadow runs across it towards the viewer. Cervantes plays the vigil as farce. Two carriers come in turn to water their mules, each goes to move the armour off the trough, and each is laid out with the lance; the landlord then hurries the dubbing through before anyone else is hurt. Doré leaves all of that out and keeps the hour before it, when the knight is alone with his arms and the moon. In this sky Pisan widens his level cuts as they near the moon, until the black left between them thins to hairlines around the halo. Short flicks pick out the edges of the clouds. A dark bar of cloud crosses the disc on the left, and a few faint strokes shade the moon’s face. # The Adventure \\ of the Windmills {chapter="Part I · Chapter VIII" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I"} :::paragraphs{style="lead"} Doré passes over the charge and draws the moment after it. The turning sail has caught the lance and lifted horse and rider clear of the plain, and Rocinante’s legs thrash at the empty air below them. ::: :::space The plate (:ref{id="windmills"}, opposite) illustrates the sentence in which the charge ends. As the knight drove his lance into the sail, in Ormsby’s words, ‘the wind whirled it round with such force that it shivered the lance to pieces, sweeping with it horse and rider, who went rolling over on the plain’. Doré stops before the fall. The broken lance is still caught in the canvas, and the knight hangs tangled against the sail with his shield flung out beside him. The composition is built on one diagonal. The sail runs from the top left corner towards the centre, the horse hangs from it, and the line carries on down the slope to the thistles in the foreground. Against it Doré sets the level plain of La Mancha, where Cervantes counts ‘thirty or forty windmills’ and the plate lines up a row of them along the horizon, turning in the same wind. Sancho, hatless, throws up one arm and clutches his head with the other; his hat lies on the ground and his ass waits behind him. He had warned his master, in Ormsby’s version, that ‘what we see there are not giants but windmills, and what seem to be their arms are the sails that turned by the wind make the millstone go’. Pisan cuts the sky in long even lines and the patched canvas of the sail in torn, crossing strokes. The mills on the horizon are drawn with a few lines each. # Works in \\ the Exhibition {style="checklist" kicker="Checklist" range="1–3"} All three plates come from the first volume of *L’Ingénieux Hidalgo Don Quichotte de la Manche*, in the French translation by Louis Viardot, published in two folio volumes by L. Hachette et Cie, Paris, in 1863 with 377 compositions by Gustave Doré, 120 of them full-page. Héliodore Pisan engraved the three plates on wood, and both men signed them in the block. Quotations are from John Ormsby’s English translation of 1885. ::resource{id="checklist"} :::paragraphs{style="colophon"} Set in Ibarra Real Nova, Libre Bodoni and Sofia Sans Condensed (SIL Open Font License). Commentary written for this catalogue under a CC BY 4.0 licence. The plates are in the public domain and reproduced from scans on Wikimedia Commons; the cover shows a detail of Cat. 3. Ormsby’s translation is Project Gutenberg eBook #996. :::`; // content.<lang>.md, inlined by the Cookbook // id, scan file, caption, the chapter it illustrates and alt text: one block per plate. const plateTexts = String.raw`libraryEsempio Markdown · 16 righe · content.plates.en.md
library-835.jpg Don Quixote in His Library Part I, ch. I: ‘which treats of the character and pursuits of the famous gentleman Don Quixote of La Mancha’ Don Quixote sits upright in a carved chair, a book in his left hand and a sword raised in his right, surrounded by the figures of his reading: knights on horseback, a dragon, a giant’s severed head, a lady in chains and two tiny knights jousting on mice. vigil vigil-835.jpg The Vigil of Arms Part I, ch. III: ‘wherein is related the droll way in which Don Quixote had himself dubbed a knight’ A moonlit inn yard. Don Quixote, bareheaded and holding a lance and a buckler, looks up at the moon; his armour stands propped on the well-head facing him, and a figure watches from the inn’s outside stair. windmills windmills-835.jpg The Adventure of the Windmills Part I, ch. VIII: ‘the terrible and undreamt-of adventure of the windmills’ A windmill’s sail sweeps Don Quixote and Rocinante off the ground, the knight tangled against the canvas and his buckler flung out; a row of mills stands on the horizon and Sancho, hatless, throws up one arm and clutches his head beside his ass.`; const FIELDS = ['id', 'file', 'caption', 'illustrates', 'altText']; const PLATES = plateTexts.trim().split(/\n\s*\n/).map((block) => Object.fromEntries(block.split('\n').map((line, i) => [FIELDS[i], line.trim()]))); // #region art: the engravings printed on the page's paper // Multiplying by the paper colour turns the scan's white into the page's cream. function onPaper(source) { const canvas = new OffscreenCanvas(source.width, source.height); const ctx = canvas.getContext('2d'); ctx.drawImage(source, 0, 0); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = palette.paper; ctx.fillRect(0, 0, source.width, source.height); return canvas.transferToImageBitmap(); } const resized = (scan, width) => createImageBitmap(scan, { resizeWidth: width, resizeQuality: 'high' }); // #endregion // #region scans: the plates, their thumbnails and the cover's detail, fetched from assets/ // plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX); // the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box. async function scan(file) { const res = await fetch(asset(file)); if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`); return createImageBitmap(await res.blob()); } async function loadPlates() { const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan)); const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail), 'Detail of Cat. 3: the knight and his horse caught on the sail.')]; for (const [i, p] of PLATES.entries()) { registerResourceImage(p.file, onPaper(scans[i])); resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText)); } return resources; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Ibarra Real Nova': ['400', '400i'], // text, the lead, the checklist 'Libre Bodoni': ['400', '400i'], // numerals, titles, drop caps, the cover 'Sofia Sans Condensed': ['400', '600', '700'], // labels, tombstones, captions, folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const words = `${markdown}\n${plateTexts}\n${CREDIT}`; // their letters decide the font subsets const [, resources] = await Promise.all([ prepareFonts(words, config(), kitFonts(FONTS)), loadPlates()]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); // #region check: every page past an opener holds a plate, so each plate faces its entry const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length); if (astray) { // text run past its verso, or the blank page that follows it const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`); kitFail(error); // the viewer's bar says why throw error; } // #endregion showPages(doc, { title: t({ en: 'Catalogue entries facing their plates' }) });Kit · core, fonts, viewer: uguale in ogni ricetta · 224 righe
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Lascia aria sotto le tavole
Una frazione di larghezza restringe l’elemento flottante stesso: al 78 % della gabbia ogni tavola è alta circa 188 mm, centrata, con circa 27 mm di gabbia vuota sotto la riga di crediti, e nessuna deve essere ridotta.
- defaultPlacement: { position: 'top', span: 'page', align: 'center',
- shrink: 'page', minScale: 0.5, captionMeasure: 'body' },
+ defaultPlacement: { position: 'top', span: 'page', align: 'center', width: 0.78 },#Componi le figure nella colonna esterna
Per figure e glosse che si impilano in una colonna esterna riservata agli elementi flottanti accanto al paragrafo che le cita, vedi il manuale con una colonna a margine.
Errori frequenti
Errore frequente
Un elemento flottante 'top' va alla pagina dopo la sua citazione, a meno che non possa aprire la pagina che lo cita
Per impostazione predefinita un elemento flottante non va mai sopra il proprio riferimento, quindi un flottante 'top' a tutta larghezza citato a pagina N apre la pagina N+1. Imposta placement.citingPage: true sulla risorsa o sul defaultPlacement del suo tipo (o layout.floatsAtCitingPage per tutto il libro, da postext 1.25) e aprirà invece la pagina N, con il testo sopra il riferimento che scende sotto di esso, quando entra entro layout.maxTopFraction e la riga che lo cita resta su quella pagina. Non apre mai la prima pagina di un capitolo né una pagina dopo un’interruzione forzata: lì apre ancora la pagina successiva, e 'auto' o 'bottom' possono prendere il piede della pagina che lo cita. Collocazione delle figure →
Errore frequente
Le bitmap prendono la dimensione di stampa da una risoluzione: dichiarala, non pixel alla dimensione di stampa
Una bitmap senza risoluzione viene impaginata a un pixel per pixel di impaginazione ai dpi del documento, quindi una foto di 2400 px è larga 406 mm su una pagina a 150 dpi. Dichiara i pixel reali del file e dai all’immagine la sua risoluzione (bitmap.resolution: 300), o imposta layout.bitmapResolution per ogni bitmap: un valore in ppi, o 'file' per leggere quello indicato dal file. La colonna limita comunque l’immagine. Figure e tabelle come risorse →
Verifica della Sandbox · bitmapTooSmall
Immagine a bassa risoluzione
Perché. Una bitmap è disegnata più di 1,5 volte più larga dei suoi pixel, quindi in stampa apparirà sfocata.
Soluzione. Fornisci circa 300 dpi alla dimensione di stampa, e dichiara la larghezza e l’altezza reali della bitmap. Documentazione →
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- The catalogue entries, captions, tombstones and checklist · Postext Cookbook · CC BY 4.0
- Quotations from Don Quixote in John Ormsby’s English translation (1885) · Miguel de Cervantes · John Ormsby · pubblico dominio
- Immagini
- Cat. 1, Don Quixote in His Library (Part I, ch. I), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide · Gustave Doré · Héliodore Pisan · pubblico dominio
- Cat. 2, The Vigil of Arms (Part I, ch. III), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide · Gustave Doré · Héliodore Pisan · pubblico dominio
- Cat. 3, The Adventure of the Windmills (Part I, ch. VIII), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide · Gustave Doré · Héliodore Pisan · pubblico dominio
- The cover’s detail of Cat. 3: a 1,100-px square cut from the same scan and reduced to 540 px · Gustave Doré · Héliodore Pisan · pubblico dominio
- Font
- Ibarra Real Nova (SIL OFL 1.1) · Libre Bodoni (SIL OFL 1.1) · Sofia Sans Condensed (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


