Em poucas palavras
Uma ficha escolar de ciências para construir um relógio de sol. Mostra boxes que se dividem entre duas colunas, passam para o alto da página seguinte ou ficam fixos no pé de uma página.
O que você vai compor
Quatro páginas de Taller de ciencias, um caderno escolar espanhol de atividades de laboratório: a Práctica 4, “Construye un reloj de sol” (Construa um relógio de sol). Na página 42, o aviso de segurança fica inteiro, e o procedimento de catorze passos, sobre fundo amarelo e com um selo de compasso no canto, começa no pé da coluna da esquerda e continua no alto da direita, na mesma medida e sem o título. A folha de dados de Madri sai do fluxo nessa página e encabeça a página 43, acima do texto. Um selo grafite, “Autoevaluación · 10 min”, fica fixo no pé da última página, com uma mão apontando na margem ao lado dele. Na abertura, uma faixa amarelo-sol traz um gráfico das sombras de uma vareta do meio-dia às cinco da tarde na latitude de Madri, e um painel lista os materiais em três colunas na largura da página.
Esta receita responde a
- Como deixo um boxe longo se dividir entre colunas e páginas, ou mantenho um boxe curto inteiro?
- Como levo um boxe para o alto ou o pé da página enquanto o texto continua correndo?
- Como fixo um selo ou um adesivo em uma posição exata da página?
- Como coloco um boxe atravessando as duas colunas no meio da página, como um painel “em números” com três blocos?
A resposta curta
const HAND = 6, GAP = 2, RULE = 0.75 * PT; // mm: the badge's hand, its gap, the hand's rule
const calloutStyles = [
// Kept whole: keepTogether defaults to true, so a box that does not fit the rest of a
// column moves on in one piece; only a box taller than a whole column splits anyway.
box('seguridad', bar('charcoal', 'aviso')),
// Split: the procedure fits a column, so it takes keepTogether: false to break where it
// falls instead of moving on whole: between steps, or inside one (splitMinLines, default
// 2, counts the box's lines on each side of the cut, not the step's: see Pitfalls). The
// rest goes on in the next column or page without the title or the icon; a corner icon
// takes no room from the text, so both parts keep one measure.
box('pasos', { keepTogether: false, background: col('light'),
icon: icon('compas', 7, { position: 'corner', cornerSide: 'outer' }) }),
// Floated: its fence adds placement="top", so the box leaves the flow where the fence
// stands and heads the next page, while the text after it fills this one.
box('datos', { span: 'page', background: col('tint') }),
// Pinned: 'fixed' sets the box on the page where its fence falls, at the bottom-left corner
// of the text block unless fixed.anchor says otherwise, and the column text keeps out of it.
// width: 'auto' shrink-wraps the title, so an empty fence prints a badge.
box('autoevaluacion', { placement: 'fixed', width: 'auto',
// Hang the hand and its rule in the margin (the outer one on this verso), so the badge
// itself lines up with the text: [hand][rule][GAP][badge].
fixed: { offset: { x: mm(-(HAND + RULE + GAP)) } },
marker: { ...icon('mano', HAND), gap: mm(GAP),
rule: { enabled: true, color: col('charcoal'), width: mm(RULE) } },
background: col('charcoal'), borderRadius: mm(3.2),
padding: { top: mm(1.6), right: mm(3.4), bottom: mm(1.6), left: mm(3.4) },
titleStyle: { ...TITLE, color: col('paper') } }),
// Across the page, in the flow: the text above it is cut level and resumes under it.
box('resumen', { span: 'page', backgroundEnabled: false,
stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('charcoal') } }),
];
Ingredientes
- Funcionalidades
- Boxes que se dividem ou ficam inteirosBoxes flutuantesBoxes fixos e selosColuna de marca ao lado do boxeBoxes na largura da páginaColunas dentro de um boxeÍcones e selos de canto nos boxesBoxesEspaço vertical explícitoCitações que posicionam as figurasPosicionamento de figurasEquilíbrio de colunasTabelas a partir de dadosEstilos de tabela com nomeFigura e Tabela no seu idiomaTítulos numeradosAberturas desenhadasAtributos de títuloCabeços e fóliosAvisos e diagnóstico
- Também usa
- Figuras exatamente aquiFaixa de capítulo em largura totalCabeços por tipo de páginaEstilos de parágrafoTipos de recurso personalizadosFiguras e tabelas como recursos
- Tipografia
- Host Grotesk, Commit Mono (SIL OFL 1.1)
- Materiais
- The shadow chart, the dial in profile and the icons, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
Preparo
#1 · Um estilo de base para todos os boxes
const TITLE = { fontFamily: LABEL, fontSize: pt(8), color: col('charcoal'),
textTransform: 'uppercase', letterSpacing: pt(1.2) }; // bold by default
const BOX_TYPE = { fontSize: pt(8.8), lineHeight: pt(12.4) }; // colours inherit bodyText
const box = (id, device) => ({ id, // margins: the defaults, snapped to whole grid lines
padding: { top: mm(3), right: mm(3.6), bottom: mm(3.4), left: mm(3.6) },
titleStyle: { ...TITLE, gap: mm(2) }, body: BOX_TYPE,
lists: { gap: mm(2), itemSpacing: pt(3) }, ...device });
const icon = (id, size, extra) => ({ kind: 'resource', resourceId: id, size: mm(size),
...extra });
// A side bar with its icon centred on it, 1.4 mm narrower than the bar.
const bar = (hue, id, width = 5.6) => ({ backgroundEnabled: false,
stripe: { enabled: true, side: 'left', width: mm(width), color: col(hue) },
icon: icon(id, width - 1.4) });
Todo estilo parte de box(): o título em monoespaçada, letra de 8,8 pt contra os 9,4 pt do texto, e o preenchimento interno. Cada um acrescenta então um único recurso: uma barra grafite no aviso de segurança, um fundo amarelo no procedimento, um tom de papel na folha de dados, um fio superior no resumo, uma pílula grafite no selo. O aluno distingue os boxes por esse recurso antes de ler qualquer título. No procedimento, o fundo também marca a continuação: a parte no alto da coluna da direita não tem título, e só o amarelo a liga aos passos 1 a 4 no pé da coluna da esquerda.
#2 · Inteiro, dividido, flutuante e fixo
O código está na resposta curta, mais acima. O procedimento mede 192 mm e numa coluna cabem 211, então um boxe mantido inteiro pularia para o alto da coluna da direita e deixaria 43 mm vazios no pé da esquerda. keepTogether: false deixa o boxe começar onde ele cai e continuar na coluna seguinte, ou na página seguinte, como mostra a segunda variação (o contêiner :::callout). A continuação perde o título e o ícone. Com um ícone na linha, ela também devolveria ao texto a coluna do ícone e comporia o resto mais largo; por isso o compasso fica no canto externo do boxe, onde não ocupa largura, e as duas partes mantêm a mesma medida. A cerca da folha de dados tem placement="top", então o boxe sai do fluxo na página 42 e encabeça a página seguinte. O selo é fixed, e um offset.x negativo leva a mão e o fio dela para a margem externa, para que a borda esquerda do selo fique alinhada com o texto.
#3 · Atravessar a página com um painel de três colunas
const materials = box('material', { span: 'page', background: col('sun'),
marginBottom: pt(LEAD), // one more grid line of air before the text resumes
padding: { top: mm(3.6), right: mm(4.4), bottom: mm(3.8), left: mm(4.4) },
columnGap: mm(GUTTER), // the page's gutter: the panel's columns sit as far apart as the text's
body: { ...BOX_TYPE, paragraphSpacing: false },
lists: { color: col('ink'), gap: mm(1.8), itemSpacing: pt(1) } });
Com span: 'page', o boxe de materiais atravessa as duas colunas. A introdução acima dele é cortada na mesma altura, e o texto continua em duas colunas embaixo dele (página 41). Dentro da cerca, :::columns{count=3 breaks="5,9"} define onde começa cada coluna em vez de balanceá-las. breaks conta blocos filhos, e cada item de lista é um bloco, então a segunda e a terceira colunas abrem no quinto e no nono blocos, os rótulos em negrito. columnGap é a medianiz de 7 mm da página, e as colunas do painel ficam tão afastadas quanto as colunas de texto.
:::callout{type="material" title="Necesitarás"}
:::columns{count=3 breaks="5,9"}
**Para la esfera**
- Cartón pluma de 20 × 30 cm
- La plantilla de la esfera
- Pegamento y rotulador
**Para el gnomon y la base**
…
:::
:::#4 · Um amarelo para os fundos, outro mais claro para o boxe longo
const palette = {
ink: '#1b2430', // text
charcoal: '#2b2d42', // the safety bar, the summary's rule, the table head, the badge, titles
sun: '#f2b705', // the opener band and the materials panel: fields, never type
light: '#fad65a', // the procedure
tint: '#fff6d6', // the data sheet
rule: '#d7dde3', // hairlines
muted: '#5d6b78', // running heads, notes
paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at charcoal, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.charcoal })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
O amarelo-sol fica reservado para as áreas de cor, a faixa e o painel, onde o grafite e a tinta preta têm contraste de 7,4:1 e 8,6:1. Como cor de letra sobre branco ele chegaria a 1,8:1, por isso nunca leva texto. O procedimento usa light, um amarelo mais pálido: a tinta preta tem contraste de 11:1 sobre ele, e a divisão ainda aparece na miniatura da galeria. A folha de dados usa tint, mais próximo do papel, para não ser confundida com o procedimento, o outro boxe com fundo da página dupla. main-color aponta para o grafite, e assim os valores padrão que a configuração não sobrescreve saem em grafite e não no azul do motor.
#5 · Abrir com uma faixa amarela
const BAND = 100; // mm from the trim to the foot of the band
const ART_W = 80; // mm: the width of the band's drawing, at the fore-edge
const AIR = 8; // mm between the band and the first line of text
const [TITLE_W, LEAD_W] = [104, 88]; // mm: the title's and the lead's measure
// Wrapped, not cut with the default ellipsis (gotcha: overflow-ellipsis-default).
const onBand = { color: col('charcoal'), align: 'left', overflow: 'wrap' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
offset: { y: mm(y) }, size: { width: mm(width) } });
const opener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('sun') },
placement: { anchor: { to: 'page', edge: 'top-left' }, size: { height: mm(BAND) } } },
{ kind: 'image', id: 'art', resourceId: 'sombras', placement: { anchor: { to: 'page',
edge: 'top-right' }, size: { width: mm(ART_W), height: mm(BAND) } } },
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: LABEL,
fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.7), textTransform: 'uppercase',
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(4) } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: TEXT,
fontSize: pt(44), fontWeight: 800, lineHeight: 0.98, placement: below('kicker', 3, TITLE_W) },
{ kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: TEXT,
fontSize: pt(10.5), lineHeight: 1.4, placement: below('title', 5, LEAD_W) },
{ kind: 'text', id: 'meta', content: '{attr.meta}', ...onBand, fontFamily: LABEL,
fontSize: pt(7.4), fontWeight: 700, letterSpacing: pt(1.1), textTransform: 'uppercase',
placement: below('lead', 4, LEAD_W) },
] } };
A faixa é um elemento de caixa ancorado na página, e o desenho, um elemento de imagem junto ao corte; o chapéu, o olho e a linha de sessões vêm de atributos na linha do título. Como a faixa está ancorada na página, a abertura já reserva a altura até o pé da faixa, e o texto começaria 3,6 mm abaixo dela. minHeight: BAND - TOP + AIR pede 8 mm; a reserva se ajusta à linha seguinte da grade, e o texto começa 8,4 mm abaixo da faixa.
A receita completa
// ═══ Postext Cookbook · Nº 021 · Boxes that split, float and pin ══════════════════ // https://postext.dev/en/cookbook/boxes-split-float-pin // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Host Grotesk, Commit Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // Four pages of a school lab workbook in Spanish, and five ways a box can sit on them. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only) const RECIPE = 'boxes-split-float-pin'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: one yellow for fields, paler ones for two boxes, near-blacks for type and bars const palette = { ink: '#1b2430', // text charcoal: '#2b2d42', // the safety bar, the summary's rule, the table head, the badge, titles sun: '#f2b705', // the opener band and the materials panel: fields, never type light: '#fad65a', // the procedure tint: '#fff6d6', // the data sheet rule: '#d7dde3', // hairlines muted: '#5d6b78', // running heads, notes paper: '#ffffff', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at charcoal, so nothing prints blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.charcoal }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const TEXT = 'Host Grotesk'; // text and display const LABEL = 'Commit Mono'; // labels: kickers, box titles, step numbers, heads, the table const LEAD = 13.6; // pt: the body leading, the pitch of the baseline grid const LINES = 44; // grid lines in a full column const [TRIM_W, TRIM_H, TOP, INNER, OUTER, GUTTER] = [195, 255, 22, 18, 14, 7]; // mm const PT = 25.4 / 72; // mm in a point // #region look: the base of every box (a mono title, smaller type), then one device each const TITLE = { fontFamily: LABEL, fontSize: pt(8), color: col('charcoal'), textTransform: 'uppercase', letterSpacing: pt(1.2) }; // bold by default const BOX_TYPE = { fontSize: pt(8.8), lineHeight: pt(12.4) }; // colours inherit bodyText const box = (id, device) => ({ id, // margins: the defaults, snapped to whole grid lines padding: { top: mm(3), right: mm(3.6), bottom: mm(3.4), left: mm(3.6) }, titleStyle: { ...TITLE, gap: mm(2) }, body: BOX_TYPE, lists: { gap: mm(2), itemSpacing: pt(3) }, ...device }); const icon = (id, size, extra) => ({ kind: 'resource', resourceId: id, size: mm(size), ...extra }); // A side bar with its icon centred on it, 1.4 mm narrower than the bar. const bar = (hue, id, width = 5.6) => ({ backgroundEnabled: false, stripe: { enabled: true, side: 'left', width: mm(width), color: col(hue) }, icon: icon(id, width - 1.4) }); // #endregion // #region answer: one style per behaviour: kept whole, split, floated, pinned, page-wide const HAND = 6, GAP = 2, RULE = 0.75 * PT; // mm: the badge's hand, its gap, the hand's rule const calloutStyles = [ // Kept whole: keepTogether defaults to true, so a box that does not fit the rest of a // column moves on in one piece; only a box taller than a whole column splits anyway. box('seguridad', bar('charcoal', 'aviso')), // Split: the procedure fits a column, so it takes keepTogether: false to break where it // falls instead of moving on whole: between steps, or inside one (splitMinLines, default // 2, counts the box's lines on each side of the cut, not the step's: see Pitfalls). The // rest goes on in the next column or page without the title or the icon; a corner icon // takes no room from the text, so both parts keep one measure. box('pasos', { keepTogether: false, background: col('light'), icon: icon('compas', 7, { position: 'corner', cornerSide: 'outer' }) }), // Floated: its fence adds placement="top", so the box leaves the flow where the fence // stands and heads the next page, while the text after it fills this one. box('datos', { span: 'page', background: col('tint') }), // Pinned: 'fixed' sets the box on the page where its fence falls, at the bottom-left corner // of the text block unless fixed.anchor says otherwise, and the column text keeps out of it. // width: 'auto' shrink-wraps the title, so an empty fence prints a badge. box('autoevaluacion', { placement: 'fixed', width: 'auto', // Hang the hand and its rule in the margin (the outer one on this verso), so the badge // itself lines up with the text: [hand][rule][GAP][badge]. fixed: { offset: { x: mm(-(HAND + RULE + GAP)) } }, marker: { ...icon('mano', HAND), gap: mm(GAP), rule: { enabled: true, color: col('charcoal'), width: mm(RULE) } }, background: col('charcoal'), borderRadius: mm(3.2), padding: { top: mm(1.6), right: mm(3.4), bottom: mm(1.6), left: mm(3.4) }, titleStyle: { ...TITLE, color: col('paper') } }), // Across the page, in the flow: the text above it is cut level and resumes under it. box('resumen', { span: 'page', backgroundEnabled: false, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('charcoal') } }), ]; // #endregion // #region panel: a yellow panel across both columns with three columns of its own const materials = box('material', { span: 'page', background: col('sun'), marginBottom: pt(LEAD), // one more grid line of air before the text resumes padding: { top: mm(3.6), right: mm(4.4), bottom: mm(3.8), left: mm(4.4) }, columnGap: mm(GUTTER), // the page's gutter: the panel's columns sit as far apart as the text's body: { ...BOX_TYPE, paragraphSpacing: false }, lists: { color: col('ink'), gap: mm(1.8), itemSpacing: pt(1) } }); // #endregion // #region opener: a sun-yellow band, a shadow chart, texts from the heading line const BAND = 100; // mm from the trim to the foot of the band const ART_W = 80; // mm: the width of the band's drawing, at the fore-edge const AIR = 8; // mm between the band and the first line of text const [TITLE_W, LEAD_W] = [104, 88]; // mm: the title's and the lead's measure // Wrapped, not cut with the default ellipsis (gotcha: overflow-ellipsis-default). const onBand = { color: col('charcoal'), align: 'left', overflow: 'wrap' }; const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) }, size: { width: mm(width) } }); const opener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('sun') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { height: mm(BAND) } } }, { kind: 'image', id: 'art', resourceId: 'sombras', placement: { anchor: { to: 'page', edge: 'top-right' }, size: { width: mm(ART_W), height: mm(BAND) } } }, { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.7), textTransform: 'uppercase', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(4) } } }, { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: TEXT, fontSize: pt(44), fontWeight: 800, lineHeight: 0.98, placement: below('kicker', 3, TITLE_W) }, { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: TEXT, fontSize: pt(10.5), lineHeight: 1.4, placement: below('title', 5, LEAD_W) }, { kind: 'text', id: 'meta', content: '{attr.meta}', ...onBand, fontFamily: LABEL, fontSize: pt(7.4), fontWeight: 700, letterSpacing: pt(1.1), textTransform: 'uppercase', placement: below('lead', 4, LEAD_W) }, ] } }; // #endregion // Running heads in the label face, the folio in bold on the outer edge. const HEAD_Y = 13; // mm from the trim to the heads' baseline area const RUN_X = OUTER + 9; // mm from the fore-edge to the running title, clear of the folio const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.4), letterSpacing: pt(1.1), textTransform: 'uppercase', color: col('muted'), ...extra, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } } }); const folio = { fontWeight: 700, fontSize: pt(8.5), color: col('ink'), letterSpacing: pt(0) }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('verso-title', '{title}', 'even', 'top-left', RUN_X), head('recto-title', 'Práctica {chapterNumber} · {chapterTitle}', 'odd', 'top-right', -RUN_X), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, folio), pages: 'opener', // the drop folio, 11 mm above the foot of the opener placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-11) } } }] }; const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) // The document's language; ragged text is never hyphenated (gotcha: ragged-no-hyphenation). locale: 'es', resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale) colorPalette, header, footer, page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(TRIM_H - TOP - LINES * LEAD * PT), left: mm(INNER), right: mm(OUTER), mirror: true } }, // a text block of LINES whole lines; left is the inner margin on a recto // A table set in a box sits right under the text above it, with no extra line of air. layout: { layoutType: 'double', gutterWidth: mm(GUTTER), inlineResourceGapInBoxes: false }, bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // references follow the bold colour textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, headings: { fontFamily: TEXT, fontWeight: 800, color: col('ink'), // No extra line under a top float: on the closing page it keeps the layout from settling // (gotcha: float-stretch-closing-page). balancing: { stretchAfterFloats: false }, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}', marginTop: pt(LEAD), marginBottom: pt(0) }, ] }, unorderedLists: { color: col('charcoal'), marginTop: pt(0), marginBottom: pt(0) }, orderedLists: { fontFamily: LABEL, fontWeight: 700, color: col('charcoal') }, // step numbers calloutStyles: [...calloutStyles, materials], tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('charcoal'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(7.4), bodyFontFamily: LABEL, bodyFontSize: pt(7.4), bodyColor: col('ink'), cellPadding: mm(1.1) }, tableStyles: [{ id: 'registro', cellPadding: mm(2.2), bodyFontSize: pt(8.4) }], captionStyle: { fontSize: pt(8), color: col('ink'), labelColor: col('charcoal'), gap: mm(2) }, paragraphStyles: [{ id: 'colofon', fontFamily: LABEL, fontSize: pt(6.6), lineHeight: pt(9.4), color: col('muted') }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Amostra em Markdown · 124 linhas · content.es.md
title: "Taller de ciencias · Cuaderno de prácticas" --- # Construye un reloj de sol {kicker="Práctica 4 · El Sol y la hora" meta="2 sesiones · por parejas · un día de sol" lead="Con una brocheta y un disco de cartón pluma construirás un reloj de sol ecuatorial. Una vez orientado al norte, casi nunca marcará la misma hora que tu móvil, y en esta práctica verás por qué."} Los egipcios ya medían las horas con sombras hace más de 3.000 años. Un reloj de sol funciona sin engranajes porque la Tierra gira sobre su eje a un ritmo casi constante, 360° en 24 horas, es decir, 15° cada hora. La sombra de una varilla bien orientada recorre entonces la esfera como la aguja de un reloj, siempre al mismo paso. Vas a construir el modelo más fácil de trazar, el reloj ecuatorial. Su varilla, el **gnomon**, apunta al polo norte celeste, muy cerca de la estrella Polar, y queda paralela al eje de la Tierra. La esfera es perpendicular a ella y, por tanto, paralela al ecuador; por eso sus líneas horarias se separan 15°, como los radios de una rueda. :::callout{type="material" title="Necesitarás"} :::columns{count=3 breaks="5,9"} **Para la esfera** - Cartón pluma de 20 × 30 cm - La plantilla de la esfera - Pegamento y rotulador **Para el gnomon y la base** - Brocheta de 15 cm - Cartón pluma para la base - Plastilina **Herramientas** - Regla metálica y cúter - Transportador y compás - Brújula o la del móvil ::: ::: ## La inclinación es tu latitud Para que el gnomon quede paralelo al eje terrestre, debe formar con el suelo un ángulo igual a la latitud del lugar: unos 40° en Madrid, 43° en Oviedo, 37° en Sevilla o 28° en Las Palmas de Gran Canaria. Cuanto más al norte vivas, más empinado quedará. Búscala en un atlas o en el mapa del móvil y redondéala al grado; un error de uno o dos grados apenas se nota en la lectura. Anótala: la necesitarás en los pasos 6 y 9. La esfera, en cambio, forma con el suelo el ángulo complementario, 90° menos la latitud: unos 50° en Madrid. Ese es el ángulo que tendrán los dos triángulos que la sostienen. Si el gnomon no queda paralelo al eje de la Tierra, la sombra ya no avanza a ritmo constante sobre la esfera y las líneas de 15° dejan de coincidir con las horas. A mediodía el error es nulo, pero crece a medida que te alejas de él, tanto hacia la mañana como hacia la tarde. Por eso el paso 9 pide medir con el transportador el ángulo entre la brocheta y la base, y corregirlo si hace falta. ## Una esfera con dos caras La plantilla es un disco de 16 cm con 24 radios, uno por hora, separados 15°. Hay que numerarlos en las dos caras, porque el Sol ilumina una u otra según la época del año. En la cara superior, las horas avanzan en el sentido de las agujas del reloj, con las 12 junto a la marca N; en la inferior, que leerás desde abajo, van en sentido contrario. :::callout{type="seguridad" title="Seguridad"} - No mires nunca al Sol directamente, ni con gafas de sol ni a través de una lente: basta un instante para dañar la retina. - El cúter lo maneja un adulto, siempre sobre la regla metálica y con el corte hacia fuera, lejos de los dedos. - Protege la punta de la brocheta con una bola de plastilina. ::: Trabajaréis por parejas: mientras uno sujeta las piezas, el otro mide y marca. Leed antes todo el procedimiento, repartid las tareas y tened a mano la hoja de datos de la página siguiente para comprobar las lecturas. Dedicad la primera sesión al montaje y la segunda, a las lecturas. :::callout{type="datos" placement="top" title="Hoja de datos · Madrid, 40,4° N · 3,7° O"} :::columns{count=2} Cuándo marca las 12 tu reloj de sol en Madrid y qué sombra da entonces un palo de un metro. Para otra localidad, suma 4 minutos por cada grado de longitud al oeste de Madrid; réstalos al este. ::: :::space ::resource{id="mediodia"} ::: :::callout{type="pasos" title="Procedimiento"} 1. Pega la plantilla sobre el cartón pluma y déjala secar cinco minutos. 2. Un adulto corta el disco de 16 cm con el cúter, en varias pasadas. 3. Pincha el centro con el compás y agranda el agujero con la brocheta. 4. Comprueba con el transportador que las líneas horarias están separadas 15° y repasa con rotulador las que van de las 6 de la mañana a las 6 de la tarde. 5. Numera las horas en las dos caras, como explica el apartado 4.2. 6. Recorta los dos soportes: triángulos rectángulos con 12 cm de base y, entre la base y la hipotenusa, 90° menos tu latitud (50° en Madrid). 7. Pasa la brocheta por el centro del disco, bien perpendicular a él, hasta que asomen 10 cm arriba y 4 cm abajo. 8. Pega los triángulos de pie sobre la base, a 12 cm uno de otro, de modo que la hipotenusa suba hacia el sur. 9. Apoya el disco en las hipotenusas, pégalo y comprueba que la brocheta forma con la base un ángulo igual a tu latitud. 10. Lleva el reloj a un sitio al que le dé el sol todo el día y nivela la base con el móvil. 11. Gira la base hasta que el extremo alto de la brocheta apunte al norte, con la brújula lejos de objetos de hierro. 12. A una hora en punto, lee la hora en el centro de la sombra y anota también la que marca el móvil. 13. Repite la lectura cada hora, al menos tres veces, y anota las horas en el apartado 4.5. 14. Calcula la diferencia media y compárala con la hoja de datos: si se aleja más de 15 minutos, revisa la orientación. ::: ## Cómo se lee Lee la hora en el centro de la sombra y no en uno de sus bordes, porque la brocheta tiene grosor. Si la sombra cae entre dos líneas, calcula los minutos a ojo: cada línea es una hora, y cada cuarto de la separación entre dos líneas, 15 minutos. Con práctica apreciarás cinco minutos, casi dos milímetros en el borde del disco. Mira la esfera de frente, sin ladear la cabeza, y siempre desde el mismo lado. Y no esperes que coincida con el móvil: tu reloj marca la hora solar del lugar, y el móvil, la oficial; el apartado 4.4 explica de dónde sale la diferencia. La sombra cae sobre la cara de la esfera que mira al Sol: la superior entre los equinoccios de marzo y de septiembre, y la inferior el resto del año. De perfil se ve por qué (:ref{id="reloj"}): en verano, el Sol del mediodía está más alto que la esfera; en invierno, más bajo. Cerca de los equinoccios pasa rozando su plano, y durante unos días la sombra se ve tan borrosa que cuesta leerla, aunque el reloj esté bien montado. ## Hora solar y hora oficial Tu reloj de sol marca la **hora solar**: las 12 en punto cuando el Sol cruza el meridiano del lugar y alcanza su mayor altura del día. El móvil da la **hora oficial**, la misma en toda la España peninsular. Entre las dos se acumulan tres diferencias. La primera es el huso horario. La hora oficial española es la de Europa central, calculada para el meridiano 15° E, pero Madrid está a 3,7° al oeste de Greenwich: el Sol cruza su meridiano unos 75 minutos más tarde de lo que supone el reloj. Es una herencia de 1940, cuando España adelantó una hora sus relojes, que desde 1901 seguían la hora de Greenwich, para igualarlos con los de Europa central. La segunda es el horario de verano, que añade otra hora entre el último domingo de marzo y el último de octubre. La tercera es la ecuación del tiempo. Como la órbita de la Tierra es una elipse y su eje está inclinado, el mediodía solar se adelanta o se retrasa a lo largo del año, hasta unos 16 minutos en noviembre y 14 en febrero. Con las tres correcciones, en Madrid el Sol pasa por el meridiano entre las 12:58 y las 14:21, según la época del año. **Un ejemplo.** El 15 de mayo miras el móvil a las 13:00. Según la hoja de datos, ese día el Sol cruza el meridiano de Madrid a las 14:11, así que aún faltan 71 minutos para el mediodía solar: la sombra de tu reloj debería marcar las 10:49. Si marca las 11:05, tu reloj adelanta 16 minutos y conviene revisar su orientación, como indica el paso 14 del procedimiento. ## Tus lecturas Anota las lecturas de los pasos 12 y 13 en la :ref{id="lecturas" style="full" case="lower"} y compara cada diferencia con la que predice la hoja de datos para ese mes. Si tu reloj falla incluso a mediodía, revisa la orientación; si acierta a mediodía pero falla por la mañana y por la tarde, revisa la inclinación. ## Tu reloj mide la longitud Los navegantes calculaban su longitud comparando el mediodía solar con la hora de un meridiano de referencia. Tú puedes hacer lo mismo. En el ejemplo, el Sol cruzó el meridiano de Madrid a las 14:11 de verano, es decir, a las 12:11 de Greenwich. Ese día la ecuación del tiempo adelanta el Sol unos 4 minutos, así que el mediodía medio habría llegado hacia las 12:15. Esos 15 minutos de retraso respecto a Greenwich, a 4 minutos por grado, dan unos 3,7° de longitud oeste: la de Madrid. Con tus lecturas no necesitas la ecuación del tiempo: anota la hora oficial a la que tu reloj marca las 12 y compárala con la que da la hoja de datos para ese mes. Cada 4 minutos de retraso te sitúan un grado al oeste de Madrid, y cada 4 de adelanto, un grado al este. :::callout{type="resumen" title="Resumen"} :::columns{count=3 breaks="2,3"} - La Tierra gira 15° cada hora: por eso las líneas horarias de la esfera se separan 15°. - El gnomon apunta al polo norte celeste, inclinado sobre el suelo tanto como la latitud del lugar. - Huso horario, horario de verano y ecuación del tiempo apartan la hora solar de la oficial. ::: ::: Antes de la próxima sesión, haced la autoevaluación de la práctica en el aula virtual y traed vuestras lecturas: con ellas calcularemos la longitud de nuestra localidad. :::callout{type="autoevaluacion" title="Autoevaluación · 10 min"} ::: :::paragraphs{style="colofon"} Compuesto en Host Grotesk y Commit Mono (SIL Open Font License) · Texto y dibujos originales, CC BY 4.0 · Datos solares calculados para 2026. :::`; // content.<lang>.md, inlined by the Cookbook // Madrid's solar noon on the 15th of each month of 2026 (NOAA's approximations; CET, and // CEST from 29 March to 25 October), the Sun's height then and a 1 m stick's shadow. const MONTHS = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']; const NOON = ['13:23', '13:29', '13:24', '14:15', '14:11', '14:15', '14:21', '14:20', '14:10', '14:00', '13:00', '13:10']; const HEIGHT = [28, 37, 47, 59, 68, 73, 71, 64, 53, 41, 31, 26]; // degrees const SHADOW = ['1,86', '1,34', '0,93', '0,60', '0,40', '0,31', '0,34', '0,49', '0,76', '1,14', '1,65', '2,02']; // metres const cell = (content, extra) => ({ content, align: 'center', ...extra }); const row = (label, values) => [cell(label, { align: 'left' }), ...values.map((v) => cell(v))]; // Each drawing's viewBox (in mm for the band's chart), rasterised at PX pixels a unit. const ART = { sombras: [ART_W, BAND], reloj: [156, 36], aviso: [24, 24], compas: [24, 24], mano: [28, 24] }; const PX = 10; const svgResource = (id, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: ART[id][0] * PX, height: ART[id][1] * PX }, ...extra }); const resources = [ // Uncited, so never placed: the opener and the box styles use them by id. svgResource('sombras'), svgResource('aviso'), svgResource('compas'), svgResource('mano'), svgResource('reloj', { placement: { position: 'top', span: 'page' }, caption: 'El reloj visto desde el este. A mediodía, el Sol ilumina la cara superior en verano ' + '(izquierda) y la inferior en invierno.', altText: 'Perfil del reloj ecuatorial con los rayos del Sol de verano y de invierno' }), { id: 'mediodia', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, caption: 'Mediodía solar en Madrid, día 15 de cada mes de 2026. De abril a octubre rige el ' + 'horario de verano.', table: { model: { headerRowCount: 1, columnWidths: [2.9, ...MONTHS.map(() => 1)], rows: [ [cell('', { isHeader: true }), ...MONTHS.map((m) => cell(m, { isHeader: true }))], row('Mediodía solar', NOON), row('Altura del Sol', HEIGHT.map((h) => `${h}°`)), row('Sombra de 1 m', SHADOW), ] } } }, // The students' log: the worked example, then empty rows tall enough to write in. { id: 'lecturas', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'top' }, caption: 'Tus lecturas. La primera fila es la del ejemplo.', table: { styleId: 'registro', model: { headerRowCount: 1, columnWidths: [1, 1, 1], rows: [ ['Hora oficial', 'Reloj de sol', 'Diferencia'].map((h) => cell(h, { isHeader: true })), ['13:00', '10:49', '2 h 11 min'].map((v) => cell(v)), ...Array.from({ length: 3 }, () => ['', '', ''].map((v) => cell(v))), ] } } }, ]; // #region art: the band's shadow chart, the figure and three icons, in the palette (no words) // An SVG drawn as an image cannot use the page's fonts (gotcha: svg-no-webfonts). const n = (v) => +v.toFixed(2); const svg = (id, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${ART[id][0] * PX}" ` + `height="${ART[id][1] * PX}" viewBox="0 0 ${ART[id].join(' ')}">${body}</svg>`; const path = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const dot = (x, y, r, fill) => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" fill="${fill}"/>`; const line = (pts) => `M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join('L')}`; const rad = (deg) => (deg * Math.PI) / 180; const LAT = rad(40.4); // Madrid // Where the tip of a vertical stick's shadow falls on flat ground (x east, y north, in stick // heights), for the Sun at declination d and hour angle h; null when the Sun is too low. function tip(d, h) { const up = Math.sin(LAT) * Math.sin(d) + Math.cos(LAT) * Math.cos(d) * Math.cos(h); if (up < Math.sin(rad(6))) return null; const north = Math.cos(LAT) * Math.sin(d) - Math.sin(LAT) * Math.cos(d) * Math.cos(h); return [(Math.cos(d) * Math.sin(h)) / up, -north / up]; } function sombras() { // a stick's shadows from noon to 5 p.m. on flat ground, from above, north up const [g, x0, y0] = [36, 7, BAND - 11]; // the stick's height and its foot, mm const at = ([x, y]) => [x0 + x * g, y0 - y * g]; const hours = [12, 13, 14, 15, 16, 17]; let out = ''; for (const hour of hours) { // hour lines: straight, from the summer to the winter solstice const pts = [-23.44, -11.5, 0, 11.5, 23.44].map((d) => tip(rad(d), rad(15 * (hour - 12)))); out += path(line(pts.filter(Boolean).map(at)), palette.charcoal, 0.35); } for (const d of [-23.44, 0, 23.44]) { // date lines: the equinox is straight, the rest curve const pts = []; for (let m = 0; m <= 84; m += 2) { const p = tip(rad(d), rad(m)); if (p) pts.push(at(p)); } out += path(line(pts), palette.charcoal, d === 0 ? 0.5 : 0.35); } for (const hour of hours) { // the equinox shadows themselves, from the foot of the stick out += path(line([[x0, y0], at(tip(0, rad(15 * (hour - 12))))]), palette.charcoal, 1.1); } return svg('sombras', out + dot(x0, y0, 1.8, palette.charcoal)); } function reloj() { // the dial in profile, seen from the east: south left, north right const panel = (ox, sunDeg, lit) => { // one noon: the Sun at sunDeg above the south horizon const [ground, u] = [33, 1.2]; // the ground line; drawing units per centimetre const foot = [ox + 42, ground]; // the triangle's north corner, at the hypotenuse's foot const up = [-Math.cos(rad(50)), -Math.sin(rad(50))]; // along the hypotenuse, 90° − 40° const along = (p, d, k) => [p[0] + d[0] * k, p[1] + d[1] * k]; const hyp = 12 * u / Math.cos(rad(50)); // a 12 cm base: the hypotenuse's length const top = along(foot, up, hyp); const mid = along(foot, up, hyp / 2); // the dial's centre const g = [Math.cos(LAT), -Math.sin(LAT)]; // the gnomon, up to the north at the latitude const s = [-Math.cos(rad(sunDeg)), -Math.sin(rad(sunDeg))]; // towards the Sun const sun = along(mid, s, 19); const face = along([0, 0], [g[0], g[1]], lit === 'top' ? 0.9 : -0.9); // the lit side const board = `<rect x="${n(top[0] - 3 * u)}" y="${ground - 0.7}" ` + `width="${n(foot[0] - top[0] + 6 * u)}" height="0.7" fill="${palette.charcoal}"/>`; // base let out = path(`M${ox} ${ground}H${ox + 74}`, palette.charcoal, 0.4) + board + shape(`${line([[foot[0], ground - 0.7], top, [top[0], ground - 0.7]])}Z`, palette.rule) + path(line([along(mid, g, -4 * u), along(mid, g, 10 * u)]), palette.charcoal, 0.9) + path(line([along(mid, up, -8 * u), along(mid, up, 8 * u)]), palette.charcoal, 1.6) + path(line([along(along(mid, up, -8 * u), face, 1), along(along(mid, up, 8 * u), face, 1)]), palette.sun, 0.9) + path(line([along(mid, g, 10.6 * u), along(mid, g, 17 * u)]), palette.charcoal, 0.35, ' stroke-dasharray="1 1.4"') // on to the Pole Star + star(...along(mid, g, 18.6 * u), 1.6); for (const k of [-1, 0, 1]) { // three rays, travelling from the Sun to the dial const start = along(along(sun, [s[1], -s[0]], k * 4), s, -3.6); out += path(line([start, along(start, s, -8)]), palette.sun, 0.8); } return out + dot(...sun, 2.6, palette.sun); }; const star = (x, y, r) => shape(`M${n(x)} ${n(y - r)}L${n(x + r * 0.3)} ${n(y - r * 0.3)} ` + `${n(x + r)} ${n(y)} ${n(x + r * 0.3)} ${n(y + r * 0.3)} ${n(x)} ${n(y + r)} ` + `${n(x - r * 0.3)} ${n(y + r * 0.3)} ${n(x - r)} ${n(y)} ${n(x - r * 0.3)} ` + `${n(y - r * 0.3)}Z`, palette.charcoal); return svg('reloj', panel(2, 73, 'top') + panel(82, 26, 'bottom')); } function aviso() { // a yellow warning triangle on the charcoal bar return svg('aviso', shape('M12 2.6 22.4 20.6H1.6Z', palette.sun, ' stroke-linejoin="round" ' + `stroke="${palette.sun}" stroke-width="1.6"`) + shape('M10.9 8.4h2.2l-.4 6.6h-1.4Z', palette.charcoal) + dot(12, 17.4, 1.2, palette.charcoal)); } function compas() { // the procedure's corner badge: a pair of compasses on a charcoal disc return svg('compas', dot(12, 12, 12, palette.charcoal) + dot(12, 6.2, 1.7, palette.light) + path('M12 7.4 7.6 18.6M12 7.4 16.4 18.6', palette.light, 1.5) + path('M9.2 14.6q2.8 1.5 5.6 0', palette.light, 1)); } function mano() { // a hand that points right, at the badge return svg('mano', shape('M3 9.6h7.4l2.2-2.8a1.6 1.6 0 0 1 2.5 2l-.9 1.2H25a1.6 1.6 0 0 1 0 3.2' + 'H16.4v.2h1.2a1.5 1.5 0 0 1 0 3h-1.2a1.5 1.5 0 0 1 0 3h-1.4a1.4 1.4 0 0 1 0 2.8H9.6' + 'L3 21.2Z', palette.charcoal)); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses: layout measures with the browser's fonts (gotcha: fonts-first). const FONTS = { 'Host Grotesk': ['400', '700', '800'], 'Commit Mono': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const drawings = { sombras, reloj, aviso, compas, mano }; await Promise.all([loadFonts(FONTS, markdown), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]); // Folio 41 is odd like page 1, a recto; the next # is Práctica 4, so the figure is 4.1. const continuation = { pageNumbering: { startAt: 41 }, headings: { h1: 3 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Taller de ciencias · Práctica 4' }); // Layout warnings in the bar: a box that no cut could split overflows as calloutOverflow. const warnings = (doc.warnings ?? []).map((w) => w.kind).join(', ') || 'none'; kitStatus(`${doc.pages.length} pages · layout warnings: ${warnings}`);Kit · 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
#Manter o procedimento inteiro
Sem essa configuração, o procedimento, que cabe numa coluna, vai inteiro para o alto da coluna da direita e deixa 43 mm vazios no pé da esquerda.
- box('pasos', { keepTogether: false, background: col('light'),
+ box('pasos', { background: col('light'),#Fazer a folha de dados flutuar para o pé
Com placement="bottom" em content.es.md, a folha de dados ocupa o pé da página 42, onde cai a cerca dela. O procedimento que ela desloca começa no alto da coluna da direita e se divide na virada da página, terminando no alto da página 43.
-:::callout{type="datos" placement="top" title="Hoja de datos · Madrid, 40,4° N · 3,7° O"}
+:::callout{type="datos" placement="bottom" title="Hoja de datos · Madrid, 40,4° N · 3,7° O"}Erros comuns
Erro comum
:::columns só funciona dentro de um boxe e nunca se divide
:::columns é ignorado fora de um boxe, e um boxe que se divide nunca corta dentro de um grupo de colunas. O atributo breaks conta blocos filhos, e um boxe aninhado conta como um. Colunas dentro de um boxe →
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
Um flutuante superior pode baixar a última coluna numa página de fechamento
No postext 1.4.1, quando um capítulo ou uma matéria termina numa página que abre com um flutuante superior na largura toda e as linhas se dividem de forma desigual entre as colunas, stretchAfterFloats acrescenta uma linha em branco sob o flutuante na coluna mais curta, em vez de deixá-la terminar antes, e as duas colunas deixam de começar na mesma linha. Defina headings.balancing.stretchAfterFloats como false ou ajuste o texto a um número par de linhas. Equilíbrio de colunas →
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
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
Texto alinhado à esquerda nunca é hifenizado
A hifenização só se aplica ao texto justificado; o texto alinhado à esquerda quebra entre palavras, então uma coluna estreita em bandeira fica muito irregular. Justifique o trecho ou aumente a medida. Hifenização e idioma do documento →
Erro comum
Texto alinhado à esquerda pode isolar a pontuação ao lado de um negrito ou de um :ref
No postext 1.4.1, o texto que não é justificado (corpo de boxes, parágrafos em bandeira) pode quebrar a linha entre um trecho em negrito ou itálico, ou um :ref, e a pontuação colada nele: um ponto final pode abrir a linha seguinte, e o “(” antes de uma referência pode fechar a linha de cima. Texto justificado nunca quebra aí. Revise os boxes de cada edição e reescreva a frase onde isso acontecer, para que o trecho fique no meio da linha. Negrito, itálico e suas cores →
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
O lineHeight de um texto de design é um múltiplo, nunca uma medida
Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →
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
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
A página 1 é ímpar: planeje com os números físicos
A página 1 fica à direita e a 2 é a primeira página par, então planeje as páginas duplas com os números físicos de página: uma abertura em página par fica de frente para a ímpar que vem depois dela. Quebras de página e de coluna →
Erro comum
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
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 →
Aviso de diagramação · calloutOverflow
Boxe transborda a coluna
Por quê. Uma caixa que nenhum corte consegue dividir (uma figura, uma tabela ou um grupo :::columns mais alto que a coluna, ou um splitMinLines alto demais) foi colocada transbordando; o motor registra isso em doc.warnings e o Sandbox mostra na lista.
Correção. Encurte a caixa, deixe que ela se divida com keepTogether: false, reduza splitMinLines ou dê a ela outro span. Docs →
- Um boxe mais alto que uma coluna inteira se divide, diga o que disser
keepTogether. A configuração só decide nos boxes que cabem numa coluna, como este procedimento de 192 mm numa coluna de 211 mm. splitMinLinesconta as linhas do boxe inteiro de cada lado do corte, não as do passo que ele corta, então um corte ainda pode deixar uma linha de um passo sozinha. O texto antes do procedimento foi ajustado para que o corte caia entre os passos 4 e 5; na variação com a folha de dados no pé, a última linha do passo 11 abre sozinha a página 43. Qualquer mudança no texto acima de um boxe dividido pode mover o corte, então confira onde ele cai depois de cada uma.- Um ícone na linha ocupa uma coluna própria, e a continuação a devolve. Com o corte entre passos, o resto do boxe se desloca para a esquerda a largura do ícone e do espaço dele; com o corte dentro de um passo, o resto do passo é recomposto mais largo e pode imprimir palavras duas vezes. Um ícone no canto, ou uma faixa lateral onde o ícone se apoie, mantém as duas partes numa só medida.
- Um boxe flutuante com
placement="top"encabeça a página seguinte àquela em que cai a cerca dele, então a cerca vai na página anterior à que o boxe deve encabeçar. Aqui ela fica logo antes do procedimento, na página 42.
Créditos
- Receita
- Ignacio Ferro
- Texto
- The solar-noon table: computed for Madrid with NOAA's solar equations · NOAA Global Monitoring Laboratory · domínio público
- Imagens
- The shadow chart, the dial in profile and the icons, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- Fontes
- Host Grotesk (SIL OFL 1.1) · Commit Mono (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)


