成品一览
一本小型图录,用于一场小型展室展览,展出Gustave Doré《Don Quixote》(巴黎,1863)中的三幅木刻版画,页面为230 × 280 mm。封面在暖黑底上用88 pt的Libre Bodoni排出DORÉ,上方是风车版画一块带框的局部。每个条目从左页开始:深褐色的图录编号排在斜体标题旁,外侧栏是展品信息,然后是一段三行首字下沉的导语和评述。评述的第一句引用图版,图版排在对面右页的开头,居中,高221 mm,于是图版、题注和出处正好填满版心。最后一页是展品清单,每行第一格是缩略图,并列出每幅图版所在的页码。
这道食谱解答
- 怎样做艺术图录:左页放条目,对面右页放图版?
- 怎样控制图的位置:页面顶部、横跨两栏、就放在这里,还是放在边栏里?
- 怎样在表格单元格里放图片或图标?
- 怎样给章首页加作者行、提要或带首字下沉的导语?
简短回答
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
breakBefore: { enabled: true, parity: 'even' },
marginBottom: pt(LEAD), advancedDesign: entryOpener(),
});
const plateType = {
id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
defaultPlacement: { position: 'top', span: 'page', align: 'center' },
};
// Under the plate (pt): a caption and a credit line at the body's leading ratio, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const UNDER = (CAPTION.size + CAPTION.note) * (LEAD / SIZE) + CAPTION.gap + CAPTION.noteGap;
const PLATE_H = (LINES * LEAD - UNDER) * PT; // 221.1 mm: the rest of the text block
// A float is not shrunk to fit the room left on its page (gap: float-shrink); fitFiguresToPage
// sets the smaller picture flush left. A width fraction narrows the float, and 'center' centres it.
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
// The print master's pixels, about 275 dpi at this size (gotcha: bitmap-print-size).
bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH },
placement: { width: Math.min(1, ((pxW / pxH) * PLATE_H) / BLOCK_W) }, createdAt: 0, updatedAt: 0,
});
用料
- 功能
- 图的放置从右页开始的章决定图位置的引用自定义资源类型编号题注题注样式来源与出处行作为资源的图和表表格单元格中的图片设计过的章首页通栏章首色带章首页中的首字下沉页面设计中的文字、线条和框页面设计中的图片标题属性标题样式对称页边距在Canvas上绘制页面
- 类型
- Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed(SIL OFL 1.1)
- 素材
library-835.jpgvigil-835.jpgwindmills-835.jpgwindmills-detail-540.jpg- Cat. 1, Don Quixote in His Library (Part I, ch. I), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, 公有领域)
- Cat. 2, The Vigil of Arms (Part I, ch. III), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, 公有领域)
- Cat. 3, The Adventure of the Windmills (Part I, ch. VIII), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, 公有领域)
- The cover’s detail of Cat. 3: a 1,100-px square cut from the same scan and reduced to 540 px (Gustave Doré · Héliodore Pisan, 公有领域)
做法
#1 · 图版等到对面那一页
代码就是上面的简短回答。每个条目都换到偶数页开始,而top浮动体从不落在引用它的那一页,所以评述第一句引用的图版会跳过左页,排在对面右页的开头。Postext 1.4.1不会把浮动体缩小到所在页剩余的空间,所以plate()根据扫描件的宽高比,以及版心除去题注和出处后剩下的高度,算出浮动体的宽度。layout.fitFiguresToPage能把高出页面的图版缩小,但1.4.1随后会把缩小的图片齐左排在一个仍然占满页宽的浮动体里。宽度比例则会让浮动体本身变窄,align: 'center'把图版连同题注一起居中。
#2 · 条目开头来自它的标题
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt (gotcha: design-lineheight-multiple)
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
const LEAD_Y = HEAD_RULE + 5; // mm: the lead paragraph, with the tombstone beside it
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const LEAD_LINES = 5; // the longest lead: shorter ones keep the commentary on the same line
const BASE = 0.8; // 1.4.1 sets a design text's first baseline 0.8 down its line box
// em: cap heights, and Libre Bodoni's figures, which stop short of its capitals ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [{ [TEXT]: 0.673, [DISPLAY]: 0.754 }, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
- inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT[DISPLAY])) * PT;
// The initial's top on the first line's capitals, its foot on the third baseline.
const dropSize = (lines) => pt(((lines - 1) * LEADIN.lead + CAP_HEIGHT[TEXT] * LEADIN.size)
/ CAP_HEIGHT[DISPLAY]);
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
{ kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y + LEAD_LINES * LEADIN.lead * PT),
slot: { elements: [...head('Cat.', '{number}'),
text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
// Baseline on the lead's; \n breaks only with a paragraphIndent (gotcha: design-text-newline).
text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
{ lineHeight: TOMB.lead / TOMB.size, paragraphIndent: pt(0.01) }),
// Drop caps exist only in design text, so the lead is an attribute (gotcha: design-text-ragged).
text('lead', '{attr.lead}', TEXT, LEADIN.size, 'ink', at('container', 'top-left', MAIN_X,
LEAD_Y, MAIN), { lineHeight: LEADIN.lead / LEADIN.size, dropCap: { lines: 3,
fontFamily: DISPLAY, fontSize: dropSize(3), color: col('sepia'), gap: mm(1.6) } }),
] } });
每个#标题都把章、展品信息和导语作为属性带着,由章首页的设计槽位画出来。大号数字就是标题自己的{number},来自numberingTemplate: '{1}';每个条目只有一幅图版,所以它和图版的Cat.计数器同步,依次为1、2、3。在1.4.1里,首字下沉只能用在设计文本里,而设计文本不能两端对齐,所以导语是一个属性,用13.5 pt齐左排在两端对齐的评述上方。dropSize(3)设定首字的大小,使它的顶部与第一行大写字母的顶部齐平,底部落在第三条基线上。minHeight为五行的导语预留空间,所以Cat. 3的导语虽然只有四行,下面的评述仍和其他条目从同一行开始。TITLE_Y把标题排得比数字低1.2 mm,让它的大写字母与数字对齐:在Libre Bodoni里,数字高0.716 em,大写字母高0.754 em。
#3 · 声明母版,绘制较小的副本
// plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX);
// the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box.
async function scan(file) {
const res = await fetch(asset(file));
if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`);
return createImageBitmap(await res.blob());
}
async function loadPlates() {
const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan));
const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail),
'Detail of Cat. 3: the knight and his horse caught on the sail.')];
for (const [i, p] of PLATES.entries()) {
registerResourceImage(p.file, onPaper(scans[i]));
resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size
picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText));
}
return resources;
}
Postext按位图声明的像素和文档的dpi确定它的尺寸,印出的宽度绝不超过这些像素允许的范围。plate()声明每幅图版的母版,也就是缩到2,400 px高的Commons扫描件:Cat. 1为1,900 × 2,400 px,在page.dpi: 150下最宽可达322 mm。所以尺寸由宽度比例决定,高221 mm,在这个高度下2,400 px约合275 dpi。如果用默认的300 dpi,图版宽到161 mm就会停住。Canvas会把注册的任何图片画进这个框,所以示例下载assets/里的三份835 px副本,共0.97 MB,而母版是5.8 MB。如果改用2,400 px的母版绘制,天空会出现摩尔纹条带,截图里的图片也会增加约0.3 MB,超出排版食谱1.4 MB的上限。
#4 · 用标题样式做封面
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more, x = 0) => text(id, content, family,
size, color, at('page', 'top', x, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
{ kind: 'box', id: 'night', style: { backgroundColor: col('night') },
placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
{ kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
{ kind: 'image', id: 'detail', resourceId: 'cover-detail',
placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
// 1.4.1 counts the tracking after the last letter as well, so the word moves right by half.
centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
textTransform: 'uppercase', letterSpacing: pt(NAME.track) }, (NAME.track / 2) * PT),
centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
{ italic: true }),
centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
] } },
});
封面是第一个标题# Doré {style="cover" …},由一个不带页码的样式画出。它的图片元素绘制windmills-detail-540.jpg,这是一个注册为资源但没有段落引用的文件,所以没有编号,也没有题注。这个文件是从风车扫描件上截下的1,100 px见方的一块,缩到540 px,正好是它在1,000 px宽页面上的宽度。Postext 1.4.1测量设计文本时,把最后一个字母后面的字距也算进去,这会让居中的名字偏离页面中线4 pt;所以名字右移其8 pt字距的一半,回到中线上。
#5 · 展品清单单元格里的缩略图
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false,
advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
...plates.map((p, i) => [
{ content: '', image: { resourceId: `${p.id}-thumb` } },
cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
cell(String(platePage(i + 1)), { align: 'right' }),
]),
] } },
createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
单元格的image把位图资源缩放到单元格的内宽,这里是23 mm。每张缩略图都是一份单独的300 px副本,按这个尺寸声明,而不是在绘制时把835 px的图版缩小。表格没有题注文字,它的类型题注前缀为空,所以不画题注。清单样式只设置numbered: false和它的版面设计,通栏、换到左页和下边距都从第1级继承。
#6 · 条目超出一页时停下
const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length);
if (astray) { // text run past its verso, or the blank page that follows it
const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`);
kitFail(error); // the viewer's bar says why
throw error;
}
条目排到左页之外时,仍会把它的图版送到下一页。评述的剩余部分接在图版后面,再加一张空白页让下一个条目落在左页,于是后面每幅图版都比清单上给出的页码晚两页。这项检查在第一个既不是章首页、也没有图版的页面处停止构建,并在查看器的工具栏里显示该页的页码。
完整食谱
// ═══ Postext Cookbook · Nº 034 · Catalogue entries facing their plates ══════════════ // https://postext.dev/en/cookbook/catalogue-facing-plates // Code: MIT · Text: original (CC BY 4.0), Ormsby 1885 (PD) · Plates: Doré and Pisan, 1863 (PD) // Fonts: Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed (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') const RECIPE = 'catalogue-facing-plates'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1b1918', paper: '#faf7f1', night: '#2a2724', // text; the page and plates; the cover sepia: '#8a6a45', gilt: '#c9ad86', // the accent on paper (4.6:1) and on the night (6.9:1) rule: '#cfc6b8', muted: '#6c665e', // hairlines; tombstones, credit lines, folios }; // A linked colour carries its hex too: postext 1.4.1 reads the hex, not the palette, in design // slots and referenceColor (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } }, ]; const [TEXT, DISPLAY, LABEL] = ['Ibarra Real Nova', 'Libre Bodoni', 'Sofia Sans Condensed']; const PT = 25.4 / 72; // mm per point const [SIZE, LEAD, LINES] = [11.5, 16, 41]; // body pt, leading pt; 41 lines make the text block const TRIM = { w: 230, h: 280 }; // mm: a catalogue trim const [TOP, INNER, OUTER] = [24, 20, 18]; // margins, mm const BLOCK_W = TRIM.w - INNER - OUTER; // 192 mm: the text block, and the widest plate const BLOCK_H = LINES * LEAD * PT; // 231.4 mm const [SIDE_PC, GUTTER] = [34, 7]; // the outer column: 65.3 mm for numeral and tombstone; mm const SIDE = (BLOCK_W * SIDE_PC) / 100; const MAIN_X = SIDE + GUTTER; // mm from the block's outer edge to the text column const MAIN = BLOCK_W - MAIN_X; // 119.7 mm: the measure, about 71 characters // #region answer: a Plate type that floats to the head of the facing recto, sized to fill it // Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top' // float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto. const entryLevel = () => ({ level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3 // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). breakBefore: { enabled: true, parity: 'even' }, marginBottom: pt(LEAD), advancedDesign: entryOpener(), }); const plateType = { id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …' numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'top', span: 'page', align: 'center' }, }; // Under the plate (pt): a caption and a credit line at the body's leading ratio, and their gaps. const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 }; const UNDER = (CAPTION.size + CAPTION.note) * (LEAD / SIZE) + CAPTION.gap + CAPTION.noteGap; const PLATE_H = (LINES * LEAD - UNDER) * PT; // 221.1 mm: the rest of the text block // A float is not shrunk to fit the room left on its page (gap: float-shrink); fitFiguresToPage // sets the smaller picture flush left. A width fraction narrows the float, and 'center' centres it. const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({ id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText, // The print master's pixels, about 275 dpi at this size (gotcha: bitmap-print-size). bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH }, placement: { width: Math.min(1, ((pxW / pxH) * PLATE_H) / BLOCK_W) }, createdAt: 0, updatedAt: 0, }); // #endregion const MASTER_PX = { library: [1900, 2400], vigil: [1921, 2400], windmills: [1923, 2400] }; const CREDIT = 'Public domain; scan from Wikimedia Commons.'; // the artists are in the tombstone // A picture drawn only in a design slot or a table cell: registered, never cited or numbered. function picture(id, image, altText) { registerResourceImage(`${id}.jpg`, image); return { id, typeId: 'plate', kind: 'bitmap', altText, createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}.jpg`, format: 'jpeg', width: image.width, height: image.height } }; } // Design-slot shorthands; text wraps instead of ending in '…' (gotcha: overflow-ellipsis-default). const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(width !== undefined && { size: { width: width === 'fill' ? 'fill' : mm(width) } }) }); const text = (id, content, fontFamily, size, color, placement, more = {}) => ({ kind: 'text', id, content, fontFamily, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap', placement, ...more }); const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(size * 0.2) }); // #region opener: the entry's head, a numeral and tombstone beside the title and the lead const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm const TITLE = { size: 30, lineHeight: 1.08 }; // pt (gotcha: design-lineheight-multiple) const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral const LEAD_Y = HEAD_RULE + 5; // mm: the lead paragraph, with the tombstone beside it const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt const LEAD_LINES = 5; // the longest lead: shorter ones keep the commentary on the same line const BASE = 0.8; // 1.4.1 sets a design text's first baseline 0.8 down its line box // em: cap heights, and Libre Bodoni's figures, which stop short of its capitals ('1': 0.716) const [CAP_HEIGHT, FIGURE_HEIGHT] = [{ [TEXT]: 0.673, [DISPLAY]: 0.754 }, 0.716]; const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink // The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top. const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT) - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT[DISPLAY])) * PT; // The initial's top on the first line's capitals, its foot on the third baseline. const dropSize = (lines) => pt(((lines - 1) * LEADIN.lead + CAP_HEIGHT[TEXT] * LEADIN.size) / CAP_HEIGHT[DISPLAY]); // Entries open on versos, whose outer column is on the left: the side column is at x = 0. const head = (label, numeral) => [ text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)), text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia', at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }), text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X, TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }), { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'), placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') }, ]; const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y + LEAD_LINES * LEADIN.lead * PT), slot: { elements: [...head('Cat.', '{number}'), text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia', at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)), // Baseline on the lead's; \n breaks only with a paragraphIndent (gotcha: design-text-newline). text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted', at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6), { lineHeight: TOMB.lead / TOMB.size, paragraphIndent: pt(0.01) }), // Drop caps exist only in design text, so the lead is an attribute (gotcha: design-text-ragged). text('lead', '{attr.lead}', TEXT, LEADIN.size, 'ink', at('container', 'top-left', MAIN_X, LEAD_Y, MAIN), { lineHeight: LEADIN.lead / LEADIN.size, dropCap: { lines: 3, fontFamily: DISPLAY, fontSize: dropSize(3), color: col('sepia'), gap: mm(1.6) } }), ] } }); // #endregion // #region cover: a heading style on warm black, a framed detail of Cat. 3 and the name const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset const FRAME_X = (TRIM.w - FRAME.w) / 2; const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm const centred = (id, content, family, size, color, y, more, x = 0) => text(id, content, family, size, color, at('page', 'top', x, y, 'fill'), { align: 'center', ...more }); const coverStyle = () => ({ id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ // painted in this order { kind: 'box', id: 'night', style: { backgroundColor: col('night') }, placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } }, { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) }, placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad), size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } }, { kind: 'image', id: 'detail', resourceId: 'cover-detail', placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) }, centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)), // 1.4.1 counts the tracking after the last letter as well, so the word moves right by half. centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1, textTransform: 'uppercase', letterSpacing: pt(NAME.track) }, (NAME.track / 2) * PT), centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4, { italic: true }), centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)), ] } }, }); // #endregion // #region checklist: a table of the works with a thumbnail in each first cell const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it // The entries' head, label and numeral from attributes; the level gives span, break and margin. const checklistStyle = () => ({ id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } }); const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more }); const checklist = (plates) => ({ id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' }, altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.', table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [ ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) => cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })), ...plates.map((p, i) => [ { content: '', image: { resourceId: `${p.id}-thumb` } }, cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`), cell(String(platePage(i + 1)), { align: 'right' }), ]), ] } }, createdAt: 0, updatedAt: 0, }); // The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1. const platePage = (n) => 2 * n + 1; const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // #endregion const foot = (id, content, parity, edge, x, more) => text(id, content, LABEL, 7.5, 'muted', at('page', edge, x, TRIM.h - 15), { ...caps(7.5), parity, ...more }); const footer = { elements: [ // folios at the outer foot; entries (versos) add the title foot('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, { color: col('ink') }), foot('verso-title', '{title}', 'even', 'top-left', OUTER + 9), foot('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, { color: col('ink'), align: 'right' }), ] }; const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, resourceTypes: [plateType, listType], // A bitmap is never set wider than its declared pixels at this dpi: at 150 dpi a 1,900-px // scan may reach 322 mm, so the width fraction decides (at 300 dpi it stops at 161 mm). page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.h - TOP - BLOCK_H), left: mm(INNER), right: mm(OUTER), mirror: true } }, // Body text never enters the outer column: the openers draw in it and the plates span it. layout: { layoutType: 'oneAndHalf', sideColumnPercent: SIDE_PC, sideColumnRole: 'floats', sideColumnSide: 'outer', gutterWidth: mm(GUTTER) }, bodyText: { fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('sepia'), referenceBold: false, // 'Cat. 1' in the accent, regular weight firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.5, maxRuntTracking: 0, // gotcha: runt-tracking-unpainted }, headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [entryLevel()] }, headingStyles: [coverStyle(), checklistStyle()], captionStyle: { fontFamily: LABEL, fontSize: pt(CAPTION.size), gap: pt(CAPTION.gap), labelColor: col('sepia'), // the label is bold by default note: { fontSize: pt(CAPTION.note), gap: pt(CAPTION.noteGap), color: col('muted') } }, tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.8), headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(8), bodyFontFamily: TEXT, bodyFontSize: pt(9.5) }, paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header: { elements: [] }, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 43行 · content.en.md
title: "Doré: Three Plates for Don Quixote" author: "The Print Room" --- # Doré {style="cover" kicker="The Print Room · A cabinet exhibition" subtitle="Three plates for Don Quixote, 1863" foot="Wood engravings by Héliodore Pisan after Gustave Doré"} # Don Quixote \\ in His Library {chapter="Part I · Chapter I" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I" lead="Doré opens the book with its hero wide awake. Seated upright in a carved chair, the hidalgo holds up a sword in his right hand and a book in his left, while everything he has read comes loose from the page and fills the room around him."} The plate (:ref{id="library"}, opposite) sets out the sentence in which the hidalgo loses his wits. In John Ormsby’s translation his fancy grew full of ‘enchantments, quarrels, battles, challenges, wounds, wooings, loves, agonies, and all sorts of impossible nonsense’, and Doré draws the list almost item by item. Knights ride out from behind the chair, a dragon uncoils above a shield of arms, a knight no taller than a folio stands on a book beside a giant’s severed head, and a lady in chains kneels at the fore-edge of a volume lettered AMADIS. Cervantes says the hidalgo ‘sold many an acre of tillageland to buy books of chivalry to read’, and Doré scatters them about the room: open on the table under the window, fallen open at his feet, and one propped on its edge at the lower left, beside the giant’s head. Daylight comes in at the window on the left and falls on his face and on the open page. The figures crowding the rest of the room have come out of the books, down to the two knights no taller than a hand who joust on the backs of mice among the flagstones. Doré gives these phantoms the same firm outline as the man who imagines them. In wood engraving whatever the tool cuts away prints white, so Pisan had to turn each of Doré’s greys into a pattern of strokes: close parallel cuts for the heavy curtain, finer and more open ones for the light on the wall, broken curls for the smoke of figures behind the chair. Both men signed the block, Doré in script at the lower left and Pisan in capitals at the lower right. # The Vigil \\ of Arms {chapter="Part I · Chapter III" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I" lead="The innkeeper has no chapel, so Don Quixote keeps his vigil of arms in the yard. Doré makes the scene a nocturne: the armour stands on the well-head, and the bareheaded knight lifts his lance towards a full moon breaking through cloud."} The plate (:ref{id="vigil"}, opposite) follows the text closely. The innkeeper has told his guest that armour may be watched anywhere, so Don Quixote lays his on a trough beside the well and paces up and down in front of it. In Ormsby’s translation the night closes in ‘with a light from the moon so brilliant that it might vie with his that lent it’. Doré stands the armour upright on the well-head, helmet and breastplate and gauntlets, so that it reads as a second knight facing the thin man in his shirt who will wear it. On the right the inn is a dark mass with an outside stair, and a figure watches from its landing: in the novel the guests ‘flocked to see it from a distance’. Water lies in the ruts of the yard and catches the light, and the knight’s shadow runs across it towards the viewer. Cervantes plays the vigil as farce. Two carriers come in turn to water their mules, each goes to move the armour off the trough, and each is laid out with the lance; the landlord then hurries the dubbing through before anyone else is hurt. Doré leaves all of that out and keeps the hour before it, when the knight is alone with his arms and the moon. In this sky Pisan widens his level cuts as they near the moon, until the black left between them thins to hairlines around the halo. Short flicks pick out the edges of the clouds. A dark bar of cloud crosses the disc on the left, and a few faint strokes shade the moon’s face. # The Adventure \\ of the Windmills {chapter="Part I · Chapter VIII" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I" lead="Doré passes over the charge and draws the moment after it. The turning sail has caught the lance and lifted horse and rider clear of the plain, and Rocinante’s legs thrash at the empty air below them."} The plate (:ref{id="windmills"}, opposite) illustrates the sentence in which the charge ends. As the knight drove his lance into the sail, in Ormsby’s words, ‘the wind whirled it round with such force that it shivered the lance to pieces, sweeping with it horse and rider, who went rolling over on the plain’. Doré stops before the fall. The broken lance is still caught in the canvas, and the knight hangs tangled against the sail with his shield flung out beside him. The composition is built on one diagonal. The sail runs from the top left corner towards the centre, the horse hangs from it, and the line carries on down the slope to the thistles in the foreground. Against it Doré sets the level plain of La Mancha, where Cervantes counts ‘thirty or forty windmills’ and the plate lines up a row of them along the horizon, turning in the same wind. Sancho, hatless, throws up one arm and clutches his head with the other; his hat lies on the ground and his ass waits behind him. He had warned his master, in Ormsby’s version, that ‘what we see there are not giants but windmills, and what seem to be their arms are the sails that turned by the wind make the millstone go’. Pisan cuts the sky in long even lines and the patched canvas of the sail in torn, crossing strokes. The mills on the horizon are drawn with a few lines each. # Works in \\ the Exhibition {style="checklist" kicker="Checklist" range="1–3"} All three plates come from the first volume of *L’Ingénieux Hidalgo Don Quichotte de la Manche*, in the French translation by Louis Viardot, published in two folio volumes by L. Hachette et Cie, Paris, in 1863 with 377 compositions by Gustave Doré, 120 of them full-page. Héliodore Pisan engraved the three plates on wood, and both men signed them in the block. Quotations are from John Ormsby’s English translation of 1885. ::resource{id="checklist"} :::paragraphs{style="colophon"} Set in Ibarra Real Nova, Libre Bodoni and Sofia Sans Condensed (SIL Open Font License). Commentary written for this catalogue under a CC BY 4.0 licence. The plates are in the public domain and reproduced from scans on Wikimedia Commons; the cover shows a detail of Cat. 3. Ormsby’s translation is Project Gutenberg eBook #996. :::`; // content.<lang>.md, inlined by the Cookbook // id, scan file, caption, the chapter it illustrates and alt text: one block per plate. const plateTexts = String.raw`libraryMarkdown样例 · 16行 · content.plates.en.md
library-835.jpg Don Quixote in His Library Part I, ch. I: ‘which treats of the character and pursuits of the famous gentleman Don Quixote of La Mancha’ Don Quixote sits upright in a carved chair, a book in his left hand and a sword raised in his right, surrounded by the figures of his reading: knights on horseback, a dragon, a giant’s severed head, a lady in chains and two tiny knights jousting on mice. vigil vigil-835.jpg The Vigil of Arms Part I, ch. III: ‘wherein is related the droll way in which Don Quixote had himself dubbed a knight’ A moonlit inn yard. Don Quixote, bareheaded and holding a lance and a buckler, looks up at the moon; his armour stands propped on the well-head facing him, and a figure watches from the inn’s outside stair. windmills windmills-835.jpg The Adventure of the Windmills Part I, ch. VIII: ‘the terrible and undreamt-of adventure of the windmills’ A windmill’s sail sweeps Don Quixote and Rocinante off the ground, the knight tangled against the canvas and his buckler flung out; a row of mills stands on the horizon and Sancho, hatless, throws up one arm and clutches his head beside his ass.`; const FIELDS = ['id', 'file', 'caption', 'illustrates', 'altText']; const PLATES = plateTexts.trim().split(/\n\s*\n/).map((block) => Object.fromEntries(block.split('\n').map((line, i) => [FIELDS[i], line.trim()]))); // #region art: the engravings printed on the page's paper // Multiplying by the paper colour turns the scan's white into the page's cream. function onPaper(source) { const canvas = new OffscreenCanvas(source.width, source.height); const ctx = canvas.getContext('2d'); ctx.drawImage(source, 0, 0); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = palette.paper; ctx.fillRect(0, 0, source.width, source.height); return canvas.transferToImageBitmap(); } const resized = (scan, width) => createImageBitmap(scan, { resizeWidth: width, resizeQuality: 'high' }); // #endregion // #region scans: the plates, their thumbnails and the cover's detail, fetched from assets/ // plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX); // the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box. async function scan(file) { const res = await fetch(asset(file)); if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`); return createImageBitmap(await res.blob()); } async function loadPlates() { const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan)); const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail), 'Detail of Cat. 3: the knight and his horse caught on the sail.')]; for (const [i, p] of PLATES.entries()) { registerResourceImage(p.file, onPaper(scans[i])); resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText)); } return resources; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Ibarra Real Nova': ['400', '400i'], // text, the lead, the checklist 'Libre Bodoni': ['400', '400i'], // numerals, titles, drop caps, the cover 'Sofia Sans Condensed': ['400', '600', '700'], // labels, tombstones, captions, folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const words = `${markdown}\n${plateTexts}\n${CREDIT}`; // their letters decide the font subsets const [, resources] = await Promise.all([loadFonts(FONTS, words), loadPlates()]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), words); // #region check: every page past an opener holds a plate, so each plate faces its entry const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length); if (astray) { // text run past its verso, or the blank page that follows it const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`); kitFail(error); // the viewer's bar says why throw error; } // #endregion showPages(doc, { title: t({ en: 'Catalogue entries facing their plates' }) });工具包 · core, fonts, viewer:每道食谱都相同 · 235行
// ─── 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
常见问题
易错点
'top'浮动体不会出现在引用它的那一页
浮动体不会排在自己的引用之前,所以在第N页引用的整页宽'top'浮动体会出现在第N+1页的顶部。把引用提前,或者使用position 'auto'或'bottom',它们可以占用引用页的底部。 图的放置 →
易错点
位图按文档dpi以px排版:声明印刷尺寸
位图资源的尺寸取自它声明的宽和高,单位是文档dpi下的像素,而不是取自文件本身。声明印刷尺寸对应的像素数(按印刷宽度约300 dpi),图才会以正确的大小出现并保持清晰。 作为资源的图和表 →
易错点
设计文本从不两端对齐,所以首字下沉旁的导语参差不齐
在postext 1.4.1中,设计文本元素只能左对齐、居中或右对齐,并按词换行:没有两端对齐,hyphenate: true也只拆分长得一整行都放不下的词。因此排在dropCap旁的导语各行,在两端对齐的正文旁边参差不齐。导语只写首字旁边那几行,并手工调整让它们排满:用lines: 1(上升式首字)时导语只有一行,调整dropCap的间距就能让它齐边;段落的其余部分在Markdown里接着写。 章首页中的首字下沉 →
易错点
属性中的\n只在paragraphIndent > 0时换行
在设计文本元素中,属性值里写的\n只有在paragraphIndent大于零或设置了首字下沉时才另起一行,否则文字留在同一行。把paragraphIndent设为一个极小值(0.01 pt),或每行用一个属性。 页面设计中的文字、线条和框 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
沙盒检查 · bitmapTooSmall
图片分辨率过低
原因. 某张位图绘制的宽度超过其像素宽度的1.5倍,印刷出来会发虚。
解决. 提供按印刷尺寸约300 dpi的图片,并声明位图的真实宽度和高度。 文档 →
致谢
- 文本
- The catalogue entries, captions, tombstones and checklist · Postext Cookbook · CC BY 4.0
- Quotations from Don Quixote in John Ormsby’s English translation (1885) · Miguel de Cervantes · John Ormsby · 公有领域
- 图片
- Cat. 1, Don Quixote in His Library (Part I, ch. I), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide · Gustave Doré · Héliodore Pisan · 公有领域
- Cat. 2, The Vigil of Arms (Part I, ch. III), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide · Gustave Doré · Héliodore Pisan · 公有领域
- Cat. 3, The Adventure of the Windmills (Part I, ch. VIII), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide · Gustave Doré · Héliodore Pisan · 公有领域
- The cover’s detail of Cat. 3: a 1,100-px square cut from the same scan and reduced to 540 px · Gustave Doré · Héliodore Pisan · 公有领域
- 字体
- Ibarra Real Nova (SIL OFL 1.1) · Libre Bodoni (SIL OFL 1.1) · Sofia Sans Condensed (SIL OFL 1.1)


