Em poucas palavras
Um capítulo de livro didático sobre montanhas, em duas colunas, com sete imagens numeradas. Mostra como cada imagem vai para o primeiro espaço livre depois que o texto a menciona.
O que você vai compor
O capítulo 2 de Mountain Landforms, um livro didático de geomorfologia numa página de 200 × 250 mm. Uma fita azul com o número do capítulo pende do alto da abertura, ao lado da placa azul-gelo do título; seguem duas colunas justificadas de Faustina. As sete figuras, pinturas geradas com modelos de difusão e rotuladas em código, são numeradas na ordem da primeira menção. Seis flutuam até o primeiro espaço livre que a sua posição permite, contando a partir do parágrafo que as cita primeiro. A Figura 2.1, auto, cai no pé da abertura; a 2.3 abre a coluna da direita da página 28 e a 2.2 ocupa o pé dessa página; a 2.4 e a 2.5 abrem as duas colunas da página 29. A Figura 2.6 fica onde o texto a insere. A Figura 2.7, uma flutuante top citada na página 30, esperaria pela página 31, mas uma flutuante não pode sair do seu capítulo, então ela vai para o pé da página 30.
Esta receita responde a
- Como acrescento uma figura com legenda numerada e a cito no texto (“ver fig. 3.2”)?
- Como decido onde vai uma figura: no alto da página, sobre as duas colunas, exatamente aqui ou na margem?
- Como faço os rótulos “Figura” e “Tabela” saírem no idioma do meu documento?
- Como adiciono imagens e tabelas a partir do código (recursos) em vez de usar ![]() do Markdown?
A resposta curta
// 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' }),
];
Ingredientes
- Funcionalidades
- Citações que posicionam as figurasPosicionamento de figurasLegendas numeradasFiguras exatamente aquiBarreiras para flutuantesFigura e Tabela no seu idiomaEstilo de legendaLinhas de fonte e créditoFiguras e tabelas como recursosTipos de recurso personalizadosAberturas desenhadasFaixa de capítulo em largura totalAtributos de títuloTítulos numeradosHifenização e idioma do documentoCabeços e fóliosCabeços por tipo de páginaPaleta de cores semânticaEspaço vertical explícitoEquilíbrio de colunas
- Também usa
- Estilos de parágrafo
- Tipografia
- Faustina, Montserrat, IBM Plex Sans Condensed (SIL OFL 1.1)
- Materiais
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, original)
- Figure 2.2: long profile of a valley glacier (Generated With Diffusion Models, original)
- Figure 2.3: a glacial cirque in section (Generated With Diffusion Models, original)
- Figure 2.4: abrasion at the base of the ice (Generated With Diffusion Models, original)
- Figure 2.5: plucking on a rock step (Generated With Diffusion Models, original)
- Figure 2.6: a roche moutonnée (Generated With Diffusion Models, original)
- Figure 2.7: lateral, medial and terminal moraines in plan (Generated With Diffusion Models, original)
Preparo
#1 · Uma paleta para as páginas e as figuras
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' } },
];
Toda cor da configuração aponta para uma destas entradas, e as pinturas foram geradas nos mesmos tons de ardósia, azul-geleira e musgo, então as figuras ficam nas cores da página. Uma pintura mantém as próprias cores: um novo valor para glacier muda a fita, a placa e os rótulos, não o gelo das figuras. A cor de destaque é escura o bastante para letra pequena: 5,2:1 sobre o branco nos rótulos das legendas e nas citações, 4,5:1 sobre a placa de gelo no antetítulo. Cada figura é um SVG que incorpora a pintura como um JPEG em data URL e põe os rótulos por cima como texto, com um halo branco fino, para que fiquem nítidos e acompanhem o idioma da edição. Os rótulos são em IBM Plex Sans Condensed, incorporada em cada SVG, porque um SVG carregado como imagem não tem acesso às fontes web da página.
#2 · Dê nome às figuras no idioma do leitor
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) dá nome aos tipos no idioma da amostra: as legendas dizem Figure 2.3 aqui e Figura 2.3 na edição em espanhol, onde só locale: 'es' hifenizaria o texto mas deixaria todas as legendas em inglês. Cada citação então toma a forma que a frase pede: case="lower" para (fig. 2.1) e, com style="full", para (figure 2.2); style="number" depois de um plural (figures 2.4 and 2.5); e text="…" para uma expressão como the chapter’s first figure, que não imprime número. Essa expressão remete a uma figura já posicionada; como primeira menção, ela ainda numeraria e posicionaria a figura.
#3 · Uma fita e uma placa de gelo para a abertura
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' })] };
A abertura são duas caixas e quatro textos: uma fita que pende do alto com o número do capítulo na ponta, uma placa de gelo que sangra pela borda externa, o antetítulo tirado do atributo topic do título, e o título e a linha fina encadeados abaixo dele. minHeight reserva a placa e pelo menos 5 mm abaixo dela, arredondados para cima em linhas inteiras da grade (uns 7 mm aqui), de modo que a Figura 2.1 ainda cabe na faixa do pé da mesma página. Os cabeços ficam nos cantos externos sobre um fio fino na cor rule, e pages: 'body' os tira da abertura, que recebe um fólio no pé.
#4 · Recuse ids desconhecidos antes da composição
// 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('; ')}`);
}
Um :ref para um id que nenhum recurso tem imprime "?", e uma figura que ninguém menciona nunca é posicionada; o Sandbox avisa sobre o primeiro caso, mas o postext 1.4.1 não dá aviso a um pen em nenhum dos dois. A verificação lê o Markdown com parseMarkdown, o analisador do próprio motor, então encontra todos os :ref e ::resource que a numeração e o posicionamento vão ler. Ela transforma um id desconhecido ou repetido, uma figura não citada, uma figura here sem linha ::resource ou um tipo inexistente num único erro antes de a primeira página ser diagramada.
#5 · Comece a contagem onde o livro está
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' }) });
Este é o capítulo 2 de um livro mais longo, então a continuação registra um capítulo antes dele: as figuras numeradas {h1}.{n} começam em 2.1, e os fólios em 27. Na página 28, a Figura 2.3 fica acima da Figura 2.2, que tem o número menor porque o texto a cita primeiro. O ![]() do Markdown é descartado, então cada figura é um recurso cujo SVG é registrado sob o seu svg.fileId antes da composição. Cada posição decorre de onde a figura é citada: auto para a 2.1, porque a placa ocupa o alto da abertura; bottom para o perfil, que ainda pode ocupar o pé da página que o cita; top para as figuras de coluna, que abrem os próximos altos de coluna livres; e top também para a 2.7, que acaba no pé da página 30 porque uma flutuante fica dentro do seu capítulo.
A receita completa
// ═══ 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 = 'en'; // @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`---Amostra em Markdown · 67 linhas · content.en.md
title: "Mountain Landforms" subtitle: "An Introduction to Geomorphology" --- # How glaciers carve mountain valleys {topic="Glacial geomorphology" lead="Ice creeps down a valley a few tens of metres a year, too slowly to watch, yet over a few glaciations it turns a river’s narrow V into a broad U. The rock keeps a record of every stage, from the cirque to the moraines."} Walk up a high mountain valley after walking up one cut by a river alone, and the difference is plain at once. The river valley is narrow and V-shaped: the water cuts down and the slopes crumble in after it. The valley a glacier has passed through is broad, flat-floored and steep-walled: it is shaped like a U (:ref{id="valleys" case="lower"}). Ice fills the valley from wall to wall, often hundreds of metres deep, and grinds its floor and its sides at the same time. Some twenty thousand years ago, when the last glaciation was at its greatest extent, ice covered much of northern Europe and ran down the valleys of the Pyrenees to below a thousand metres. There were glaciers in the Picos de Europa, the Sierra de Gredos and the Sierra Nevada too. Nearly all of them have gone, but the land keeps their marks so sharply that the size of a glacier that melted thousands of years ago can still be worked out. This chapter explains what those marks are and how to read them. ## Ice that flows A glacier is born where more snow falls than melts. Year after year each layer is buried under the next, and the weight squeezes the air out from between the flakes. Fresh snow weighs about a hundred kilograms per cubic metre; packed down, it turns into firn, a granular material, and then into dense, bluish glacier ice at more than eight hundred. In the Alps the change takes a few decades; in Antarctica, where so little snow falls that each year adds only a few centimetres, it can take centuries. Every glacier has two halves (:ref{id="profile" style="full" case="lower"}). In the upper part, the accumulation zone, each winter leaves more snow than the summer can melt. In the lower part, the ablation zone, the reverse is true: ice is lost, and the glacier survives only because ice keeps arriving from above. The boundary between them, the equilibrium line, shows at the end of summer as the edge of the year’s snow on the bare ice. If the climate cools, the line moves down and the front advances; if it warms, the line climbs and the front retreats. Once the ice in the cirque at the head of the valley (:ref{id="cirque" case="lower"}) is a few tens of metres thick, it begins to flow under its own weight. It deforms slowly without breaking, the way a mass of pitch creeps downhill, while the top thirty metres or so, too lightly loaded to flow, crack into crevasses. Where the bed is wet, the glacier also slides on a film of meltwater. Valley glaciers move this way at tens to hundreds of metres a year, faster in the middle and at the surface than along the walls, where friction holds them back. Louis Agassiz showed it in the 1840s with a line of stakes driven across the Unteraar glacier in Switzerland: over the years the line bent downstream in the middle. Even a retreating glacier keeps flowing downhill. Its snout, the point where the ice finishes melting, moves back because each summer it melts faster than the flow can replace it. ## Where glaciers are born A cirque is an armchair-shaped hollow carved into the head of a valley. Ice gathers in the hollow, rotates downhill as if in a spoon and deepens the floor below the rim; when the glacier melts, the basin fills with water and becomes a tarn. To deepen it, the ice works with two complementary tools, those of figures :ref{id="abrasion" style="number"} and :ref{id="plucking" style="number"}. Meanwhile the back wall retreats: two cirques growing back to back sharpen a knife-edged arête between them, and three or more attacking one summit leave it a pyramidal horn, like the Matterhorn in the Alps. In the Pyrenees most cirques face north or east, where the snow lasts longest, and many hold one of the small, deep tarns the Aragonese call *ibones*, frozen over from early winter until late spring. ## The tools of the ice Ice is softer than almost any rock and on its own would barely scratch it; it wears down its bed with two tools. The first tool is abrasion. Stones frozen into the base of the glacier scratch the bed like sandpaper and leave parallel striations that show, thousands of years later, which way the ice was moving; the dust they grind, rock flour, gives glacial lakes their milky turquoise. The second is plucking: meltwater seeps into cracks in the bed, freezes again and welds blocks to the ice, which carries them off as it moves. Both tools work at once on any knob of rock in the bed, and the result is one of the most characteristic forms of a glacial landscape, the roche moutonnée: ::resource{id="roche"} :::space{lines=1} Its up-glacier face, polished by abrasion, is smooth and gentle; its down-glacier face, where the ice plucked blocks away, is steep and rough. Look at which way the rough face points and you know which way the ice was going. The Genevan naturalist Horace-Bénédict de Saussure gave it its French name at the end of the eighteenth century. ## Troughs The work of those tools, added up over tens of thousands of years, transforms the whole valley. The glacier straightens the river’s winding valley and truncates the spurs that separated its bends. It widens and deepens the floor into the U of :ref{id="valleys" text="the chapter’s first figure"}, stepped in basins and rock bars, and the Ordesa valley in the Aragonese Pyrenees is a textbook trough. Because a thick glacier cuts deeper than a thin one, the main valley sinks lower than its tributaries: when the ice goes, the side valleys are left hanging hundreds of metres above it and their streams leap down as waterfalls, as in Yosemite Valley, California. Where the sea has flooded a trough, it becomes a fjord; Norway’s Sognefjord reaches more than two hundred kilometres inland and is over thirteen hundred metres deep. ## What the glacier leaves behind Whatever a glacier plucks away ends up somewhere. Debris falling from the slopes rides along the edges of the ice and builds lateral moraines; where two glaciers join, their lateral moraines merge into a medial moraine that runs down the ice as a dark stripe. At the front, the glacier unloads like a conveyor belt and heaps up an arc of debris, the terminal moraine, which marks its furthest advance. Moraines are chaotic mixtures of clay, sand, stones and boulders of every size, without the sorting that water gives its sediments. Some boulders, the erratics, travelled tens of kilometres and now rest on rock of a quite different kind. Many terminal moraines hold back lakes (:ref{id="moraines" case="lower"}). Lake Sanabria in Zamora, the largest lake of glacial origin in the Iberian Peninsula, is dammed by the moraines of the glacier that came down from the Sierra Segundera. ## Reading a glacial landscape Four marks are enough to tell that a glacier once filled a valley that has no ice today: - **The profile.** A U-shaped trough with a flat floor and steep walls, like the one in :ref{id="valleys" style="full" case="lower"}. - **Hanging valleys.** Side valleys that end high on the slope, their streams falling as waterfalls. - **The rock.** Polished, striated surfaces and roches moutonnées, whose rough face looks down the valley (:ref{id="roche" case="lower"}). - **The deposits.** Unsorted moraines, erratic boulders and the lakes they dam. None of these marks is enough on its own: a river polishes stones too, and a rockfall leaves chaotic debris at the foot of a slope. Found together, valley after valley, they identify a former glacier, and on a map they give its outline and its size: the crests of the lateral moraines mark how high its surface reached, the terminal moraine its snout, and the floors of its cirques the snowline of a climate several degrees colder than today’s. ## Glaciers in retreat The glaciers left in the Iberian Peninsula are small, and all of them are in the Pyrenees, on the north faces of its highest summits: Aneto, Maladeta, Monte Perdido. They have lost most of their area since the middle of the nineteenth century, when the Little Ice Age ended, and several have shrunk to ice patches that no longer flow. In many summers the equilibrium line now climbs above their summits: the whole glacier lies in the ablation zone of :ref{id="profile" style="full" case="lower"}, and the ice it loses is never replaced. What survives clings to the shade of the north faces, fed as much by avalanches and wind-blown snow as by the snow that falls on it. Glaciologists follow the retreat with Agassiz’s methods and with new ones: ablation stakes that stand a little taller every summer, photographs repeated from the same viewpoints, and terrain models surveyed by laser and by drone, which compared year after year give the volume of ice lost. Many of the glaciers named on nineteenth-century maps are already gone. When the last of them melts, the Maladeta massif will look much as the Sierra de Gredos does now, more than ten thousand years after its glaciers disappeared, with tarns in its cirques and moraines across its valleys. :::paragraphs{style="colophon"} Set in Faustina, Montserrat and IBM Plex Sans Condensed (SIL Open Font License) · Text: CC BY 4.0 · Figures: diffusion models :::`; // 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`valleysAmostra em Markdown · 33 linhas · content.figures.en.md
A river cuts a V; a glacier widens the valley into a U. Dashed, the V the ice wore away. Schematic sections, not to scale. Two valley sections: left, a V-shaped river valley with a river at the bottom; right, a U-shaped glacial valley full of ice, with the old V profile dashed. profile Profile of a valley glacier: ice fed above the equilibrium line flows down to melt below it. Vertical exaggeration ×2. Long section of a glacier from the cirque to the snout, with the snowy accumulation zone, the equilibrium line, flow arrows and the terminal moraine. cirque A cirque in section. The ice rotates in the basin and deepens it below the rock lip. - Section of a cirque: a steep back wall, the bergschrund, ice rotating in a basin and a rock lip downstream. abrasion Abrasion: stones held in the base of the ice scratch the bed. - Detail of a glacier’s base: stones frozen into the ice scratch the bedrock, leaving striations and rock flour. plucking Plucking: water freezes in the joints and the ice carries blocks away. - Detail of the downstream side of a rock step: ice in the joints and a block being pulled away by the glacier. roche Roche moutonnée. The ice polished the gentle face and plucked the steep one. The ice moved from left to right. Profile of a roche moutonnée: a smooth, gentle face on the left and a stepped, steep face on the right. moraines Two glaciers join: their lateral moraines become a medial one, and the terminal moraine dams a lake. Plan view, not to scale. Plan of two ice tongues that join, with lateral and medial moraines; below the snout, a lake is held in by the arc of the terminal moraine, which only its outlet stream crosses.`; 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' }) }); // #endregionKit · core, fonts, viewer, images: igual em todas as receitas · 271 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** 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, '-'); } /** 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 v1 ── 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. */ 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 ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Numere as figuras sem o capítulo
Tire o capítulo dos números e as figuras saem de 1 a 7; para uma contagem única no livro inteiro, diagrame os capítulos com buildBundle ou passe a cada capítulo o continuationAfter() do anterior.
- resourceTypes: defaultResourceTypes(LANG),
+ resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type,
+ numberingTemplate: '{n}', resetOn: 'never' })),#Ponha as legendas em cima, numa barra
A legenda sobe para cima da figura, numa barra no azul-gelo da paleta, e a linha de crédito fica embaixo da figura; reajuste o texto, já que as duas edições passam então para uma quinta página.
captionStyle: { // a cor do texto segue bodyText; a nota tem 0,85 × o corpo da legenda
+ position: 'above', backgroundEnabled: true, background: col('ice'),
fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),#Ponha uma figura na margem
Uma página em coluna e meia dá às figuras e às legendas um canal externo em que o texto nunca entra: veja o livro didático com coluna na margem.
#Faça as tabelas flutuarem do mesmo jeito
Tabelas também são recursos, citadas e posicionadas pelas mesmas regras, e uma tabela longa se divide entre páginas: veja a ficha técnica.
Erros comuns
Erro comum
Um flutuante 'top' nunca cai na página que o cita
Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita. Posicionamento de figuras →
Erro comum
A figura entra na fila onde começa o parágrafo que a cita
Uma figura entra na fila quando começa o parágrafo que a cita, não na linha do seu :ref. Se esse parágrafo começa no pé de uma página e a citação cai na seguinte, uma figura 'top' pode abrir essa página acima da frase que a cita. Coloque o :ref no início do parágrafo, ou abra um parágrafo novo com ele. Citações que posicionam as figuras →
Erro comum
Uma figura no texto ganha espaço acima, mas não abaixo
No postext 1.4.1, uma figura que ::resource coloca na posição 'here' ganha uma linha da grade de espaço acima, mas abaixo só o que sobra quando a linha seguinte se ajusta à grade de linhas de base: de uma linha inteira a quase nada, então o parágrafo seguinte pode começar colado à legenda. Ponha :::space{lines=1} depois da linha ::resource; como todo :::space, ele é descartado no alto de uma coluna. Figuras exatamente aqui →
Erro comum
Um id desconhecido em :ref imprime “?” sem aviso do motor
Um :ref para um id que nenhum recurso tem imprime “?” e não posiciona nada, e só o Sandbox avisa. Confira se cada id citado existe. Citações que posicionam as figuras →
Erro comum
Traduza Figura e Tabela com defaultResourceTypes(locale)
O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes. Figura e Tabela no seu idioma →
Erro comum
::resource{id="…"} só aceita aspas duplas
Uma inserção em bloco só é reconhecida como ::resource{id="…"} com aspas duplas; qualquer outra forma fica no texto como uma linha visível. Figuras exatamente aqui →
Erro comum
A maioria dos avisos só existe no Sandbox
Ids, estilos e diretivas desconhecidos, fontes ausentes e linhas frouxas são verificados pelo Sandbox, não pelo motor: um pen recebe apenas doc.warnings e parseMarkdownWithIssues. Um estilo desconhecido é substituído sem aviso e uma diretiva desconhecida é impressa como texto, então confira os seus ids. Avisos e diagnóstico →
Erro comum
O texto dentro de um SVG <img> não pode usar fontes web
Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →
Erro comum
Sem <marker> nem filtros na arte SVG (vira bitmap)
Uma figura SVG só continua vetorial no PDF sem <marker>, filtros e máscaras; caso contrário, é rasterizada, e filtros muito aninhados podem deixá-la em branco no Chrome. Desenhe as pontas de seta como caminhos (paths). Figuras e tabelas como recursos →
Erro comum
Só 8 idiomas têm hifenização, com o código exato
A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano. Hifenização e idioma do documento →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
O excesso de texto de design é 'ellipsis-end' por padrão
Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
Verificação do Sandbox · unknownResourceId
Recurso desconhecido
Por quê. Um :ref ou ::resource cita um id que nenhum recurso tem; a referência imprime “?” e nada é colocado.
Correção. Corrija o id (só com aspas duplas) ou acrescente o recurso. Docs →
Verificação do Sandbox · danglingTypeRef
Tipo de recurso desconhecido
Por quê. O typeId de um recurso cita um tipo que resourceTypes não define mais, então é usado um tipo padrão.
Correção. Defina o tipo ou aponte o recurso para um tipo existente. Docs →
- Na página 30, o parágrafo sobre os lagos cita a Figura 2.7 na primeira frase, porque uma figura entra na fila onde começa o parágrafo que a cita. Se essa frase fechasse o parágrafo anterior, que começa na página 29, a figura teria aberto a página 30, acima da linha que a cita.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Imagens
- Figure 2.1: a V-shaped river valley and a U-shaped glacial valley · Generated With Diffusion Models · original
- Figure 2.2: long profile of a valley glacier · Generated With Diffusion Models · original
- Figure 2.3: a glacial cirque in section · Generated With Diffusion Models · original
- Figure 2.4: abrasion at the base of the ice · Generated With Diffusion Models · original
- Figure 2.5: plucking on a rock step · Generated With Diffusion Models · original
- Figure 2.6: a roche moutonnée · Generated With Diffusion Models · original
- Figure 2.7: lateral, medial and terminal moraines in plan · Generated With Diffusion Models · original
- Fontes
- Faustina (SIL OFL 1.1) · Montserrat (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


