简单来说
一本虚构国家的学校地图册中的三页。地图是不带文字的图画,所有地名都写在正文里,所以翻译只换文字,地图保持不变。
成品一览
一本瓦雷尼亚学校地图册中的三幅图版,瓦雷尼亚是为这份配方虚构的国家。图版 1 标出它在大陆上的位置;图版 2 把地形图铺满左右两页,从利拉海一直画到与布鲁纳维亚的边界,用分层设色表示高度,画出三条河和一条剖面线;图版 3 画出穿过阿尔巴山脉的那条剖面。每幅图版以一条蓝色色带开头,以图例或词汇表和几道课堂练习结尾。图上没有一个字:43 个地名、高程和比例尺数字都是 Markdown 里的 ::label 行,放在每幅地图自带的锚点上。所以西班牙文、英文和阿拉伯文三个版本共用同样的三幅图,阿拉伯文版从右往左读,在右侧装订。
这道食谱解答
- 怎样从正文里给地图写上地名,让译本只换文字、不动地图本身?
- 怎样把一幅地图铺满跨页,并在从右到左的版本里让地名照样好读?
简短回答
// A label takes the style its anchor names: seas and rivers in italic blue, ranges in spaced
// brown capitals, towns on a white halo. Arabic is neither slanted nor letter-spaced, and
// Naskh and Kufi set small, so the Arabic edition gets the same styles upright, unspaced and
// 15 % larger.
const [SLANT, TRACK, SIZE] = AR ? ['normal', 0, 1.15] : ['italic', 1, 1];
const halo = { color: col('paper'), width: pt(1.4) };
const style = (id, size, color, more) => ({ id, fontFamily: LABEL, fontSize: pt(size * SIZE),
fontWeight: AR ? 500 : 400, color: col(color), ...more });
const water = (id, size, spacing, more) => style(id, size, 'water', { fontStyle: SLANT,
textTransform: spacing ? 'uppercase' : 'none', letterSpacing: spacing * TRACK, ...more });
const caps = (id, size, color, spacing, more) => style(id, size, color, {
fontWeight: AR ? 700 : 600, textTransform: 'uppercase', letterSpacing: spacing * TRACK,
...more });
const labelStyles = [
water('ocean', 9.5, 0.3), water('sea', 7.5, 0.16), water('river', 6.8, 0, { halo }),
caps('range', 7, 'relief', 0.32, { halo }), caps('region', 6.5, 'muted', 0.24),
caps('country', 8.5, 'muted', 0.36), caps('nation', 12, 'ink', 0.42),
style('capital', 7.6, 'ink', { fontWeight: 700, halo }), style('town', 7, 'ink', { halo }),
style('peak', 6.5, 'ink', { fontWeight: AR ? 700 : 600, halo }), style('island', 6.5, 'ink'),
style('point', 8.5, 'band', { fontWeight: 700, halo }), style('tick', 6, 'muted'),
];
// An anchor is a point of the picture in fractions of its width and height, with the side of
// the label that stands there, its turn and its style. km(win) reads a point in km off a map.
const km = (win) => (id, x, y, labelStyle, more) => ({ id, x: (x - win.x0) / (win.x1 - win.x0),
y: (y - win.y0) / (win.y1 - win.y0), style: labelStyle, ...more });
用料
做法
#1 · 一个地点就是图上的一个点
const above = { verticalAlign: 'bottom' }, below = { verticalAlign: 'top' };
const r = km(WIN.relief), l = km(WIN.locator);
const ANCHORS = {
relief: [
r('ocean', 172, 157, 'ocean'), r('lira', 20, 52, 'sea', { rotate: -84 }),
r('gulf', 318, 154, 'sea', { width: 0.07 }), r('nema', 22, 100, 'island', above),
r('albar-range', 98, 108, 'range', { rotate: -33 }),
r('gris-range', 308, 96, 'range', { rotate: -86 }),
r('plateau', 236, 78, 'region'), r('plain', 70, 98, 'region', { width: 0.08 }),
r('sela', 108, 62, 'river', { rotate: -4 }), r('duna', 121, 136, 'river', { rotate: -62 }),
r('merel', 281, 92, 'river', { rotate: -47 }), r('ormo', 254, 104, 'river', above),
...TOWNS.map(([id, x, y]) => r(id, x, y - 2.6, id === 'almaren' ? 'capital' : 'town', above)),
...PEAKS.map(([id, x, y]) => r(`${id}-peak`, x, y + 2.4, 'peak', below)),
r('ostelia', 300, 9, 'country'), r('brunavia', 390, 112, 'country', { rotate: -90 }),
r('varenia', 250, 46, 'nation'),
r('a', PROFILE[0][0], PROFILE[0][1] - 2, 'point', above),
r('b', PROFILE[1][0], PROFILE[1][1] + 2, 'point', below),
r('scale-0', SCALE.x, SCALE.y + 3, 'tick', below),
r('scale-25', SCALE.x + 25, SCALE.y + 3, 'tick', below),
r('scale-50', SCALE.x + 50, SCALE.y + 3, 'tick', below),
r('north', NORTH.x, NORTH.y - 8, 'point', above),
],
locator: [
l('varenia', 205, 80, 'nation'), l('ostelia', 150, -100, 'country'),
l('brunavia', 560, -120, 'country'), l('lira', 210, -250, 'sea', { rotate: 20 }),
l('ocean', -180, -40, 'ocean', { width: 0.2 }), l('almaren', 259, 127 + 22, 'capital', below),
],
// The section: fractions of the drawing, whose plot starts 14 mm in and runs 3 to 51 mm down.
section: (() => {
const [w, h] = MAP.section, x = (u) => (14 + u * (w - 14)) / w;
const y = (m) => (h - 7 - m * (h - 10) / PROFILE_TOP) / h;
return [
{ id: 'a', x: x(0), y: (h - 5.5) / h, style: 'point', ...below },
{ id: 'b', x: x(0.98), y: (h - 5.5) / h, style: 'point', ...below },
...[500, 1000, 1500, 2000].map((m) => ({ id: `m${m}`, x: 7 / w, y: y(m), style: 'tick' })),
{ id: 'sela', x: x(0.3), y: y(700), style: 'region' },
{ id: 'crest', x: x(0.47), y: y(2260), style: 'range', ...above },
{ id: 'plain', x: x(0.82), y: y(650), style: 'region' },
];
})(),
};
锚点是按图片宽度和高度的比例给出的点,所以框把地图放到多大,它都成立;内容放不下、地图被缩小时,地名也跟着走。km() 把绘图所用的公里坐标换算成这些比例:城市的圆点和它的名字都来自 TOWNS,不会彼此错开。锚点保存的是属于地点、而不属于语言的东西:样式、河名或山名沿走向的旋转角度,以及标注的哪一侧落在这个点上。城市名居中放在圆点上方,因为 align: 'start' 对英文名是左边,对阿拉伯文名却是右边。
#2 · 三个 Markdown 文件,一个资源
const ALT = t({
es: {
locator: 'Mapa de situación: Varenia, en amarillo, entre Ostelia, Brunavia, el mar de Lira '
+ 'y el océano Brumoso.',
relief: 'Mapa físico de Varenia en tintas de altitud, con la sierra Albar, los montes '
+ 'Grises, los ríos Sela, Duna y Merel y el corte A–B.',
section: 'Perfil topográfico de A a B: el valle del Sela, la sierra Albar y la llanura '
+ 'costera.',
},
en: {
locator: 'Locator map: Varenia, in yellow, between Ostelia, Brunavia, the Lira Sea and the '
+ 'Misty Ocean.',
relief: 'Relief map of Varenia in height tints, with the Albar Range, the Grey Hills, the '
+ 'rivers Sela, Duna and Merel and the line of section A–B.',
section: 'Section from A to B: the Sela valley, the Albar Range and the coastal plain.',
},
ar: {
locator: 'خريطة الموقع: فارينيا باللون الأصفر بين أوستيليا وبرونافيا '
+ 'وبحر ليرا والمحيط الضبابي.',
relief: 'خريطة طبيعية لفارينيا بألوان الارتفاع، فيها سلسلة ألبار '
+ 'والتلال الرمادية وأنهار سيلا ودونا وميريل وخط المقطع أ–ب.',
section: 'مقطع تضاريسي من أ إلى ب: وادي سيلا وسلسلة ألبار '
+ 'والسهل الساحلي.',
},
});
const resources = Object.entries(MAP).map(([id, [w, h]]) => ({ id, typeId: 'map', kind: 'svg',
createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 },
altText: ALT[id], anchors: ANCHORS[id] }));
const drawings = { locator: locatorMap, relief: reliefMap, section: profile };
for (const [id, draw] of Object.entries(drawings)) await loadSvg(`${id}.svg`, draw(MAP[id]));
资源只有一个 SVG 和一份锚点列表,供所有版本共用。每个版本在 :::figure{resource="relief" slot="map"} 里为每个锚点写一行 ::label[…]{at="…"},于是 ::label[Llanura de Calvera]{at="plain"} 和 ::label[Calvera Plain]{at="plain"} 落在同一处,需要时按锚点的 width 折成两行。随版本变化的只有替代文本,因为资源不从 Markdown 取文字。带样式的锚点如果在某个版本里没有标注,会报 anchorUnlabelled;at 指向不存在的锚点的标注不会画出,并报 labelAnchorUnknown。
#3 · 单页图版和跨页图版
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const kicker = { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: LABEL,
fontSize: pt(AR ? 10 : 8.5), fontWeight: AR ? 500 : 600, letterSpacing: pt(1.3 * TRACK),
textTransform: 'uppercase', color: col('sky'), align: AR ? 'right' : 'left',
// Decoration stands on the sheet: the kicker goes to the side the page starts reading from.
placement: at('container', AR ? 'top-right' : 'top-left', 0, -11) };
const band = { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } };
const sheet = {
id: 'sheet', header: false, decoration: { elements: [band, kicker] },
frames: [
{ name: 'title', takes: 'h1', placement: at('container', 'top-left', 0, -3) },
{ name: 'text', flow: true, columns: 2, columnGap: mm(7),
placement: at('container', 'top-left', 0, BAND - PAGE.top + 7) },
{ name: 'map' },
{ name: 'key', style: 'key', width: mm(66) },
{ name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
],
// A longer text pushes the map and the key down; the map is set smaller before the text is.
groups: [{ name: 'column', kind: 'stack', frames: ['text', 'map', 'key'], gap: mm(6) }],
};
// The double sheet: one band and one map across both pages, the boxes under the map.
const spread = {
id: 'spread', spread: true, header: false, decoration: { elements: [band, kicker] },
frames: [
{ name: 'title', takes: 'h1', width: mm(177), placement: at('container', 'top-left', 0, -3) },
{ name: 'map', crossGutter: true, // the one frame that runs across the spine
placement: at('container', 'top-left', 0, BAND - PAGE.top + 5) },
{ name: 'text', flow: true, columns: 2, columnGap: mm(7), width: mm(177) },
{ name: 'key', style: 'key', width: mm(72), placement: at('#map', 'below', 213, 6) },
{ name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
],
groups: [{ name: 'column', kind: 'stack', frames: ['map', 'text'], gap: mm(6) }],
};
两个模板有同样的五个框,所以一幅图版的 Markdown 不论占一页还是两页,结构都一样:标题、不指定槽位的正文、地图、图例和练习。跨页图版用 spread: true 把两页当作一张宽 390 毫米的纸来排,只有地图框设了 crossGutter,它跨过书脊,而正文和方框各自留在自己那一页。正文、地图和图例组成一个 stack 组:在测试台的压力测试里,所有文字加长 30 %,阿拉伯文版把位置图缩到 87.5 %,下面的方框随之上移,不出任何警告。
#4 · 页面、分层设色和图例共用一套调色板
const palette = {
ink: '#1f2a2e', paper: '#ffffff', muted: '#55646a', rule: '#c9d2d6',
band: '#1d5f73', // the accent: the head of each sheet, box stripes, list numbers
sky: '#d4e9f2', // the kicker on the band
tint: '#eef2ef', // the key's box
water: '#22597f', river: '#2c6e9b', sea: '#d4e9f2', deep: '#a9cfe0', // names and lines of water
low: '#b5d39b', hill: '#dfe3a2', upland: '#ecd08f', mount: '#d4a46c', peak: '#a8754d',
relief: '#6e4527', // the names of ranges and the line of the section
land: '#ebe6da', country: '#f2c46b', border: '#8a3b52', // the locator map
};
// col(id) links a colour to its palette entry; the drawings read the same hex from `palette`.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) // the defaults' id
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
五种高度色都是调色板里的条目。绘图用 palette.low 到 palette.peak 填充各个色层,图版 2 的图例用同样的标识写 :swatch{color="low"},所以改一个十六进制值,地图和图例会一起改。水体名称用 water,在浅海上的对比度是 6:1,在深海上是 4.5:1;白色标题在 band 上是 7.2:1。
#5 · 阿拉伯文版
代码见上面的简短回答。locale: 'ar' 让正文从右往左排,并在右侧装订,所以跨页从右手页开始,各个框随镜像后的版心排列:标题和正文在右边,图例在练习的右边。地图本身不镜像,每个阿拉伯文地名都落在它的地点所在之处。阿拉伯文的标注样式去掉斜体和字距,并放大 15 %,因为库法体排出来偏小。装饰和页码仍按纸面定位,所以眉题移到右上角,页码换到另一侧。
完整食谱
// ═══ Postext Cookbook · Nº 157 · A school atlas labelled from the Markdown ═══════ // https://postext.dev/en/cookbook/school-atlas-labelled-maps // Code: MIT · Text: original (CC BY 4.0) · Maps: drawn in code from seed 2026 (CC BY 4.0) // Fonts: Source Sans 3, Source Serif 4, Noto Naskh and Kufi Arabic (OFL) · Needs postext ≥ 1.38.0 // // Three pages of a school atlas of Varenia, an invented country: a locator map, the relief map // across a double page and a section through the mountains. The maps are drawn without a letter. // Every name on them is a ::label in content.<lang>.md, standing at an anchor of the resource, // so the Spanish, English and Arabic editions share the same drawings and the same anchors. import { buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en' | 'ar') const RECIPE = 'school-atlas-labelled-maps'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: the page's colours and the maps' tints, one object for both const palette = { ink: '#1f2a2e', paper: '#ffffff', muted: '#55646a', rule: '#c9d2d6', band: '#1d5f73', // the accent: the head of each sheet, box stripes, list numbers sky: '#d4e9f2', // the kicker on the band tint: '#eef2ef', // the key's box water: '#22597f', river: '#2c6e9b', sea: '#d4e9f2', deep: '#a9cfe0', // names and lines of water low: '#b5d39b', hill: '#dfe3a2', upland: '#ecd08f', mount: '#d4a46c', peak: '#a8754d', relief: '#6e4527', // the names of ranges and the line of the section land: '#ebe6da', country: '#f2c46b', border: '#8a3b52', // the locator map }; // col(id) links a colour to its palette entry; the drawings read the same hex from `palette`. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) // the defaults' id .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const AR = LANG === 'ar'; // Three voices per script: a text face, a display face and the face of the map labels. const [TEXT, DISPLAY, LABEL] = AR ? ['Noto Naskh Arabic', 'Noto Kufi Arabic', 'Noto Kufi Arabic'] : ['Source Sans 3', 'Source Serif 4', 'Source Sans 3']; const PAGE = { w: 210, h: 280, top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored const BAND = 44; // mm from the trim to the foot of the band at the head of a sheet // #region answer: label styles in the config, label fields on the anchors of a resource // A label takes the style its anchor names: seas and rivers in italic blue, ranges in spaced // brown capitals, towns on a white halo. Arabic is neither slanted nor letter-spaced, and // Naskh and Kufi set small, so the Arabic edition gets the same styles upright, unspaced and // 15 % larger. const [SLANT, TRACK, SIZE] = AR ? ['normal', 0, 1.15] : ['italic', 1, 1]; const halo = { color: col('paper'), width: pt(1.4) }; const style = (id, size, color, more) => ({ id, fontFamily: LABEL, fontSize: pt(size * SIZE), fontWeight: AR ? 500 : 400, color: col(color), ...more }); const water = (id, size, spacing, more) => style(id, size, 'water', { fontStyle: SLANT, textTransform: spacing ? 'uppercase' : 'none', letterSpacing: spacing * TRACK, ...more }); const caps = (id, size, color, spacing, more) => style(id, size, color, { fontWeight: AR ? 700 : 600, textTransform: 'uppercase', letterSpacing: spacing * TRACK, ...more }); const labelStyles = [ water('ocean', 9.5, 0.3), water('sea', 7.5, 0.16), water('river', 6.8, 0, { halo }), caps('range', 7, 'relief', 0.32, { halo }), caps('region', 6.5, 'muted', 0.24), caps('country', 8.5, 'muted', 0.36), caps('nation', 12, 'ink', 0.42), style('capital', 7.6, 'ink', { fontWeight: 700, halo }), style('town', 7, 'ink', { halo }), style('peak', 6.5, 'ink', { fontWeight: AR ? 700 : 600, halo }), style('island', 6.5, 'ink'), style('point', 8.5, 'band', { fontWeight: 700, halo }), style('tick', 6, 'muted'), ]; // An anchor is a point of the picture in fractions of its width and height, with the side of // the label that stands there, its turn and its style. km(win) reads a point in km off a map. const km = (win) => (id, x, y, labelStyle, more) => ({ id, x: (x - win.x0) / (win.x1 - win.x0), y: (y - win.y0) / (win.y1 - win.y0), style: labelStyle, ...more }); // #endregion // #region templates: a sheet and a double sheet, each a band, a text, a map and two boxes const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const kicker = { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: LABEL, fontSize: pt(AR ? 10 : 8.5), fontWeight: AR ? 500 : 600, letterSpacing: pt(1.3 * TRACK), textTransform: 'uppercase', color: col('sky'), align: AR ? 'right' : 'left', // Decoration stands on the sheet: the kicker goes to the side the page starts reading from. placement: at('container', AR ? 'top-right' : 'top-left', 0, -11) }; const band = { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } }; const sheet = { id: 'sheet', header: false, decoration: { elements: [band, kicker] }, frames: [ { name: 'title', takes: 'h1', placement: at('container', 'top-left', 0, -3) }, { name: 'text', flow: true, columns: 2, columnGap: mm(7), placement: at('container', 'top-left', 0, BAND - PAGE.top + 7) }, { name: 'map' }, { name: 'key', style: 'key', width: mm(66) }, { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) }, ], // A longer text pushes the map and the key down; the map is set smaller before the text is. groups: [{ name: 'column', kind: 'stack', frames: ['text', 'map', 'key'], gap: mm(6) }], }; // The double sheet: one band and one map across both pages, the boxes under the map. const spread = { id: 'spread', spread: true, header: false, decoration: { elements: [band, kicker] }, frames: [ { name: 'title', takes: 'h1', width: mm(177), placement: at('container', 'top-left', 0, -3) }, { name: 'map', crossGutter: true, // the one frame that runs across the spine placement: at('container', 'top-left', 0, BAND - PAGE.top + 5) }, { name: 'text', flow: true, columns: 2, columnGap: mm(7), width: mm(177) }, { name: 'key', style: 'key', width: mm(72), placement: at('#map', 'below', 213, 6) }, { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) }, ], groups: [{ name: 'column', kind: 'stack', frames: ['map', 'text'], gap: mm(6) }], }; // #endregion // Boxes: the key on a pale blue tint, the tasks behind a stripe in the band's blue. const boxText = { fontFamily: TEXT, fontSize: pt(AR ? 10.5 : 8.6), lineHeight: pt(AR ? 17 : 12), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'start' }; // ragged in every script: a box's short lines are never stretched const boxTitle = { fontFamily: LABEL, fontSize: pt(AR ? 9.5 : 8), fontWeight: 700, letterSpacing: pt(1.2 * TRACK), textTransform: 'uppercase', color: col('band') }; const calloutStyles = [ { id: 'key', backgroundEnabled: true, background: col('tint'), border: { enabled: false }, padding: { top: mm(3), right: mm(3.5), bottom: mm(3), left: mm(3.5) }, titleStyle: boxTitle, body: boxText }, { id: 'tasks', backgroundEnabled: false, border: { enabled: false }, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') }, padding: { top: mm(2.5), right: mm(0), bottom: mm(0), left: mm(0) }, titleStyle: boxTitle, body: boxText }, ]; // The folio and the atlas's name at the foot, on the outer side. An Arabic book is bound on the // right, so its rectos (odd pages) stand left of the spine and their outer edge is the left. const foot = (parity) => { const left = (parity === 'even') !== AR; const text = (id, content, x, extra) => ({ kind: 'text', id: `${id}-${parity}`, content, parity, fontFamily: LABEL, fontSize: pt(AR ? 9 : 8), fontWeight: AR ? 500 : 400, color: col('muted'), align: left ? 'left' : 'right', ...extra, placement: at('page', left ? 'bottom-left' : 'bottom-right', left ? x : -x, -11) }); return [text('folio', '{pageNumber}', PAGE.outer, { fontWeight: 700, color: col('band') }), text('title', '{title}', PAGE.outer + 8)]; }; const footer = { elements: [...foot('even'), ...foot('odd')] }; const config = { locale: t({ es: 'es', en: 'en-gb', ar: 'ar' }), // written out: Arabic sets the page right to left colorPalette, labelStyles, calloutStyles, footer, header: { elements: [] }, pageTemplates: [sheet, spread], // The maps are a type of their own with no caption: their titles are the pages' headings. resourceTypes: [{ id: 'map', name: t({ es: 'Mapa', en: 'Map', ar: 'خريطة' }), shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '', defaultPlacement: { position: 'here' } }], page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, layout: { layoutType: 'single' }, bodyText: AR ? { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(20), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(1), hyphenation: { enabled: false } } : { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(14), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: DISPLAY, fontWeight: 700, color: col('paper'), levels: [ { level: 1, fontSize: pt(AR ? 24 : 26), lineHeight: pt(AR ? 34 : 30), marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: false } }, ] }, orderedLists: { numberFormat: 'arabic', color: col('band'), fontWeight: 700, marginTop: pt(0), marginBottom: pt(0) }, paragraphStyles: [{ id: 'colophon', fontFamily: TEXT, fontSize: pt(AR ? 8.5 : 7.5), lineHeight: pt(AR ? 14 : 10), color: col('muted'), textAlign: 'start', firstLineIndent: pt(0) }], }; // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 128行 · content.es.md
title: "Atlas escolar de Varenia" --- ::::page{template="sheet" unit="Lámina 1 · Situación"} # Varenia en el mapa Varenia es un país del sur del continente. Al norte limita con Ostelia y al este con Brunavia. Al oeste la baña el mar de Lira, y al sur, el océano Brumoso, que entra en la costa por el golfo de Sora. Tiene unos 40 000 km² y cerca de cuatro millones de habitantes. La capital, Almaren, está junto al río Merel, a pocos kilómetros del lago Ormo. Casi la mitad de la población vive en la costa, en ciudades con puerto como Calvera, Tessa y Puerto Sora. Este mapa muestra dónde está Varenia; las dos páginas siguientes, su relieve y sus ríos. :::figure{resource="locator" slot="map"} ::label[Varenia]{at="varenia"} ::label[Ostelia]{at="ostelia"} ::label[Brunavia]{at="brunavia"} ::label[Mar de Lira]{at="lira"} ::label[Océano Brumoso]{at="ocean"} ::label[Almaren]{at="almaren"} ::: :::frame{slot="key" title="Leyenda"} :swatch{color="country"} Varenia \\ :swatch{color="land"} Otros países \\ :swatch{color="sea"} Mar y océano \\ :swatch{color="border"} Frontera \\ :swatch{color="ink"} Capital ::: :::frame{slot="tasks" title="Actividades"} 1. ¿Con qué países limita Varenia? Di por qué lado está cada uno. 2. ¿Qué mar baña la costa oeste? ¿Y la costa sur? 3. Busca la capital. ¿Está en la costa o en el interior? ::: :::: ::::page{template="spread" spread unit="Lámina 2 · Relieve y ríos"} # El relieve y los ríos de Varenia La sierra Albar cruza el oeste del país de suroeste a noreste. Es la cordillera más alta de Varenia: el pico Albar llega a 2565 m. Al este están los montes Grises, más bajos y redondeados. Entre las dos se extiende la meseta de Ormo, a unos 600 m de altitud. Los ríos nacen en las montañas y bajan hasta el mar. El Sela corre hacia el oeste y desemboca en Calvera. El Duna, corto y rápido, baja hacia el sur. El Merel, el más largo, atraviesa el lago Ormo y llega al golfo de Sora. La línea A–B marca el corte de la lámina 3. :::figure{resource="relief" slot="map"} ::label[Océano Brumoso]{at="ocean"} ::label[Mar de Lira]{at="lira"} ::label[Golfo de Sora]{at="gulf"} ::label[Isla Nema]{at="nema"} ::label[Sierra Albar]{at="albar-range"} ::label[Montes Grises]{at="gris-range"} ::label[Meseta de Ormo]{at="plateau"} ::label[Llanura de Calvera]{at="plain"} ::label[Río Sela]{at="sela"} ::label[Río Duna]{at="duna"} ::label[Río Merel]{at="merel"} ::label[Lago Ormo]{at="ormo"} ::label[Almaren]{at="almaren"} ::label[Calvera]{at="calvera"} ::label[Tessa]{at="tessa"} ::label[Puerto Sora]{at="sora"} ::label[Brena]{at="brena"} ::label[Pico Albar \\ 2565 m]{at="albar-peak"} ::label[Pico Gris \\ 1274 m]{at="gris-peak"} ::label[Ostelia]{at="ostelia"} ::label[Brunavia]{at="brunavia"} ::label[Varenia]{at="varenia"} ::label[A]{at="a"} ::label[B]{at="b"} ::label[0]{at="scale-0"} ::label[25]{at="scale-25"} ::label[50 km]{at="scale-50"} ::label[N]{at="north"} ::: :::frame{slot="key" title="Altitud"} :swatch{color="peak"} Más de 1800 m \\ :swatch{color="mount"} De 1000 a 1800 m \\ :swatch{color="upland"} De 500 a 1000 m \\ :swatch{color="hill"} De 200 a 500 m \\ :swatch{color="low"} De 0 a 200 m \\ :swatch{color="sea"} Mar ::: :::frame{slot="tasks" title="Actividades"} 1. ¿Qué cordillera es más alta, la sierra Albar o los montes Grises? ¿Cómo lo sabes por el color? 2. Sigue el río Merel desde su nacimiento. ¿Por qué lago pasa? ¿Dónde desemboca? 3. Mide con la escala la distancia en línea recta de Calvera a Almaren. ::: :::: ::::page{template="sheet" unit="Lámina 3 · Corte del relieve"} # Un corte de la sierra Albar Un perfil topográfico muestra el relieve como si cortáramos el terreno a lo largo de una línea. Este sigue la línea A–B del mapa físico: sale de las colinas del norte, baja al valle del Sela, sube por la sierra Albar hasta más de 2100 m y acaba en la llanura de la costa sur. La línea A–B mide 126 km. Para que se vean las montañas, la escala vertical está exagerada unas catorce veces, así que en el dibujo las laderas parecen mucho más empinadas de lo que son. :::paragraphs{style="colophon"} Varenia y sus mapas se han inventado para este atlas. Texto y mapas: CC BY 4.0. Tipos: Source Sans 3 y Source Serif 4 (SIL OFL). ::: :::figure{resource="section" slot="map"} ::label[A]{at="a"} ::label[B]{at="b"} ::label[500 m]{at="m500"} ::label[1000 m]{at="m1000"} ::label[1500 m]{at="m1500"} ::label[2000 m]{at="m2000"} ::label[Valle del Sela]{at="sela"} ::label[Sierra Albar]{at="crest"} ::label[Llanura costera]{at="plain"} ::: :::frame{slot="key" title="Vocabulario"} **Sierra:** cordillera con las cumbres en fila, como los dientes de una sierra. **Meseta:** llanura alta, a más de 400 m sobre el nivel del mar. **Valle:** tierra baja entre montañas por la que suele correr un río. **Llanura:** terreno bajo y casi plano, como el de la costa sur. ::: :::frame{slot="tasks" title="Actividades"} 1. ¿A qué altitud aproximada está la cresta de la sierra Albar en este corte? 2. ¿Por qué el valle del Sela está más bajo que las tierras que lo rodean? 3. Dibuja en tu cuaderno el perfil de una línea que vaya de Calvera a Almaren. 4. Busca en el mapa físico de qué color es la tierra del punto A y del punto B. ¿Coincide con el perfil? ::: ::::`; // content.<lang>.md: every word of the pages, maps included // #region art: one seeded height field, drawn as the locator map, the relief map and a profile // Varenia is invented: a coast, two ranges and three rivers laid out in km (x east, y south), // warped by value noise, cut into height bands by marching squares. The drawings carry no // letters: every name, number and the N of the arrow is a ::label of the Markdown, standing at // an anchor of ANCHORS. No markers, filters or masks. const SEED = 2026; // the seed every coast and height is drawn from const MAP = { locator: [177, 118], relief: [390, 160], section: [177, 100] }; // drawings, in mm const WIN = { // the km each map shows: x east, y south relief: { x0: 0, x1: 400, y0: 0, y1: 400 * MAP.relief[1] / MAP.relief[0] }, locator: { x0: -330, x1: 770, y0: -520, y1: -520 + 1100 * MAP.locator[1] / MAP.locator[0] }, }; const TOWNS = [['almaren', 259, 127, 'capital'], ['calvera', 53, 66], ['tessa', 108, 142], ['sora', 296, 132], ['brena', 346, 92]]; const PEAKS = [['albar', 170, 63], ['gris', 325, 55]]; const PROFILE = [[92, 32], [166, 134]]; // A and B, the ends of the section const PROFILE_TOP = 2600; // m at the head of the section const SCALE = { x: 8, y: 157 }, NORTH = { x: 22, y: 140 }; // the scale bar and the arrow function mulberry32(seed) { // a seeded PRNG: never Math.random() in a recipe 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 rand = mulberry32(SEED); const LATTICE = Array.from({ length: 64 * 64 }, () => rand() * 2 - 1); const ease = (u) => u * u * (3 - 2 * u); function noise(x, y) { // value noise in −1…1 on a 64 × 64 lattice that wraps const [i, j] = [Math.floor(x), Math.floor(y)], [u, v] = [ease(x - i), ease(y - j)]; const at = (a, b) => LATTICE[((b & 63) << 6) | (a & 63)]; const top = at(i, j) + (at(i + 1, j) - at(i, j)) * u; const foot = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * u; return top + (foot - top) * v; } const fbm = (x, y) => [1, 2, 4, 8].reduce((s, f) => s + noise(f * x, f * y) / (2 * f), 0); // Polylines in km. A range is a crest line and its height; a river runs from source to mouth. const RANGES = [ { crest: [[62, 132], [96, 108], [128, 88], [158, 70], [184, 56]], height: 2150, width: 15 }, { crest: [[318, 30], [326, 62], [322, 96], [312, 124]], height: 1050, width: 13 }, ]; const RIVERS = { sela: [[156, 78], [138, 70], [118, 66], [97, 68], [78, 62], [60, 64], [40, 62]], duna: [[126, 112], [120, 126], [112, 138], [106, 150], [101, 168]], merel: [[306, 44], [296, 62], [281, 80], [266, 96], [256, 110], [258, 124], [268, 138], [282, 150], [292, 168]], }; const LAKE = { x: 254, y: 111, rx: 7.5, ry: 4.8 }; const BORDERS = [ // Varenia and Ostelia; Varenia and Brunavia; Ostelia and Brunavia [[30, 12], [80, 6], [128, 10], [172, 18], [214, 12], [256, 24], [298, 22], [338, 34], [376, 44]], [[376, 44], [372, 76], [380, 104], [368, 132], [374, 156], [370, 190]], [[376, 44], [392, -40], [384, -170], [420, -330], [404, -520]], ]; function segDist(x, y, pts) { // the distance from (x, y) to a polyline, km let best = Infinity; for (let i = 1; i < pts.length; i++) { const [[ax, ay], [bx, by]] = [pts[i - 1], pts[i]], [dx, dy] = [bx - ax, by - ay]; const u = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy))); best = Math.min(best, Math.hypot(ax + u * dx - x, ay + u * dy - y)); } return best; } function landness(x, y) { // km inland from the coast; negative out at sea // Far from Varenia (the window of the relief map) the coast takes larger turns. const far = Math.min(1, Math.hypot(Math.max(0, -x, x - 400), Math.max(0, -y, y - 175)) / 160); const west = x - (50 + 26 * fbm(y / 46, 3.1) + 9 * fbm(y / 11, 8.2) + far * (240 * fbm(y / 520, 7.7) - 0.45 * Math.max(0, -y - 80))); const south = 146 + 16 * fbm(x / 52, 1.3) + 6 * fbm(x / 13, 2.6) + far * (200 * fbm(x / 480, 4.4) + 0.8 * Math.max(0, x - 430)) - y; const gulf = Math.hypot((x - 312) / 1.3, y - 166) - 38 - 6 * fbm(x / 12, y / 12); const [lu, lv] = [(x - 200) * 0.94 + (y + 240) * 0.34, (y + 240) * 0.94 - (x - 200) * 0.34]; const lira = Math.hypot(lu / 2.6, lv) - 92 - 30 * fbm(x / 70, y / 70); // an inland sea, north const cape = 1 - Math.hypot((x - 90) / 22, (y - 148) / 17) + 0.3 * fbm(x / 8, y / 8); const nema = 8 - Math.hypot(x - 22, (y - 110) * 1.4) + 3 * fbm(x / 6, y / 6); return Math.max(Math.min(west, Math.max(south, cape * 12), gulf, lira), nema); } function elevation(x, y) { // metres; below 0 is sea, NaN is the lake const wx = x + 6 * fbm(x / 24, y / 24 + 5), wy = y + 6 * fbm(x / 24 + 9, y / 24); const coast = landness(wx, wy); if (coast < 0) return Math.max(-900, coast * 28); if (((x - LAKE.x) / LAKE.rx) ** 2 + ((y - LAKE.y) / LAKE.ry) ** 2 < 1) return NaN; let e = 20 + Math.min(coast, 45) * 4 + 430 * Math.exp(-(((x - 236) / 58) ** 2 + ((y - 88) / 34) ** 2)); for (const { crest, height, width } of RANGES) { const ridge = Math.exp(-((segDist(wx, wy, crest) / width) ** 2)); e += height * ridge * (0.75 + 0.5 * fbm(x / 9, y / 9)); } for (const river of Object.values(RIVERS)) { e -= 420 * Math.exp(-((segDist(x, y, river) / 6) ** 2)); } e += 170 * fbm(x / 7, y / 7); return Math.max(e, 2 + coast * 0.6); } const EDGES = { 1: ['B', 'L'], 2: ['R', 'B'], 3: ['R', 'L'], 4: ['T', 'R'], 6: ['T', 'B'], 7: ['T', 'L'], 8: ['L', 'T'], 9: ['B', 'T'], 11: ['R', 'T'], 12: ['L', 'R'], 13: ['B', 'R'], 14: ['L', 'B'], 5: ['T', 'L', 'B', 'R', 'T', 'R', 'B', 'L'], // saddles: joined, then apart 10: ['R', 'T', 'L', 'B', 'L', 'T', 'R', 'B'] }; function contours(grid, nx, ny, level) { // marching squares: closed loops above `level` const v = (i, j) => (i >= 0 && j >= 0 && i < nx && j < ny ? grid[j * nx + i] : -1e9); const at = new Map(), next = new Map(); const edge = (i, j, side) => { // T, B, L or R of cell (i, j), as a key shared by neighbours const [a, b, horizontal] = { T: [i, j, 1], B: [i, j + 1, 1], L: [i, j, 0], R: [i + 1, j, 0] }[side]; const key = `${horizontal ? 'h' : 'v'}${a},${b}`; if (!at.has(key)) { const [p, q] = horizontal ? [v(a, b), v(a + 1, b)] : [v(a, b), v(a, b + 1)]; const u = Math.max(0, Math.min(1, (level - p) / (q - p))); at.set(key, horizontal ? [a + u, b] : [a, b + u]); } return key; }; for (let j = -1; j < ny; j++) { for (let i = -1; i < nx; i++) { const corners = [v(i, j), v(i + 1, j), v(i + 1, j + 1), v(i, j + 1)]; const index = corners.reduce((n, c) => n * 2 + (c > level), 0); let sides = EDGES[index]; if (!sides) continue; const high = corners.reduce((a, b) => a + b) / 4 > level; // a saddle: which way it joins if (sides.length > 4) sides = high ? sides.slice(0, 4) : sides.slice(4); for (let k = 0; k < sides.length; k += 2) { next.set(edge(i, j, sides[k]), edge(i, j, sides[k + 1])); } } } const loops = []; for (const start of [...next.keys()]) { const loop = []; for (let k = start; next.has(k);) { loop.push(at.get(k)); const n = next.get(k); next.delete(k); k = n; } if (loop.length > 4) loops.push(loop); } return loops; } const chaikin = (loop) => loop.flatMap((p, i) => { const q = loop[(i + 1) % loop.length]; return [[0.75 * p[0] + 0.25 * q[0], 0.75 * p[1] + 0.25 * q[1]], [0.25 * p[0] + 0.75 * q[0], 0.25 * p[1] + 0.75 * q[1]]]; }); const r1 = (n) => Math.round(n * 10) / 10; // The window win = { x0, x1, y0, y1 } in km, drawn w × h units; field(x, y) gives the value. function raster(win, w, h, field, cell = 1) { const [nx, ny] = [Math.ceil(w / cell) + 1, Math.ceil(h / cell) + 1]; const grid = new Float32Array(nx * ny); for (let j = 0; j < ny; j++) { for (let i = 0; i < nx; i++) { grid[j * nx + i] = field(win.x0 + i * (win.x1 - win.x0) / (nx - 1), win.y0 + j * (win.y1 - win.y0) / (ny - 1)); } } const k = w / (win.x1 - win.x0); // units per km const X = (x) => r1((x - win.x0) * k), Y = (y) => r1((y - win.y0) * k); const above = (level, g = grid) => contours(g, nx, ny, level).map((loop) => { const p = chaikin(chaikin(loop)); return `M${p.map(([i, j]) => `${r1(i * w / (nx - 1))} ${r1(j * h / (ny - 1))}`).join('L')}Z`; }).join(''); const masked = (keep) => grid.map((value, n) => (keep(win.x0 + (n % nx) * (win.x1 - win.x0) / (nx - 1), win.y0 + Math.floor(n / nx) * (win.y1 - win.y0) / (ny - 1)) ? value : -1e9)); return { X, Y, k, w, h, above, masked, grid, nx, ny }; } const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" ` + `width="${w * 10}" height="${h * 10}">${body}</svg>`; const fill = (d, id, more = '') => (d ? `<path d="${d}" fill="${palette[id]}" ` + `fill-rule="evenodd"${more}/>` : ''); const line = (d, id, width, more = '') => `<path d="${d}" fill="none" stroke="${palette[id]}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${more}/>`; const open = (pts) => [pts[0], ...pts.slice(1).flatMap((q, i) => { // Chaikin on an open line const p = pts[i]; return [[0.75 * p[0] + 0.25 * q[0], 0.75 * p[1] + 0.25 * q[1]], [0.25 * p[0] + 0.75 * q[0], 0.25 * p[1] + 0.75 * q[1]]]; }).slice(1, -1), pts[pts.length - 1]]; const smooth = (pts) => open(open(pts)); // an open line, rounded const path = (s, pts) => `M${pts.map(([x, y]) => `${s.X(x)} ${s.Y(y)}`).join('L')}`; const landRuns = (pts) => { // the stretches of a line that run over land const runs = [[]]; for (const p of dense(pts, 3)) { if (landness(...p) > 0) runs[runs.length - 1].push(p); else if (runs[runs.length - 1].length) runs.push([]); } return runs.filter((run) => run.length > 1); }; const onLand = (pts) => { // a river line cut where it reaches the sea const out = []; for (let i = 0; i < pts.length; i++) { out.push(pts[i]); if (i && landness(...pts[i]) < 0.6) break; } return out; }; const dense = (pts, step = 1.5) => pts.slice(1).flatMap(([bx, by], i) => { const [ax, ay] = pts[i], n = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) / step)); return Array.from({ length: n }, (_, k) => [ax + (bx - ax) * k / n, ay + (by - ay) * k / n]); }).concat([pts[pts.length - 1]]); const lakePath = (s) => { const [cx, cy, rx, ry] = [s.X(LAKE.x), s.Y(LAKE.y), LAKE.rx * s.k, LAKE.ry * s.k]; return `M${cx - rx} ${cy}a${rx} ${ry} 0 1 0 ${2 * rx} 0a${rx} ${ry} 0 1 0 ` + `${-2 * rx} 0Z`; }; const BANDS = [[0, 'low'], [200, 'hill'], [500, 'upland'], [1000, 'mount'], [1800, 'peak']]; function reliefMap([w, h]) { // the spread: hypsometric tints, rivers, borders, towns, a scale const height = (x, y) => { const e = elevation(x, y); return Number.isNaN(e) ? 30 : e; }; const s = raster(WIN.relief, w, h, height, 0.9); let out = `<rect width="${w}" height="${h}" fill="${palette.deep}"/>` + fill(s.above(-160), 'sea'); for (const [level, id] of BANDS) out += fill(s.above(level), id); // Hill shading lit from the north-west: the slopes that face away darken, the others lighten. const { nx, ny } = s; const shade = s.grid.map((e, n) => { const [i, j] = [n % nx, Math.floor(n / nx)]; if (e <= 0 || i === 0 || j === 0 || i === nx - 1 || j === ny - 1) return 0; const dx = s.grid[n + 1] - s.grid[n - 1], dy = s.grid[n + nx] - s.grid[n - nx]; return (dx + dy) / 900; // grows with the slope down to the south-east }); out += fill(s.above(0.35, shade), 'ink', ' fill-opacity="0.13"') + fill(s.above(0.9, shade), 'ink', ' fill-opacity="0.1"') + fill(s.above(0.35, shade.map((v) => -v)), 'paper', ' fill-opacity="0.22"'); out += fill(lakePath(s), 'sea') + line(lakePath(s), 'river', 0.5); for (const river of Object.values(RIVERS)) { out += line(path(s, smooth(onLand(dense(river)))), 'river', 0.9); } for (const run of BORDERS.slice(0, 2).flatMap(landRuns)) { out += line(path(s, run), 'border', 0.7, ' stroke-dasharray="2.4 1.2"'); } out += line(path(s, PROFILE), 'ink', 0.5, ' stroke-dasharray="1 1"'); const dot = (x, y, r, more = '') => `<circle cx="${s.X(x)}" cy="${s.Y(y)}" r="${r}" ` + `fill="${palette.ink}"${more}/>`; const ring = ` stroke="${palette.paper}" stroke-width="0.5"`; for (const [x, y] of PROFILE) out += dot(x, y, 1); for (const [, x, y, kind] of TOWNS) { out += kind === 'capital' ? `<rect x="${s.X(x) - 1.4}" y="${s.Y(y) - 1.4}" width="2.8" height="2.8" ` + `fill="${palette.ink}"${ring}/>` : dot(x, y, 1.2, ring); } for (const [, x, y] of PEAKS) { const [px, py] = [s.X(x), s.Y(y)]; out += `<path d="M${px - 1.5} ${py + 1.1}L${px} ${py - 1.5}L${px + 1.5} ${py + 1.1}Z" ` + `fill="${palette.ink}"/>`; } // A scale of 50 km in the south-west sea, and the north arrow above it. const [sx, sy] = [s.X(SCALE.x), s.Y(SCALE.y)], step = 25 * s.k; out += `<rect x="${sx}" y="${sy}" width="${step}" height="1.4" fill="${palette.ink}"/>` + `<rect x="${sx + step}" y="${sy}" width="${step}" height="1.4" fill="${palette.paper}" ` + `stroke="${palette.ink}" stroke-width="0.3"/>`; const [ax, ay] = [s.X(NORTH.x), s.Y(NORTH.y)]; out += `<path d="M${ax} ${ay - 7}L${ax + 2.6} ${ay + 1}L${ax} ${ay - 1}L${ax - 2.6} ${ay + 1}Z" ` + `fill="${palette.ink}"/>`; return svg(w, h, out + `<rect x="0.15" y="0.15" width="${w - 0.3}" height="${h - 0.3}" ` + `fill="none" stroke="${palette.ink}" stroke-width="0.3"/>`); // the neat line } function locatorMap([w, h]) { // Varenia among its neighbours, flat tints const s = raster(WIN.locator, w, h, landness, 1); const north = (x) => interp(BORDERS[0], x); const east = (y) => interp(BORDERS[1].map(([a, b]) => [b, a]), y); const inVarenia = (x, y) => y > north(x) && x < east(y) && x > -200; let out = `<rect width="${w}" height="${h}" fill="${palette.sea}"/>` + fill(s.above(0), 'land') + fill(s.above(0, s.masked(inVarenia)), 'country'); for (const run of BORDERS.flatMap(landRuns)) { out += line(path(s, run), 'border', 0.5, ' stroke-dasharray="1.6 0.8"'); } out += line(s.above(0), 'river', 0.3); const [, x, y] = TOWNS[0]; out += `<rect x="${s.X(x) - 1.2}" y="${s.Y(y) - 1.2}" width="2.4" height="2.4" ` + `fill="${palette.ink}"/>`; return svg(w, h, out); } function interp(pts, x) { // a polyline read as y(x); outside it, its end values if (x <= pts[0][0]) return pts[0][1]; for (let i = 1; i < pts.length; i++) { const [[ax, ay], [bx, by]] = [pts[i - 1], pts[i]]; if (x <= bx) return ay + (by - ay) * (x - ax) / (bx - ax); } return pts[pts.length - 1][1]; } function profile([w, h]) { // the section A–B: heights ×16, tinted by band, a rule each 500 m const n = 300, [[ax, ay], [bx, by]] = PROFILE, top = PROFILE_TOP, [x0, y0, y1] = [14, 3, h - 7]; const Y = (m) => r1(y1 - m * (y1 - y0) / top); const pts = Array.from({ length: n + 1 }, (_, i) => { const e = elevation(ax + (bx - ax) * i / n, ay + (by - ay) * i / n); return [r1(x0 + i * (w - x0) / n), Y(Math.max(0, Number.isNaN(e) ? 0 : e))]; }); const ground = `M${x0} ${y1}L${pts.map(([x, y]) => `${x} ${y}`).join('L')}L${w} ${y1}Z`; let out = `<defs><clipPath id="ground"><path d="${ground}"/></clipPath></defs>`; for (let m = 500; m < top; m += 500) out += line(`M${x0} ${Y(m)}H${w}`, 'rule', 0.3); out += '<g clip-path="url(#ground)">'; BANDS.forEach(([level, id], i) => { const next = BANDS[i + 1]?.[0] ?? top; out += `<rect x="${x0}" y="${Y(next)}" width="${w - x0}" height="${r1(Y(level) - Y(next))}" ` + `fill="${palette[id]}"/>`; }); const crest = line(`M${pts.map(([x, y]) => `${x} ${y}`).join('L')}`, 'relief', 0.6); return svg(w, h, `${out}</g>${crest}${line(`M${x0} ${y1}H${w}`, 'ink', 0.5)}` + line(`M${x0} ${y0}V${y1}`, 'ink', 0.4)); } // #endregion // #region anchors: the points of each map where a name stands, the same in every edition const above = { verticalAlign: 'bottom' }, below = { verticalAlign: 'top' }; const r = km(WIN.relief), l = km(WIN.locator); const ANCHORS = { relief: [ r('ocean', 172, 157, 'ocean'), r('lira', 20, 52, 'sea', { rotate: -84 }), r('gulf', 318, 154, 'sea', { width: 0.07 }), r('nema', 22, 100, 'island', above), r('albar-range', 98, 108, 'range', { rotate: -33 }), r('gris-range', 308, 96, 'range', { rotate: -86 }), r('plateau', 236, 78, 'region'), r('plain', 70, 98, 'region', { width: 0.08 }), r('sela', 108, 62, 'river', { rotate: -4 }), r('duna', 121, 136, 'river', { rotate: -62 }), r('merel', 281, 92, 'river', { rotate: -47 }), r('ormo', 254, 104, 'river', above), ...TOWNS.map(([id, x, y]) => r(id, x, y - 2.6, id === 'almaren' ? 'capital' : 'town', above)), ...PEAKS.map(([id, x, y]) => r(`${id}-peak`, x, y + 2.4, 'peak', below)), r('ostelia', 300, 9, 'country'), r('brunavia', 390, 112, 'country', { rotate: -90 }), r('varenia', 250, 46, 'nation'), r('a', PROFILE[0][0], PROFILE[0][1] - 2, 'point', above), r('b', PROFILE[1][0], PROFILE[1][1] + 2, 'point', below), r('scale-0', SCALE.x, SCALE.y + 3, 'tick', below), r('scale-25', SCALE.x + 25, SCALE.y + 3, 'tick', below), r('scale-50', SCALE.x + 50, SCALE.y + 3, 'tick', below), r('north', NORTH.x, NORTH.y - 8, 'point', above), ], locator: [ l('varenia', 205, 80, 'nation'), l('ostelia', 150, -100, 'country'), l('brunavia', 560, -120, 'country'), l('lira', 210, -250, 'sea', { rotate: 20 }), l('ocean', -180, -40, 'ocean', { width: 0.2 }), l('almaren', 259, 127 + 22, 'capital', below), ], // The section: fractions of the drawing, whose plot starts 14 mm in and runs 3 to 51 mm down. section: (() => { const [w, h] = MAP.section, x = (u) => (14 + u * (w - 14)) / w; const y = (m) => (h - 7 - m * (h - 10) / PROFILE_TOP) / h; return [ { id: 'a', x: x(0), y: (h - 5.5) / h, style: 'point', ...below }, { id: 'b', x: x(0.98), y: (h - 5.5) / h, style: 'point', ...below }, ...[500, 1000, 1500, 2000].map((m) => ({ id: `m${m}`, x: 7 / w, y: y(m), style: 'tick' })), { id: 'sela', x: x(0.3), y: y(700), style: 'region' }, { id: 'crest', x: x(0.47), y: y(2260), style: 'range', ...above }, { id: 'plain', x: x(0.82), y: y(650), style: 'region' }, ]; })(), }; // #endregion // #region resources: three drawings, their anchors and their alternative text const ALT = t({ es: { locator: 'Mapa de situación: Varenia, en amarillo, entre Ostelia, Brunavia, el mar de Lira ' + 'y el océano Brumoso.', relief: 'Mapa físico de Varenia en tintas de altitud, con la sierra Albar, los montes ' + 'Grises, los ríos Sela, Duna y Merel y el corte A–B.', section: 'Perfil topográfico de A a B: el valle del Sela, la sierra Albar y la llanura ' + 'costera.', }, en: { locator: 'Locator map: Varenia, in yellow, between Ostelia, Brunavia, the Lira Sea and the ' + 'Misty Ocean.', relief: 'Relief map of Varenia in height tints, with the Albar Range, the Grey Hills, the ' + 'rivers Sela, Duna and Merel and the line of section A–B.', section: 'Section from A to B: the Sela valley, the Albar Range and the coastal plain.', }, ar: { locator: 'خريطة الموقع: فارينيا باللون الأصفر بين أوستيليا وبرونافيا ' + 'وبحر ليرا والمحيط الضبابي.', relief: 'خريطة طبيعية لفارينيا بألوان الارتفاع، فيها سلسلة ألبار ' + 'والتلال الرمادية وأنهار سيلا ودونا وميريل وخط المقطع أ–ب.', section: 'مقطع تضاريسي من أ إلى ب: وادي سيلا وسلسلة ألبار ' + 'والسهل الساحلي.', }, }); const resources = Object.entries(MAP).map(([id, [w, h]]) => ({ id, typeId: 'map', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, altText: ALT[id], anchors: ANCHORS[id] })); const drawings = { locator: locatorMap, relief: reliefMap, section: profile }; for (const [id, draw] of Object.entries(drawings)) await loadSvg(`${id}.svg`, draw(MAP[id])); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Source Sans 3': ['400', '400i', '600', '700'], // text, boxes and map labels 'Source Serif 4': ['700'], // the sheets' titles 'Noto Naskh Arabic': ['400', '700'], // the Arabic text and boxes 'Noto Kufi Arabic': ['500', '700'], // the Arabic titles and map labels }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadArabicFonts(FONTS, markdown + JSON.stringify(resources)); const doc = await withLoadedFonts(() => buildDocument({ markdown, resources }, config), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ es: 'Atlas escolar de Varenia', en: 'A school atlas of Varenia', ar: 'أطلس فارينيا المدرسي' }) });工具包 · core, fonts, viewer, images, arabic, book:每道食谱都相同 · 410行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · arabic v2 ── Arabic-script faces · postext.dev/cookbook // Fontsource ships an Arabic family as one file per subset and weight: the // `arabic` file holds the letters, the harakat, the Arabic-Indic digits, the // Arabic punctuation and the presentation forms; `latin` and `latin-ext` // hold the rest. loadFonts loads the latin files; this block adds the arabic // file of every Arabic family, for the canvas and for the PDF, which shapes // the letters with HarfBuzz from the same bytes. /** The code points of Fontsource's `arabic` subset, as its stylesheets * declare them (the same unicode-range the browser picks the file by). A * function, not a const: the kit is inlined after the recipe's top-level * awaits, and a const read before its line throws, where a function * declaration is hoisted. */ function arabicRange() { return 'U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,' + 'U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC,U+102E0-102FB,' + 'U+10E60-10E7E,U+10EC2-10EC4,U+10EFC-10EFF,U+1EE00-1EEFF'; } /** Whether code point `cp` is in the arabic file. */ function inArabicRange(cp) { inArabicRange.ranges ??= arabicRange().split(',').map((part) => { const [lo, hi = lo] = part.slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return inArabicRange.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); } /** Whether Fontsource serves `family` with an `arabic` subset. Fails when * the API does not answer: an Arabic face taken for a Latin one would set * its letters in a system face. */ async function isArabicFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.includes('arabic'); } /** The arabic file of a face. */ async function arabicFileUrl(family, weight, style) { const id = fontsourceId(family); return `${await fontsourceAt(id)}files/${id}-arabic-${weight}-${style}.woff2`; } /** faces = { Amiri: ['400', '700'] }, as for loadFonts, after it: the whole * FONTS object may be passed, its families without an arabic subset are * left alone. Adds the arabic file of every listed weight of each Arabic * family (the latin files come from loadFonts) and loads it. `text` is * the sample: fails when it holds an Arabic-script character the arabic * file does not cover. List every weight the pages set in Arabic: a weight * left to the engine's later round gets the latin file only, and its Arabic letters * fall back to a system face. Resolves to the number of files loaded. */ async function loadArabicFonts(faces, text = '') { kitStatus('Loading fonts…'); let loaded = 0; try { const outside = [...new Set(text)].filter((ch) => /\p{Script=Arabic}/u.test(ch) && !inArabicRange(ch.codePointAt(0))); if (outside.length) throw new Error(`Fontsource's arabic files have no ${outside.slice(0, 12).join(' ')}`); for (const [family, specs] of Object.entries(faces)) { if (!(await isArabicFamily(family))) continue; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const face = new FontFace(family, `url(${await arabicFileUrl(family, weight, style)}) format('woff2')`, { weight: String(weight), style, unicodeRange: arabicRange() }); document.fonts.add(await face.load().catch(() => { throw new Error(`Fontsource has no arabic file for ${family} ${weight} ${style}`); })); loaded++; } } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with Arabic faces: a family with an * arabic subset gets its arabic file when its pages set Arabic letters * (`request.codePoints`), then its latin file, and its latin-ext file for * the letters beyond latin (transliteration: ā ḥ ʿ), each marked with its * unicode-range and its place in the order the screen added them (latin * and latin-ext by loadFonts, then arabic): the arabic file also has a * space and brackets, which the browser takes from latin, the file whose * range holds them, and postext-pdf follows it. Any other family goes to * fontsourceProvider (the "pdf" block). */ async function arabicPdfProvider(family, weight, style, request) { if (!(await isArabicFamily(family))) return fontsourceProvider(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const wanted = [...(request?.codePoints ?? [])]; const id = fontsourceId(family); const subsets = !wanted.length || wanted.some(inArabicRange) ? ['arabic', 'latin'] : ['latin']; if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) { subsets.push('latin-ext'); } const at = await fontsourceAt(id); return Promise.all(subsets.map(async (subset) => { const url = `${at}files/${id}-${subset}-${w}-${s}.woff2`; const res = await fetch(url); if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: subset === 'arabic' ? arabicRange() : kitRanges()[subset], order: ['latin', 'latin-ext', 'greek', 'arabic'].indexOf(subset) }); })); } // ─── Kit · book v1 ── books bound on either edge · postext.dev/cookbook // A book bound on the right (Arabic, Hebrew or Persian text, vertical // Chinese, or page.binding 'right') opens from what a Latin reader calls // the back: page 1 lies alone on the left of the spine, then [3 | 2]. /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right', for * text that runs right to left and for vertical text, when the binding is * left to 'auto') lies on the desk as it opens: page 1 alone on the left * of the spine, then [3 | 2], the spine shade on each page's inner edge. * `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-book')) { // The pages keep direction ltr, as in a left-bound book: a canvas takes // the direction its element inherits, and under the spread's rtl a run // painted for an ltr canvas would end where the engine starts it. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-book"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] 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); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗ (在新标签页中打开)
变化
#给没有锚点的地点写标注
标注也可以放在按图片百分比给出的点上,并使用自己的样式。
:::figure{resource="relief" slot="map"}
+::label[Faro de Nema]{at="7% 68%" style="town"}
::label[Océano Brumoso]{at="ocean"}#给地图编号并加题注
给地图类型设一个计数器和前缀,再给每个资源写题注:参见带编号地图的地图册图版,它把地图题注放在一条蓝色横条上。
常见问题
版面警告 · labelAnchorUnknown
标注指向不存在的锚点
原因. 某个 :::figure 中的 ::label 用 at= 指向了该图资源中不存在的锚点。这个标注不会画在图片上;可重排 EPUB 仍会列出它,图片的替代文本也会提到它。
解决. 修改 at= 中的锚点 id(警告会列出资源已有的锚点),在资源中添加该锚点,或用 at="x% y%" 指定位置。 文档 →
版面警告 · anchorUnlabelled
锚点缺少标注
原因. 图资源的某个锚点设置了标注样式,但在当前语言中图里没有任何 ::label 位于此处:地图为这个词留了位置,这一版本却缺了它。这只是提示,不是错误;没有样式的锚点(例如漫画角色的锚点)不会报告。
解决. 在该语言的 Markdown 中写上标注(::label[…]{at="锚点"});如果这一版本不需要这个词,就删除锚点上的样式。 文档 →
- 每幅地图的替代文本写在资源里(
ALT),每个版本一条:资源不从 Markdown 取文字,所以这是页面上唯一不在content.<lang>.md里的文字。 - 在从右到左的页面上,框随版心镜像,模板的装饰却不镜像:要像
kicker那样按版本放置眉题。 - 分两栏的框可能在段落最后一行之前断开,把这一行留在第二栏顶端。请逐个版本检查分栏,遇到这种情况就把一句话挪到下一段。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- Source Sans 3 (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1) · Noto Naskh Arabic (SIL OFL 1.1) · Noto Kufi Arabic (SIL OFL 1.1)


