Kurz gesagt
Eine vierseitige Geschichtsprüfung mit Deckblatt, Antwortkreisen und linierten Seiten zum Schreiben. Sie zeigt, wie man Fragen und Teilfragen nummeriert und die Punkte für jede angibt.
Was Sie setzen
Teil 2 einer Probeprüfung in Geschichte zum Amerikanischen Bürgerkrieg, gesetzt als vierseitiges Heft auf US Letter. Das Deckblatt ist ein karminrotes Band mit der Jahreszahl 1863 als Kontur, über den Feldern für die Prüflinge, den Anweisungen in zwei Spalten und einem Raster aus Buchstabenkreisen für Section A. Seite 2 enthält zehn Multiple-Choice-Fragen, jede gefolgt von ihren vier Antworten, und jede Antwort beginnt mit einem Buchstabenkreis wie im Raster. Seite 3 setzt die Gettysburg Address als „Source A“ und nummeriert die Fragen dazu mit 11, a), i), die Punkte für jeden Teil in einem Chip am rechten Rand. Seite 4 ist für die Antworten liniert, eine Linie alle zwei Textzeilen, und jede Liniengruppe beginnt mit der Nummer ihres Teils. Jede Seite nach dem Deckblatt trägt eine Pagina und einen linierten Rand für die Prüfenden, und die einzige rechte Seite darunter sagt „Turn over“.
Dieses Rezept beantwortet
- Wie baue ich eine Arbeitsblattseite: Schreiblinien, Antwortkästen, Wortspeicher, Checklisten?
- Wie passe ich Listen an: Aufzählungszeichen je Ebene, Nummerierung (a)/(i), Aufgaben-Kontrollkästchen, Abstände, die im Raster bleiben?
- Wie erstelle ich Inline-Chips: Tastaturtasten, Schlagwörter, Wortspeicher für Übungen?
- Wie gestalte ich mehrere Tabellen in einem Dokument unterschiedlich (Füllungen, Zebrastreifen, abgerundete Rahmen)?
- Wie blende ich Kolumnentitel auf Auftakten und Vakatseiten aus oder färbe eine leere linke Seite in der Teilfarbe?
Die kurze Antwort
// 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: '' }]) });
Zutaten
- Funktionen
- Nummerierte ListenInline-ChipsTabulatoren und FüllzeichenBenannte TabellenformateTabellen aus DatenEigene RessourcentypenAbbildungen genau hierZellfüllungenFormat der BildunterschriftSeiten- und SpaltenumbrücheKopfzeilen nach SeitenrolleKolumnentitel und SeitenzahlenÜberschriftenformateAbschnittsgeometrieGestaltete AuftakteÜberschriftenattributeBilder in SeitengestaltungenKästenSpalten in einem Kasten oder im TextAbsatzformate
- Schrift
- PT Serif, Inter Tight (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Fragen nummerieren und ihre Antworten einhängen
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), numberWidth: 'level', // 5 pt to the options or next part; text after '10.'
levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic' and '.', the defaults
{ level: 2, numberFormat: 'lower-alpha', separator: ')' },
{ level: 3, numberFormat: 'lower-roman', separator: ')' }] };
// Options: a bulletless item at the questions' depth, '10.' plus GAP in (boxes set their own).
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
indent: pt(width('10') + width('.') + GAP), levels: [{ level: 1, bulletChar: '' }] });
Jede Ebene von orderedLists wählt ihr eigenes numberFormat und ihren separator, und separatorColor färbt Punkt und Klammern in der Akzentfarbe, während die Nummer schwarz bleibt (nummerierte Listen). Mit numberWidth: 'level' werden die Nummern an der breitesten ihrer Tiefe rechtsbündig ausgerichtet, sodass der Text jeder Frage 23 pt eingerückt beginnt: die 17 pt von „10.“ plus GAP. Die vier Antworten sind ein Aufzählungspunkt mit leerem bulletChar, auf derselben Tiefe wie die Fragen statt darunter verschachtelt. Sein Text beginnt beim indent der Liste, also „10.“ in der Schrift der Nummern gemessen plus GAP, sodass die Antworten in jeder Schrift unter dem ersten Buchstaben der Frage stehen; der Kasten mit den Anweisungen setzt indent: pt(0), weil die Listen in einem Kasten den Einzug des Dokuments übernehmen. Zwischen zwei Einträgen derselben Tiefe gilt das itemSpacing des ersten: 5 pt nach einem nummerierten Eintrag, 15 pt nach einer Zeile mit Antworten. Unter ihrer Frage verschachtelt, bekäme die Antwortzeile auf beiden Seiten die 5 pt der Fragenliste.
#2 · Kreise und Punkte aus Chips bauen
// 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) })];
Ein Chip ist ein Kasten in der Zeile, und ein Zeilenumbruch fällt nie hinein (Inline-Chips). Mit einem Versal darin macht paddingX: em(0.3) den Kasten etwa so breit wie hoch: 4,5 bis 4,8 mm breit im Raster bei 4,7 mm Höhe. borderRadius: em(1) ist mehr als die Hälfte des Kastens, und der Radius eines Chips wird auf die Hälfte seiner kürzeren Seite begrenzt, sodass die Enden ganz rund sind. Ein Chip ohne style nimmt den ersten Eintrag von chipStyles, daher sind die achtzig Kreise, vierzig im Raster und vierzig bei den Antworten, schlichte Chips wie :chip[A]; nur das ausgefüllte Beispiel in den Anweisungen und die Punkte nennen einen Stil. Die Punkte stehen rechtsbündig: Jeder Teil endet mit :tab vor seinem Chip, und der einzige Tabstopp des Textes, am Ende der Satzbreite mit align: 'end', lässt den Chip dort enden (Tabulatoren und Tabstopps). Die Listeneinträge der Fragen übernehmen die Tabstopps des Textes, sodass die Chips von a) und von i) an derselben Stelle enden. Ein Teil, dessen letzte Zeile keinen Platz mehr für seinen Chip hat, nimmt sein letztes Wort mit in die nächste Zeile.
#3 · Jedem Formular einen eigenen Tabellenstil geben
// 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) },
// No rules: the tint of each cell meets its neighbours' and the grid reads as one panel.
{ ...form, id: 'grid', rules: 'none', 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 no-break space.
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\u00A0\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
Die Felder für die Prüflinge, das Kreisraster und die Antwortlinien sind drei tableStyles, pro Tabelle mit styleId gewählt (benannte Tabellenstile). Die Antwortlinien unter Die kurze Antwort verwenden rules: 'horizontal' und einen Innenabstand, der jede Zeile zwei Textzeilen tief macht, sodass jede Linie 30 pt (10,6 mm) unter der vorigen liegt, auf jeder zweiten Zeile des 15-pt-Rasters des Textes. Die leere mittlere Zeile der Zelle MARK enthält ein geschütztes Leerzeichen, denn eine Zellenzeile, die leer ist oder nur gewöhnliche Leerzeichen enthält, trägt nichts bei: Diese Zelle ist drei Zeilen tief und setzt die ganze Zeile auf 14,4 mm, gegenüber 10,8 mm mit zwei Zeilen. Das Raster zeichnet keine Linien, sodass der Farbton jeder Zelle an den der Nachbarzelle stößt und das Raster als eine Fläche wirkt. Sein Titel ist eine Legende 1,6 mm darüber (Legendenstil). Eine Überschrift stünde mindestens eine Textzeile (5,3 mm) höher, weil jede „here“ platzierte Tabelle so viel Raum über sich hält. Der Typ form hat ein leeres captionPrefix, sodass kein Etikett „Form 1.“ vor dem Titel steht.
#4 · Das Deckblatt aus den Attributen der Überschrift zeichnen
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' makes the cover an opener page, which the body pages' furniture skips.
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))] } } };
Das Deckblatt ist eine Überschrift, # History {style="cover" …}, und ihr Stil zeichnet ein Band, die Jahreszahl und vier Textzeilen, gefüllt aus den Attributen der Überschrift. Mit span: 'page' zählt die Seite als Auftakt, sodass Randlinie, Pagina und „Turn over“ der Textseiten draußen bleiben und der Hinweis an ihrem Fuß gedruckt wird. Ohne die Einstellung ist das Deckblatt eine Textseite wie die übrigen. Die margins des Stils verbreitern die Spalte des Deckblatts für die Formulare auf die vollen 168 mm, und die nächste Überschrift, eine Ebene 1 ohne Stil, bringt die Textseiten auf die Satzbreite von 134 mm zurück.
#5 · Pagina und „Turn over“ vom Deckblatt fernhalten
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' })] };
Jedes laufende Element der Textseiten trägt pages: 'body', sodass das Deckblatt, ein Auftakt, nur seinen eigenen Hinweis zeigt (pages: 'opener'). Die Abschnitte beginnen deshalb mit :::pagebreak und einer Überschrift ohne breakBefore. Eine Überschrift, die die Seite umbricht, macht ihre Seite zum Auftakt, und die Seiten 2 und 3 verlören Pagina, Kolumnentitel und Rand. parity: 'odd' hält „Turn over“ auf der rechten Seite, da eine linke Seite der folgenden ohnehin gegenübersteht.
Das ganze Rezept
// ═══ 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.25.0 import { buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, inlineSvgFonts, } from 'https://esm.sh/postext'; const LANG = 'en'; // @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 // 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 }); // 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), numberWidth: 'level', // 5 pt to the options or next part; text after '10.' levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic' and '.', the defaults { level: 2, numberFormat: 'lower-alpha', separator: ')' }, { level: 3, numberFormat: 'lower-roman', separator: ')' }] }; // Options: a bulletless item at the questions' depth, '10.' plus GAP in (boxes set their own). 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 indent: pt(width('10') + width('.') + GAP), levels: [{ level: 1, bulletChar: '' }] }); // #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) }, // No rules: the tint of each cell meets its neighbours' and the grid reads as one panel. { ...form, id: 'grid', rules: 'none', 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 no-break space. 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\u00A0\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' makes the cover an opener page, which the body pages' furniture skips. 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 = () => ({ locale: t({ en: 'en-us', es: 'es' }), // the language of the hyphenation patterns 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, tabStops: [{ position: 'end', align: 'end' }] }, // 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), indent: pt(0), 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`---Markdown-Beispiel · 100 Zeilen · content.en.md
title: "History Paper 2" subtitle: "The American Civil War, 1863" --- # History {style="cover" session="Mock examinations · Spring 2026" paper="Paper 2 · Sources and interpretations" topic="The American Civil War, 1863" date="Thursday 14 May 2026 · Morning · Time allowed: 1 hour 15 minutes"} ::resource{id="candidate"} :::callout{type="rubric"} :::columns{count=2 breaks="6"} **Instructions** - Use black ink. Use a pencil for Section A. - Answer every question. - Section A: shade one bubble for each question, like this: :chip[C]{style="filled"}. Rub out any mark you change. - Section B: write on the lines on page 4. **Information** - The number of marks is shown at the end of each part, like this: :chip[4 marks]{style="marks"} - Section A: 10 marks. Section B: 15 marks. - Spend about 45 minutes on Section B. ::: ::: ::resource{id="grid"} :::pagebreak # Multiple choice {section="Section A" marks="10 marks"} Shade one bubble for each question in the grid on page 1: 1. The Emancipation Proclamation took effect on 1 January 1863. Where did it declare enslaved people free? - :chip[A] every state :chip[B] border states :chip[C] rebel states :chip[D] western states 2. Who commanded the Union army at the Battle of Gettysburg? - :chip[A] U. S. Grant :chip[B] G. G. Meade :chip[C] G. B. McClellan :chip[D] W. T. Sherman 3. Which Confederate stronghold surrendered on 4 July 1863? - :chip[A] New Orleans :chip[B] Vicksburg :chip[C] Memphis :chip[D] Baton Rouge 4. How many years are “four score and seven”? - :chip[A] 47 :chip[B] 67 :chip[C] 87 :chip[D] 107 5. Who gave the two-hour main oration at the dedication of the cemetery at Gettysburg? - :chip[A] E. Everett :chip[B] F. Douglass :chip[C] W. H. Seward :chip[D] J. Hay 6. Under the Enrollment Act of March 1863, what could a drafted man pay to be excused from service? - :chip[A] \$100 :chip[B] \$300 :chip[C] \$500 :chip[D] \$1,000 7. Which regiment of Black soldiers led the assault on Fort Wagner, South Carolina, in July 1863? - :chip[A] 20th Maine :chip[B] 9th Ohio :chip[C] 2nd Iowa :chip[D] 54th Massachusetts 8. Which state joined the Union on 20 June 1863? - :chip[A] Nevada :chip[B] West Virginia :chip[C] Kansas :chip[D] Nebraska 9. At which battle in May 1863 was General Thomas “Stonewall” Jackson mortally wounded? - :chip[A] Chancellorsville :chip[B] Antietam :chip[C] Fredericksburg :chip[D] Shiloh 10. Which city saw four days of riots against the draft in July 1863? - :chip[A] Boston :chip[B] Philadelphia :chip[C] New York :chip[D] Chicago :::pagebreak # Source-based questions {section="Section B" marks="15 marks"} :::callout{type="source" title="Source A"} Four score and seven years ago our fathers brought forth, on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal. Now we are engaged in a great civil war, testing whether that nation, or any nation so conceived and so dedicated, can long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that nation might live. It is altogether fitting and proper that we should do this. But, in a larger sense, we can not dedicate—we can not consecrate—we can not hallow—this ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poor power to add or detract. The world will little note, nor long remember what we say here, but it can never forget what they did here. It is for us the living, rather, to be dedicated here to the unfinished work which they who fought here have thus far so nobly advanced. It is rather for us to be here dedicated to the great task remaining before us—that from these honored dead we take increased devotion to that cause for which they gave the last full measure of devotion—that we here highly resolve that these dead shall not have died in vain—that this nation, under God, shall have a new birth of freedom—and that government of the people, by the people, for the people, shall not perish from the earth. :::paragraphs{style="signature"} *Abraham Lincoln. November 19, 1863.* ::: ::: :::paragraphs{style="credit"} Lincoln’s address at the dedication of the Soldiers’ National Cemetery, Gettysburg, 19 November 1863; the text of the Bliss copy, the last of five in his hand and the only one he signed. ::: 11. Study Source A, then answer every part on the lines on page 4. 1. Lincoln counts back “four score and seven years” in his first line. 1. Which year is he counting back to? :tab :chip[1 mark]{style="marks"} 2. Explain why Lincoln dated the nation’s birth from that year, and not from 1787, when the Constitution was written. :tab :chip[2 marks]{style="marks"} 2. Lincoln says the war is “testing whether that nation … can long endure”. Describe two ways in which the Union was stronger in November 1863 than in January. :tab :chip[4 marks]{style="marks"} 3. “The address honours the dead but says little about the future of the nation.” How far do you agree? Explain your answer with Source A and your own knowledge. :tab :chip[8 marks]{style="marks"} :::pagebreak Write your answers to Question 11 on these lines. ::resource{id="lines-ai"} ::resource{id="lines-aii"} ::resource{id="lines-b"} ::resource{id="lines-c"} :::space :::paragraphs{style="end"} **END OF QUESTIONS** Source A: the Gettysburg Address, Bliss copy, in the public domain. Questions written for this paper, CC BY 4.0. Set in PT Serif and Inter Tight, SIL Open Font License. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the outlined year // The year is SVG text in Inter Tight 800, stroked and not filled: loadSvg embeds the face the // text names (inlineSvgFonts). The capitals are 0.7275 em tall, and the 1's flag starts 0.03 em in. function year() { // 10 units to the millimetre const [w, h] = [PAGE.w * 10, (YEAR.cap - YEAR.cut + YEAR.pad) * 10]; const size = (YEAR.cap * 10) / 0.7275; // font size: cap height to YEAR.cap const x = LEFT * 10 - 0.0303 * size; // line the 1's flag up with the text return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ` + `${h}"><text x="${x.toFixed(1)}" y="${(YEAR.cap + YEAR.pad) * 10}" font-family="${LABEL}" ` + `font-weight="800" font-size="${size.toFixed(1)}" fill="none" stroke="${palette.paper}" ` + 'stroke-width="8.5" stroke-linejoin="round">1863</text></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'], 'Inter Tight': ['400', '600', '700', '800'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([prepareFonts(markdown, config(), kitFonts(FONTS)), loadSvg('year.svg', year())]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'History Paper 2', es: 'Historia, prueba 2' }) });Kit · core, fonts, viewer, images: in jedem Rezept gleich · 270 Zeilen
// ─── 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 · images v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Auf A4 drucken
Band, Jahreszahl, Randlinie und Hinweis nehmen ihre Maße aus PAGE, daher ist A4 eine einzeilige Änderung. Die Blöcke des Deckblatts behalten ihre Plätze, und das Weiß unter dem Raster wächst in beiden Ausgaben um 17,6 mm.
-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#Eine Wortbank oder eine Checkliste hinzufügen
Wortbänke, Lücken und auszumalende Kreise sind ebenfalls Chips: das Arbeitsblatt mit Antwortkästen und Wortspeicher setzt eine Bank gelber Chips in einen verschachtelten Kasten und eine Checkliste, die jede Zeile mit einem leeren Kreis beginnt.
Häufige Fehler
Häufiger Fehler
Ein einzelnes $ eröffnet Mathematik: \$ schreiben
Ein Dollarzeichen eröffnet Mathematik im Text, sodass ein Preis wie $40 eine Formel beginnt. Schreiben Sie \$40. Maskierung und wörtliche Zeichen →
- Antwortlinien zwischen den Teilen einer Frage teilen ihre Liste: Die Liste nach der Tabelle beginnt eine neue Folge, und eine Folge richtet ihre Nummern an ihrer eigenen breitesten aus, sodass der Text von ii) 1 mm rechts vom Text von i) begänne. Hier stehen die Antworten auf einer eigenen Seite, und jeder Teil bleibt in einer Liste.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- The Gettysburg Address (19 November 1863), text of the Bliss copy · Abraham Lincoln · gemeinfrei
- The questions, the instructions and the Spanish translation of Source A · Ignacio Ferro · CC BY 4.0
- Schriften
- PT Serif (SIL OFL 1.1) · Inter Tight (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


