En poques paraules
Un examen d'història de quatre pàgines amb portada, bombolles per marcar i ratlles per escriure. Ensenya a numerar preguntes i apartats i a mostrar la puntuació de cadascun.
Què compondràs
La prova 2 d'un simulacre d'examen d'història sobre la guerra de Secessió, en un quadernet de quatre pàgines en mida carta. La portada obre amb una banda carmesí i l'any 1863 dibuixat en contorn; a sota hi van les caselles de l'alumne, les instruccions a dues columnes i la plantilla de bombolles de la part A. A la pàgina 2 hi ha deu preguntes tipus test, cadascuna amb les seves quatre opcions a sota, i cada opció comença per una bombolla amb la seva lletra, com les de la plantilla. La pàgina 3 recull el discurs de Gettysburg com a font A i, després, les preguntes numerades 11, a), i), amb la puntuació de cada apartat en un xip. La pàgina 4 és per a les respostes: una ratlla cada dues línies de text, en grups que comencen pel número del seu apartat. Després de la portada, cada pàgina porta foli i un marge per al corrector, i l'única senar d'aquestes diu «Gira el full».
Aquesta recepta respon a
- Com faig una fitxa d'exercicis: línies per omplir, caixes de resposta, bancs de paraules, llistes de comprovació?
- Com personalitzo les llistes: pics per nivell, numeració (a)/(i), caselles de tasques i un espaiat que respecti la retícula?
- Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
- Com dono estils diferents a diverses taules (emplenaments, files alternes, marcs arrodonits) en un mateix document?
- Com amago les capçaleres a les obertures i a les pàgines en blanc, o pinto una pàgina parella en blanc amb el color de la part?
La resposta curta
// A table row is one line (the table's size × the body's leading ratio) plus cellPadding above
// and below: at the body size, (ROW − LEAD) / 2 of padding makes every row ROW deep, so the
// rules keep to the text's 15 pt grid, 10.6 mm apart: room for handwriting.
const ROW = 2 * LEAD; // pt
const lines = { id: 'lines', rules: 'horizontal', // a rule on the top and foot of every row
borderColor: col('rule'), borderWidth: pt(0.5), bodyFontFamily: LABEL, bodyFontSize: pt(BODY),
bodyColor: col('crimson'), cellPadding: pt((ROW - LEAD) / 2) };
// Every table in this paper is set 'here': where its ::resource directive stands in the text.
const table = (id, styleId, model) => ({ id, typeId: 'form', kind: 'table', createdAt: 0,
updatedAt: 0, placement: { position: 'here' }, table: { styleId, model } });
// One table per answer, the part's number in its first margin cell.
const answerLines = (id, part, rows) => table(id, 'lines', { columnWidths: [1, 5], rows: Array
.from({ length: rows }, (_, i) => [{ content: i ? '' : `**${part}**` }, { content: '' }]) });
Ingredients
- Funcions
- Llistes numeradesChips en líniaEstils de taula amb nomTaules a partir de dadesTipus de recurs propisFigures just aquíEmplenaments de cel·laEstil dels peusSalts de pàgina i de columnaCapçaleres segons el tipus de pàginaCapçaleres i folisEstils de títolGeometria per seccióObertures dissenyadesAtributs de títolImatges als dissenys de pàginaRequadresColumnes dins d'un requadreEstils de paràgrafPaleta de color semàntica
- També fa servir
- Figures i taules com a recursosEspai vertical explícit
- Tipografia
- PT Serif, Inter Tight (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Numera les preguntes i posa'n les opcions a sota
const [NUMBER, GAP] = [12.5, 6]; // pt: the question numbers' size; number to text
const orderedLists = { fontFamily: LABEL, color: col('ink'), // bold, by default
separatorColor: col('crimson'), gap: pt(GAP), marginTop: pt(LEAD / 2), marginBottom: pt(0),
itemSpacing: pt(5), // after every numbered item: to a question's options, or the next part
levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic', '.' (gotcha: numbering-vocabularies)
{ level: 2, numberFormat: 'lower-alpha', separator: ')' },
{ level: 3, numberFormat: 'lower-roman', separator: ')' }] };
// Options: a nested item with no bullet, indented by the widest number ('10.') plus GAP.
const width = (s) => { const ctx = new OffscreenCanvas(1, 1).getContext('2d');
ctx.font = `700 ${NUMBER}pt "${LABEL}"`; return ctx.measureText(s).width * 0.75; }; // pt
const unorderedLists = () => ({ gap: pt(0), marginTop: pt(0), marginBottom: pt(0),
itemSpacing: pt(LEAD), // after the options: a line before the next question
levels: [{ level: 2, bulletChar: '', indent: pt(width('10') + width('.') + GAP) }] });
Cada nivell d'orderedLists té el seu propi numberFormat i el seu separator; separatorColor posa en el color d'accent el punt i el parèntesi de tancament, i el número queda en negre (llistes ordenades). Els números d'una llista s'alineen a la dreta amb el més ample del seu tram, de manera que el text de totes les preguntes comença a 23 pt: els 17 pt de «10.» més GAP. Les quatre opcions formen un element niat de llista no ordenada, amb bulletChar buit. El seu text comença a l'indent del nivell, que és l'amplada de «10.» mesurada en la font dels números més GAP, i així les opcions queden sota la primera lletra de la pregunta amb qualsevol tipus de lletra. La separació depèn de l'element anterior: 5 pt després d'un element numerat, 15 pt després d'una línia d'opcions.
#2 · Fes les bombolles i les puntuacions amb xips
// A chip is 0.8 em above the baseline and 0.25 em below, plus paddingY and the border on each
// side: 1.29 em and two borders. One capital is 0.63–0.72 em wide, so paddingX 0.3 em (and the
// same two borders) squares the box; a radius over half its smaller side is clamped to that half.
const chip = (id, look) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true,
paddingY: em(0.12), ...look });
const bubble = (id, fill, ink, edge = 'crimson') => chip(id, { color: col(ink), paddingX: em(0.3),
background: col(fill), borderColor: col(edge), borderWidth: pt(0.7), borderRadius: em(1) });
const chipStyles = [bubble('bubble', 'paper', 'crimson'), // first: a bare :chip[A] takes it
bubble('filled', 'ink', 'paper', 'ink'), chip('marks', { color: col('crimson'),
background: col('blush'), borderWidth: pt(0), paddingX: em(0.45), gap: em(0.5) })];
Un xip és una caixa dins de la línia, i un salt de línia no hi cau mai a dins (xips en línia). Amb una sola majúscula a dins, paddingX: em(0.3) deixa la caixa gairebé tan ampla com alta: a la plantilla, de 4,5 a 4,8 mm d'amplada per 4,7 mm d'alçada. borderRadius: em(1) supera la meitat de la caixa, i com que el radi d'un xip es limita a la meitat del seu costat menor, els extrems queden arrodonits del tot. Un xip sense style pren el primer estil de chipStyles, de manera que les vuitanta bombolles, quaranta a la plantilla i quaranta a les opcions, s'escriuen sense estil, com :chip[A]; només l'exemple omplert de les instruccions i les puntuacions n'indiquen un. Com que Postext 1.4.1 no té tabulacions, la puntuació no pot anar alineada a la dreta; cada xip tanca el seu apartat, com a mínim a 0,5 em (gap) de l'última paraula.
#3 · Dona a cada formulari el seu estil de taula
// No table has a header row; the flag keeps the default grey fill off one added later.
const form = { bodyFontFamily: LABEL, borderRadius: mm(2), headerBackgroundEnabled: false };
const tableStyles = [lines,
{ ...form, id: 'candidate', rules: 'grid', borderColor: col('rule'), borderWidth: pt(0.75),
bodyFontSize: pt(7.5), bodyColor: col('crimson'), cellPadding: mm(1.8) },
// Tint fills leave hairline seams between cells on a canvas: grid rules in the tint hide them.
{ ...form, id: 'grid', rules: 'grid', borderColor: col('tint'), bodyBackgroundEnabled: true,
bodyBackground: col('tint'), bodyFontSize: pt(11.5), cellPadding: mm(1.4) }];
const cell = (content, extra) => ({ content, align: 'center', verticalAlign: 'middle', ...extra });
// The MARK cell sets the row's depth: three lines, the blank one a U+2060 word joiner, since a
// cell line that is empty or holds only a no-break space is dropped (gotcha: cell-blank-line).
const candidate = table('candidate', 'candidate', { columnWidths: [4.2, 1.2, 2, 1.3], rows: [[
...t({ en: ['NAME', 'CLASS', 'CANDIDATE NUMBER'], es: ['NOMBRE Y APELLIDOS', 'GRUPO',
'N.º DE EXAMEN'] }).map((l) => cell(`**${l}**`, { align: 'left', verticalAlign: 'top' })),
cell(`**${t({ en: 'MARK', es: 'NOTA' })}**\n\u2060\n**/ ${t({ en: '25', es: '10' })}**`,
{ align: 'right', background: col('tint') })]] });
const GROUP = ['A', 'B', 'C', 'D'].map((l) => cell(`:chip[${l}]`)), num = (n) => cell(`**${n}**`);
const grid = { ...table('grid', 'grid', { columnWidths: [0.7, 1, 1, 1, 1, 1.4, 0.7, 1, 1, 1, 1],
rows: [1, 2, 3, 4, 5].map((n) => [num(n), ...GROUP, cell(''), num(n + 5), ...GROUP]) }),
caption: t({ en: '**Section A answer grid**', es: '**Parte A: plantilla de respuestas**' }) };
// The grid's title is its caption: a heading would stand a body line off any 'here' table.
const captionStyle = { fontFamily: LABEL, fontSize: pt(9.4), color: col('crimson'),
position: 'above', gap: mm(1.6) }; // the rubric's size
Les caselles de l'alumne, la plantilla de bombolles i les ratlles són tres tableStyles, que cada taula tria amb styleId (estils de taula amb nom). Les ratlles de la resposta curta fan servir rules: 'horizontal' i un farciment que dona a cada fila l'alçada de dues línies del text, de manera que cada filet cau 30 pt (10,6 mm) per sota de l'anterior, sobre una de cada dues línies de la retícula de base de 15 pt. Postext 1.4.1 descarta la línia d'una cel·la que és buida o només porta un espai no separable; per això la línia del centre de la casella NOTA, que va en blanc, porta un unidor de paraules (U+2060): aquesta cel·la ocupa tres línies i fixa l'alçada de tota la fila en 14,4 mm, enfront de 10,8 mm amb un espai no separable. Els filets de la plantilla van en el mateix rosa que el fons, perquè el llenç deixa fils clars entre cel·les emplenades. El rètol de la plantilla és un peu col·locat 1,6 mm per sobre (estil dels peus de recurs). Un títol quedaria com a mínim una línia de text (5,3 mm) més amunt, perquè tota taula col·locada amb 'here' deixa aquest espai al damunt. El tipus form té el captionPrefix buit, i per això el rètol no porta al davant una etiqueta «Form 1.».
#4 · Dibuixa la portada amb els atributs del títol
const BAND = 116; // mm: the crimson band, bled off the top and both sides
const YEAR = { cap: 62, cut: 12, pad: 2 }; // mm: the digits' cap height; the band cuts 12 off
// span 'page': an opener page, and the band paints above the column (a design in it is clipped).
const cover = { id: 'cover', span: 'page', margins: { right: mm(LEFT) }, // forms full width
advancedDesign: { enabled: true, slot: { elements: [ // the date line sets the height
{ kind: 'box', id: 'band', style: { backgroundColor: col('crimson') },
placement: at(0, 0, { width: mm(PAGE.w), height: mm(BAND) }) },
// Design text has no outline, so the year is an image, cut off by the band's foot.
{ kind: 'image', id: 'year', resourceId: 'year', placement: at(0, BAND - YEAR.cap
+ YEAR.cut - YEAR.pad, { width: mm(PAGE.w), height: mm(YEAR.cap - YEAR.cut + YEAR.pad) }) },
text('session', '{attr.session}', LABEL, 8.5, 600, 'blush', at(LEFT, 14), tag),
// 0.9 mm to the left: the H's side bearing at 64 pt (84 of 2048 units), so its stem aligns.
text('title', '{titleText}', LABEL, 64, 800, 'paper', at(LEFT - 0.9, 19), { lineHeight: 1 }),
text('paper', '{attr.paper}', LABEL, 15, 700, 'paper', at(LEFT, 44)),
text('topic', '{attr.topic}', TEXT, 15, 400, 'blush', at(LEFT, 52), { italic: true }),
text('date', '{attr.date}', LABEL, 9.5, 700, 'ink', at(LEFT, BAND + 6))] } } };
La portada és un sol títol, # Historia {style="cover" …}, i el seu estil dibuixa la banda, l'any i quatre línies de text que pren dels atributs del títol. Amb span: 'page', la pàgina compta com a obertura i la banda es pot pintar per sobre del marge superior de 22 mm. Sense això, el disseny queda retallat a dalt de la columna, de manera que la banda comença al marge i es perd la línia de la convocatòria. Els margins de l'estil eixamplen la columna de la portada fins als 168 mm per als formularis, i el títol següent, un de nivell 1 sense estil, torna les altres pàgines a la mesura de 134 mm.
#5 · Deixa la portada sense foli ni «Gira el full»
const [MARGIN, FOOT] = [LEFT + MEASURE + 7, -13]; // mm: the margin rule, 7 off the text; the foot
const body = { pages: 'body', ...tag }; // body pages only: never the cover
const header = { elements: [
text('running', '{title} · {chapterTitle}', LABEL, 7.5, 600, 'muted', at(LEFT, 12), body),
{ kind: 'rule', id: 'margin', direction: 'vertical', pages: 'body', color: col('rule'),
thickness: pt(0.75), placement: at(MARGIN, TOP, { height: mm(PAGE.h - TOP - BOTTOM) }) },
text('note', t({ en: 'Do not write in this margin', es: 'No escribas en este margen' }), LABEL,
7.5, 600, 'muted', at(MARGIN + 3, TOP, { width: mm(28) }), { ...body, lineHeight: 1.3 })] };
const footer = { elements: [
text('notice', t({ en: 'Do not turn over until you are told to do so',
es: 'No des la vuelta a la hoja hasta que se te indique' }), LABEL, 8.5, 700, 'crimson',
at(0, FOOT, { width: mm(PAGE.w) }, 'bottom-left'), { pages: 'opener', align: 'center', ...tag }),
text('folio', '{pageNumber}', LABEL, 9, 700, 'ink', at(LEFT, FOOT, { width: mm(MEASURE) },
'bottom-left'), { ...body, align: 'center' }), // centred under the text
// Rectos only: a verso faces the page that follows it.
text('turn', t({ en: 'Turn over ›', es: 'Pasa la página ›' }), LABEL, 9, 700, 'ink',
at(-RIGHT, FOOT, null, 'bottom-right'), { ...body, parity: 'odd', align: 'right' })] };
Tots els elements fixos de les pàgines de cos porten pages: 'body', de manera que la portada, que és una obertura, només mostra el seu avís (pages: 'opener'). Per això cada part comença amb :::pagebreak i un títol sense breakBefore. Un títol que salta de pàgina converteix la seva en obertura, i les pàgines 2 i 3 perdrien el foli, la capçalera i el marge. parity: 'odd' deixa «Gira el full» a la pàgina senar, perquè la pàgina parella ja té al davant la següent.
La recepta completa
// ═══ Postext Cookbook · Nº 049 · Exam paper with an answer sheet ═════════════════ // https://postext.dev/en/cookbook/exam-paper // Code: MIT · Text: Lincoln (PD); questions, Spanish translation (CC BY 4.0) · Art: in code // Fonts: PT Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'exam-paper'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1b1a1f', paper: '#ffffff', muted: '#6b6466', // heads, credits: 5.8:1 crimson: '#9b1c31', // the one accent: cover, numbering, bubbles, marks (8.1:1 on white) blush: '#f3cdd4', tint: '#fbeff1', // type on the band, marks chips; the grid, the rubric box rule: '#b9aeb0' }; // answer lines and hairlines // Hex and id: design slots and referenceColor read only the hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults are linked to 'main-color': point it at the accent. const colorPalette = Object.entries({ ...palette, 'main-color': palette.crimson }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, LABEL, PAGE] = ['PT Serif', 'Inter Tight', { w: 215.9, h: 279.4 }]; // US Letter const [TOP, BOTTOM, LEFT, MEASURE] = [22, 24, 24, 134]; // mm; 134 mm: about 75 characters const RIGHT = PAGE.w - LEFT - MEASURE; // 57.9 mm: the examiner's margin, never mirrored const [BODY, LEAD] = [11, 15]; // pt: text size and leading, the grid every line keeps to const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, family, size, weight, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col(color), align: 'left', overflow: 'wrap', placement, ...extra }); const tag = { textTransform: 'uppercase', letterSpacing: pt(1.4) }; // 0.16–0.19 em const small = { fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }; // #region answer: ruled answer lines: a table whose rows are two body lines deep // A table row is one line (the table's size × the body's leading ratio) plus cellPadding above // and below: at the body size, (ROW − LEAD) / 2 of padding makes every row ROW deep, so the // rules keep to the text's 15 pt grid, 10.6 mm apart: room for handwriting. const ROW = 2 * LEAD; // pt const lines = { id: 'lines', rules: 'horizontal', // a rule on the top and foot of every row borderColor: col('rule'), borderWidth: pt(0.5), bodyFontFamily: LABEL, bodyFontSize: pt(BODY), bodyColor: col('crimson'), cellPadding: pt((ROW - LEAD) / 2) }; // Every table in this paper is set 'here': where its ::resource directive stands in the text. const table = (id, styleId, model) => ({ id, typeId: 'form', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, table: { styleId, model } }); // One table per answer, the part's number in its first margin cell. const answerLines = (id, part, rows) => table(id, 'lines', { columnWidths: [1, 5], rows: Array .from({ length: rows }, (_, i) => [{ content: i ? '' : `**${part}**` }, { content: '' }]) }); // #endregion // #region numbering: 11 → a) → i), the separators in the accent; options under a question const [NUMBER, GAP] = [12.5, 6]; // pt: the question numbers' size; number to text const orderedLists = { fontFamily: LABEL, color: col('ink'), // bold, by default separatorColor: col('crimson'), gap: pt(GAP), marginTop: pt(LEAD / 2), marginBottom: pt(0), itemSpacing: pt(5), // after every numbered item: to a question's options, or the next part levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic', '.' (gotcha: numbering-vocabularies) { level: 2, numberFormat: 'lower-alpha', separator: ')' }, { level: 3, numberFormat: 'lower-roman', separator: ')' }] }; // Options: a nested item with no bullet, indented by the widest number ('10.') plus GAP. const width = (s) => { const ctx = new OffscreenCanvas(1, 1).getContext('2d'); ctx.font = `700 ${NUMBER}pt "${LABEL}"`; return ctx.measureText(s).width * 0.75; }; // pt const unorderedLists = () => ({ gap: pt(0), marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(LEAD), // after the options: a line before the next question levels: [{ level: 2, bulletChar: '', indent: pt(width('10') + width('.') + GAP) }] }); // #endregion // #region chips: bubbles that read as circles, and marks at the end of a part // A chip is 0.8 em above the baseline and 0.25 em below, plus paddingY and the border on each // side: 1.29 em and two borders. One capital is 0.63–0.72 em wide, so paddingX 0.3 em (and the // same two borders) squares the box; a radius over half its smaller side is clamped to that half. const chip = (id, look) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true, paddingY: em(0.12), ...look }); const bubble = (id, fill, ink, edge = 'crimson') => chip(id, { color: col(ink), paddingX: em(0.3), background: col(fill), borderColor: col(edge), borderWidth: pt(0.7), borderRadius: em(1) }); const chipStyles = [bubble('bubble', 'paper', 'crimson'), // first: a bare :chip[A] takes it bubble('filled', 'ink', 'paper', 'ink'), chip('marks', { color: col('crimson'), background: col('blush'), borderWidth: pt(0), paddingX: em(0.45), gap: em(0.5) })]; // #endregion // #region forms: the candidate boxes and the bubble grid are named table styles too // No table has a header row; the flag keeps the default grey fill off one added later. const form = { bodyFontFamily: LABEL, borderRadius: mm(2), headerBackgroundEnabled: false }; const tableStyles = [lines, { ...form, id: 'candidate', rules: 'grid', borderColor: col('rule'), borderWidth: pt(0.75), bodyFontSize: pt(7.5), bodyColor: col('crimson'), cellPadding: mm(1.8) }, // Tint fills leave hairline seams between cells on a canvas: grid rules in the tint hide them. { ...form, id: 'grid', rules: 'grid', borderColor: col('tint'), bodyBackgroundEnabled: true, bodyBackground: col('tint'), bodyFontSize: pt(11.5), cellPadding: mm(1.4) }]; const cell = (content, extra) => ({ content, align: 'center', verticalAlign: 'middle', ...extra }); // The MARK cell sets the row's depth: three lines, the blank one a U+2060 word joiner, since a // cell line that is empty or holds only a no-break space is dropped (gotcha: cell-blank-line). const candidate = table('candidate', 'candidate', { columnWidths: [4.2, 1.2, 2, 1.3], rows: [[ ...t({ en: ['NAME', 'CLASS', 'CANDIDATE NUMBER'], es: ['NOMBRE Y APELLIDOS', 'GRUPO', 'N.º DE EXAMEN'] }).map((l) => cell(`**${l}**`, { align: 'left', verticalAlign: 'top' })), cell(`**${t({ en: 'MARK', es: 'NOTA' })}**\n\u2060\n**/ ${t({ en: '25', es: '10' })}**`, { align: 'right', background: col('tint') })]] }); const GROUP = ['A', 'B', 'C', 'D'].map((l) => cell(`:chip[${l}]`)), num = (n) => cell(`**${n}**`); const grid = { ...table('grid', 'grid', { columnWidths: [0.7, 1, 1, 1, 1, 1.4, 0.7, 1, 1, 1, 1], rows: [1, 2, 3, 4, 5].map((n) => [num(n), ...GROUP, cell(''), num(n + 5), ...GROUP]) }), caption: t({ en: '**Section A answer grid**', es: '**Parte A: plantilla de respuestas**' }) }; // The grid's title is its caption: a heading would stand a body line off any 'here' table. const captionStyle = { fontFamily: LABEL, fontSize: pt(9.4), color: col('crimson'), position: 'above', gap: mm(1.6) }; // the rubric's size // #endregion // #region cover: a band with the outlined year, filled in from the heading's attributes const BAND = 116; // mm: the crimson band, bled off the top and both sides const YEAR = { cap: 62, cut: 12, pad: 2 }; // mm: the digits' cap height; the band cuts 12 off // span 'page': an opener page, and the band paints above the column (a design in it is clipped). const cover = { id: 'cover', span: 'page', margins: { right: mm(LEFT) }, // forms full width advancedDesign: { enabled: true, slot: { elements: [ // the date line sets the height { kind: 'box', id: 'band', style: { backgroundColor: col('crimson') }, placement: at(0, 0, { width: mm(PAGE.w), height: mm(BAND) }) }, // Design text has no outline, so the year is an image, cut off by the band's foot. { kind: 'image', id: 'year', resourceId: 'year', placement: at(0, BAND - YEAR.cap + YEAR.cut - YEAR.pad, { width: mm(PAGE.w), height: mm(YEAR.cap - YEAR.cut + YEAR.pad) }) }, text('session', '{attr.session}', LABEL, 8.5, 600, 'blush', at(LEFT, 14), tag), // 0.9 mm to the left: the H's side bearing at 64 pt (84 of 2048 units), so its stem aligns. text('title', '{titleText}', LABEL, 64, 800, 'paper', at(LEFT - 0.9, 19), { lineHeight: 1 }), text('paper', '{attr.paper}', LABEL, 15, 700, 'paper', at(LEFT, 44)), text('topic', '{attr.topic}', TEXT, 15, 400, 'blush', at(LEFT, 52), { italic: true }), text('date', '{attr.date}', LABEL, 9.5, 700, 'ink', at(LEFT, BAND + 6))] } } }; // #endregion // #region furniture: margin, folio and 'Turn over' on body pages; a notice on the cover const [MARGIN, FOOT] = [LEFT + MEASURE + 7, -13]; // mm: the margin rule, 7 off the text; the foot const body = { pages: 'body', ...tag }; // body pages only: never the cover const header = { elements: [ text('running', '{title} · {chapterTitle}', LABEL, 7.5, 600, 'muted', at(LEFT, 12), body), { kind: 'rule', id: 'margin', direction: 'vertical', pages: 'body', color: col('rule'), thickness: pt(0.75), placement: at(MARGIN, TOP, { height: mm(PAGE.h - TOP - BOTTOM) }) }, text('note', t({ en: 'Do not write in this margin', es: 'No escribas en este margen' }), LABEL, 7.5, 600, 'muted', at(MARGIN + 3, TOP, { width: mm(28) }), { ...body, lineHeight: 1.3 })] }; const footer = { elements: [ text('notice', t({ en: 'Do not turn over until you are told to do so', es: 'No des la vuelta a la hoja hasta que se te indique' }), LABEL, 8.5, 700, 'crimson', at(0, FOOT, { width: mm(PAGE.w) }, 'bottom-left'), { pages: 'opener', align: 'center', ...tag }), text('folio', '{pageNumber}', LABEL, 9, 700, 'ink', at(LEFT, FOOT, { width: mm(MEASURE) }, 'bottom-left'), { ...body, align: 'center' }), // centred under the text // Rectos only: a verso faces the page that follows it. text('turn', t({ en: 'Turn over ›', es: 'Pasa la página ›' }), LABEL, 9, 700, 'ink', at(-RIGHT, FOOT, null, 'bottom-right'), { ...body, parity: 'odd', align: 'right' })] }; // #endregion const section = { enabled: true, slot: { elements: [ // Section A, Section B: in the column { kind: 'rule', id: 'top', color: col('crimson'), thickness: pt(2), placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill' } } }, text('kicker', '{attr.section} · {attr.marks}', LABEL, 8.5, 700, 'crimson', { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(3) } }, tag), text('title', '{titleText}', LABEL, 20, 800, 'ink', { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(1.2) }, size: { width: 'fill' } }, { lineHeight: 1.05 })] } }; const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales) resourceTypes: [{ id: 'form', name: 'Form', shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], // no label colorPalette, chipStyles, tableStyles, captionStyle, orderedLists, header, footer, headingStyles: [cover], unorderedLists: unorderedLists(), // measured now that the fonts are in page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(LEFT), right: mm(RIGHT) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // No page break: a :::pagebreak opens each section, so its page stays a body page with a // folio. A heading that breaks the page makes an opener, which pages: 'body' leaves bare. headings: { fontFamily: LABEL, levels: [{ level: 1, breakBefore: { enabled: false }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: section }] }, calloutStyles: [{ id: 'rubric', background: col('tint'), borderRadius: mm(2), columnGap: mm(8), padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) }, marginTop: pt(0), marginBottom: pt(0), lists: { bulletChar: '–', color: col('crimson'), gap: mm(2), itemSpacing: pt(3) }, body: { fontFamily: LABEL, fontSize: pt(9.4), lineHeight: pt(13), boldColor: col('crimson'), paragraphSpacing: false } }, { id: 'source', backgroundEnabled: false, marginTop: pt(LEAD), stripe: { enabled: true, side: 'left', width: pt(3), color: col('crimson') }, padding: { top: mm(1), right: mm(0), bottom: mm(1), left: mm(6) }, titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('crimson'), gap: mm(2), ...tag }, body: { fontSize: pt(10.5), lineHeight: pt(LEAD), textAlign: 'justify', paragraphSpacing: false, firstLineIndent: mm(4) } }], paragraphStyles: [{ id: 'signature', textAlign: 'right' }, { id: 'credit', ...small }, { id: 'end', ...small, boldColor: col('crimson'), textAlign: 'center', spaceBetween: pt(8) }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 100 línies · content.es.md
title: "Historia, prueba 2" subtitle: "La guerra de Secesión, 1863" --- # Historia {style="cover" session="Simulacro de examen · Primavera de 2026" paper="Prueba 2 · Fuentes e interpretaciones" topic="La guerra de Secesión, 1863" date="Jueves, 14 de mayo de 2026 · 9:00 · Duración: 1 hora y 15 minutos"} ::resource{id="candidate"} :::callout{type="rubric"} :::columns{count=2 breaks="6"} **Instrucciones** - Usa bolígrafo negro; para la parte A, lápiz. - Responde a todas las preguntas. - Parte A: rellena una burbuja por pregunta, así: :chip[C]{style="filled"}. Si cambias una respuesta, borra la marca. - Parte B: escribe en las líneas de la página 4. **Información** - Junto a cada apartado figura su puntuación, por ejemplo: :chip[1 punto]{style="marks"} - Parte A: 5 puntos. Parte B: 5 puntos. - Dedica unos 45 minutos a la parte B. ::: ::: ::resource{id="grid"} :::pagebreak # Preguntas tipo test {section="Parte A" marks="5 puntos"} Rellena una burbuja por pregunta en la plantilla de la página 1: 1. La Proclamación de Emancipación entró en vigor el 1 de enero de 1863. ¿En qué estados declaraba libres a las personas esclavizadas? - :chip[A] todos :chip[B] los fronterizos :chip[C] los rebeldes :chip[D] los del Oeste 2. ¿Quién mandaba el ejército de la Unión en la batalla de Gettysburg? - :chip[A] U. S. Grant :chip[B] G. G. Meade :chip[C] G. B. McClellan :chip[D] W. T. Sherman 3. ¿Qué plaza fuerte confederada se rindió el 4 de julio de 1863? - :chip[A] Nueva Orleans :chip[B] Vicksburg :chip[C] Memphis :chip[D] Baton Rouge 4. Si una *score* son veinte años, ¿cuántos son «four score and seven»? - :chip[A] 47 :chip[B] 67 :chip[C] 87 :chip[D] 107 5. ¿Quién pronunció el discurso principal, de dos horas, en la inauguración del cementerio de Gettysburg? - :chip[A] E. Everett :chip[B] F. Douglass :chip[C] W. H. Seward :chip[D] J. Hay 6. Con la Ley de Reclutamiento de marzo de 1863, ¿cuánto podía pagar un reclutado para quedar exento del servicio? - :chip[A] 100 dólares :chip[B] 300 dólares :chip[C] 500 dólares :chip[D] 1000 dólares 7. ¿Qué regimiento de soldados negros encabezó el asalto a Fort Wagner (Carolina del Sur) en julio de 1863? - :chip[A] 20.º Maine :chip[B] 9.º Ohio :chip[C] 2.º Iowa :chip[D] 54.º Massachusetts 8. ¿Qué estado se incorporó a la Unión el 20 de junio de 1863? - :chip[A] Nevada :chip[B] Virginia Occidental :chip[C] Kansas :chip[D] Nebraska 9. ¿En qué batalla de mayo de 1863 fue herido de muerte el general Thomas «Stonewall» Jackson? - :chip[A] Chancellorsville :chip[B] Antietam :chip[C] Fredericksburg :chip[D] Shiloh 10. ¿En qué ciudad hubo cuatro días de disturbios contra el reclutamiento en julio de 1863? - :chip[A] Boston :chip[B] Filadelfia :chip[C] Nueva York :chip[D] Chicago :::pagebreak # Comentario de una fuente {section="Parte B" marks="5 puntos"} :::callout{type="source" title="Fuente A"} Hace ochenta y siete años, nuestros padres crearon en este continente una nueva nación, concebida en la libertad y consagrada al principio de que todos los hombres son creados iguales. Ahora estamos empeñados en una gran guerra civil que pone a prueba si esa nación, o cualquier nación así concebida y así consagrada, puede perdurar mucho tiempo. Nos hemos reunido en un gran campo de batalla de esa guerra. Hemos venido a dedicar una parte de ese campo como último lugar de reposo de quienes dieron aquí la vida para que esa nación pudiera vivir. Es del todo justo y apropiado que lo hagamos. Pero, en un sentido más amplio, no podemos dedicar —no podemos consagrar, no podemos santificar— este suelo. Los valientes, vivos y muertos, que lucharon aquí ya lo han consagrado, muy por encima de nuestro pobre poder de añadir o quitar. El mundo apenas reparará en lo que aquí decimos, ni lo recordará por mucho tiempo, pero nunca podrá olvidar lo que ellos hicieron aquí. Somos más bien nosotros, los vivos, quienes debemos dedicarnos aquí a la obra inconclusa que los que aquí lucharon han hecho avanzar tan noblemente. Somos más bien nosotros quienes debemos dedicarnos aquí a la gran tarea que aún tenemos por delante: que de estos muertos venerados tomemos una devoción mayor por la causa a la que ellos dieron la última y plena medida de su devoción; que resolvamos aquí firmemente que estos muertos no habrán muerto en vano; que esta nación, bajo Dios, tenga un nuevo nacimiento de la libertad, y que el gobierno del pueblo, por el pueblo y para el pueblo no desaparezca de la tierra. :::paragraphs{style="signature"} *Abraham Lincoln. 19 de noviembre de 1863.* ::: ::: :::paragraphs{style="credit"} Discurso de Lincoln del 19 de noviembre de 1863 en la inauguración del Cementerio Nacional de los Soldados, en Gettysburg. Traducción, hecha para esta prueba, de la copia Bliss, la única que firmó. ::: 11. Lee la fuente A y responde en las líneas de la página 4. 1. Lincoln empieza con las palabras «Hace ochenta y siete años». 1. ¿A qué año se remonta? :chip[0,5 puntos]{style="marks"} 2. ¿Por qué no cuenta desde 1787, el año de la Constitución? :chip[1 punto]{style="marks"} 2. La guerra pone a prueba «si esa nación […] puede perdurar». Describe dos éxitos de la Unión entre enero y noviembre de 1863. :chip[1,5 puntos]{style="marks"} 3. «El discurso honra a los muertos y dice poco del futuro de la nación». ¿Estás de acuerdo? Usa la fuente A y tus conocimientos. :chip[2 puntos]{style="marks"} :::pagebreak Escribe en estas líneas tus respuestas a la pregunta 11. ::resource{id="lines-ai"} ::resource{id="lines-aii"} ::resource{id="lines-b"} ::resource{id="lines-c"} :::space :::paragraphs{style="end"} **FIN DE LAS PREGUNTAS** Fuente A: discurso de Gettysburg, copia Bliss, de dominio público. Traducción y preguntas escritas para esta prueba, CC BY 4.0. Compuesto en PT Serif e Inter Tight, SIL Open Font License. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the outlined year // The digits are Inter Tight 800 outlines (SIL OFL), 102.4 units to the em, cap height 74.5, // on a baseline at 0: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts). const YEAR_OUTLINE = 'M38.2-74.5V0H20.2V-57.7H19.8L3.1-47.5V-63.1L21.5-74.5ZM75.8 1Q67.1 1 60.3-1.8' + 'Q53.6-4.5 49.7-9.3Q45.8-14.1 45.8-20.1Q45.8-24.8 48-28.6Q50.2-32.5 54-35' + 'Q57.8-37.6 62.5-38.4V-38.9Q56.4-40.1 52.4-44.7Q48.5-49.2 48.5-55.4Q48.5-61.2 52-65.7' + 'Q55.6-70.2 61.8-72.9Q68-75.5 75.8-75.5Q83.7-75.5 89.9-72.9Q96.1-70.2 99.7-65.7' + 'Q103.2-61.2 103.2-55.4Q103.2-49.2 99.2-44.6Q95.2-40.1 89.2-38.9V-38.4' + 'Q93.8-37.6 97.6-35Q101.4-32.5 103.7-28.6Q105.9-24.8 105.9-20.1Q105.9-14.1 102-9.3' + 'Q98.2-4.5 91.4-1.8Q84.6 1 75.8 1ZM75.8-11.7Q79.2-11.7 81.7-13Q84.2-14.2 85.6-16.5' + 'Q87-18.8 87-21.7Q87-24.5 85.6-26.8Q84.1-29 81.6-30.3Q79.1-31.6 75.8-31.6' + 'Q72.6-31.6 70.1-30.3Q67.6-29 66.1-26.8Q64.7-24.6 64.7-21.7Q64.7-18.8 66.1-16.5' + 'Q67.5-14.3 70-13Q72.6-11.7 75.8-11.7ZM75.8-44.3Q78.7-44.3 80.9-45.5' + 'Q83.1-46.6 84.3-48.7Q85.6-50.8 85.6-53.4Q85.6-56 84.3-58Q83.1-60 80.9-61.1' + 'Q78.7-62.2 75.8-62.2Q73-62.2 70.8-61.1Q68.6-60 67.3-58Q66.1-56 66.1-53.4' + 'Q66.1-50.8 67.3-48.7Q68.6-46.7 70.8-45.5Q73-44.3 75.8-44.3ZM143.7 1Q137.6 1 132-1' + 'Q126.4-3 122.1-7.3Q117.7-11.7 115.2-18.7Q112.7-25.8 112.7-36Q112.7-45.2 114.9-52.5' + 'Q117.1-59.8 121.2-65Q125.4-70.1 131.1-72.8Q136.9-75.5 144-75.5Q151.9-75.5 157.8-72.5' + 'Q163.8-69.4 167.4-64.3Q171-59.2 171.7-53H154Q153.2-56.5 150.5-58.3Q147.8-60.2 144-60.2' + 'Q137.2-60.2 133.8-54.2Q130.5-48.4 130.5-38.4H130.9Q132.5-41.8 135.3-44.2' + 'Q138.2-46.6 141.9-47.8Q145.7-49.1 149.8-49.1Q156.5-49.1 161.6-46Q166.7-43 169.6-37.6' + 'Q172.5-32.2 172.5-25.3Q172.5-17.5 168.9-11.6Q165.2-5.7 158.7-2.3Q152.2 1 143.7 1Z' + 'M143.6-12.9Q146.9-12.9 149.5-14.4Q152.1-16 153.6-18.7Q155.1-21.4 155.1-24.7' + 'Q155.1-28.1 153.6-30.8Q152.1-33.5 149.5-35Q147-36.6 143.6-36.6Q140.4-36.6 137.7-35' + 'Q135.1-33.4 133.6-30.7Q132.1-28.1 132.1-24.7Q132.1-21.4 133.6-18.7Q135.1-16 137.7-14.4' + 'Q140.3-12.9 143.6-12.9ZM208.3 1Q199.8 1 193.2-1.9Q186.6-4.8 182.8-10' + 'Q179.1-15.2 179-21.9H197.1Q197.2-19.5 198.6-17.6Q200.1-15.7 202.7-14.7' + 'Q205.2-13.7 208.4-13.7Q211.6-13.7 214-14.8Q216.5-15.9 217.8-17.9Q219.2-19.9 219.2-22.5' + 'Q219.2-25.2 217.7-27.2Q216.2-29.2 213.4-30.4Q210.7-31.5 206.9-31.5H199.6V-44.3H206.9' + 'Q210.2-44.3 212.7-45.4Q215.2-46.5 216.6-48.5Q218-50.5 218-53Q218-55.6 216.8-57.4' + 'Q215.6-59.3 213.5-60.4Q211.3-61.5 208.4-61.5Q205.4-61.5 203-60.4Q200.6-59.3 199.2-57.4' + 'Q197.8-55.5 197.7-53H180.5Q180.5-59.6 184.2-64.7Q187.8-69.8 194.1-72.6' + 'Q200.4-75.5 208.5-75.5Q216.5-75.5 222.5-72.7Q228.6-69.9 232-65.1Q235.4-60.2 235.4-54.2' + 'Q235.4-48 231.3-43.9Q227.2-39.8 220.7-38.8V-38.2Q229.3-37.2 233.7-32.6' + 'Q238.1-28.1 238.1-21.2Q238.1-14.7 234.3-9.7Q230.5-4.7 223.7-1.8Q217 1 208.3 1Z'; function year() { // 10 units to the millimetre const [w, h] = [PAGE.w * 10, (YEAR.cap - YEAR.cut + YEAR.pad) * 10]; const s = (YEAR.cap * 10) / 74.5; // scale: cap height to YEAR.cap const x = LEFT * 10 - 3.1 * s; // the 1's flag starts 3.1 units in: line it up with the text return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ` + `${h}"><path transform="translate(${x.toFixed(1)} ${(YEAR.cap + YEAR.pad) * 10}) ` + `scale(${s.toFixed(4)})" d="${YEAR_OUTLINE}" fill="none" stroke="${palette.paper}" ` + `stroke-width="${(8.5 / s).toFixed(3)}" stroke-linejoin="round"/></svg>`; // 0.85 mm } // typeId 'form' too: the drawing is placed by the cover's design, never by ::resource, and a // type with no captionPrefix spares it a figure number. const picture = { id: 'year', typeId: 'form', kind: 'svg', altText: '1863', createdAt: 0, updatedAt: 0, svg: { fileId: 'year.svg', width: PAGE.w * 10, height: (YEAR.cap - YEAR.cut + YEAR.pad) * 10 } }; // in the drawing's own units // #endregion const resources = [picture, candidate, grid, answerLines('lines-ai', '11 a) i)', 1), answerLines('lines-aii', '11 a) ii)', 2), answerLines('lines-b', '11 b)', 4), answerLines('lines-c', '11 c)', 8)]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'PT Serif': ['400', '400i', '700', '700i'], // (gotcha: fonts-first) 'Inter Tight': ['400', '600', '700', '800'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), loadSvg('year.svg', year())]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'History Paper 2', es: 'Historia, prueba 2' }) });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
#Imprimeix-lo en A4
La banda, l'any, el filet del marge i l'avís prenen les mides de PAGE, així que passar a A4 és canviar una línia. Els blocs de la portada no es mouen i el blanc sota la plantilla creix 17,6 mm en les dues edicions.
-const [TEXT, LABEL, PAGE] = ['PT Serif', 'Inter Tight', { w: 215.9, h: 279.4 }]; // US Letter
+const [TEXT, LABEL, PAGE] = ['PT Serif', 'Inter Tight', { w: 210, h: 297 }]; // A4#Afegeix un banc de paraules o una llista de comprovació
Els bancs de paraules, els buits i els cercles per pintar també són xips: la fitxa amb caixes de resposta i banc de paraules compon un banc de xips grocs dins d'un requadre niat i una llista de comprovació que obre cada línia amb un cercle buit.
Errors freqüents
Error freqüent
Llistes 'arabic', recursos 'roman-upper', pàgines 'upper-roman'
Cada opció de numeració escriu els formats a la seva manera: les llistes fan servir numberFormat 'arabic' ('decimal' imprimeix «undefined»), els tipus de recurs counterFormat 'roman-upper' i les pàgines i :::numbering 'upper-roman'. Llistes numerades →
Error freqüent
Una línia en blanc dins d'una cel·la desapareix
Un salt de línia dins d'una cel·la comença un paràgraf nou, però postext 1.4.1 descarta el paràgraf buit o que només porta espais de no separació (U+00A0), així que una cel·la escrita 'NOM\n\n' ocupa una sola línia. Per deixar línies on escriure, posa un unidor de paraules (U+2060) a cada línia en blanc: 'NOM\n\u2060\n\u2060' n'ocupa tres. Taules a partir de dades →
Error freqüent
Una taula 'here' no es parteix mai
Només es parteixen entre columnes i pàgines les taules flotants; una taula col·locada 'here' es mou sencera. Deixa flotar les taules llargues o mantén curtes les taules en línia. Taules que passen de pàgina →
Error freqüent
En el text en bandera no s'eviten les línies curtes
optimalLineBreaking, avoidRunts, runtPenalty i runtMinCharacters actuen sobre l'algorisme de Knuth–Plass, que postext 1.4.1 només aplica al text justificat. Un paràgraf en bandera es talla línia a línia i pot acabar en una sola paraula curta, diguin el que diguin aquestes opcions. Revisa les últimes línies del text en bandera i reescriu el paràgraf que acabi en una línia curta. Vídues, òrfenes i línies curtes →
Error freqüent
Un $ sol obre matemàtiques: escriu \$
El signe de dòlar obre matemàtiques en línia, de manera que un preu com $40 comença una fórmula. Escriu \$40. Escapaments i caràcters literals →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
El text dins d'un SVG <img> no pot fer servir fonts web
Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →
Error freqüent
Una configuració es desa a la memòria cau per identitat: crea un objecte nou
El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →
Error freqüent
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
- Posar ratlles entre els apartats d'una pregunta en parteix la llista: la llista que segueix la taula comença un tram nou, i cada tram alinea els seus números amb el més ample dels seus, de manera que el text de ii) començaria 1 mm a la dreta del d'i). Aquí les respostes van en una pàgina pròpia i tots els apartats queden en una sola llista.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- The Gettysburg Address (19 November 1863), text of the Bliss copy · Abraham Lincoln · domini públic
- The questions, the instructions and the Spanish translation of Source A · Ignacio Ferro · CC BY 4.0
- Fonts
- PT Serif (SIL OFL 1.1) · Inter Tight (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)


