かんたんな説明
山の地形を扱う教科書の1章を、番号付きの図7点とともに2段で組みます。本文が初めて図にふれたあとの最初の空いた場所へ、各図が移っていくしくみを示します。
できあがり
200 × 250 mmのページに組む地形学の教科書『Mountain Landforms』の第2章です。章扉では、章番号を入れた青いリボンが天から垂れ下がり、その横に表題の氷青色の板が置かれます。続いてFaustinaの両端そろえの2段が流れます。拡散モデルで生成した絵にコードでラベルを付けた7点の図には、初出順に番号が振られます。6点は、最初に引用した段落から数えて、配置の指定が許す最初の空いた場所へフロートします。autoの図2.1は章扉の下部に、図2.3は28ページの右段の先頭に、図2.2は同じページの下部に収まります。図2.4と図2.5は29ページの2つの段の先頭に入ります。図2.6は本文が埋め込んだ位置に組まれます。図2.7は30ページで引用されるtopのフロートなので31ページを待つはずですが、フロートは章の外に出られないため、30ページの下部に入ります。
このレシピが答える質問
- 番号付きキャプションのある図を加え、本文で参照するには(「図3.2を参照」)?
- 図の位置を制御するには(ページの上端、2段をまたぐ、まさにこの位置、余白)?
- 「図」や「表」のラベルを文書の言語で表示するには?
- 画像や表を、Markdownの![]()ではなくコードから(リソースとして)追加するには?
手短な答え
// In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1.
// Captions, credits and alt texts come from content.figures.<lang>.md.
const figure = (id, height, placement) => {
if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`);
const [caption, note, altText] = TEXTS[id];
// An SVG fills the width of its slot (a column or the text block, or a fraction of
// either), so its width and height only give its shape.
const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1);
return { id, typeId: 'figure', kind: 'svg', caption, note, altText,
svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 };
};
// In any order: the first mention of each one in the text, a :ref or a ::resource line,
// decides its number.
const resources = [
// Cited on the opener page: 'auto' may take that page's foot band, where 'top'
// could only open the next page (gotcha: top-float-next-page).
figure('valleys', 56, { position: 'auto', span: 'page' }),
// Across both columns, but only in a foot band: the page it is cited on, if both
// columns still have room there, else the foot of the next page.
figure('profile', 60, { position: 'bottom', span: 'page' }),
// A column figure that takes only a column head: the next one still empty after its
// citation, here the right column of the same page, above the text that follows it.
figure('cirque', 48, { position: 'top' }),
// Cited in the same sentence, the two take the next two column heads, side by side.
figure('abrasion', 48, { position: 'top' }),
figure('plucking', 48, { position: 'top' }),
// No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline
// figure gets a grid line above it but only the grid snap below, so the Markdown follows
// it with :::space{lines=1} (gotcha: here-figure-no-space-after).
figure('roche', 42, { position: 'here' }),
// A band of its own, half the text width and centred. It is cited on the chapter's last
// page, where a 'top' float would wait for the next page; a float cannot leave its
// chapter, so this one goes to the foot of the last page. A float is queued where its
// citing paragraph starts, so that paragraph starts on the last page
// (gotcha: float-queues-at-paragraph).
figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }),
];
材料
- 機能
- 図を配置する参照図の配置番号付きキャプションその場に置く図フロートの障壁「図」と「表」を文書の言語でキャプションのスタイル出典とクレジットリソースとしての図と表独自のリソースの種類デザインした章扉段抜きの章見出し帯見出しの属性番号付き見出しハイフネーションと文書の言語柱とノンブルページの役割ごとの柱セマンティックカラーパレット明示的な縦のアキ段末そろえ
- 併用する機能
- 段落スタイル
- 種類
- Faustina, Montserrat, IBM Plex Sans Condensed (SIL OFL 1.1)
- 素材
abrasion-1080.jpgcirque-1080.jpgmoraines-1080.jpgplucking-1080.jpgprofile-1400.jpgroche-1080.jpgvalleys-1536.jpg- Figure 2.1: a V-shaped river valley and a U-shaped glacial valley (Generated With Diffusion Models, オリジナル)
- Figure 2.2: long profile of a valley glacier (Generated With Diffusion Models, オリジナル)
- Figure 2.3: a glacial cirque in section (Generated With Diffusion Models, オリジナル)
- Figure 2.4: abrasion at the base of the ice (Generated With Diffusion Models, オリジナル)
- Figure 2.5: plucking on a rock step (Generated With Diffusion Models, オリジナル)
- Figure 2.6: a roche moutonnée (Generated With Diffusion Models, オリジナル)
- Figure 2.7: lateral, medial and terminal moraines in plan (Generated With Diffusion Models, オリジナル)
作り方
#1 · ページと図にひとつのパレット
const palette = {
ink: '#1b2227', // text: a cold near-black
glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water
ice: '#e3f1f8', // the opener slab
rock: '#5b5a57', // bedrock in the drawings
moss: '#7d8f4e', // valley floors and pines
rule: '#c6d3db', // the hairline under the running heads
muted: '#5d6a72', // running heads, credit notes, the colophon
paper: '#ffffff',
};
// A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read
// the hex, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
// The engine's defaults link to 'main-color' (#295aa3): pointing it at the accent keeps
// that second blue off the page.
{ id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } },
];
設定のどの色もこのパレットのいずれかの項目にリンクしています。絵も同じスレート、氷河の青、苔の色で生成したので、図はページの色になじみます。絵は自分の色を保ちます。glacierの値を変えると、リボン、板、ラベルの色は変わりますが、図の中の氷の色は変わりません。アクセントの色は小さな文字にも使える暗さです。キャプションのラベルと引用では白に対して5.2:1、キッカーでは氷青色の板に対して4.5:1です。各図は、絵をJPEGのデータURLとして埋め込み、その上に細い白の縁取り付きの文字でラベルを重ねたSVGです。こうするとラベルはくっきりと保たれ、版の言語に従います。画像として読み込んだSVGはページのWebフォントを使えないので、ラベルのIBM Plex Sans Condensedは各SVGに埋め込んでいます。
#2 · 図の名前を読み手の言語で
const captions = () => ({
// config.locale sets hyphenation, not captions (gotcha: resource-types-locale):
// 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter.
resourceTypes: defaultResourceTypes(LANG),
captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default
note: { color: col('muted'), gap: mm(0.6) }, // the credit line
},
});
defaultResourceTypes(LANG)はサンプルの言語で種類に名前を付けます。キャプションは、英語版ではFigure 2.3、スペイン語版ではFigura 2.3となります。locale: 'es'だけでは本文はスペイン語でハイフネーションされても、キャプションはすべて英語のままです。各引用は、その文に必要な形をとります。case="lower"で(fig. 2.1)、さらにstyle="full"を加えて(figure 2.2)、複数形のあとではstyle="number"(「figures 2.4 and 2.5」)、そしてtext="…"で、番号を印字しない「the chapter’s first figure」のような語句になります。この語句は、すでに配置された図を指し戻すためのものです。初出として使うと、やはり図に番号が振られ、配置されます。
#3 · 章扉のリボンと氷の板
const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge },
offset: { x: mm(x), y: mm(y) },
...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
align: 'left', ...extra });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em
// Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default).
const wrap = { overflow: 'wrap' };
const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length
const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon
const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst
const opener = {
enabled: true,
// At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines,
// so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab.
minHeight: mm(SLAB + 5),
slot: { elements: [
{ kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge
placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) },
{ kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head
placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) },
text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall:
at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot
{ fontWeight: 800, lineHeight: 1, align: 'center' }),
text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}',
es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier',
at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }),
text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
{ fontWeight: 800, lineHeight: 1.06, ...wrap }),
text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
{ italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }),
] },
};
const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads
const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier',
at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [ // outer corners, over a hairline; never on the opener
folio('verso-folio', 'even', 'top-left', OUTER),
head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
{ kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'),
thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE),
size: { width: 'fill', height: 'auto' } } },
] };
const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text
text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9),
{ fontWeight: 800, align: 'center', pages: 'opener' })] };
章扉は2つのボックスと4つの文字でできています。天から垂れ下がり下端に章番号を入れたリボン、小口の外へはみ出す氷の板、見出しのtopic属性から取るキッカー、その下に連ねた表題とリード文です。minHeightは板と、その下の少なくとも5 mmを確保し、グリッドの行単位に切り上げます(ここでは約7 mm)。こうして図2.1も同じページの下部の帯に収まります。柱は小口側の隅に、ruleの色の細罫の上に置かれ、pages: 'body'で章扉には付けません。章扉には代わりにページの下にノンブルを入れます。
#4 · ビルドの前に未知のidをはじく
// An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1
// warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the
// mentions exactly as numbering and placement read them; an embed needs double quotes
// (gotcha: resource-double-quotes).
function checkFigures() {
const [named, embedded] = [[], new Set()];
for (const block of parseMarkdown(markdown)) {
if (block.type === 'resourceBlock' && block.resourceId) {
named.push(block.resourceId);
embedded.add(block.resourceId);
}
for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId);
}
const ids = resources.map((r) => r.id);
const types = new Set(captions().resourceTypes.map((type) => type.id));
const problems = [
...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`),
...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`),
...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`),
...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id))
.map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`),
...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`),
];
if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`);
}
どのリソースにもないidを指す:refは「?」と印字され、だれも言及しない図は配置されません。Sandboxは前者について警告しますが、postext 1.4.1はCodePenのサンプルにどちらの警告も出しません。このチェックはエンジン自身のパーサーであるparseMarkdownでMarkdownを読むので、番号付けと配置が読むすべての:refと::resourceを見つけられます。未知のidや重複したid、引用されない図、::resourceの行がないhereの図、存在しない種類を、最初のページを組む前に1つのエラーにまとめます。
#5 · 本の現在位置から数え始める
const face = await labelFace();
for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId
const art = await dataUrl(PAINTINGS[id]);
await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" `
+ `height="${n2(height)}" preserveAspectRatio="none"/>${DRAWINGS[id](width, height)}`));
}
// One chapter came before: figures number 2.1, 2.2… and the folios start at 27.
const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1
headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
() => buildDocument({ markdown, resources, continuation }, config()), words);
showPages(doc, { title: t({ en: 'Figures that float to where you cite them',
es: 'Figuras que flotan hasta donde las citas' }) });
これは長い本の第2章なので、continuationには前に1章あることを記録します。{h1}.{n}で番号を振る図は2.1から、ノンブルは27から始まります。28ページでは図2.3が図2.2の上に立ちます。図2.2の番号が小さいのは、本文がそちらを先に引用しているからです。Markdownの![]()は取り除かれるので、図はすべてリソースで、そのSVGはビルドの前にsvg.fileIdの名前で登録します。各図の配置は引用される位置から決めます。図2.1は板が章扉の上部を埋めるのでauto、縦断面図は引用したページの下部にまだ入れるのでbottom、段の図は次に空いている段の先頭を占めるのでtopです。図2.7もtopですが、フロートは章の中にとどまるので、30ページの下部に収まります。
レシピの全体
// ═══ Postext Cookbook · Nº 009 · Figures that float to where you cite them ═════════ // https://postext.dev/en/cookbook/figures-float-where-cited // Code: MIT · Text: original (CC BY 4.0) · Figures: diffusion models, labels in code // Fonts: Faustina, Montserrat, IBM Plex Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // // Chapter 2 of a geomorphology textbook. Six of its seven figures float, each to the first // free slot its placement allows, counting from the paragraph that first cites it. Figure 2.6 // is set where ::resource embeds it. The figures are numbered in order of first mention. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, parseMarkdown, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en') const RECIPE = 'figures-float-where-cited'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: eight named colours; the paintings were made to match them const palette = { ink: '#1b2227', // text: a cold near-black glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water ice: '#e3f1f8', // the opener slab rock: '#5b5a57', // bedrock in the drawings moss: '#7d8f4e', // valley floors and pines rule: '#c6d3db', // the hairline under the running heads muted: '#5d6a72', // running heads, credit notes, the colophon paper: '#ffffff', }; // A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read // the hex, not the palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color' (#295aa3): pointing it at the accent keeps // that second blue off the page. { id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } }, ]; // #endregion const TEXT = 'Faustina'; // one family each for text, display and labels const DISPLAY = 'Montserrat'; const LABEL = 'IBM Plex Sans Condensed'; const LEAD = 13.4; // body leading in pt: the grid every float band snaps to const [PAGE_W, PAGE_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [200, 250, 22, 20, 18, 14, 6]; // mm const MEASURE = PAGE_W - INNER - OUTER; // 168 mm: the text block, and a page-wide figure const COLUMN = (MEASURE - GUTTER) / 2; // 81 mm: a column, and a column figure // #region captions: the type name in the document's language; bold label, italic description const captions = () => ({ // config.locale sets hyphenation, not captions (gotcha: resource-types-locale): // 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter. resourceTypes: defaultResourceTypes(LANG), captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2), labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default note: { color: col('muted'), gap: mm(0.6) }, // the credit line }, }); // #endregion // #region furniture: an ice slab off the fore-edge, a ribbon from the head, running heads const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) }); const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), placement, align: 'left', ...extra }); const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em // Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default). const wrap = { overflow: 'wrap' }; const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst const opener = { enabled: true, // At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines, // so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab. minHeight: mm(SLAB + 5), slot: { elements: [ { kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) }, { kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) }, text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall: at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot { fontWeight: 800, lineHeight: 1, align: 'center' }), text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}', es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier', at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }), text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W), { fontWeight: 800, lineHeight: 1.06, ...wrap }), text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W), { italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }), ] }, }; const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted', at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra }); const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier', at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra }); const header = { elements: [ // outer corners, over a hairline; never on the opener folio('verso-folio', 'even', 'top-left', OUTER), head('verso-title', '{title}', 'even', 'top-left', OUTER + 8), head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }), folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }), { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'), thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE), size: { width: 'fill', height: 'auto' } } }, ] }; const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9), { fontWeight: 800, align: 'center', pages: 'opener' })] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) ...captions(), colorPalette, page: { width: mm(PAGE_W), height: mm(PAGE_H), dpi: 150, // a compact textbook trim margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText: { // justified serif; first lines indented 4 mm, except after a heading fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('glacier'), // citations in the accent, like the caption labels they name firstLineIndent: mm(4), indentAfterHeading: false }, headings: { fontFamily: DISPLAY, fontWeight: 800, color: col('ink'), // Columns end flush by adding grid lines above the H2s. Beside a float band a column can // come up several lines short; one line per heading (the default is 4) keeps a section // head from floating in a gap, and the balancer's other levers take what is left. balancing: { maxLinesPerHeading: 1 }, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, fontSize: pt(27), span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}', marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below ], }, unorderedLists: { color: col('glacier'), marginTop: pt(0), marginBottom: pt(0) }, paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.2), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 67行 · content.es.md
title: "Relieves de montaña" subtitle: "Introducción a la geomorfología" --- # Cómo esculpen los glaciares el paisaje {topic="Geomorfología glaciar" lead="El hielo baja por el valle unas decenas de metros al año, demasiado despacio para verlo, pero en unas pocas glaciaciones convierte la estrecha V de un río en una ancha U. La roca guarda cada etapa, del circo a las morrenas."} Quien sube a un valle de alta montaña después de haber recorrido otro excavado solo por un río nota enseguida la diferencia. El valle fluvial es estrecho y tiene forma de V: el río ahonda su cauce y las laderas se desmoronan tras él. El valle por el que pasó un glaciar es ancho, de fondo plano y paredes casi verticales, con la forma de una U (:ref{id="valleys" case="lower"}). El hielo llena el valle de pared a pared y lo lima a la vez por el fondo y por los lados. Hace unos veinte mil años, en el momento de máxima extensión de la última glaciación, el hielo cubría buena parte del norte de Europa y bajaba por los valles del Pirineo hasta cotas inferiores a los mil metros. También había glaciares en los Picos de Europa, en Gredos y en Sierra Nevada. Casi todos han desaparecido, pero el relieve conserva sus huellas con tanta nitidez que se puede reconstruir el tamaño de un glaciar que se fundió hace milenios. Este capítulo explica cuáles son esas huellas y cómo se leen. ## El hielo que fluye Un glaciar nace donde cae más nieve de la que se funde. Año tras año, cada capa queda enterrada bajo la siguiente y su peso expulsa el aire de entre los copos. La nieve recién caída pesa unos cien kilogramos por metro cúbico; al compactarse se convierte en neviza, un material granuloso, y después en hielo compacto y azulado, que supera los ochocientos. En los Alpes la transformación dura unas décadas; en la Antártida, donde nieva tan poco que cada año suma apenas unos centímetros, puede llevar siglos. Todo glaciar tiene dos mitades (:ref{id="profile" style="full" case="lower"}). En la parte alta, la zona de acumulación, cada invierno deja más nieve de la que el verano consigue fundir. En la parte baja, la zona de ablación, ocurre lo contrario: el hielo se pierde y el glaciar solo se mantiene porque le llega hielo de arriba. La frontera entre ambas, la línea de equilibrio, se reconoce a finales del verano como el límite de la nieve del año sobre el hielo desnudo. Si el clima se enfría, la línea baja y el frente avanza; si se calienta, la línea sube y el frente retrocede. Cuando el hielo del circo de cabecera (:ref{id="cirque" case="lower"}) alcanza unas decenas de metros de espesor, empieza a fluir bajo su propio peso. El hielo se deforma despacio y sin romperse, como lo haría una masa de brea, mientras los treinta metros de arriba, con demasiado poco peso encima para fluir, se rompen en grietas. Donde el lecho está húmedo, el glaciar además resbala sobre una película de agua de fusión. Así avanzan los glaciares de valle, entre unas decenas y unos cientos de metros al año, más deprisa en el centro que junto a las paredes, donde el rozamiento los frena. Louis Agassiz lo comprobó en la década de 1840 con una hilera de estacas clavada de un borde a otro del glaciar del Unteraar, en Suiza: con los años, la hilera se curvó valle abajo por el centro. Incluso un glaciar en retirada sigue fluyendo hacia abajo. Si su frente retrocede es porque cada verano se funde allí más hielo del que llega. ## Donde nacen los glaciares Un circo es una hondonada con forma de sillón excavada en la cabecera del valle. El hielo se acumula en el fondo, gira pendiente abajo como en una cuchara y rebaja el suelo por debajo del borde; cuando el glaciar se funde, la cubeta se llena de agua y nace un lago. Para ahondarla, el hielo trabaja con dos herramientas complementarias, las de las figuras :ref{id="abrasion" style="number"} y :ref{id="plucking" style="number"}. La pared del fondo, mientras tanto, retrocede: dos circos que crecen espalda con espalda afilan entre ellos una arista, y tres o más que atacan una misma cumbre la dejan convertida en un pico piramidal, como el Cervino. En el Pirineo, la mayoría de los circos miran al norte o al este, donde la nieve dura más, y muchos guardan uno de esos lagos, que en Aragón llaman ibones. ## Las herramientas del hielo El hielo es más blando que casi cualquier roca y por sí solo apenas la rayaría; erosiona su lecho con dos herramientas. La primera herramienta es la abrasión. Los cantos incrustados en la base del glaciar rayan el lecho como una lija y dejan estrías paralelas que señalan, milenios después, la dirección en que se movía el hielo; el polvo que producen, la harina de roca, da a los lagos glaciares su color turquesa lechoso. La segunda es el arranque: el agua de fusión se cuela en las grietas del lecho, vuelve a helarse y suelda los bloques al hielo, que se los lleva al avanzar. Las dos herramientas actúan a la vez sobre cualquier resalte del lecho, y el resultado es una de las formas más características del paisaje glaciar, la roca aborregada: ::resource{id="roche"} :::space{lines=1} Su cara de aguas arriba, pulida por abrasión, es suave y tendida; la de aguas abajo, donde el glaciar arrancó bloques, es abrupta y rugosa. Basta mirar hacia dónde apunta la cara áspera para saber hacia dónde iba el hielo. El nombre francés, *roche moutonnée*, lo acuñó el naturalista ginebrino Horace-Bénédict de Saussure a finales del siglo XVIII. ## Valles en artesa El trabajo de esas herramientas, sumado durante decenas de miles de años, transforma el valle entero. El glaciar endereza el valle sinuoso del río y trunca los espolones que separaban sus curvas. Ensancha y ahonda el fondo hasta darle el perfil en U de :ref{id="valleys" text="la primera figura del capítulo"}, escalonado en cubetas y umbrales, y el valle de Ordesa, en el Pirineo aragonés, es una artesa de manual. Como un glaciar grueso excava más que uno delgado, el valle principal se hunde más que los de sus afluentes: cuando el hielo desaparece, los valles laterales quedan colgados a cientos de metros sobre el principal y sus arroyos saltan en cascada, como en el valle de Yosemite, en California. Donde el mar ha invadido una artesa se forma un fiordo; el de Sogn, en Noruega, penetra más de doscientos kilómetros tierra adentro y supera los mil trescientos metros de profundidad. ## Lo que deja el glaciar Todo lo que el glaciar arranca acaba depositado en algún sitio. Los derrubios que caen de las laderas viajan sobre los bordes del hielo y forman morrenas laterales; donde dos glaciares se unen, sus morrenas laterales se funden en una morrena central que recorre el hielo como una franja oscura. En el frente, el glaciar suelta su carga como una cinta transportadora y levanta un arco de derrubios, la morrena frontal, que señala hasta dónde llegó la lengua de hielo en su máximo avance. Las morrenas son mezclas caóticas de arcilla, arena, cantos y bloques de todos los tamaños, sin la clasificación que el agua impone a sus sedimentos. Algunos bloques, los erráticos, viajaron decenas de kilómetros y descansan hoy sobre rocas de naturaleza muy distinta. Muchas morrenas frontales retienen lagos (:ref{id="moraines" case="lower"}). El de Sanabria, en Zamora, el mayor lago de origen glaciar de la península, está represado por las morrenas del glaciar que bajaba de la sierra Segundera. ## Cómo leer un paisaje glaciar Bastan cuatro huellas para reconocer el paso de un glaciar por un valle que hoy no tiene hielo: - **El perfil.** Una artesa en U, de fondo plano y paredes abruptas, como la de la :ref{id="valleys" style="full" case="lower"}. - **Los valles colgados.** Afluentes que terminan a media ladera y cuyos arroyos caen en cascada. - **La roca.** Superficies pulidas y estriadas y rocas aborregadas, con la cara abrupta vuelta valle abajo (:ref{id="roche" case="lower"}). - **Los depósitos.** Morrenas sin clasificar, bloques erráticos y los lagos que represan. Ninguna de esas huellas basta por sí sola: un río también pule los cantos, y un desprendimiento deja derrubios caóticos al pie de una ladera. Juntas, y repetidas valle tras valle, prueban que allí hubo un glaciar. ## Glaciares en retirada Los glaciares que quedan en la península son pequeños y están todos en el Pirineo, en las caras norte de sus cumbres más altas: el Aneto, la Maladeta, el Monte Perdido. Han perdido la mayor parte de su superficie desde mediados del siglo XIX, cuando terminó la Pequeña Edad de Hielo, y varios se han reducido a heleros, masas de hielo que ya no fluyen. En muchos veranos, la línea de equilibrio sube hoy por encima de sus cumbres: el glaciar entero queda en la zona de ablación de la :ref{id="profile" style="full" case="lower"} y el hielo que pierde ya no se repone. Lo que queda se refugia a la sombra de las paredes norte, alimentado tanto por los aludes y la nieve que arrastra el viento como por la que cae. Los glaciólogos siguen ese retroceso con los métodos de Agassiz y con otros nuevos: estacas de ablación que cada verano asoman un poco más, fotografías repetidas desde los mismos puntos y modelos del terreno levantados con láser y con drones, que comparados año tras año dan el volumen de hielo perdido. Cuando desaparezca el último, el macizo de la Maladeta se parecerá a la sierra de Gredos, que perdió sus glaciares hace más de diez mil años y conserva lagunas en los circos y morrenas que cierran los valles. :::paragraphs{style="colophon"} Compuesto en Faustina, Montserrat e IBM Plex Sans Condensed (SIL Open Font License) · Texto: CC BY 4.0 · Figuras: modelos de difusión :::`; // content.<lang>.md, inlined by the Cookbook // Caption, credit note ('-' for none) and alt text of each figure, one block per figure. const figureTexts = String.raw`valleysMarkdownの見本 · 33行 · content.figures.es.md
Un río abre un valle en V; un glaciar lo ensancha en U. A trazos, la V que borró el hielo. Secciones esquemáticas, sin escala. Dos secciones de valle: a la izquierda, un valle fluvial en V con un río en el fondo; a la derecha, un valle glaciar en U lleno de hielo, con el antiguo perfil en V a trazos. profile Perfil de un glaciar: el hielo nacido sobre la línea de equilibrio baja a fundirse bajo ella. Exageración vertical ×2. Sección longitudinal de un glaciar desde el circo hasta el frente, con la zona de acumulación nevada, la línea de equilibrio, flechas de flujo y la morrena frontal. cirque Circo glaciar en sección. El hielo gira en la cubeta y la ahonda por debajo del umbral. - Sección de un circo: pared abrupta, rimaya, hielo que gira en una cubeta y umbral rocoso aguas abajo. abrasion Abrasión: los cantos presos en la base del hielo rayan el lecho. - Detalle de la base de un glaciar: cantos incrustados en el hielo rayan la roca y dejan estrías y harina de roca. plucking Arranque: el agua se hiela en las diaclasas y el hielo se lleva los bloques. - Detalle del lado de aguas abajo de un resalte rocoso: agua helada en las diaclasas y un bloque que el hielo arranca. roche Roca aborregada. El hielo pulió la cara tendida y arrancó bloques de la abrupta. El hielo iba de izquierda a derecha. Perfil de una roca aborregada: una cara suave y tendida a la izquierda y otra escalonada y abrupta a la derecha. moraines Dos glaciares se unen: sus morrenas laterales forman la central, y la frontal represa un lago. Vista en planta, sin escala. Plano de dos lenguas de hielo que confluyen, con morrenas laterales y central; bajo el frente, el arco de la morrena frontal retiene un lago que solo cruza su arroyo de desagüe.`; const TEXTS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/) .map((block) => block.split('\n').map((line) => line.trim())) .map(([id, caption, note, alt]) => [id, [caption, note === '-' ? undefined : note, alt]])); // #region answer: six figures float to the first slot their placement allows; one stays put // In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1. // Captions, credits and alt texts come from content.figures.<lang>.md. const figure = (id, height, placement) => { if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`); const [caption, note, altText] = TEXTS[id]; // An SVG fills the width of its slot (a column or the text block, or a fraction of // either), so its width and height only give its shape. const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1); return { id, typeId: 'figure', kind: 'svg', caption, note, altText, svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 }; }; // In any order: the first mention of each one in the text, a :ref or a ::resource line, // decides its number. const resources = [ // Cited on the opener page: 'auto' may take that page's foot band, where 'top' // could only open the next page (gotcha: top-float-next-page). figure('valleys', 56, { position: 'auto', span: 'page' }), // Across both columns, but only in a foot band: the page it is cited on, if both // columns still have room there, else the foot of the next page. figure('profile', 60, { position: 'bottom', span: 'page' }), // A column figure that takes only a column head: the next one still empty after its // citation, here the right column of the same page, above the text that follows it. figure('cirque', 48, { position: 'top' }), // Cited in the same sentence, the two take the next two column heads, side by side. figure('abrasion', 48, { position: 'top' }), figure('plucking', 48, { position: 'top' }), // No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline // figure gets a grid line above it but only the grid snap below, so the Markdown follows // it with :::space{lines=1} (gotcha: here-figure-no-space-after). figure('roche', 42, { position: 'here' }), // A band of its own, half the text width and centred. It is cited on the chapter's last // page, where a 'top' float would wait for the next page; a float cannot leave its // chapter, so this one goes to the foot of the last page. A float is queued where its // citing paragraph starts, so that paragraph starts on the last page // (gotcha: float-queues-at-paragraph). figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }), ]; // #endregion // #region check: every cited id exists and every figure gets placed, before the build // An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1 // warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the // mentions exactly as numbering and placement read them; an embed needs double quotes // (gotcha: resource-double-quotes). function checkFigures() { const [named, embedded] = [[], new Set()]; for (const block of parseMarkdown(markdown)) { if (block.type === 'resourceBlock' && block.resourceId) { named.push(block.resourceId); embedded.add(block.resourceId); } for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId); } const ids = resources.map((r) => r.id); const types = new Set(captions().resourceTypes.map((type) => type.id)); const problems = [ ...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`), ...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`), ...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`), ...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id)) .map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`), ...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`), ]; if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`); } // #endregion // #region art: seven paintings (JPEGs in assets/) under vector labels in the book's language // Each figure is an SVG: the painting, embedded as a data URL at the figure's printed size in // mm, then its labels as text, so they stay sharp and follow the edition's language. const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')).join('')}`; const C = { ink: palette.ink, flow: mix(palette.glacier, palette.ink, 0.4), snow: palette.paper }; const n2 = (v) => +v.toFixed(2); // A hairline, drawn over a wider white one so it reads on rock and ice alike. const hairline = ([x1, y1], [x2, y2]) => [['#ffffff', 0.55], [C.ink, 0.18]].map(([c, w]) => `<path d="M${n2(x1)} ${n2(y1)}L${n2(x2)} ${n2(y2)}" stroke="${c}" stroke-width="${w}" ` + 'stroke-linecap="round"/>').join(''); // A label with a thin white halo, and an optional leader to the point it names. function label(x, y, words, { anchor = 'start', to, bold = false, color = C.ink } = {}) { const halo = color === C.snow ? C.ink : '#ffffff'; const leader = to ? hairline([to[0], to[1]], [to[2] ?? x, to[3] ?? y - 0.9]) : ''; return `${leader}<text x="${n2(x)}" y="${n2(y)}" text-anchor="${anchor}" fill="${color}"` + ` stroke="${halo}" stroke-width="0.5" stroke-linejoin="round" paint-order="stroke"` + `${bold ? ' font-weight="600"' : ''}>${words}</text>`; } const L = (en, es) => t({ en, es }); // An SVG loaded as an <img> has no access to the page's web fonts (gotcha: svg-no-webfonts), // so each drawing embeds the two weights its labels use. The latin subsets cover the English // and Spanish labels. const LABEL_MM = 2.45; // the label size in the drawings' millimetres: about 7 pt in print async function labelFace() { const id = fontsourceId(LABEL); const faces = await Promise.all(['400', '600'].map(async (weight) => { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-` + 'normal.woff2'; const res = await fetch(url); if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `@font-face{font-family:L;font-weight:${weight};` + `src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`; })); return `${faces.join('')}text{font-family:L;font-size:${LABEL_MM}px}`; } // The viewBox is the figure's printed size in mm; the SVG's own size is set in mm too. const svg = (w, h, face, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${n2(w)}mm" ` + `height="${n2(h)}mm" viewBox="0 0 ${n2(w)} ${n2(h)}"><style>${face}</style>${body}</svg>`; // The labels of each figure, in its millimetres, placed on its painting. const DRAWINGS = { valleys: () => label(14, 5, L('River valley', 'Valle fluvial'), { bold: true }) + label(100, 5, L('Glacial valley', 'Valle glaciar'), { bold: true }) + label(47, 50.4, L('river', 'río'), { to: [43.4, 46.6, 46.8, 49.4] }) + label(126, 21, L('ice', 'hielo'), { anchor: 'middle', bold: true }) + label(125.5, 53.2, L('earlier V-shaped valley', 'antiguo valle en V'), { anchor: 'middle', to: [125.5, 48, 125.5, 51.3] }), profile: () => label(48, 8, L('accumulation zone', 'zona de acumulación'), { anchor: 'middle', bold: true, color: C.flow }) + label(100, 20, L('ablation zone', 'zona de ablación'), { anchor: 'middle', bold: true, color: C.flow }) + label(57, 19.6, L('equilibrium line', 'línea de equilibrio'), { to: [52.8, 27, 56.6, 20.2] }) + label(108, 43.3, L('ice flow', 'flujo del hielo'), { bold: true, color: C.flow }) + label(166, 41.6, L('terminal moraine', 'morrena frontal'), { anchor: 'end', to: [146, 45.5, 150, 42.2] }) + label(4, 57.4, L('bedrock', 'lecho rocoso')), cirque: () => label(2.5, 30, L('back wall', 'pared')) + label(26, 6.6, L('bergschrund', 'rimaya'), { to: [20, 12.5, 25.6, 7.2] }) + label(30, 22.6, L('rotation', 'rotación'), { bold: true, color: C.flow }) + label(33, 40, L('basin', 'cubeta')) + label(66, 17.4, L('rock lip', 'umbral'), { anchor: 'middle', to: [60.5, 22.8, 64.4, 18.3] }), abrasion: () => label(55.5, 8.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow }) + label(24, 16.6, L('stones in the ice', 'cantos presos en el hielo'), { to: [38.5, 22.6, 38, 17.4] }) + label(20, 37, L('striations', 'estrías'), { anchor: 'end', to: [24, 29.5, 18, 35.8] }) + label(40.5, 37, L('rock flour', 'harina de roca'), { to: [32, 26, 40.5, 35.8] }), plucking: () => label(30, 9.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow }) + label(63.5, 18.5, L('plucked block', 'bloque arrancado'), { to: [60, 21, 63.3, 19.2] }) + label(4, 43, L('ice in the joints', 'hielo en las diaclasas'), { to: [25.2, 32, 14, 41.3] }), roche: () => label(4, 5, L('ice, long gone', 'el hielo, hoy fundido'), { bold: true, color: C.flow }) + label(25, 16.5, L('abrasion: smooth', 'abrasión: pulida'), { anchor: 'end', to: [30, 18.8, 25.5, 16.9] }) + label(63, 11.2, L('plucking: rough', 'arranque: rugosa'), { to: [57.5, 15.5, 63.2, 11.9] }), // Placed on a 100.8 × 60 mm drawing; k scales the positions, not the type, to the figure. moraines: (w) => { const k = w / 100.8; const at = (x, y, words, o = {}) => label(x * k, y * k, words, { ...o, ...(o.to && { to: o.to.map((v) => v * k) }) }); return at(4, 25.5, L('lateral moraine', 'morrena lateral'), { to: [37, 16, 21, 24.3] }) + at(62, 33, L('medial moraine', 'morrena central'), { to: [50.5, 33, 61.5, 32.3] }) + at(50, 45, L('lake', 'lago'), { anchor: 'middle', bold: true, color: C.snow }) + at(97.5, 55, L('terminal moraine', 'morrena frontal'), { anchor: 'end', to: [60, 49, 78, 54] }); }, }; // A painting as a data URL: an SVG drawn as an image cannot fetch anything itself. async function dataUrl(url) { const res = await fetch(url); if (!res.ok) throw new Error(`Painting not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `data:image/jpeg;base64,${btoa(bin)}`; } const PAINTINGS = { // each figure's painting, a file in assets/, named by its width in pixels valleys: asset('valleys-1536.jpg'), profile: asset('profile-1400.jpg'), cirque: asset('cirque-1080.jpg'), abrasion: asset('abrasion-1080.jpg'), plucking: asset('plucking-1080.jpg'), roche: asset('roche-1080.jpg'), moraines: asset('moraines-1080.jpg') }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first) Faustina: ['400', '400i', '700'], // text Montserrat: ['800'], // display: title, section heads, numeral, folios 'IBM Plex Sans Condensed': ['400', '400i', '600', '700'], // labels: kicker, heads, captions }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const words = `${markdown}\n${figureTexts}`; // captions too: their letters decide the subsets await loadFonts(FONTS, words); checkFigures(); // a wrong id stops here, and the viewer's bar says why // #region build: register the drawings, then set chapter 2 of a longer book const face = await labelFace(); for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId const art = await dataUrl(PAINTINGS[id]); await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" ` + `height="${n2(height)}" preserveAspectRatio="none"/>${DRAWINGS[id](width, height)}`)); } // One chapter came before: figures number 2.1, 2.2… and the folios start at 27. const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1 headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2 const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), words); showPages(doc, { title: t({ en: 'Figures that float to where you cite them', es: 'Figuras que flotan hasta donde las citas' }) }); // #endregionキット · core, fonts, viewer, images:全レシピ共通 · 270行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#章番号を付けずに図番号を振る
番号から章を外すと、図は1〜7と印字されます。本全体で1つの通し番号にするには、章をbuildBundleで組むか、各章に前の章のcontinuationAfter()を渡します。
- resourceTypes: defaultResourceTypes(LANG),
+ resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type,
+ numberingTemplate: '{n}', resetOn: 'never' })),#キャプションを上に、帯に載せて
キャプションは図の上に移り、パレットの氷青色の帯に載ります。クレジットの行は図の下に残ります。こうするとどちらの版も5ページ目にはみ出すので、本文の分量を調整し直してください。
captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
+ position: 'above', backgroundEnabled: true, background: col('ice'),
fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),#図を余白に置く
段と半段のページなら、本文が入らない小口側の溝を図とキャプションに与えられます。余白に段を設けた教科書を参照してください。
#表も同じようにフロートさせる
表もリソースで、同じ規則で引用され配置されます。長い表はページをまたいで分割されます。技術データシートを参照してください。
よくあるつまずき
つまずき
'top'のフロートは参照したページには入らない
フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。 図の配置 →
つまずき
図は参照する段落の始まりで待ち行列に入る
図が待ち行列に入るのは、:refがある行ではなく、図を参照する段落が始まった時点です。その段落があるページの下端で始まり、参照が次のページに来ると、'top'の図は参照する文より上、次のページの頭に置かれることがあります。:refは段落の前のほうに置くか、:refで新しい段落を始めてください。 図を配置する参照 →
つまずき
インラインの図は上に空きがあり、下にはない
postext 1.4.1では、::resourceで位置'here'に置いた図は、上にグリッド1行分の空きを取りますが、下には次の行がベースライングリッドにそろうときの残りしか取りません。1行分からほぼゼロまでばらつくため、次の段落がキャプションのすぐ下から始まることがあります。::resourceの行のあとに:::space{lines=1}を置いてください。ほかの:::spaceと同じく、段の頭では捨てられます。 その場に置く図 →
つまずき
不明なidの:refは'?'を印字し、エンジンは警告しない
どのリソースにもないidへの:refは「?」を印字して何も配置せず、警告するのはSandboxだけです。参照するidがすべて存在するか確認してください。 図を配置する参照 →
つまずき
Figure/TableはdefaultResourceTypes(locale)でローカライズする
設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。 「図」と「表」を文書の言語で →
つまずき
::resource{id="…"}は二重引用符でしか書けない
ブロックの埋め込みとして認識されるのは、二重引用符を使った::resource{id="…"}の形だけです。ほかの書き方は、目に見える1行として本文に残ります。 その場に置く図 →
つまずき
警告の多くはSandboxにしかない
不明なid、スタイル、ディレクティブ、欠けたフォント、ゆるい行を検査するのはSandboxで、エンジンではありません。CodePenのサンプルで得られるのはdoc.warningsとparseMarkdownWithIssuesだけです。不明なスタイルは何の知らせもなくフォールバックし、不明なディレクティブはテキストとして印字されるので、idを確認してください。 警告と診断 →
つまずき
SVGの<img>内のテキストはWebフォントを使えない
SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →
つまずき
SVGの図に<marker>やフィルターを使わない(ラスターにフォールバックする)
SVGの図がPDFでベクターのまま残るのは、<marker>、フィルター、マスクを使っていない場合だけです。使っているとラスターにフォールバックし、深く入れ子にしたフィルターはChromeで図を真っ白にすることがあります。矢じりはパスで描いてください。 リソースとしての図と表 →
つまずき
ハイフネーションできるのは8つのロケールだけで、コードは完全一致
ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
デザインのテキストのoverflowの既定値は'ellipsis-end'
幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
Sandboxの検査 · unknownResourceId
不明なリソース
原因. :refまたは::resourceが、どのリソースにもないidを指定しています。参照は「?」と印字され、何も配置されません。
対処. idを修正する(二重引用符のみ使えます)か、リソースを追加してください。 ドキュメント →
Sandboxの検査 · danglingTypeRef
不明なリソースの種類
原因. リソースのtypeIdが、resourceTypesにもう定義されていない種類を指しているため、既定の種類が使われます。
対処. 種類を定義するか、既存の種類をリソースに指定してください。 ドキュメント →
- 30ページでは、湖についての段落がその最初の文で図2.7を引用しています。図は引用した段落が始まる位置で順番待ちに入るからです。その文が29ページから始まる前の段落の最後にあったら、図は30ページの先頭、それを引用する行より上に置かれていたはずです。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- 画像
- Figure 2.1: a V-shaped river valley and a U-shaped glacial valley · Generated With Diffusion Models · オリジナル
- Figure 2.2: long profile of a valley glacier · Generated With Diffusion Models · オリジナル
- Figure 2.3: a glacial cirque in section · Generated With Diffusion Models · オリジナル
- Figure 2.4: abrasion at the base of the ice · Generated With Diffusion Models · オリジナル
- Figure 2.5: plucking on a rock step · Generated With Diffusion Models · オリジナル
- Figure 2.6: a roche moutonnée · Generated With Diffusion Models · オリジナル
- Figure 2.7: lateral, medial and terminal moraines in plan · Generated With Diffusion Models · オリジナル
- フォント
- Faustina (SIL OFL 1.1) · Montserrat (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1)


