跳到主要内容
食谱编号21

排版食谱 · 第6章 · 框与注释

会拆分、会浮动、会固定的标注框

一份实验练习册:步骤框拆到两栏排,数据表移到下一页页首,徽标固定在页脚。

页码42–43 · 第2–3页,共4页

  • 西班牙文样例:尚无中文版本
  • 成品尺寸195 × 255 mm
  • 2栏, 栏间距7 mm
  • Host Grotesk 9.4/13.6
  • Commit Mono
  • 4页
  • 难度
  • Postext 1.4.1
  • 排版用时37 ms
  • 226行代码

成品一览

《Taller de ciencias》的四页。这是一本西班牙语学校实验练习册,内容是Práctica 4“Construye un reloj de sol”(做一个日晷)。第42页上,安全警示保持完整;十四步的实验步骤铺在黄色底上,角上有一枚指南针徽标,从左栏底部开始,在右栏顶部接着排,行长不变,也不再重复标题。马德里的数据表在这一页离开正文流,排到第43页页首、正文之上。最后一页页脚固定着一枚炭灰色的“Autoevaluación · 10 min”徽标,旁边页边距里有一只指示的手。章首页上,一条日光黄色带里画着马德里纬度下一根木棍从正午到下午5点的影子,一块面板通栏横跨页面,把材料分三栏列出。

这道食谱解答

  • 怎样让长框跨栏跨页拆分,或让短框保持完整?
  • 怎样把框浮动到页面顶部或底部,同时让正文继续排下去?
  • 怎样把徽标或贴纸固定在页面上的某个位置?
  • 怎样在页面中间排一个横跨两栏的框,比如三格并列的“数字一览”面板?

简短回答

script.js · 第55–85行在完整代码中
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') } }),
];

用料

类型
Host Grotesk, Commit Mono(SIL OFL 1.1)
素材
  • The shadow chart, the dial in profile and the icons, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)

做法

#1 · 所有标注框共用一个基础样式

script.js · 第39–51行在完整代码中
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) });

每个样式都从box()出发:等宽字体的标题、8.8 pt的字号(正文是9.4 pt)和内边距。然后各加一种手段:安全警示是一条炭灰色竖条,实验步骤是黄色底,数据表是纸色底,小结是顶部一条线,徽标是炭灰色的圆角胶囊。学生不用看标题,凭这些手段就能分清各个框。对实验步骤来说,底色还标示了续排:右栏顶部那一段没有标题,只能靠黄色把它和左栏底部的第1至4步联系起来。

#2 · 保持完整、拆分、浮动、固定

代码就是上面的简短回答。实验步骤高192 mm,一栏能容纳211 mm,所以如果整框不拆,它会跳到右栏顶部,在左栏底部留下43 mm空白。keepTogether: false让它在落点处开始,然后在下一栏接着排,也可以接到下一页,第二个变化就是这种情况(:::callout容器)。续排部分会去掉标题和图标。如果图标是行内图标,续排时还会把图标占的那一栏还给正文,剩下的部分就排得更宽;所以指南针放在框的外角,不占宽度,两段的行长保持一致。数据表的围栏带有placement="top",于是这个框在第42页离开正文流,排到下一页页首。徽标是fixed,负的offset.x把指示的手和它的线移进外侧页边距,徽标的左边缘因而和正文对齐。

#3 · 用三栏面板横跨页面

script.js · 第89–94行在完整代码中
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) } });

有了span: 'page',材料框就横跨两栏。它上方的引言齐平截断,正文在它下方恢复两栏(第41页)。围栏里的:::columns{count=3 breaks="5,9"}直接指定每栏从哪里开始,而不是让各栏齐底。breaks按子块计数,每个列表项算一个块,所以第二栏和第三栏分别从第五块和第九块,也就是加粗的小标题开始。columnGap取页面的7 mm栏间距,面板内各栏的间距和正文栏一样。

:::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 · 色块用一种黄,长框用浅一些的黄

script.js · 第16–29行在完整代码中
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' } }));

日光黄只用于色块,也就是色带和面板,炭灰色和墨色在上面的对比度分别是7.4:1和8.6:1。作为白底上的文字颜色,它只有1.8:1,所以从不用来印字。实验步骤用light,一种更淡的黄:墨色在上面达到11:1,而在图库缩略图里仍看得出拆分处。数据表用更接近纸色的tint,不会和跨页上另一个有底色的框,也就是实验步骤混淆。main-color指向炭灰色,配置里没有覆盖的默认值因此印成炭灰色,而不是引擎的蓝色。

#5 · 章首页用一条黄色带开头

script.js · 第98–121行在完整代码中
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) },
] } };

色带是锚定在页面上的方框元素,图是切口一侧的图片元素;眉题、导语和课时行来自标题行上的属性。因为色带锚定在页面上,章首页本来就会预留到色带底边的高度,正文会在色带下方3.6 mm处开始。minHeight: BAND - TOP + AIR要求8 mm;预留高度对齐到下一条网格线,正文从色带下方8.4 mm处开始。

完整食谱

沙盒
// ═══ 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
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  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`---
Markdown样例 · 124行 · content.es.mdtitle: "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}`);
工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗

变化

#实验步骤整框不拆

去掉这项设置后,实验步骤因为放得进一栏,会整框移到右栏顶部,在左栏底部留下43 mm空白。

-  box('pasos', { keepTogether: false, background: col('light'),
+  box('pasos', { background: col('light'),

#把数据表浮动到页脚

在content.es.md里改成placement="bottom"后,数据表占据第42页底部,也就是它的围栏所在的那一页。被它挤走的实验步骤从右栏顶部开始,跨翻页拆开,在第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"}

常见问题

易错点

:::columns只在框内起作用,且不会拆分

:::columns在标注框之外会被忽略,拆分的框也不会从columns组中间断开。breaks属性按子块计数,嵌套的框算一个。 框内分栏 →

易错点

'top'浮动体不会出现在引用它的那一页

浮动体不会排在自己的引用之前,所以在第N页引用的整页宽'top'浮动体会出现在第N+1页的顶部。把引用提前,或者使用position 'auto'或'bottom',它们可以占用引用页的底部。 图的放置 →

易错点

在收尾页上,顶部浮动体可能把最后一栏往下推

在postext 1.4.1中,当一章或一篇文章结束在以整页宽顶部浮动体开头的页面上,而且各栏的行数分配不均时,stretchAfterFloats会在较短的一栏中、浮动体下方加一个空行,而不是让它提前结束,于是两栏不再从同一行开始。把headings.balancing.stretchAfterFloats设为false,或者把文字调整到偶数行。 各栏齐底 →

易错点

传入任何headings对象都会关掉H1换页

默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →

易错点

用defaultResourceTypes(locale)本地化Figure/Table

配置的locale决定断词,不决定题注:没有resourceTypes时,内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es');其他语言请在resourceTypes中自己写出名称。 用你的语言显示“图”和“表” →

易错点

齐左文字从不断词

断词只用于两端对齐的文字;左对齐不齐行的文字在词间断行,所以窄栏的齐左文字行尾会很参差。把这段改为两端对齐,或加大行长。 断词与文档语言 →

易错点

齐左文字可能让标点与粗体或:ref分开

在postext 1.4.1中,不两端对齐的文字(框内正文、齐左段落)可能在粗体或斜体片段(或:ref)与紧挨着它的标点之间断行:句号可能出现在下一行行首,引用前的'('可能留在上一行行尾。两端对齐的文字从不在这里断行。逐一检查每个版本中的框,遇到这种情况就改写句子,让这个片段落在行中间。 粗体、斜体及其颜色 →

易错点

替换调色板时,设计元素和引用颜色不会跟着变

postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →

易错点

设计文本的lineHeight是倍数,不是尺寸

在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →

易错点

设计文本的overflow默认为'ellipsis-end'

宽度放不下的设计文本元素默认以省略号结尾。需要折成多行的标题,设置overflow: 'wrap'。 页面设计中的文字、线条和框 →

易错点

SVG <img>中的文字不能使用网络字体

SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →

易错点

第1页是右页:按物理页码规划页面

第1页是右页(奇数页),第2页是第一个左页(偶数页),所以要按物理页码规划跨页:偶数页上的章首页对着它后面的奇数页。 换页与换栏 →

易错点

配置按对象身份缓存:每次新建一个对象

引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

版面警告 · calloutOverflow

标注框溢出所在栏

原因. 某个无法在任何位置切开的框(比栏还高的图、表或:::columns组,或splitMinLines设得太高)被放置后溢出;引擎把它记在doc.warnings中,沙盒会列出它。

解决. 缩短这个框、用keepTogether: false让它可以拆分、调低splitMinLines,或换一种通栏设置。 文档 →

  • 比一整栏还高的标注框,不管keepTogether怎么设都会拆开。这项设置只对放得进一栏的框起作用,比如这里高192 mm、放进211 mm栏的实验步骤。
  • splitMinLines计算的是拆分处两侧整个框的行数,而不是被拆开的那一步的行数,所以拆分后某一步仍可能只剩一行。实验步骤前面的正文经过调整,让拆分落在第4步和第5步之间;在浮动到页脚的变化里,第11步的最后一行独自排在第43页开头。拆分框上方的正文每改一次,拆分位置都可能移动,所以每次修改后都要看看它落在哪里。
  • 行内图标自己占一栏,续排时会把这一栏让出来。在两步之间拆开时,框的后半段会左移图标宽度加间距;在一步之内拆开时,这一步剩下的文字会按更宽的行长重排,可能把一些词印两遍。把图标放在角上,或者设一条侧边色条让图标坐在上面,两段就能保持同一行长。
  • 用placement="top"浮动的框排在围栏所在页的下一页页首,所以围栏要放在框应当出现的那一页的前一页。这里它紧挨在实验步骤前面,位于第42页。

致谢

文本
图片
  • The shadow chart, the dial in profile and the icons, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
字体
Host Grotesk (SIL OFL 1.1) · Commit Mono (SIL OFL 1.1)
沙盒