成品一览
把Galdós的《Marianela》(1878)开头排成11.5 × 18 cm的口袋本,第一章对面是一幅推想的Socartes矿区地图。在章的Libre Bodoni斜体标题上方,用小型大写字母拼写出capítulo primero;正文用Gentium Book Plus,从页面四分之一高度处开始,首字是一个抬高的牛血红色字母。单词按西班牙语音节断开(hie-rro、esta-blecimiento),词间距保持在正常宽度的1.7倍以内;没有哪一段以断词后的词尾结束。对白用raya排,这种长破折号也用来开合叙述者的incisos(插入语)。旅人的口令放在«comillas latinas»中,而在版本说明于引文内部再次引用它时,用英文双引号。地图是Figura 1,在正文中引作[fig. 1]。书眉用Marcellus SC,左页印作者,右页印书名。
这道食谱解答
- 怎样让西班牙文、法文或德文获得良好的两端对齐和断词?
- 怎样避免段末孤行、段首孤行和只有一个词的末行(孤字),并让标题与正文不分开?
- 怎样写对话破折号、段首的年份、价格和字面符号,而不被Markdown误读?
- 怎样让“图”和“表”的标签使用文档的语言?
- 怎样给章首页加作者行、提要或带首字下沉的导语?
简短回答
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
// The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
// Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
// the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
// A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
.map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
用料
- 功能
- 断词与文档语言最优断行(Knuth–Plass)章首页中的首字下沉设计过的章首页标题属性页面设计中的文字、线条和框段末孤行、段首孤行与孤字对称页边距从右页开始的章按页面角色显示书眉书眉与页码决定图位置的引用编号题注用你的语言显示“图”和“表”图就放在这里标题样式显式竖向间距段落样式转义与字面字符纸张颜色
- 类型
- Gentium Book Plus, Libre Bodoni, Marcellus SC(SIL OFL 1.1)
- 素材
- The conjectural map of the Socartes mines, drawn in code in the page’s palette (Ignacio Ferro, MIT)
做法
#1 · 西班牙语音节,收紧词间距
代码见上面的简短回答。locale: 'es'选用西班牙语的断词规则,而且只有这个代码才行:用'es-ES'时,这几页会按美式规则断成cabal-los、hi-erro和establec-imiento,还有一行的词间距会拉大到2.13倍(支持的语言)。两端对齐、Knuth–Plass以及段首孤行、段末孤行规则默认都已开启。86 mm的行长还需要收紧词间距:用默认的maxWordSpacing: 2时,有八行超过1.6倍,最宽的达到1.89倍;设为1.7时最宽为1.65倍,因为断行程序改为断开hie-rro和ca-lles(词间距上下限)。超过上限的行代价高得多,但仍然允许,所以在更窄、没有更好断点的行长上,仍可能超出。runtMinCharacters: 26能抓住默认的20个空格宽度放过的断词残片:siem- / pre adelante.变成Adelante, / siempre adelante.,So- / cartes [fig. 1].变成de / Socartes [fig. 1].(孤字)。
#2 · 由整行构成的口袋本页面
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
下页边距由成品尺寸、上页边距和LINES算出,所以版心恰好容纳30行13.8 pt,每个满页都止于同一条基线,第10页和第11页就是如此。设mirror: true后,在跨页的两页上,15 mm的内侧页边距都在书脊一侧,14 mm的外侧页边距都在切口一侧(镜像页边距),86 mm的行长可容纳约57个10 pt的Gentium Book Plus字符。
#3 · 用文字写出的章序与抬高的首字
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
enabled: true,
slot: { elements: [
// Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
// from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
{ kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
placement: centred(LABEL_Y) },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
placement: centred(TITLE_Y) },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own; -LEAD also takes back
// the line the initial's font box adds below the lead (gotcha: drop-cap-extra-line).
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
marginBottom: pt(-LEAD), advancedDesign: opener };
编号模板只印数字(1、01、I、i、A、a),不印文字,所以capítulo primero来自标题自己的ordinal属性(标题属性)。Postext只在设计文字中绘制首字,而设计文字是齐左排的,并且只有在该文字设了overflow: 'wrap'时才会绘制。为了藏起不齐的右边,首字旁只放一行:lines: 1让首字抬高,与lead属性中的那一行并排,并反复调整INITIAL_GAP,直到这一行恰好排满,离行长只差0.1 mm。段落在Markdown中接着排,不缩进(文字元素)。章这一级的marginBottom取代默认的外边距(默认值会多出一个空行);它的值-LEAD还收回了首字1.2 em的字框给首页标题增加的那一行高度。

#4 · 地图作为Figura 1与章首相对
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
+ 'las minas de Socartes.',
note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
+ 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0), // no -LEAD
footer: { elements: [] }, // no drop folio
advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
position: 'here'把地图放在::resource{id="plano"}所在的位置,位于一个单独的标题之下。这个标题让书中第8页成为首页,而书眉会跳过首页。它的样式清空页脚并自带分页,因此即使各章都从右页开始,这幅插图仍与第一章相对,不带书眉和页码(标题样式)。简短回答中的西班牙语资源类型在地图下印Figura 1.(不设resourceTypes时,无论语言是什么都印Figure 1.1),在正文中印Fig. 1,Markdown中:ref的case="lower"把它变成fig. 1。引用放在方括号里,西班牙语版本用方括号标出编者在作者原文之外添加的内容。
#5 · 左页印作者,右页印书名
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
kind: 'text', id, content, parity, pages, // running heads skip the openers
fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
{ ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
head('verso-author', '{author}', 'even', 'top', -SHIFT),
head('recto-title', '{title}', 'odd', 'top', SHIFT),
{ ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
parity: 'even'把作者放在左页,'odd'把书名放在右页,pages: 'body'让两者都不出现在首页上,首页唯一的页面元素是用pages: 'opener'设置的页脚页码。每条书眉都锚定在页面上,并用SHIFT移到版心的中线上;由于内外页边距不同,版心中线与页面中线相差半毫米。出于同样的原因,页脚页码分奇偶两种。页码用Gentium排,因为Marcellus SC把1和0画得像I和O。
#6 · Markdown中的西班牙语标点
const paragraphStyles = [
{ id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
textAlign: 'center' },
// In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
{ id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
marginTop: pt(LEAD) },
{ id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
最后一页第12页以三个段落样式收尾。牛血红色Bodoni的星号居中排在一个:::space{lines=2}之后,因为空行不会增加空白。版本说明是8.3 pt、11 pt行距,版权页信息是7.5 pt,用书眉的灰色。版本说明保持墨色:段落样式没有斜体颜色,灰色说明中的斜体Marianela会比周围的字印得更深。在Markdown中,对白以raya(—)开头,绝不能用连字符,因为行首的连字符加空格会开启一个列表。说明开头的*1878.*会开启一个编号列表,所以在年份前加一个词连接符(U+2060)。星号要转义(\*);不转义的话,第一个会开启项目符号列表,另两个会开启一段斜体,这一行就消失了。
完整食谱
// ═══ Postext Cookbook · Nº 016 · Justified Spanish in a pocket novel ═════════════════ // https://postext.dev/en/cookbook/spanish-pocket-novel // Code: MIT · Text: B. Pérez Galdós, Marianela, 1878 (PD, Gutenberg #17340) · Map: drawn in code // Fonts: Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only) const RECIPE = 'spanish-pocket-novel'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1e1b18', // text: a warm near-black oxblood: '#8b2e2a', // the one accent: chapter label, rule, initial, asterisks, route muted: '#6d645a', // running heads, the map's paths, the caption's credit paper: '#f7f2e8', // an ivory book paper slag: '#c8553d', moss: '#6f7a4f', water: '#58707f', // the map: mined earth, woods, river }; // Each colour names its palette entry and carries its hex (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' } })), { id: 'main-color', name: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } }, ]; const TEXT = 'Gentium Book Plus'; // the text face const DISPLAY = 'Libre Bodoni'; // the chapter's title and the initial const LABEL = 'Marcellus SC'; // small capitals: chapter label, running heads, map names const [BODY, LEAD] = [10, 13.8]; // pt: the body size, and its leading: the grid's pitch const smallCaps = { fontFamily: LABEL, fontSize: pt(9), letterSpacing: pt(1.6), // 2 labels color: col('oxblood'), align: 'center' }; // #region page: a pocket paperback; the text block holds 30 whole lines const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight const LINES = 30; // lines of LEAD per page, so every full page ends on the same line const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt const page = { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72), left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto }; // #endregion // #region answer: Spanish syllables, word spaces under 1.7×, captions that say Figura const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales) const bodyText = { // config().bodyText fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), referenceBold: false, // '[fig. 1]' reads in roman, like the words around it firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out. // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles. // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through. maxWordSpacing: 1.7, runtMinCharacters: 26, }; // config().resourceTypes, as the locale leaves captions in English (gotcha: // resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1. const resourceTypes = defaultResourceTypes(LOCALE) .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' })); // #endregion // #region opener: the chapter spelled out, its title, and the lead under a raised initial const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page const INITIAL = 3 * LEAD; // pt: the initial's size, three leads // Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside // the initial, fitted flush by the gap; the paragraph goes on in the Markdown. const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } }); const opener = { enabled: true, slot: { elements: [ // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"} { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps, placement: centred(LABEL_Y) }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6), color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true, fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center', overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default) placement: centred(TITLE_Y) }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY), lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple) color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL), color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } }, ] }, }; // The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do. // marginBottom replaces the level's default, a blank line of its own; -LEAD also takes back // the line the initial's font box adds below the lead (gotcha: drop-cap-extra-line). const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(-LEAD), advancedDesign: opener }; // #endregion // #region heads: the author on the verso, the title on the recto; a drop folio on the opener const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({ kind: 'text', id, content, parity, pages, // running heads skip the openers fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }, }); const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre // Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline. const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') }; const header = { elements: [ { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio }, head('verso-author', '{author}', 'even', 'top', -SHIFT), head('recto-title', '{title}', 'odd', 'top', SHIFT), { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio }, ] }; const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x, DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] }; // #endregion // #region figure: the map faces chapter I as Figura 1; the text cites it as [fig. 1] const MAP_H = 112; // mm: a frontispiece map, the measure wide const resources = [{ id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de ' + 'las minas de Socartes.', note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.', altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, ' + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.', }]; // The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip. const frontispiece = { id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break) breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0), // no -LEAD footer: { elements: [] }, // no drop folio advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name', content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } }, }; const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2), labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } }; // #endregion // #region styles: the asterisks that close the excerpt, the edition's note, the colophon const paragraphStyles = [ { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'), textAlign: 'center' }, // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour). { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0), marginTop: pt(LEAD) }, { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2) }, ]; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: LOCALE, resourceTypes, colorPalette, page, layout: { layoutType: 'single' }, bodyText, headings: { fontFamily: DISPLAY, // the designs paint the titles; this keeps Open Sans unloaded levels: [chapter], }, headingStyles: [frontispiece], paragraphStyles, captionStyle, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 45行 · content.es.md
title: "Marianela" author: "Benito Pérez Galdós" --- # Las minas de Socartes {style="lamina"} ::resource{id="plano"} # Perdido {ordinal="primero" lead="Se puso el sol. Tras el breve crepúsculo vino tranquila"} y oscura la noche, en cuyo negro seno murieron poco a poco los últimos rumores de la tierra soñolienta, y el viajero siguió adelante en su camino, apresurando su paso a medida que avanzaba la noche. Iba por angosta vereda, de esas que sobre el césped traza el constante pisar de hombres y brutos, y subía sin cansancio por un cerro en cuyas vertientes se alzaban pintorescos grupos de guinderos, hayas y robles. (Ya se ve que estamos en el Norte de España.) Era un hombre de mediana edad, de complexión recia, buena talla, ancho de espaldas, resuelto de ademanes, firme de andadura, basto de facciones, de mirar osado y vivo, ligero a pesar de su regular obesidad, y (dígase de una vez aunque sea prematuro) excelente persona por doquiera que se le mirara. Vestía el traje propio de los señores acomodados que viajan en verano, con el redondo sombrerete, que debe a su fealdad el nombre de hongo, gemelos de campo pendientes de una correa, y grueso bastón que, entre paso y paso, le servía para apalear las zarzas cuando extendían sus ramas llenas de afiladas uñas para atraparle la ropa. Detúvose, y mirando a todo el círculo del horizonte, parecía impaciente y desasosegado. Sin duda no tenía gran confianza en la exactitud de su itinerario y aguardaba el paso de algún aldeano que le diese buenos informes topográficos para llegar pronto y derechamente a su destino. —No puedo equivocarme —murmuró—. Me dijeron que atravesara el río por la pasadera… así lo hice. Después que marchara adelante, siempre adelante. En efecto, allá, detrás de mí queda esa apreciable villa, a quien yo llamaría *Villafangosa* por el buen surtido de lodos que hay en sus calles y caminos… De modo que por aquí, adelante, siempre adelante (me gusta esta frase, y si yo tuviera escudo no le pondría otra divisa) he de llegar a las famosas minas de Socartes [:ref{id="plano" case="lower"}]. Después de andar largo trecho, añadió: —Me he perdido, no hay duda de que me he perdido… Aquí tienes, Teodoro Golfín, el resultado de tu «adelante, siempre adelante». Estos palurdos no conocen el valor de las palabras. O han querido burlarse de ti, o ellos mismos ignoran dónde están las minas de Socartes. Un gran establecimiento minero ha de anunciarse con edificios, chimeneas, ruido de arrastres, resoplido de hornos, relincho de caballos, trepidación de máquinas, y yo no veo, ni huelo, ni oigo nada… Parece que estoy en un desierto… ¡qué soledad! Si yo creyera en brujas, pensaría que mi destino me proporcionaba esta noche el honor de ser presentado a ellas… ¡Demonio!, ¿pero no hay gente en estos lugares?… Aún falta media hora para la salida de la luna. ¡Ah!, bribona, tú tienes la culpa de mi extravío… Si al menos pudiera conocer el sitio donde me encuentro… ¿Pero qué más da? (Al decir esto, hizo un gesto propio del hombre esforzado que desprecia los peligros). Golfín, tú que has dado la vuelta al mundo, ¿te acobardarás ahora?… ¡Ah!, los aldeanos tenían razón: adelante, siempre adelante. La ley universal de la locomoción no puede fallar en este momento. Y puesta denodadamente en ejecución aquella osada ley, recorrió un kilómetro, siguiendo a capricho las veredas que le salían al paso y se cruzaban y se quebraban en ángulos mil, cual si quisiesen engañarle y confundirle más. Por grande que fuera su resolución e intrepidez, al fin tuvo que pararse. Las veredas, que al principio subían, luego empezaron a bajar, enlazándose; y al fin bajaron tanto, que nuestro viajero hallose en un talud, por el cual solo habría podido descender echándose a rodar. —¡Bonita situación! —exclamó sonriendo y buscando en su buen humor lenitivo a la enojosa contrariedad—. ¿En dónde estás, querido Golfín? Esto parece un abismo. ¿Ves algo allá abajo? Nada, absolutamente nada… pero el césped ha desaparecido, el terreno está removido. Todo es aquí pedruscos y tierra sin vegetación, teñida por el óxido de hierro… Sin duda estoy en las minas… pero ni alma viviente, ni chimeneas humeantes, ni ruido, ni un tren que murmure a lo lejos, ni siquiera un perro que ladre… ¿Qué haré?, hay por aquí una vereda que vuelve a subir. ¿Seguirela? ¿Desandaré lo andado?… ¡Retroceder! ¡Qué absurdo! O yo dejo de ser quien soy, o llegaré esta noche a las famosas minas de Socartes y abrazaré a mi querido hermano. Adelante, siempre adelante. Dio un paso y hundiose en la frágil tierra movediza. —¿Esas tenemos, señor planeta?… ¿Conque quiere usted tragarme?… Si ese holgazán satélite quisiera alumbrar un poco, ya nos veríamos las caras usted y yo… Y a fe que por aquí abajo no hemos de ir a ningún paraíso. Parece esto el cráter de un volcán apagado… Hay que andar suavemente por tan delicioso precipicio. ¿Qué es esto? ¡Ah! Una piedra; magnífico asiento para echar un cigarro, esperando a que salga la luna. El discreto Golfín se sentó tranquilamente como podría haberlo hecho en el banco de un paseo; y ya se disponía a fumar, cuando sintió una voz… sí, indudablemente era una voz humana que lejos sonaba, un quejido patético, mejor dicho, melancólico canto, formado de una sola frase, cuya última cadencia se prolongaba apianándose en la forma que los músicos llamaban *morendo*, y que se apagaba al fin en el plácido silencio de la noche, sin que el oído pudiera apreciar su vibración postrera. :::space{lines=2} :::paragraphs{style="asterismo"} \* \* \* ::: :::paragraphs{style="nota"} 1878. Galdós publica *Marianela* en Madrid, en la Imprenta y Litografía de La Guirnalda. Seguimos el comienzo de su capítulo primero en el texto de dominio público de Project Gutenberg (eBook 17340), con la ortografía y la puntuación actuales: raya en los diálogos y comillas latinas para la consigna del viajero, que se vuelven inglesas cuando la cita va dentro de otra: «el resultado de tu “adelante, siempre adelante”». Los corchetes señalan lo que añade el editor. ::: :::paragraphs{style="colofon"} Compuesto en Gentium Book Plus, Libre Bodoni y Marcellus SC (SIL OFL) :::`; // content.es.md, inlined by the Cookbook // #region art: the map of Socartes, in millimetres at its printed size, in the palette function mulberry32(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round( parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k) .toString(16).padStart(2, '0')).join('')}`; const n2 = (v) => +v.toFixed(2); // A smooth path through the points (Catmull-Rom as cubic Béziers), open or closed. function smooth(list, close = false) { const p = close ? [list.at(-1), ...list, list[0], list[1]] : [list[0], ...list, list.at(-1)]; let d = `M${n2(p[1][0])} ${n2(p[1][1])}`; for (let i = 1; i < p.length - 2; i++) { const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]]; d += `C${n2(b[0] + (c[0] - a[0]) / 6)} ${n2(b[1] + (c[1] - a[1]) / 6)} ` + `${n2(c[0] - (e[0] - b[0]) / 6)} ${n2(c[1] - (e[1] - b[1]) / 6)} ${n2(c[0])} ${n2(c[1])}`; } return close ? `${d}Z` : d; } async function labelFace() { // Marcellus SC inside the SVG (gotcha: svg-no-webfonts) const url = 'https://cdn.jsdelivr.net/npm/@fontsource/marcellus-sc@5/files/' + 'marcellus-sc-latin-400-normal.woff2'; const res = await fetch(url); if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`; } function drawMap(face, W, H) { // drawn for W 86 × H 112: the mines above, the town below const rnd = mulberry32(1878); const C = { ground: mix(palette.paper, palette.moss, 0.16), hill: mix(palette.moss, palette.paper, 0.45), wood: palette.moss, shade: mix(palette.moss, palette.ink, 0.35), earth: mix(palette.slag, palette.paper, 0.3), bench: palette.slag, deep: mix(palette.slag, palette.ink, 0.2), pit: mix(palette.slag, palette.ink, 0.45), edge: mix(palette.slag, palette.ink, 0.5), water: palette.water, ink: palette.ink, path: palette.muted, route: palette.oxblood, stream: mix(palette.water, palette.ink, 0.3), // the river's name: 5.7:1 on the ground }; const out = [`<rect width="${W}" height="${H}" fill="${C.ground}"/>`]; const fill = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`); const stroke = (d, color, w, extra = '') => out.push(`<path d="${d}" fill="none" ` + `stroke="${color}" stroke-width="${w}" stroke-linecap="round" ` + `stroke-linejoin="round"${extra}/>`); // The names, set first so that no tree grows over them. const labels = [[33, 30.5, 'minas de', 'end'], [33, 34, 'Socartes', 'end'], [34.4, 19, 'talleres', 'end'], [43.6, 52.4, 'talud', 'end', C.route], [12, 70.5, 'cerro', 'middle'], [25.5, 96.4, 'pasadera'], [70, 94.5, 'río', 'middle', C.stream], [3, 109.6, 'Villafangosa'], [5, 11.4, 'N', 'middle']]; const box = ([x, y, text, anchor = 'start']) => { const w = text.length * 1.45; const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x; return [left - 0.8, y - 2.6, left + w + 0.8, y + 0.8]; }; const clear = (x, y) => labels.map(box).every(([a, b, c, d]) => x < a || x > c || y < b || y > d); // The mines: iron-stained earth cut in benches down to the pit, the "extinct crater". const mine = [[40, 6], [58, 3], [76, 5], [84, 17], [83, 35], [74, 47], [58, 52], [45, 50], [36, 40], [35, 20]]; [[1, C.earth], [0.76, C.bench], [0.52, C.deep], [0.28, C.pit]].forEach(([k, color], i) => { const [cx, cy] = [60 + i * 1.6, 27 - i * 1.4]; // each bench deeper, the pit off-centre const ring = mine.map(([x, y]) => [cx + (x - 60) * k + (rnd() - 0.5) * 2.4 * k, cy + (y - 27) * k + (rnd() - 0.5) * 2.4 * k]); out.push(`<path d="${smooth(ring, true)}" fill="${color}" stroke="${C.edge}" ` + 'stroke-width="0.25"/>'); }); fill('M28.4 11.6h6.5v3.2h-6.5zM33 7h0.9v4.7h-0.9z', C.ink); // workshops, chimney, by the rim // The hill of cherry, beech and oak, and the paths that cross it at a thousand angles. out.push(`<path d="${smooth([[19, 73], [23, 61], [36, 56], [50, 59], [55, 72], [49, 85], [35, 90], [23, 84]], true)}" fill="${C.hill}" stroke="${C.shade}" stroke-width="0.25"/>`); for (let i = 0; i < 100; i++) { const a = rnd() * Math.PI * 2; const r = Math.sqrt(rnd()) * 16; const [x, y] = [37 + Math.cos(a) * r, 73 + Math.sin(a) * r]; const [size, shade] = [0.55 + rnd() * 0.55, rnd() < 0.3 ? C.shade : C.wood]; if (clear(x, y)) { out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(size)}" fill="${shade}"/>`); } } [[[22, 80], [30, 74], [28, 64], [38, 58]], [[26, 62], [36, 70], [46, 64], [52, 70]], [[27, 87], [33, 79], [42, 82], [50, 76]], [[38, 57], [40, 67], [33, 76], [37, 89]]] .forEach((path) => stroke(smooth(path), C.path, 0.3, ' stroke-dasharray="1 0.8"')); // The river, the footbridge, and the town the traveller left behind. stroke(smooth([[0, 92], [12, 93], [22, 99], [34, 100], [50, 96], [66, 98], [W + 1, 102]]), C.water, 2.4); stroke('M20.4 95.2L18.6 101.2', C.ink, 0.7); for (let i = 0; i < 18; i++) { const [x, y] = [3 + (i % 6) * 2.2 + rnd() * 0.5, 98.6 + Math.floor(i / 6) * 2.3 + rnd() * 0.5]; fill(`M${n2(x)} ${n2(y)}h1.5v1.2h-1.5z`, C.ink); } fill('M5.2 94.4h1v2.4h-1zM4 96.2h3.4v2h-3.4z', C.ink); // the church // Golfín's walk: over the footbridge, up the hill by its paths, down to the edge. stroke(smooth([[12, 101], [17, 99.4], [21, 94], [25, 88], [31, 84], [27, 78], [33, 72], [40, 75], [45, 68], [40, 62], [45.3, 53.6]]), C.route, 0.75, ' stroke-dasharray="0.05 1.3"'); out.push(`<circle cx="46.4" cy="51.6" r="1.4" fill="none" stroke="${C.route}" ` + 'stroke-width="0.6"/>'); // North, up: a half-inked arrowhead over an N. fill('M5 3.2L6.6 8.2L5 7.2Z', C.ink); out.push(`<path d="M5 3.2L3.4 8.2L5 7.2Z" fill="none" stroke="${C.ink}" stroke-width="0.2"/>`); labels.forEach(([x, y, text, anchor = 'start', color = C.ink]) => out.push(`<text x="${x}" ` + `y="${y}" text-anchor="${anchor}" fill="${color}">${text}</text>`)); out.push(`<rect x="0.2" y="0.2" width="${W - 0.4}" height="${H - 0.4}" fill="none" ` + `stroke="${C.ink}" stroke-width="0.4"/>`); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" ` + `viewBox="0 0 ${W} ${H}"><style>${face}text{font-family:L;font-size:2.35px}</style>` + `${out.join('')}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Gentium Book Plus': ['400', '400i', '700'], // text, captions (700: 'Figura 1.'), the note 'Libre Bodoni': ['400', '400i', '700'], // asterisks, the chapter's title, the initial 'Marcellus SC': ['400'], // chapter label, plate name, running heads }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('plano.svg', drawMap(await labelFace(), MEASURE, MAP_H)); // The map is book page 8, a verso, facing chapter I: folios and parity follow the book. const continuation = { pageIndexOffset: 7, pageNumbering: { startAt: 8 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Español justificado en una novela de bolsillo' });工具包 · 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上的食谱文件夹 ↗
变化
#每章从右页开始
设为'odd'时,本该从左页开始的章前面会多出一个空白页,就像版面更宽裕的版本那样。第一章本来就从右页开始,插图也保持在原页,因为它的样式自带分页。
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },#按章给图编号
直接使用内置的西班牙语资源类型时,图在每个一级标题内编号:地图变成Figura 1.1,引用变成fig. 1.1。
-const resourceTypes = defaultResourceTypes(LOCALE)
- .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
+const resourceTypes = defaultResourceTypes(LOCALE);常见问题
易错点
用defaultResourceTypes(locale)本地化Figure/Table
配置的locale决定断词,不决定题注:没有resourceTypes时,内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es');其他语言请在resourceTypes中自己写出名称。 用你的语言显示“图”和“表” →
易错点
设计文本从不两端对齐,所以首字下沉旁的导语参差不齐
在postext 1.4.1中,设计文本元素只能左对齐、居中或右对齐,并按词换行:没有两端对齐,hyphenate: true也只拆分长得一整行都放不下的词。因此排在dropCap旁的导语各行,在两端对齐的正文旁边参差不齐。导语只写首字旁边那几行,并手工调整让它们排满:用lines: 1(上升式首字)时导语只有一行,调整dropCap的间距就能让它齐边;段落的其余部分在Markdown里接着写。 章首页中的首字下沉 →
易错点
首字下沉的字框可能在导语下多出一个空行
在postext 1.4.1中,章首页会预留每个元素框的高度,而dropCap首字的框高1.2 em,会伸到它所跨的最后一行基线以下。导语在首字下方没有行时,这段伸出部分会让预留高度向上取整,多出一个网格行,正文便在一个空行之后才开始。给该标题级别设marginBottom为负一个行距(对齐网格时会减去),并在该级别中不绘制首字的标题样式里把它重设为0。 章首页中的首字下沉 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
段落样式没有斜体颜色
在postext 1.4.1中,段落样式可以设置color和boldColor,但没有italicColor:其中的斜体文字取bodyText.italicColor。于是一个淡色样式(小字、出处行)里的斜体书名会比周围的文字颜色更深。这类样式要么沿用正文的墨色,要么不用斜体。 段落样式 →
易错点
标题样式会继承其级别的分页设置
headingStyles中的条目没有写出的字段都取自它所属的标题级别,breakBefore也不例外。在:::pagebreak之后以H1设置样式的目录页或版权页会继承parity 'odd',结果落在一张空白页之后。给这类样式设置breakBefore: { enabled: false }。 标题样式 →
易错点
SVG <img>中的文字不能使用网络字体
SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 章首页正文起点下移九行而不是八行时,第11页短了一行,因为
avoidWidows不允许在页面最后一行开始一个段落。修改设置或文字之后,要检查每页的页脚。 lead属性在句中(…vino tranquila)停下,Markdown段落从那里接着排(y oscura la noche),因为它恰好容纳首字旁放得下的那些词。换字体、字号、行长或INITIAL_GAP都会破坏这种恰好排满的效果:在两者之间挪动词语,直到这一行再次排满。- 这几款字体的Fontsource latin文件中都没有星号三角⁂(U+2042),浏览器会从系统字体中取用。无论如何,西班牙语图书用三个隔开的星号标出段落分隔。
致谢
- 文本
- Marianela (1878), the opening of chapter I, “Perdido”, with today’s spelling and punctuation · Benito Pérez Galdós · 公有领域
- The edition’s note and the map’s caption · Postext Cookbook · 原创
- 图片
- The conjectural map of the Socartes mines, drawn in code in the page’s palette · Ignacio Ferro · MIT
- 字体
- Gentium Book Plus (SIL OFL 1.1) · Libre Bodoni (SIL OFL 1.1) · Marcellus SC (SIL OFL 1.1)


