かんたんな説明
古い魚市場を図書館に変える設計案を、町議会が募集する要項です。節の題は広い左の余白に置かれ、隣の本文の1行目と同じ高さにそろいます。
できあがり
建築設計競技の募集要項をA4判5ページに組みます。スペインの架空の港町Puerto Linceの議会が、1931年の魚市場を町の図書館に変える設計案を募るものです。レイアウトはスイスの自治体の報告書にならいます。どのページにも左側を50 mmの余白欄が縦に走り、各節は赤い番号と版面全体にわたる黒い罫で始まります。節の題はNoto Serif Displayで欄に立ち、本文の1行目と同じ高さにそろいます。小節の番号は赤いDM Monoで段間にぶら下がるので、小節の題はどれも本文の左端をそろえて保ちます。第4節の審査基準は赤い太字の語で始まります。1ページ目は敷地図の上に58 ptの題を置き、2ページ目には敷地の平面図が欄に立ち、3ページ目は岸壁側の立面図で始まります。
このレシピが答える質問
- 見出しに番号(1、1.1、1.1.1)を振り、レベルごとに異なるスタイルにするには?
- 幅の広い本文の段と幅の狭いサイド段からなる1段半のレイアウトにするには?
- 図の位置を制御するには(ページの上端、2段をまたぐ、まさにこの位置、余白)?
- 本文や囲みの中で、キーワード(太字やイタリック)に色を付けるには?
手短な答え
// 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) } };
材料
- 機能
- 欄外の注デザインした章扉1段半組みフロート用の余白段番号付き見出し見出しの属性ページデザインのテキスト・罫・ボックスデザイン要素の基準位置ページデザインの画像図を配置する参照図の配置囲み段落スタイル太字・イタリックとその色柱とノンブル表スタイルキャプションのスタイル「図」と「表」を文書の言語で横置きのキャプションその場に置く図
- 種類
- Mona Sans, Noto Serif Display, DM Mono (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 節の題は欄の中の囲みから印刷する
コードは上の手短な答えにあります。見出しはデザインのいちばん下の要素までの高さを確保するので、節の1行目と同じ高さで欄に題を描くと、本文が1行、2行の題なら2行押し下げられます。そこで見出しのデザインには番号と罫だけを描かせ、sideHeads()が各## Titleのテキストを、見出しの直後に置いた:::calloutフェンスへ写します。span="side"にするとその囲みは流れを離れ、本文が再開するグリッド線の位置で欄に立ちます(囲みスタイル)。囲みと本文はどちらも行のベースラインを行の高さの0.8下に置くので、本文の14 ptの行送りに乗った囲みは、2行の題の各行を隣の段落のベースラインにそろえます。Markdownはふつうの## Programa de necesidadesのままです。見出しのデザインが{titleText}を印刷しないため、囲みが題を印刷していても、Sandboxは警告「見出しのタイトルを参照していません」を表示します。

#2 · 欄は見出しと図のために空けておく
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'で本文は欄に入りません(レイアウトの種類)。章扉の後、欄に入るのは6つの節の題と敷地の平面図(どちらもspan: 'side'で送る)、そして表1のキャプションだけです。サイド段の初期値は'right'で、紙の片面に刷る要項なら、どのページでも同じ側に置かれます。sideColumnSide: 'left'はそれを左に置き、各節の題が本文より先に読まれるようにします。
#3 · 番号をぶら下げ、いちばん小さな見出しを追い込みにする
// 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'に固定し、本文の2 mm手前で終わる12 mmの箱の中で右そろえにするので、2.1や3.3のような番号は段間にぶら下がり、題はどれもその下の本文とそろいます(要素の配置)。箱には固定の幅が必要です。'auto'の幅は段に収まるよう切り詰められ、段間に出た位置では幅がまったく残らず、番号が描かれません。レベル2は'{2}'、レベル3は'{2}.{3}'を印刷します。{1}を使うと、要項に1つだけあるH1の1がすべての番号の頭に付いてしまうからです。第4節の基準は見出しなしで組む4番目のレベルです。各段落は太字の語で始まり、bodyText.boldColorがそれを赤で印刷します(本文)。ほかの段落には太字がなく、表のセルは色をtableStyleから取るので、表1の太字のTotalの行は黒のままです。
#4 · 図と表にそれぞれの配置を与える
// 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 · 要項を敷地図で始める
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'にすると、章扉のコンテナーは欄を含む版面全体になります(幅と詳細デザイン)。キッカーと題は57 mm内側、本文の段の左端に置き、平面図は欄と段にまたがります。これがないとコンテナーは本文の段になり、すべての要素が57 mm右へずれて、題、リード文、図がページの右端からはみ出します。画像要素は高さを確保しないので、minHeightで確保する高さを図の下端までにします。それをグリッド1行単位に切り上げるので、第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.md
title: "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がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。 ウィドウ、オーファン、ラント →
つまずき
章扉の画像は確保する高さに数えられない
postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。 デザインした章扉 →
つまずき
'top'のフロートは参照したページには入らない
フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。 図の配置 →
つまずき
インラインの表で始まるページは、待っているtopフロートを飛ばす
postext 1.4.1では、インラインの表(::resourceで位置'here'に置いた表)が次のページの頭に移ると、前のページで参照したページ幅の'top'フロートはそのページの頭に入らず、さらに次のページまで待ちます。表もフロートにする(位置'top'または'auto'にして:refで参照する)か、次のページが本文で始まる位置で図を参照してください。 図の配置 →
つまずき
SVGの<img>内のテキストはWebフォントを使えない
SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →
つまずき
デザインのテキストのlineHeightは倍率で、寸法ではない
デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です(lineHeight: 1.05)。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。 ページデザインのテキスト・罫・ボックス →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
Figure/TableはdefaultResourceTypes(locale)でローカライズする
設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。 「図」と「表」を文書の言語で →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
Sandboxの検査 · headingAdvancedWithoutTitleText
見出しのタイトルを参照していません
原因. 詳細デザインに{titleText}を印字する要素がないため、見出し自体の文言が表示されません。
対処. 見出しを意図的に非表示にする場合を除き、{titleText}を含むテキスト要素を追加してください。 ドキュメント →
- サイドの図は参照されたページで欄の頭を占め、その後にフェンスで置いた欄外見出しは図の下、つまり自分の節の始まりより下に積まれます。第2節が敷地の平面図を参照しているように、サイドの図はそのページの節見出しの後で参照してください。
- postext 1.4.1では、見出しと最初の段落の間にフェンスで置いたサイドの囲みがあると、
indentAfterHeading: falseでもその段落に1行目の字下げが付きます。この要項には字下げがないので何も見えません。字下げのある本文なら、各節の最初の段落を、スタイルでfirstLineIndent: 0を設定した:::paragraphsコンテナーで包んでください。 - postext 1.4.1では、段落スタイルの
spaceBetweenがコンテナーの最後の段落の後にも加えられ、本文の段落間隔のように次の見出しのmarginTopと相殺されません。基準をそうしたコンテナーに入れてspaceBetweenを1行にしたところ、第5節の上には空き行が3行、ほかの節の上には2行入りました。そのためこの要項では、追い込みの語を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)


