跳到主要内容
食谱编号45

排版食谱 · 第3章 · 标题与章首页

旁标题、悬挂编号与段内标题

一份竞赛任务书:节标题立在页边的旁栏里,与正文基线对齐;小节编号悬挂在栏间距中,段内标题用红色。

本页内容
类型
报告
输出
Canvas
难度
高级
Postext
已用Postext 1.4.1测试
需要≥ 1.4.1
许可证
更新于2026年9月26日
代码MIT · 文本CC BY 4.0
  • 西班牙文样例:尚无中文版本
  • 成品尺寸210 × 297 mm
  • 一栏半, 栏间距7 mm
  • Mona Sans 10/14
  • DM Mono
  • Noto Serif Display
  • 5页
  • 难度
  • Postext 1.4.1
  • 排版用时12 ms
  • 249行代码

成品一览

一份五页A4的建筑设计竞赛任务书:虚构的西班牙港口城镇Puerto Lince的市政府征集方案,要把1931年建成的鱼市改造成镇图书馆。版面仿照瑞士的市政报告。每页左侧有一条50 mm宽的旁栏,每一节都以一个红色编号和一道横贯整个版心的黑线开始。节标题用Noto Serif Display立在旁栏中,与正文第一行对齐。小节编号用红色DM Mono悬挂在栏间距中,因此每个小节标题都与正文左边缘对齐。第4节的评审标准以红色粗体词语开头。第1页在一幅总平面图上方排了58 pt的标题,第2页的旁栏中有一幅地块平面图,第3页以码头一侧的立面图开头。

这道食谱解答

  • 怎样给标题编号(1、1.1、1.1.1),并给每一级设置不同样式?
  • 怎样做一栏半版式,一个宽的正文栏加一个窄的侧栏?
  • 怎样控制图的位置:页面顶部、横跨两栏、就放在这里,还是放在边栏里?
  • 怎样给正文或框里的关键词(粗体或斜体)着色?

简短回答

script.js · 第57–81行在完整代码中
// The heading stays in the flow and keeps its number, but its design has no {titleText}:
// it prints the number in the channel and a rule from there to the column's right edge.
const [NUMBER, RULE] = [8.5, 0.75]; // pt: the DM Mono number, whose figures are 0.7 em tall
// Centre the rule on the figures: their baseline is 0.8 of the line down, less 0.35 em.
const RULE_Y = pt(NUMBER * (0.8 - 0.7 / 2) - RULE / 2);
const h2 = { level: 2, numberingTemplate: '{2}', lineHeight: pt(LEAD), // one grid line
  marginTop: pt(2 * LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: {
    elements: [
      { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
        fontSize: pt(NUMBER), lineHeight: 1, color: col('signal'),
        placement: at('container', 'top-left', mm(-HANG)) },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(RULE),
        color: col('ink'), placement: { ...at('#number', 'right-of', mm(2), RULE_Y),
          size: { width: 'fill' } } },
    ] } } };
// The title goes in a box fenced right after the heading: span="side" stands it in the
// channel on the grid line where the text resumes (gotcha: side-box-starts-at-fence).
// Attributes at the end of the line stay with the heading.
const sideHeads = (md) => md.replace(/^## (.+?)(\s*\{[^}]*\})?$/gm,
  (line, title) => `${line}\n\n:::callout{type="sidehead" span="side"}\n${title}\n:::`);
// The box has no background and no padding, and it takes the text's leading, so each
// line of the title sits on a baseline of the text beside it.
const sidehead = { id: 'sidehead', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: DISPLAY, fontSize: pt(13.5), lineHeight: pt(LEAD) } };

用料

类型
Mona Sans, Noto Serif Display, DM Mono(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 用旁栏中的一个框印出节标题

代码见上面的简短回答。标题会为其版面设计中最低的那个元素预留空间,所以如果在旁栏中画一个与本节第一行对齐的标题,正文会被推下一行,两行的标题则推下两行。因此标题的版面设计只画编号和分隔线,而sideHeads()把每个## Title的文字复制到紧随标题之后的一个:::callout中。设为span="side"后,这个框脱离正文流,立在旁栏中正文接续的那条网格线上(标注框样式)。框和正文都把一行的基线放在行高的0.8处,所以按正文14 pt行距排的框,会让两行标题的每一行都落在旁边段落某一行的基线上。Markdown仍是普通的## Programa de necesidades。由于标题的版面设计没有印{titleText},沙盒会列出警告未引用标题文字,尽管框里印出了标题。

Page 2: the number and the rule come from the heading, the title from the box in the channel, level with the first line of the text. The plot plan stacks under it.

#2 · 旁栏只留给标题和图

script.js · 第47–53行在完整代码中
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnPercent: (SIDE / CONTENT) * 100, // 50 of the 178 mm between the margins
  sideColumnSide: 'left', // on every page: the brief is printed on one side of the sheet
  sideColumnRole: 'floats', // no text in the channel: what span: 'side' sends, side captions
  gutterWidth: mm(GUTTER),
};

sideColumnRole: 'floats'让正文不进入旁栏(版式类型)。首页之后,旁栏中只有六个节标题和地块平面图(都用span: 'side'送进去),以及表1的题注。旁栏默认为'right'(右侧),单面印刷的任务书在每页都把它放在同一侧。sideColumnSide: 'left'把它放到左边,这样每个节标题都在其正文之前被读到。

#3 · 悬挂编号,并把最低一级标题排成段内标题

script.js · 第85–102行在完整代码中
// The title keeps the text's left edge; the number, right-aligned in a 12 mm box, ends
// 2 mm short of it. The box needs that fixed width: an 'auto' width is clamped to the
// column and would shrink to nothing out here.
const H3 = { size: 10.5, number: 9 }; // pt; two boxes one grid line tall share a baseline
const h3 = { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(H3.size),
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0),
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(H3.size), lineHeight: LEAD / H3.size, color: col('ink'), align: 'left',
      overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
      fontSize: pt(H3.number), lineHeight: LEAD / H3.number, color: col('signal'),
      align: 'right', placement: { ...at('#title', 'left-of', mm(-2)),
        size: { width: mm(12) } } },
  ] } } };
// Level 4 is not a heading: '**Accesibilidad.** Todo el edificio…' opens its paragraph.
// No other paragraph has bold; table cells keep tableStyle's ink, so Total stays black.
const runIn = { boldColor: col('signal') }; // spread into bodyText

小节标题从栏的左边缘开始。它的编号以'left-of'锚定在标题左侧,在一个12 mm宽、距正文2 mm处结束的框中右对齐,所以2.1、3.3这样的编号悬挂在栏间距中,每个标题都与下面的正文对齐(元素定位)。这个框需要固定宽度。'auto'宽度会被限制在栏内,放到栏间距里就一点宽度也不剩,编号也就画不出来。第2级印'{2}',第3级印'{2}.{3}',因为用{1}的话,每个编号都会以任务书唯一那个H1的1开头。第4节的评审标准是不用标题排的第四级。每段以一个粗体词语开头,bodyText.boldColor把它印成红色(正文)。其他段落都没有粗体,表格单元格的颜色取自tableStyle,所以表1加粗的Total行仍是黑色。

#4 · 为每个图和表分别指定位置

script.js · 第326–354行在完整代码中
// The plot plan stands in the channel; the elevation crosses channel and column at the
// head of the next page; the programme floats to the head of the text column, with its
// caption beside it in the channel; the calendar sits where ::resource puts it. The
// programme floats instead of sitting 'here' because an inline table that opens a page
// keeps a pending top figure off that page (gotcha: inline-table-skips-top-float).
const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000
const resources = [
  { id: 'parcela', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'parcela.svg', width: PLAN_W * 10, height: 400 },
    placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50
    caption: 'La parcela, 1:2000. Lonja y caseta de básculas, en gris; seis tamarindos en el '
      + 'borde norte. Flechas rojas: accesos posibles; flecha gris: servicio.',
    altText: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' },
  { id: 'fachada', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'fachada.svg', width: 1780, height: 300 },
    placement: { position: 'top', span: 'page' },
    caption: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con '
      + 'las armaduras corroídas.',
    altText: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' },
  { id: 'programa', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'top', captionSide: true },
    caption: 'Programa de superficies útiles por zonas.', table: programme },
  { id: 'calendario', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' }, caption: 'Calendario del concurso.', table: calendar },
  // No :ref cites the site plan: only the opener's image element draws it.
  { id: 'situacion', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'situacion.svg', width: MAP.w * 10, height: MAP.h * 10 },
    altText: 'Plano de situación del muelle de Poniente con la parcela de la lonja en rojo.' },
];

地块平面图用span: 'side'放进旁栏,叠在第2节的旁标题下方(定位)。width: PLAN_W / SIDE让它宽44 mm,88米的地块恰好是1:2000。立面图是横贯页面的'top'浮动体。它在第2页被引用,排在第3页开头,因为浮动体从不排在其引用位置之前。功能需求表同样在第2页被引用,浮动到第3页正文栏的顶部;若随文排,它会占据第3页开头,把立面图挤到第4页。captionSide: true让它的题注立在旁栏中,与表格顶部对齐。日程表放在::resource所在的位置,即任务书末尾。

#5 · 以总平面图开启任务书

script.js · 第106–131行在完整代码中
const MAP = { y: 68.5, w: CONTENT, h: 52 }; // mm: from the top of the text block
const display = { fontFamily: DISPLAY, fontWeight: 300, color: col('ink'), align: 'left',
  overflow: 'wrap' };
const opener = { enabled: true,
  // The plan is an image element, which reserves no height (gotcha:
  // opener-image-no-reserve): minHeight carries the reserve down to its foot.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)), // on a grid line
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('signal'),
      placement: at('container', 'top-left', mm(HANG)) },
    { kind: 'text', id: 'series', content: '{subtitle}', ...label,
      placement: { ...at('container', 'top-left'), size: { width: mm(SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(58),
      lineHeight: 0.96, placement: { ...at('#kicker', 'below', mm(0), mm(4.5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', ...display, italic: true,
      fontSize: pt(14), lineHeight: 1.3, placement: { ...at('#title', 'below', mm(0), mm(5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'legend', content: '{attr.map}', fontFamily: TEXT, fontSize: pt(7.5),
      lineHeight: 1.4, color: col('muted'), align: 'left', overflow: 'wrap',
      placement: { ...at('#lead', 'align-top', mm(-HANG), pt(2)),
        size: { width: mm(SIDE - 6) } } },
    { kind: 'image', id: 'map', resourceId: 'situacion', // across the channel and the column
      placement: { ...at('container', 'top-left', mm(0), mm(MAP.y)),
        size: { width: mm(MAP.w) } } },
  ] } };

设为span: 'page'时,首页标题的容器是整个版心,包括旁栏(span与高级版面设计)。眉题和标题放在57 mm处,即正文栏的左边缘,平面图横跨旁栏和正文栏。不设时,容器是正文栏,所有元素右移57 mm,标题、导语和平面图都会超出页面右边缘。图片元素不预留高度,所以用minHeight把预留空间设到平面图底部。向上取整到整条网格线后,第1节的分隔线与正文的距离和后面每一节都相同。眉题、导语和图例都是# Bases del concurso这一行的属性。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 045 · Side heads, hanging numbers and run-in heads ═══════
// https://postext.dev/en/cookbook/side-heads-hanging-numbers
// Code: MIT · Text: original (CC BY 4.0) · Drawings: made in code (MIT)
// Fonts: Mona Sans, Noto Serif Display, DM Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'side-heads-hanging-numbers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // a Swiss municipal report: black, white and one signal red
  ink: '#17171a', // text and rules
  signal: '#d42a1f', // section numbers, run-in heads, the plot on the plans (5.1:1)
  tint: '#f9dcd7', // the plot's ground on the plans
  sea: '#d8e2e8', // water on the plans
  stone: '#cacad0', // built ground on the plans
  rule: '#c3c3ca', // hairlines under the running head and in tables
  muted: '#5e5e67', // running heads, legends, the colophon (6.4:1)
  paper: '#ffffff',
};
// The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Defaults this config does not restate link to 'main-color', so it points at the accent.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.signal })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Mona Sans', 'Noto Serif Display', 'DM Mono'];
const TRIM = { width: 210, height: 297 }; // mm: A4, like every document of the competition
const [TOP, LEFT, RIGHT] = [26, 16, 16]; // mm; not mirrored: the brief prints one-sided
const LEAD = 14; // pt: the body leading, the pitch of the baseline grid
const LINES = 50; // grid lines in the text block
const PT = 25.4 / 72; // mm in a point
const GRID = LEAD * PT; // mm: one line of the grid
const CONTENT = TRIM.width - LEFT - RIGHT; // 178 mm
const [SIDE, GUTTER] = [50, 7]; // mm: the margin channel on the left, and the gap after it
const MAIN = CONTENT - SIDE - GUTTER; // 121 mm: the text column
const HANG = SIDE + GUTTER; // mm from the channel's left edge to the text's left edge
const at = (to, edge, x = mm(0), y = mm(0)) => ({ anchor: { to, edge }, offset: { x, y } });
// Kickers, running heads and folios: tracked capitals in the mono.
const LABEL_PT = 7.5;
const label = { fontFamily: LABEL, fontWeight: 500, fontSize: pt(LABEL_PT), lineHeight: 1.35,
  letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'), align: 'left' };

// #region channel: one text column, and on its left a channel for boxes and figures only
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnPercent: (SIDE / CONTENT) * 100, // 50 of the 178 mm between the margins
  sideColumnSide: 'left', // on every page: the brief is printed on one side of the sheet
  sideColumnRole: 'floats', // no text in the channel: what span: 'side' sends, side captions
  gutterWidth: mm(GUTTER),
};
// #endregion

// #region answer: side heads: the heading draws a number and a rule, a box prints the title
// The heading stays in the flow and keeps its number, but its design has no {titleText}:
// it prints the number in the channel and a rule from there to the column's right edge.
const [NUMBER, RULE] = [8.5, 0.75]; // pt: the DM Mono number, whose figures are 0.7 em tall
// Centre the rule on the figures: their baseline is 0.8 of the line down, less 0.35 em.
const RULE_Y = pt(NUMBER * (0.8 - 0.7 / 2) - RULE / 2);
const h2 = { level: 2, numberingTemplate: '{2}', lineHeight: pt(LEAD), // one grid line
  marginTop: pt(2 * LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: {
    elements: [
      { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
        fontSize: pt(NUMBER), lineHeight: 1, color: col('signal'),
        placement: at('container', 'top-left', mm(-HANG)) },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(RULE),
        color: col('ink'), placement: { ...at('#number', 'right-of', mm(2), RULE_Y),
          size: { width: 'fill' } } },
    ] } } };
// The title goes in a box fenced right after the heading: span="side" stands it in the
// channel on the grid line where the text resumes (gotcha: side-box-starts-at-fence).
// Attributes at the end of the line stay with the heading.
const sideHeads = (md) => md.replace(/^## (.+?)(\s*\{[^}]*\})?$/gm,
  (line, title) => `${line}\n\n:::callout{type="sidehead" span="side"}\n${title}\n:::`);
// The box has no background and no padding, and it takes the text's leading, so each
// line of the title sits on a baseline of the text beside it.
const sidehead = { id: 'sidehead', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: DISPLAY, fontSize: pt(13.5), lineHeight: pt(LEAD) } };
// #endregion

// #region lower: level 3 hangs its number in the gutter; level 4 runs into its paragraph
// The title keeps the text's left edge; the number, right-aligned in a 12 mm box, ends
// 2 mm short of it. The box needs that fixed width: an 'auto' width is clamped to the
// column and would shrink to nothing out here.
const H3 = { size: 10.5, number: 9 }; // pt; two boxes one grid line tall share a baseline
const h3 = { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(H3.size),
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0),
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(H3.size), lineHeight: LEAD / H3.size, color: col('ink'), align: 'left',
      overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
      fontSize: pt(H3.number), lineHeight: LEAD / H3.number, color: col('signal'),
      align: 'right', placement: { ...at('#title', 'left-of', mm(-2)),
        size: { width: mm(12) } } },
  ] } } };
// Level 4 is not a heading: '**Accesibilidad.** Todo el edificio…' opens its paragraph.
// No other paragraph has bold; table cells keep tableStyle's ink, so Total stays black.
const runIn = { boldColor: col('signal') }; // spread into bodyText
// #endregion

// #region opener: the brief's first page: kicker, title, lead and the site plan
const MAP = { y: 68.5, w: CONTENT, h: 52 }; // mm: from the top of the text block
const display = { fontFamily: DISPLAY, fontWeight: 300, color: col('ink'), align: 'left',
  overflow: 'wrap' };
const opener = { enabled: true,
  // The plan is an image element, which reserves no height (gotcha:
  // opener-image-no-reserve): minHeight carries the reserve down to its foot.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)), // on a grid line
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('signal'),
      placement: at('container', 'top-left', mm(HANG)) },
    { kind: 'text', id: 'series', content: '{subtitle}', ...label,
      placement: { ...at('container', 'top-left'), size: { width: mm(SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(58),
      lineHeight: 0.96, placement: { ...at('#kicker', 'below', mm(0), mm(4.5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', ...display, italic: true,
      fontSize: pt(14), lineHeight: 1.3, placement: { ...at('#title', 'below', mm(0), mm(5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'legend', content: '{attr.map}', fontFamily: TEXT, fontSize: pt(7.5),
      lineHeight: 1.4, color: col('muted'), align: 'left', overflow: 'wrap',
      placement: { ...at('#lead', 'align-top', mm(-HANG), pt(2)),
        size: { width: mm(SIDE - 6) } } },
    { kind: 'image', id: 'map', resourceId: 'situacion', // across the channel and the column
      placement: { ...at('container', 'top-left', mm(0), mm(MAP.y)),
        size: { width: mm(MAP.w) } } },
  ] } };
// #endregion

// #region furniture: letterhead and folio, the same on every page of a one-sided brief
const [HEAD_Y, FOOT_Y] = [13, 11]; // mm from the top and from the foot of the sheet
const FILE_NO = 'BML-2026/04'; // the council's file number, also in the opener's kicker
const header = { elements: [
  // The council's name in two lines, the second on the running head's baseline.
  { kind: 'text', id: 'city', content: '{author}', ...label, color: col('ink'),
    overflow: 'wrap', placement: { ...at('page', 'top-left', mm(LEFT),
      mm(HEAD_Y - LABEL_PT * label.lineHeight * PT)), size: { width: mm(40) } } },
  { kind: 'text', id: 'book', content: '{title}', ...label,
    placement: at('page', 'top-left', mm(LEFT + HANG), mm(HEAD_Y)) },
  { kind: 'text', id: 'folio', content: t({ en: 'Page {pageNumber} of {totalPages}',
    es: 'Página {pageNumber} de {totalPages}' }), ...label,
  placement: at('page', 'top-right', mm(-RIGHT), mm(HEAD_Y)) },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: { ...at('page', 'top-left', mm(LEFT), mm(HEAD_Y + 5)),
      size: { width: mm(CONTENT) } } },
] };
const footer = { elements: [
  { kind: 'text', id: 'file', content: `{chapterTitle} · ${FILE_NO}`, ...label,
    placement: at('page', 'bottom-left', mm(LEFT + HANG), mm(-FOOT_Y)) },
] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  // Figura / Tabla, counted through the whole brief: 1, 2… (gotcha: resource-types-locale)
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * GRID), left: mm(LEFT),
      right: mm(RIGHT), mirror: false } },
  layout,
  bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    ...runIn, boldFontWeight: 600, referenceColor: col('ink'), referenceBold: false,
    italicColor: col('ink'), // an italic would otherwise take main-color, the red
    // Report texture: ragged right, no indent, a blank line between paragraphs.
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // The hidden text of levels 2 and 3 asks for this weight, and 600 is loaded already.
  headings: { fontFamily: TEXT, fontWeight: 600,
    // Balancing would add lines above heads to fill short pages: three blank lines over
    // some side heads instead of two. Off, the white above every head is the same.
    balancing: { enabled: false }, levels: [
    // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
    // span: 'page' gives the opener the whole text block, channel included, as container.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: opener },
    h2, h3,
  ] },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5),
    color: col('muted') }],
  calloutStyles: [sidehead],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(8),
    bodyFontSize: pt(8.6), cellPadding: mm(1.4) },
  captionStyle: { fontSize: pt(8), labelColor: col('signal'), gap: mm(2) },
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 108行 · content.es.mdtitle: "Nueva Biblioteca Municipal" subtitle: "Concurso de proyectos" author: "Ayuntamiento de Puerto Lince" --- # Bases del concurso {kicker="Expediente BML-2026/04" lead="Concurso abierto y anónimo, en dos fases, para convertir la lonja de 1931 en la biblioteca de la ciudad." map="Plano de situación a escala 1:3000, con la parcela de la lonja en rojo y la ciudad construida en gris."} ## Objeto del concurso El Ayuntamiento de Puerto Lince convoca un concurso de proyectos con intervención de jurado para elegir la propuesta arquitectónica de la nueva Biblioteca Municipal, que ocupará la antigua lonja del pescado en el muelle de Poniente. La biblioteca actual funciona desde 1987 en la planta baja de la Casa de Cultura: 610 m² para una ciudad de 24.300 habitantes, sin posibilidad de ampliarse. El concurso se rige por estas bases y por la Ley 9/2017, de Contratos del Sector Público. Pueden participar los arquitectos con título habilitante en un Estado de la Unión Europea, solos o al frente de un equipo. ### Alcance del encargo El equipo ganador redactará el proyecto básico y el de ejecución, el estudio de seguridad y salud y el proyecto de actividad, y dirigirá la obra. Los honorarios, fijados en 412.000 € más IVA, incluyen también el diseño del mobiliario fijo y de la señalética. ### Presupuesto y plazo El presupuesto máximo de ejecución material, que incluye la urbanización de la parcela, es de 4.850.000 € sin IVA. Quedan fuera del concurso las propuestas que lo superen en más de un 10 %. La obra durará unos veinte meses, y la biblioteca debería abrir en 2030. ## El emplazamiento La lonja ocupa el frente de la ciudad hacia el puerto viejo, entre el barrio de pescadores y el ensanche de 1905. Es el único edificio del puerto viejo anterior a 1936 que sigue en pie, y el Ayuntamiento lo compró a la Autoridad Portuaria en 2022. ### La parcela La parcela es un rectángulo de 70 por 45 metros, 3.150 m² (:ref{id="parcela" case="lower"}). Linda al sur con el paseo del Muelle, al este con la calle de la Aduana y al norte con la plaza de las Redes, que el planeamiento prevé hacer peatonal en 2028. Al oeste queda la caseta de básculas, de propiedad municipal, que puede demolerse o integrarse en el conjunto. Descontada la nave, quedan 1.300 m² edificables en un máximo de dos plantas y 9,5 m de altura a cornisa. Ninguna construcción nueva puede adelantarse a la línea de la fachada del muelle. ### La lonja de 1931 La lonja es una nave de hormigón armado de 52 por 24 metros, cubierta por trece bóvedas de cañón de cuatro metros de luz que apoyan en pórticos. La construyó la Junta de Obras del Puerto, y en ella se subastó el pescado hasta 2009, cuando la subasta pasó al puerto nuevo. El catálogo municipal la protege en grado 2. Deben conservarse la estructura, las bóvedas y la fachada al muelle con sus once arcos de medio punto (:ref{id="fachada" case="lower"}). La inspección de 2025 encontró armaduras corroídas en los cuatro pórticos del extremo este; su reparación entra en el presupuesto, y cada propuesta explicará cómo piensa hacerla sin desmontar las bóvedas. ### Accesos y arbolado La entrada principal puede plantearse desde la plaza o desde el paseo, pero la carga y descarga se hará por la calle de la Aduana. Los seis tamarindos del borde norte, de unos cuarenta años, se conservarán. ## Programa de necesidades Las superficies de la tabla son útiles (:ref{id="programa" case="lower"}). Cada zona admite un 10 % de más o de menos, siempre que el total no cambie. La nave alojará las salas de lectura y de encuentro; lo que necesite cerramientos, instalaciones o control del ruido puede ir en la edificación nueva. ### Áreas públicas La sala general reunirá la colección de préstamo para adultos, unos 38.000 documentos, con noventa puestos de lectura. La hemeroteca y el fondo local compartirán una sala contigua, con doce puestos de consulta y un escáner de uso libre. La sala infantil tendrá entrada propia desde el exterior y un rincón de cuentacuentos para veinticinco niños. El espacio joven y la sala polivalente deben poder abrirse fuera del horario de la biblioteca, con el resto del edificio cerrado. ### Áreas internas El proceso técnico, la dirección y el depósito cerrado suman 260 m². Estas áreas han de comunicarse con la zona de carga sin cruzar las salas públicas. El depósito tendrá estanterías compactas; su forjado se calculará con una sobrecarga de 12 kN/m² y quedará a nivel de la calle. ### Espacios exteriores Entre la nave y la edificación nueva se pide un patio de lectura al aire libre de al menos 180 m², protegido del viento del norte, que en invierno sopla aquí con rachas de más de 60 km/h. ## Criterios de proyecto El jurado valorará las propuestas con los criterios siguientes, ordenados de mayor a menor peso. **Relación con la lonja.** Las intervenciones sobre la nave serán reconocibles y reversibles. No se admiten forjados que corten las bóvedas; sí altillos exentos que dejen ver los pórticos completos. **Accesibilidad.** Todo el edificio será accesible sin ayuda en silla de ruedas, patio y altillos incluidos. Ninguna rampa superará el 6 % de pendiente. **Energía y confort.** La propuesta dirá cómo se ventila la nave y cómo se protege del sol, porque sus bóvedas no admiten aislamiento por el interior. Se valorará que la climatización pueda regularse sala por sala. **Mantenimiento.** La lonja está a 18 metros del agua y el salitre le llega todo el año. Los materiales nuevos se elegirán para ese ambiente, y la memoria incluirá un plan de limpieza de cubiertas y canalones. **Coste.** El jurado comprobará que la estimación de coste se ajusta al máximo del apartado 1.2. ## Entrega de propuestas El concurso se desarrolla en dos fases. Toda la documentación se entrega en papel en el registro general del Ayuntamiento, y en PDF en la plataforma de contratación, antes de las 14.00 h del día fijado. ### Primera fase Cada equipo entregará dos paneles DIN A1 en vertical, sobre soporte rígido ligero, con la idea general, la planta baja a escala 1:200 y una sección por la nave. Los acompañará una memoria de cuatro páginas DIN A4 como máximo; el jurado no leerá las páginas que excedan ese límite. En esta fase no se admiten maquetas. ### Segunda fase El jurado elegirá cinco propuestas, que desarrollarán su idea en tres paneles DIN A1, una memoria de diez páginas y una estimación de coste por capítulos. Cada equipo seleccionado recibirá 6.000 € al entregar la documentación completa. ### Anonimato Los paneles y la memoria llevarán solo un lema de hasta cinco palabras, que se repetirá en el sobre cerrado con los datos del equipo. Se excluirá cualquier propuesta con nombres, logotipos o referencias que permitan reconocer a sus autores. Los sobres se abrirán en un acto público, después de que el jurado haya firmado el fallo. ## Jurado y plazos El jurado se constituirá antes de que termine el plazo de consultas, y su composición se publicará en el perfil del contratante. Sus deliberaciones serán secretas, y el acta del fallo explicará por escrito las razones de cada premio y de cada accésit. ### Composición del jurado El jurado tendrá siete miembros con voto: la concejala de Cultura, que lo presidirá; la arquitecta municipal; la directora de la biblioteca; tres arquitectos, dos de ellos designados por el colegio profesional, y una bibliotecaria del servicio regional de bibliotecas. Un técnico de contratación actuará como secretario, con voz y sin voto. ### Premios El primer premio conlleva la adjudicación del contrato de redacción del proyecto y dirección de obra. El segundo premio recibirá 12.000 € y el tercero, 8.000 €. Hay además dos accésits de 4.000 €, que el jurado puede declarar desiertos. ### Calendario Las fechas de la tabla no se moverán (:ref{id="calendario" case="lower"}). Las consultas se enviarán por escrito, y las respuestas se publicarán sin identificar a quien pregunta. ::resource{id="calendario"} :::paragraphs{style="colophon"} Documento de ejemplo: Puerto Lince, su lonja y este concurso son ficticios. Compuesto en Mona Sans, Noto Serif Display y DM Mono (SIL Open Font License). Texto original, CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook // The two tables: every column after the first is right-aligned, its header too. const row = (cells, header = false) => cells.map((content, i) => ({ content, isHeader: header, align: i > 0 ? 'right' : 'left' })); const programme = { model: { headerRowCount: 1, columnWidths: [3, 1], rows: [ row(['Zona', 'm²'], true), row(['Acogida y préstamo', '120']), row(['Sala general', '560']), row(['Sala infantil', '240']), row(['Espacio joven', '140']), row(['Hemeroteca y fondo local', '150']), row(['Sala polivalente', '160']), row(['Aulas de formación (2)', '100']), row(['Proceso técnico y dirección', '140']), row(['Depósito cerrado', '120']), row(['Aseos, almacenes e instalaciones', '270']), row(['**Total**', '**2.000**']), ] } }; const calendar = { model: { headerRowCount: 1, columnWidths: [3, 2], rows: [ row(['Hito', 'Fecha'], true), row(['Publicación del anuncio', '15 de octubre de 2026']), row(['Fin del plazo de consultas', '13 de noviembre de 2026']), row(['Entrega de la primera fase', '11 de enero de 2027, 14.00 h']), row(['Selección de cinco equipos', '5 de febrero de 2027']), row(['Entrega de la segunda fase', '22 de abril de 2027, 14.00 h']), row(['Fallo del jurado', '20 de mayo de 2027']), ] } }; // #region resources: four placements: the channel, across the page, a column top, here // The plot plan stands in the channel; the elevation crosses channel and column at the // head of the next page; the programme floats to the head of the text column, with its // caption beside it in the channel; the calendar sits where ::resource puts it. The // programme floats instead of sitting 'here' because an inline table that opens a page // keeps a pending top figure off that page (gotcha: inline-table-skips-top-float). const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000 const resources = [ { id: 'parcela', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'parcela.svg', width: PLAN_W * 10, height: 400 }, placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50 caption: 'La parcela, 1:2000. Lonja y caseta de básculas, en gris; seis tamarindos en el ' + 'borde norte. Flechas rojas: accesos posibles; flecha gris: servicio.', altText: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' }, { id: 'fachada', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'fachada.svg', width: 1780, height: 300 }, placement: { position: 'top', span: 'page' }, caption: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con ' + 'las armaduras corroídas.', altText: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' }, { id: 'programa', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'top', captionSide: true }, caption: 'Programa de superficies útiles por zonas.', table: programme }, { id: 'calendario', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, caption: 'Calendario del concurso.', table: calendar }, // No :ref cites the site plan: only the opener's image element draws it. { id: 'situacion', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'situacion.svg', width: MAP.w * 10, height: MAP.h * 10 }, altText: 'Plano de situación del muelle de Poniente con la parcela de la lonja en rojo.' }, ]; // #endregion // #region art: the three drawings, made in code with a seeded PRNG const rng = (seed) => () => { // Mulberry32: the same town on every run seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const n = (v) => (+v).toFixed(2); const svg = (w, h, body, view = `0 0 ${w} ${h}`) => '<svg xmlns="http://www.w3.org/2000/svg" ' + `width="${w * 10}" height="${h * 10}" viewBox="${view}">${body}</svg>`; const rect = (x, y, w, h, fill, extra = '') => `<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" fill="${fill}" ${extra}/>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${r}" fill="${fill}" ${extra}/>`; const path = (d, extra) => `<path d="${d}" ${extra}/>`; const stroke = (color, width) => `fill="none" stroke="${color}" stroke-width="${width}"`; const through = (points) => `M${points.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')}`; const arrow = (x, y, angle, color) => path('M0 -4 L0 3 M-1.6 1.2 L0 3.6 L1.6 1.2 Z', `transform="translate(${x} ${y}) rotate(${angle}) scale(1.3)" fill="${color}" ` + `stroke="${color}" stroke-width="0.7"`); const north = (x, y, s) => path(`M${x} ${y} L${x + 0.3 * s} ${y + s} L${x} ${y + 0.8 * s} ` + `L${x - 0.3 * s} ${y + s} Z`, `fill="${palette.ink}"`); // Site plan, 1:3000 (1 mm = 3 m): the old quarter, the 1905 ensanche, the plaza, the plot. function situacionSvg() { const { w, h } = MAP; const P = palette; const rand = rng(45); const m = (metres) => metres / 3; // mm on the plan const QUAY = h - 10; // the water's edge const PLOT = { x: 94, y: QUAY - m(18) - m(45), w: m(70), h: m(45) }; // behind the promenade const EAST = PLOT.x + PLOT.w; // the calle de la Aduana starts here // The sea: a beach on the west, then the quay of the old harbour and the fishing pier. let body = path(`M0 ${h - 5} C14 ${h - 5.5} 30 ${QUAY + 1} 42 ${QUAY} L${w} ${QUAY} V${h} H0 Z`, `fill="${P.sea}"`); body += rect(150, QUAY - 1, 7, h - QUAY + 1, P.stone); body += path(`M${w} ${h - 3.5} L161 ${h - 2.5}`, stroke(P.stone, 2.2)); for (let i = 0; i < 7; i++) { // boats moored along the quay body += rect(90 + i * 8 + rand() * 3, QUAY + 1.2, 1.3, 3 + rand(), P.rule, 'rx="0.6"'); } // The old quarter: one built mass cut by lanes that wander, and the old road to the port. const lane = (points, width) => path(through(points), `${stroke(P.paper, width)} stroke-linejoin="round" stroke-linecap="round"`); body += rect(-1, -1, EAST + 1, QUAY - m(18) + 1, P.stone); for (let x = 2; x < EAST; x += 7 + rand() * 5) { // lanes down to the sea const points = []; for (let y = -2; y <= QUAY - 5; y += 6) points.push([x + (rand() - 0.5) * 3.2, y]); body += lane(points, rand() < 0.25 ? 1.9 : 1); } for (let y = 4; y < QUAY - 10; y += 6 + rand() * 3.5) { // lanes along the coast const points = []; for (let x = -2; x <= EAST + 2; x += 8) points.push([x, y + (rand() - 0.5) * 2.4]); body += lane(points, rand() < 0.2 ? 1.7 : 0.9); } body += lane([[6, -2], [34, 12], [58, QUAY - 7]], 2.6); body += rect(PLOT.x - 4, 8, PLOT.w + 4, QUAY - m(18) - 8, P.paper); // the plaza de las Redes // The ensanche: chamfered blocks built round a courtyard. const [c, bw, bh] = [2, 13, 9]; for (let x = EAST + 5; x < w; x += bw + 2.8) { for (let y = -4; y < QUAY - 16; y += bh + 2.6) { body += path(`${through([[x + c, y], [x + bw - c, y], [x + bw, y + c], [x + bw, y + bh - c], [x + bw - c, y + bh], [x + c, y + bh], [x, y + bh - c], [x, y + c]])} Z`, `fill="${P.stone}"`) + rect(x + 3.2, y + 3, bw - 6.4, bh - 6, P.paper, 'fill-opacity="0.55"'); } } for (let x = 6; x < w - 4; x += 8.6) body += circle(x, QUAY - m(9), 0.9, P.rule); // promenade for (let i = 0; i < 10; i++) { body += circle(PLOT.x + (i % 5) * 5, 11 + Math.floor(i / 5) * 4.2, 0.9, P.rule); } // The plot, the lonja along its south edge and the weighbridge hut: as in figure 1. body += rect(PLOT.x, PLOT.y, PLOT.w, PLOT.h, P.tint, `stroke="${P.signal}" stroke-width="0.5"`); body += rect(PLOT.x + m(9), PLOT.y + m(21), m(52), m(24), P.signal) + rect(PLOT.x + m(1.5), PLOT.y + m(29), m(5.5), m(8), P.stone); body += north(86, h - 7, 5.5); body += rect(44, h - 3.4, m(100), 0.8, P.ink) + rect(44, h - 3.4, m(50), 0.8, P.paper, `stroke="${P.ink}" stroke-width="0.2"`); // 100 m return svg(w, h, body); } // The plot, 1:2000 (1 mm = 2 m), drawn in metres: 88 m across the figure's 44 mm. function parcelaSvg() { const P = palette; let body = rect(-9, 63, 88, 3, P.sea); // the harbour, past the 18 m promenade body += rect(0, 0, 70, 45, P.tint, `stroke="${P.signal}" stroke-width="0.9"`); body += rect(9, 21, 52, 24, P.stone); // the lonja for (let x = 13; x < 61; x += 4) body += path(`M${x} 21 V45`, stroke(P.paper, 0.35)); body += rect(1.5, 29, 5.5, 8, P.stone); // the weighbridge hut for (let i = 0; i < 6; i++) { // the six tamarinds body += circle(9 + i * 10.4, 6.5, 3.1, 'none', `stroke="${P.muted}" stroke-width="0.5"`) + circle(9 + i * 10.4, 6.5, 0.6, P.muted); } body += arrow(35, -7, 0, P.signal) + arrow(35, 52.5, 180, P.signal) // the entrances + arrow(75.5, 14, 90, P.muted); // service body += north(-6, -12, 6); return svg(PLAN_W, 40, body, '-9 -14 88 80'); } // The quay front, 1:300 (1 m = 3.33 mm), drawn in metres: 13 vaults on 14 porticos. function fachadaSvg() { const P = palette; const [VIEW_W, VIEW_H, SKY] = [CONTENT * 0.3, 9, 7.4]; // m; SKY: ground to the top edge const X = (x) => n(x + (VIEW_W - 52) / 2); const Y = (y) => n(SKY - y); // y up from the ground const CORNICE = 6.2; const R = (4 + 0.9 * 0.9) / (2 * 0.9); // radius of a 4 m vault that rises 0.9 m let roof = `M${X(0)} ${Y(0)} V${Y(CORNICE)}`; for (let i = 1; i <= 13; i++) roof += ` A${R} ${R} 0 0 1 ${X(4 * i)} ${Y(CORNICE)}`; let body = path(`${roof} V${Y(0)} Z`, `fill="${P.paper}" stroke="${P.ink}" stroke-width="0.08"`); body += path(`M${X(0)} ${Y(CORNICE - 0.35)} H${X(52)} M${X(0)} ${Y(0.5)} H${X(52)}`, stroke(P.ink, 0.04)); for (let i = 0; i < 13; i++) { // eleven arches; a square door in each end bay const cx = 4 * i + 2; body += i === 0 || i === 12 ? rect(+X(cx - 1.1), +Y(3.6), 2.2, 3.6, P.stone) : path(`M${X(cx - 1.2)} ${Y(0.5)} V${Y(3.4)} A1.2 1.2 0 0 1 ${X(cx + 1.2)} ${Y(3.4)} ` + `V${Y(0.5)} Z`, `fill="${P.stone}"`); } for (let i = 0; i <= 13; i++) { // the porticos' pilasters; the four eastern ones in red const x = Math.min(Math.max(4 * i - 0.25, 0), 51.5); body += rect(+X(x), +Y(CORNICE - 0.35), 0.5, CORNICE - 0.35, i >= 10 ? P.signal : P.paper, `stroke="${P.ink}" stroke-width="0.04"`); } body += path(`M0 ${Y(0)} H${VIEW_W}`, stroke(P.ink, 0.12)); // the quay body += rect(+X(0), +Y(-0.9), 5, 0.22, P.ink) // 10 m + rect(+X(5), +Y(-0.9), 5, 0.22, P.paper, `stroke="${P.ink}" stroke-width="0.04"`); return svg(CONTENT, VIEW_H / 0.3, body, `0 0 ${VIEW_W} ${VIEW_H}`); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Mona Sans': ['400', '600'], 'Noto Serif Display': ['300', '300i', '400'], 'DM Mono': ['500'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const source = sideHeads(markdown); await loadSvg('situacion.svg', situacionSvg()); await loadSvg('parcela.svg', parcelaSvg()); await loadSvg('fachada.svg', fachadaSvg()); await loadFonts(FONTS, source); const doc = await buildWithFonts(() => buildDocument({ markdown: source, resources }, config()), source); showPages(doc, { title: t({ en: 'Competition brief', es: 'Bases del concurso' }) });
工具包 · 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上的食谱文件夹 ↗

变化

#分隔线只画在正文栏

把分隔线锚定在标题自身的框上而不是编号上,它就从正文左边缘画到栏的右边缘,旁栏中只剩编号和标题。

-        color: col('ink'), placement: { ...at('#number', 'right-of', mm(2), RULE_Y),
+        color: col('ink'), placement: { ...at('container', 'top-left', mm(0), RULE_Y),

常见问题

易错点

侧栏框与其围栏之后的块齐平开始

在postext 1.4.1中,span: 'side'的框在侧栏中的位置,是正文排到其围栏处时的高度,落在下一个网格行上,并排在已在那里的框之下。把旁注围在它所解释的段落之前:围在段落之后,旁注就会从下一段旁边开始。会超出栏底的框会向上滑,在上方框允许的范围内,直到底边落在栏底;仍然放不下的,就等到下一页的侧栏。 旁注 →

易错点

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

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

易错点

齐左文字从不检查孤字

optimalLineBreaking、avoidRunts、runtPenalty和runtMinCharacters作用于Knuth–Plass断行器,而postext 1.4.1只对两端对齐的文字运行它。齐左段落逐行断行,无论这些设置怎么写,都可能以一个短词结尾。检查齐左文字的末行,改写以孤字结尾的段落。 段末孤行、段首孤行与孤字 →

易错点

章首页的图片从不计入它预留的高度

在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →

易错点

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

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

易错点

以行内表开头的页面会跳过等待中的顶部浮动体

在postext 1.4.1中,当一个行内表(位置'here',用::resource排入)移到下一页顶部时,在前一页引用的整页宽'top'浮动体不会占据该页顶部,而是等到再下一页。把表也设为浮动(位置'top'或'auto',用:ref引用),或者在下一页以正文开头的地方引用该图。 图的放置 →

易错点

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

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

易错点

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

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

易错点

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

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

易错点

用defaultResourceTypes(locale)本地化Figure/Table

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

易错点

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

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

沙盒检查 · headingAdvancedWithoutTitleText

标题文字未印出

原因. 某个高级设计中没有任何元素印出{titleText},因此标题本身的文字永远不会出现。

解决. 加一个带{titleText}的文字元素,除非这个标题本来就应该不可见。 文档 →

  • 旁栏中的图会占据引用它那一页旁栏的顶部,排在它之后的旁标题会叠在图下方,低于本节的开头。旁栏中的图要在其所在页的节标题之后引用,就像第2节引用地块平面图那样。
  • 在postext 1.4.1中,排在标题和第一段之间的旁栏框会让这一段首行缩进,即便设了indentAfterHeading: false。这份任务书没有缩进,所以看不出来。如果正文有缩进,就把每节第一段包进一个:::paragraphs容器,其样式设firstLineIndent: 0。
  • 在postext 1.4.1中,段落样式的spaceBetween也会加在容器最后一段之后,而且不像正文段落间距那样与下一个标题的marginTop合并。把评审标准放进这样的容器并设一行spaceBetween时,第5节上方有三行空白,其他各节都是两行,所以这份任务书用bodyText.boldColor给段内词语上色。

致谢

文本
原创文字, CC BY 4.0
字体
Mona Sans (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · DM Mono (SIL OFL 1.1)
沙盒