成品一览
这是一场美国内战历史模拟考试的第二份试卷,排成一本四页的US Letter小册子。封面是一条深红色色带,上面用轮廓线画出年份1863,下方是考生信息框、分两栏排的须知,以及Section A的带字母涂卡网格。第2页是十道选择题,每题后面跟着四个选项,每个选项都以一个与网格中相同的带字母圆圈开头。第3页把Gettysburg Address作为Source A排出,关于它的题目按11、a)、i)编号,每个小题的分值放在一个标签里。第4页画好了答题线,每两行文字一条横线,每组横线以对应小题的题号开头。封面之后的每一页都有页码和一条供阅卷人使用的边栏线,其中唯一的右页印着Turn over。
这道食谱解答
- 怎样做练习页:填空横线、答题框、词库、清单?
- 怎样定制列表:每级的项目符号、(a)/(i)编号、任务复选框、保持在网格上的间距?
- 怎样做行内标签:键盘按键、标签、练习用的词库?
- 怎样在一份文档里给几张表设不同的样式(填色、斑马纹单元格、圆角边框)?
- 怎样在章首页和空白页上隐藏书眉,或者把空白左页涂成篇的颜色?
简短回答
// 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: '' }]) });
用料
做法
#1 · 给题目编号,让选项悬挂排列
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) }] });
orderedLists的每一级各自选择numberFormat和separator,separatorColor把句点和括号印成强调色,数字本身仍为墨色(有序列表)。列表的编号按同一组中最宽的编号右对齐,所以每道题的文字都从23 pt处开始:“10.”的17 pt加上GAP。四个选项是一个嵌套的无序列表项,其bulletChar为空。它的文字从该级的indent开始,这个值是用编号字体测得的“10.”宽度加上GAP,所以无论用什么字体,选项都与题目的第一个字母对齐。间距取决于它前面的项:编号项之后为5 pt,选项行之后为15 pt。
#2 · 用行内标签做涂卡圆圈和分值
// 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) })];
行内标签是行内的一个框,行永远不会在标签中间断开(行内标签)。里面放一个大写字母时,paddingX: em(0.3)让框的宽度与高度大致相同:网格中宽4.5到4.8 mm,高4.7 mm。borderRadius: em(1)超过框的一半,而标签的圆角半径会被限制为较短边的一半,所以两端完全是圆的。没有style的标签采用chipStyles的第一项,所以八十个圆圈(网格中四十个,选项中四十个)都是像:chip[A]这样不带样式的标签;只有须知中已涂满的示例和分值指定了样式。Postext 1.4.1没有制表位,分值无法右对齐;每个标签放在小题末尾,与最后一个词至少隔0.5 em(gap)。
#3 · 每种表格用各自的表格样式
// 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
考生信息框、涂卡网格和答题线是三种tableStyles,每张表用styleId选取(命名表格样式)。简短回答中的答题线使用rules: 'horizontal',内边距让每行高两行正文,所以每条横线都比上一条低30 pt(10.6 mm),落在正文15 pt网格的每隔一行上。Postext 1.4.1会丢掉空的单元格行或只含不换行空格的单元格行,所以MARK单元格中间的空行放了一个U+2060词连接符:这个单元格高三行,使整行为14.4 mm,用不换行空格时则是10.8 mm。网格用填充色的浅色描边,因为Canvas会在相邻的填充单元格之间留下淡淡的缝隙。它的标题是一个题注,排在网格上方1.6 mm处(题注样式)。如果用标题,至少会高出一行正文(5.3 mm),因为每张放在“here”的表格上方都要留出这么多空间。form类型的captionPrefix为空,所以标题前不会出现“Form 1.”这样的标签。
#4 · 用标题属性画出封面
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))] } } };
封面是一个标题# History {style="cover" …},它的样式画出一条色带、年份和四行文字,文字取自标题属性。设span: 'page'后,这一页算作章首页,色带可以画到22 mm的上页边距之上。没有它,版面设计会在栏顶被裁掉,色带从页边距处开始,场次说明也就丢了。样式的margins把封面的栏加宽到整整168 mm,用来放各种表格;下一个标题是不带样式的一级标题,使正文页回到134 mm的行长。
#5 · 封面上不印页码和Turn over
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' })] };
正文页上每个重复元素都带pages: 'body',所以封面作为章首页只显示它自己的提示(pages: 'opener')。因此各部分都以:::pagebreak加一个不带breakBefore的标题开始。一个带分页的标题会让它所在的页成为章首页,第2页和第3页就会失去页码、书眉和边栏线。parity: 'odd'让Turn over只出现在右页上,因为左页本来就对着下一页。
完整食谱
// ═══ 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 = '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 // 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`---Markdown样例 · 100行 · 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? :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. :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. :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. :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 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' }) });工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行
// ─── 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#印在A4上
色带、年份、边栏线和提示的尺寸都取自PAGE,所以改成A4只需改一行。封面上各块位置不变,两个语言版本中网格下方的空白都增加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#加一个词库或核对清单
词库、填空和要涂色的圆圈也都是行内标签:带答题框和词库的练习纸在一个嵌套框里排出一组黄色标签作为词库,还有一张每行以空心圆开头的核对清单。
常见问题
易错点
列表写'arabic',资源写'roman-upper',页码写'upper-roman'
每种编号设置对格式的拼写都不同:列表的numberFormat用'arabic'(写'decimal'会打印出"undefined"),资源类型的counterFormat用'roman-upper',页码和:::numbering用'upper-roman'。 编号列表 →
易错点
表格单元格中的空行会被丢弃
表格单元格中的换行会开始一个新段落,但postext 1.4.1会丢弃空段落和只含不换行空格(U+00A0)的段落,所以写成'NAME\n\n'的单元格只有一行高。要留出填写用的空行,在每个空行上放一个词连接符(U+2060):'NAME\n\u2060\n\u2060'有三行高。 由数据生成的表 →
易错点
齐左文字从不检查孤字
optimalLineBreaking、avoidRunts、runtPenalty和runtMinCharacters作用于Knuth–Plass断行器,而postext 1.4.1只对两端对齐的文字运行它。齐左段落逐行断行,无论这些设置怎么写,都可能以一个短词结尾。检查齐左文字的末行,改写以孤字结尾的段落。 段末孤行、段首孤行与孤字 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
SVG <img>中的文字不能使用网络字体
SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 如果在一道题的各小题之间插入答题线,就会把它的列表拆开:表格之后的列表另起一组,而每组按自己最宽的编号右对齐,于是ii)的文字会比i)的文字向右偏1 mm。这里答题线单独放在一页上,所有小题都留在同一个列表里。
致谢
- 文本
- The Gettysburg Address (19 November 1863), text of the Bliss copy · Abraham Lincoln · 公有领域
- The questions, the instructions and the Spanish translation of Source A · Ignacio Ferro · CC BY 4.0
- 字体
- PT Serif (SIL OFL 1.1) · Inter Tight (SIL OFL 1.1)


