かんたんな説明
架空の国の学校地図帳から3ページ。地図は文字のない絵で、地名はすべて本文に書くので、翻訳では言葉だけを替え、地図はそのまま使えます。
できあがり
架空の国ヴァレニアの学校地図帳から、図版を3枚組みます。図版1は大陸の中での位置を示します。図版2は地形図を左右2ページに広げ、リラ海からブルナビアとの国境まで、段彩で高さを表し、3本の川と断面線を描きます。図版3は、その断面線に沿ってアルバル山脈を切った断面図です。どの図版も青い帯で始まり、凡例か用語集と、授業で使う問題で終わります。絵には文字が1つもありません。43の地名、標高、縮尺の数字は、どれもMarkdownの::label行で、それぞれの地図が持つアンカーの位置に置かれます。そのためスペイン語版・英語版・アラビア語版は同じ3枚の絵を共有し、アラビア語版は右から読み、右綴じになります。
このレシピが答える質問
- 地図の地名を本文の側から入れて、翻訳では絵に手を付けずに言葉だけを替えるには?
- 1枚の地図を見開きに広げ、右から左に読む版でも地名を読みやすく保つには?
手短な答え
// 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 });
材料
- 機能
- 図に載せるラベル見開きのテンプレートテンプレートページグループの中で伸びる枠色見本右から左のテキスト右綴じの本アラビア語のフォントリソースとしての図と表独自のリソースの種類囲み番号付きリスト段落スタイルページデザインのテキスト・罫・ボックス見開きの余白セマンティックカラーパレットキャンバス上のページ
- 併用する機能
- 枠に固定した飾り
- 種類
- Source Sans 3, Source Serif 4, Noto Naskh Arabic, Noto Kufi Arabic (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 場所は絵の上の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 · 3つのMarkdownに1つのリソース
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が1つ、アンカーの一覧が1つで、すべての版が共有します。各版は:::figure{resource="relief" slot="map"}の中に、アンカーごとに::label[…]{at="…"}を1行書きます。::label[Llanura de Calvera]{at="plain"}と::label[Calvera Plain]{at="plain"}は同じ位置に置かれ、必要ならアンカーのwidthで2行に折り返されます。版によって変わるのは代替テキストだけです。リソースはMarkdownから言葉を受け取らないからです。スタイルを持つアンカーにある版がラベルを書かないとanchorUnlabelledが出ます。atが存在しないアンカーを指すラベルは描かれず、labelAnchorUnknownが出ます。
#3 · 1ページの図版と見開きの図版
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) }],
};
2つのテンプレートは同じ5つの枠を持つので、図版のMarkdownは1ページでも2ページでも同じ形になります。タイトル、スロットを指定しない本文、地図、凡例、問題です。見開きの図版ではspread: trueが2ページを幅390 mmの1枚のシートとして組み、crossGutterを持つのは地図の枠だけです。地図はのどをまたぎ、本文と囲みはそれぞれのページに収まります。本文、地図、凡例はstackグループです。テストベンチの負荷テストですべての文章を30 %長くすると、アラビア語版は位置図を87.5 %に縮め、その下の囲みが上に詰まります。警告は出ません。
#4 · ページ、段彩、凡例に1つのパレット
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' } }));
5段階の高さの色はパレットの項目です。絵はpalette.lowからpalette.peakで各層を塗り、図版2の凡例は同じIDで:swatch{color="low"}を書きます。16進値を1つ変えれば、地図と凡例が同時に変わります。水域の名前に使う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)に、版ごとに1つずつあります。リソースはMarkdownから言葉を受け取らないので、ページの中でcontent.<lang>.mdの外にある文章はこれだけです。 - 右から左のページでは、枠は版面といっしょに反転しますが、テンプレートの装飾は反転しません。
kickerのように、小見出しの位置を版ごとに決めてください。 - 2段組の枠は、段落を最後の1行の手前で切り、その1行を2段目の頭に残すことがあります。版ごとに段を確かめ、そうなったら1文を次の段落に移してください。
クレジット
- 本文
- 書き下ろしの文章, 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)


