かんたんな説明
雑誌の天気のページを、同じテキストから2回作ります。1組の設定から、段組みの印刷版と、画面で読むための暗い1段の版を作る方法を示します。
できあがり
小さな雑誌の秋のセクション『Notes on Weather』を、同じMarkdownから2回組みます。紙では225 × 297 mmの号です。雨のような青の部扉、淡い霧色の帯の下で始まる3つの記事、両端そろえの2段、雨量計と風配図の図、霧に包まれた谷の写真で構成されます。画面では、Newsreaderで組んだ左そろえ(行末不ぞろい)の1段で、ほぼ黒の地に淡い灰色の文字です。読者が選択してコピーできるHTMLのテキストで、部扉も霧色の帯もノンブルもありません。ペインは雨量計の位置で開き、雨量計はペインの高さに合わせて縮められ、夜の配色で描かれます。その横には雨量計が載っている印刷ページがあります。上へスクロールするとRainの章扉が現れ、ペインの角をドラッグすると、テキストが新しい大きさで組み直されます。画面のデザインは、印刷用の設定の中のhtmlViewer.overridesにある9つのキーです。
このレシピが答える質問
- 同じ文書を、画面幅に応じてスクロールで読めるHTML表示にするには?
- 1つの設定から、画面版に印刷版より簡素なデザインを与えるには?
手短な答え
// config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it.
const screenOverrides = () => ({
colorPalette: paletteOf(night), // arrays are replaced whole: the night values
parts: { page: false }, // no divider page; the part still names the notes after it
// 96 dpi: the mm and pt inherited from print render at their CSS size.
page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } },
layout: { layoutType: 'single', // one column
fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print
bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading
// A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force
// a paragraph taller than the pane whole into a one-line gap under a figure, and off the page.
avoidWidows: false },
// Heading levels merge on `level`: the print level keeps everything not restated here.
headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
marginTop: px(40), marginBottom: px(26),
advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air
footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already)
captionStyle: { fontSize: px(13), gap: px(10) },
paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole
});
// The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only).
// Wide panes get wider margins, so the measure stops at MEASURE.
const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px
const MIN_SIDE = 34; // px: the side margins of a narrow pane
function screenConfig({ width, height }) {
const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object
const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2));
return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height),
margins: { ...merged.page.margins, left: side, right: side } } });
}
材料
- 機能
- 画面用の簡素なデザインHTMLの読書版ページに収める図デザインした章扉部扉段抜きの章見出し帯見出しの属性図の配置セマンティックカラーパレットページの役割ごとの柱1段組みと2段組み用紙の色キャンバス上のページリソースとしての図と表「図」と「表」を文書の言語で独自のリソースの種類図を配置する参照段落スタイル
- 併用する機能
- 段末そろえ
- 種類
- Newsreader, Gloock, Reddit Sans (SIL OFL 1.1)
- 素材
valley-1610.jpg- Radiation fog in a valley at dawn (Figure 2) (Generated With Diffusion Models, オリジナル)
- The rain gauge, the wind rose and the part page's rain, drawn in code in both palettes (Ignacio Ferro, CC BY 4.0)
作り方
#1 · 色の名前は1組、値は2組
const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1',
rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' };
const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129',
rule: '#2e3742', muted: '#98a2ae', paper: '#111418' };
const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue
Object.entries({ ...values, 'main-color': values.rain })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback
// Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text,
// table and box styles and the page background, but not into design-slot elements or
// bodyText.referenceColor, so rewrite every linked colour from the palette the config carries.
function relink(config) {
const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex]));
const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v
: Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] }
: Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])));
return walk(config);
}
設定の中のすべての色はパレットのidを指し、その横に書いた16進値は昼の配色の予備にすぎません。そのため画面版は配列を1つ置き換えるだけで夜の配色に切り替わります。手短な答えにある上書きは、オブジェクトをキーごとに、見出しの階層をlevelごとにマージし、それ以外の配列はcolorPaletteも含めてまるごと置き換えます。main-colorはパレットの一部なので、それを使うエンジンの既定値も変わります。postext 1.4.1では新しいパレットがデザイン要素と参照の色に届かないので、relink()がマージ後の設定のパレットから、リンクされた色をすべて書き直します。
#2 · 章扉は、紙では帯、画面では言葉
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
color: col('ink'), align: 'left', ...style,
overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default)
const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans',
{ fontWeight: 600, textTransform: 'uppercase', color: col('rain') });
const title = text('title', '{titleText}', 'Gloock');
const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true });
const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } });
const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } });
const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } });
const fromTop = (y, x) => at('container', 'top-left', y, x);
// minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs
// from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored).
const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('fog') },
placement: band(mm(0), mm(BAND)) },
{ kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'),
placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot
{ ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) },
{ ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) },
{ ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) },
] } };
const screenOpener = [
{ ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) },
{ ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') },
{ ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'),
placement: under('title', px(10), 'fill') },
];
肩書き、題、リードの要素は一度だけ定義して両方のデザインに展開するので、2つの版は同じ見出し属性{attr.kicker}、{attr.lead}と部の{partTitle}を表示します。紙では第1階層の見出しは改ページして両段に渡り、そのデザインが裁ち落としから霧色の帯を垂らします。minHeightは、天の余白から下の帯の深さにAIR、つまり帯と1行目のあいだの9 mmを加えた分を確保します。印刷版の題を54 ptで組むのは、どれも短い1語で、そのページで唯一の大きな文字だからです。画面では同じ階層を、帯も空きもない段内の見出しとして組みます。上書きはlevelごとにマージされるので、改めて指定しなかった見出しの設定はすべて印刷版のまま残ります。
#3 · 紙にしかない部扉
const onField = { color: col('paper'), lineHeight: 1 };
const parts = {
margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low
bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'),
numberColor: col('paper') },
design: { elements: [
{ kind: 'box', id: 'field', style: { backgroundColor: col('rain') },
placement: band(mm(0), 'fill') },
{ kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') },
text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9),
letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }),
text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190),
placement: under('series', mm(10), mm(150)) }),
text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60),
placement: under('numeral', mm(-6), mm(150)) }),
] },
};
印刷版では:::partが雨のような青の部扉を開きます。四方を裁ち落としにした箱、文字を避ける雨の線の画像要素、囲いから取った数字と題、白抜きの記事一覧です。画面の上書きはparts.page: falseを指定するので、ページは開かず一覧も組まれません。それでも部は後に続く記事に番号と題を与え、画面の肩書きも印刷版と同じく「Autumn · Note 1」になります。
#4 · どの図も2回描く
// The valley is a photograph, one JPEG in assets/ that both editions share, at its pixels.
const photo = { kind: 'bitmap', svg: undefined,
bitmap: { fileId: 'valley-1610.jpg', format: 'jpeg', width: 1610, height: 690 } };
const figure = (id, edition, [width, height], placement, caption, alt) => ({ id,
typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement,
svg: { fileId: `${id}-${edition}.svg`, width, height },
caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt>
const figures = (edition) => [
figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, {
en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.',
es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.',
}, {
en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow '
+ 'tube, half full, beside a graduated measuring stick.',
es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un '
+ 'tubo estrecho, medio lleno, junto a una regla graduada.' }),
{ ...figure('valley', edition, [1610, 690], { position: 'bottom', span: 'page' }, {
en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.',
es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.',
}, {
en: 'A valley between wooded hills lies under layers of fog at sunrise, with a church '
+ 'tower, trees and a river showing through it, seen from a fenced bank.',
es: 'Un valle entre lomas boscosas yace bajo la niebla al amanecer; asoman la torre de '
+ 'una iglesia, árboles y un río, vistos desde un ribazo con una cerca.' }), ...photo },
figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, {
en: 'Where a year of morning winds came from at the garden station: west and south-west.',
es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.',
}, {
en: 'A wind rose of sixteen petals on three rings; the longest petals point west and '
+ 'south-west, the shortest east.',
es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos '
+ 'apuntan al oeste y al suroeste, los más cortos al este.' }),
figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered
];
パレットを入れ替えてもSVGの色は変わらないので、各図はパレットごとに1つずつ作ります。谷は写真で、2つの版で1つのJPEGを共有します。figures(edition)は両方の版に同じリソースを作り、それぞれがその版のファイルを指します。印刷版ではgauge-day.svg、画面版ではgauge-night.svgです。id、キャプション、代替テキスト、配置は変わらないので、Markdownの:refはどちらの版でも同じ図と番号に解決されます。画面版では代替テキストが各<img alt>に入り、スクリーンリーダーがそこから読み取ります。
#5 · 画面版を組み込む
const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it
if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but
// not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column)
pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both';
}
pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but
// inherited text properties (letter-spacing, text-transform…) can, and the lines were measured
// without them: `all: initial` on the wrapper stops them at the edition's edge.
const inShadow = `<style>:host>div{all:initial;display:block}`
+ `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue
let size = '';
function showScreen() {
const [width, height] = [pane.clientWidth, pane.clientHeight];
if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded
const first = !size; // the first build opens on Figure 1's page, like the print proof
size = `${width}×${height}`;
const screenDoc = buildDocument({ markdown, resources: figures('night') },
screenConfig({ width, height }));
const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds
shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc,
{ mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`;
const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode
pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages
screenLabel.textContent =
`${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`;
}
showScreen();
let timer = 0; // debounced; its first call, on observe(), finds the size unchanged
new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); })
.observe(pane);
画面のページはどれもペインと同じ高さなので、fitFiguresToPageが雨量計をキャプションといっしょに1ページに収まるまで縮めます(レイアウト)。CSSが前提とする96 dpiでは、印刷版から受け継いだミリメートルとポイントが公称の大きさで描かれます(HTMLビューアーの統合)。CodePenのサンプルは、renderToHtmlが絶対配置で出力する行をShadow DOMに書き込みます。そこならページのセレクターは届きません。ラッパーのall: initialはletter-spacingのような継承プロパティも遮断します。エンジンはそれらなしで行を計測しているので、値が継承されると文字が重なって印刷されてしまいます。HTMLのページは塗らなければ透明なので、page.backgroundColorがパレットから夜の紙色を取ります。ResizeObserverは150 ms待ち、ペインの幅か高さが変わったときだけ、新しく作ったconfig()から組み直します。組み直したあと、ペインは高さに対して同じ割合の位置までスクロールを戻します。
レシピの全体
// ═══ Postext Cookbook · Nº 011 · One source, print and screen editions ═══════════ // https://postext.dev/en/cookbook/print-and-screen-editions // Code: MIT · Text: original (CC BY 4.0) · Drawings: code (CC BY 4.0) · Photo: diffusion models // Fonts: Newsreader, Gloock, Reddit Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'print-and-screen-editions'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: one set of colour ids, two sets of values: day for paper, night for screens const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1', rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' }; const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129', rule: '#2e3742', muted: '#98a2ae', paper: '#111418' }; const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue Object.entries({ ...values, 'main-color': values.rain }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback // Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text, // table and box styles and the page background, but not into design-slot elements or // bodyText.referenceColor, so rewrite every linked colour from the palette the config carries. function relink(config) { const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex])); const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v : Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] } : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]))); return walk(config); } // #endregion const px = (value) => ({ value, unit: 'px' }); // screen sizes, written as CSS pixels const [TOP, BAND, AIR] = [22, 86, 9]; // mm: top margin, the fog band's depth, air under it // #region opener: print opens each note under a fog band; the screen keeps the words only const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily, color: col('ink'), align: 'left', ...style, overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default) const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans', { fontWeight: 600, textTransform: 'uppercase', color: col('rain') }); const title = text('title', '{titleText}', 'Gloock'); const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true }); const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } }); const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } }); const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } }); const fromTop = (y, x) => at('container', 'top-left', y, x); // minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs // from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored). const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('fog') }, placement: band(mm(0), mm(BAND)) }, { kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'), placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot { ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) }, { ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) }, { ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) }, ] } }; const screenOpener = [ { ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) }, { ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') }, { ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'), placement: under('title', px(10), 'fill') }, ]; // #endregion // #region answer: the screen edition lives in the same config, as overrides of the print one // config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it. const screenOverrides = () => ({ colorPalette: paletteOf(night), // arrays are replaced whole: the night values parts: { page: false }, // no divider page; the part still names the notes after it // 96 dpi: the mm and pt inherited from print render at their CSS size. page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } }, layout: { layoutType: 'single', // one column fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading // A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force // a paragraph taller than the pane whole into a one-line gap under a figure, and off the page. avoidWidows: false }, // Heading levels merge on `level`: the print level keeps everything not restated here. headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false }, marginTop: px(40), marginBottom: px(26), advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already) captionStyle: { fontSize: px(13), gap: px(10) }, paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole }); // The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only). // Wide panes get wider margins, so the measure stops at MEASURE. const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px const MIN_SIDE = 34; // px: the side margins of a narrow pane function screenConfig({ width, height }) { const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2)); return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height), margins: { ...merged.page.margins, left: side, right: side } } }); } // #endregion // #region part: the divider page, a rain field bled off every edge under the part's numeral const onField = { color: col('paper'), lineHeight: 1 }; const parts = { margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'), numberColor: col('paper') }, design: { elements: [ { kind: 'box', id: 'field', style: { backgroundColor: col('rain') }, placement: band(mm(0), 'fill') }, { kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') }, text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9), letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }), text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190), placement: under('series', mm(10), mm(150)) }), text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60), placement: under('numeral', mm(-6), mm(150)) }), ] }, }; // #endregion const foot = text('foot', '{pageNumber} {title} · {partTitle}', 'Reddit Sans', { fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.5), textTransform: 'uppercase', color: col('muted'), align: 'center', placement: { ...at('container', 'top', mm(9)), size: { width: 'fill' } }, pages: 'opener' }); // the notes, not the part page: each fits its opening page (a note that ran // on would need a copy of this element with pages: 'body') const colophon = { id: 'colophon', fontFamily: 'Reddit Sans', fontSize: pt(7.5), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(14) }; // A factory: the engine caches resolved configs per object (gotcha: config-cache-identity). const config = () => ({ locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) // The locale does not name the figures (gotcha: resource-types-locale): "Figura" in Spanish, // one count for the whole issue, and a lower-case "fig." in Spanish running text. resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never', ...(type.id === 'figure' && { shortLabel: t({ en: 'Fig.', es: 'fig.' }) }), })), colorPalette: paletteOf(day), page: { width: mm(225), height: mm(297), dpi: 150, backgroundColor: col('paper'), // dark at night margins: { top: mm(TOP), bottom: mm(24), left: mm(20), right: mm(16), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(7) }, bodyText: { fontFamily: 'Newsreader', fontSize: pt(10), lineHeight: pt(14), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rain'), textAlign: 'justify', // the default, stated for contrast with the screen's ragged 'left' firstLineIndent: mm(4.5), indentAfterHeading: false }, // hyphenation, widows: on by default headings: { fontFamily: 'Gloock', fontWeight: 400, color: col('ink'), // Off: 1.4.1 drops the column under a closing page's column float a line (here English Rain // under the gauge, Spanish Wind under the rose; gotcha: float-stretch-closing-page). balancing: { stretchAfterFloats: false }, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: printOpener }, ] }, captionStyle: { fontFamily: 'Reddit Sans', fontSize: pt(8), color: col('muted'), labelColor: col('rain'), gap: mm(2.4) }, parts, paragraphStyles: [colophon], header: { elements: [] }, footer: { elements: [foot] }, htmlViewer: { overrides: screenOverrides() }, // canvas and PDF ignore it; an HTML host applies it }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 50行 · content.en.md
title: "Notes on Weather" subtitle: "Autumn" author: "Postext Cookbook" --- :::part{number="I" title="Autumn"} 1. Rain, and thirty-one years of the garden gauge 2. Fog, where it comes from and when it lifts 3. Wind, from the shed vane to the Beaufort scale ::: # Rain {kicker="Note 1" lead="A copper can at the end of the garden has kept this household’s rain for thirty-one years, and its book holds over eleven thousand readings to a tenth of a millimetre."} Rain is measured as a depth: the height the water would stand if none of it ran off, sank in or dried. A millimetre of rain is a litre on every square metre of ground, so a gardener and a hydrologist can use the same number. The instrument that counts it has hardly changed in a century and a half; :ref{id="gauge"} shows it in section. A funnel of known width catches the rain and leads it into a narrow inner tube with a tenth of the funnel’s area, so every millimetre that falls stands ten millimetres deep inside, deep enough to read to a tenth with a graduated stick. What overflows the tube in a downpour waits in the outer can and is measured afterwards. The reading is made at eight o’clock every morning, whatever the weather, and written down before breakfast. It is dull work. Climatologists describe a place by its averages over thirty years, and our book now covers thirty-one. In that time the gauge has recorded an average of 612 millimetres a year, from 402 in the driest year to 871 in the wettest. No gauge catches all the rain. Wind carries drops past the mouth of the funnel, so a gauge on an exposed post catches less than falls on the lawn around it, and one beside a wall or a tree catches less again. The rule is to stand it at least twice as far from any obstacle as the obstacle is tall, with its rim a foot above the grass. Ours stands alone at the far end of the lawn for that reason. A raindrop’s shape depends on its size, and it is never the teardrop of the weather map. The smallest are perfect spheres, held round by their own surface tension. Above two millimetres or so, the air pushing up from below flattens them into something closer to a bread roll, and past five or six millimetres they are torn into smaller drops as they fall. A large drop reaches the ground at about nine metres a second, hard enough to drum on leaves and roofs, which is why a summer shower can be heard before it is felt. The heaviest rain falls from the tallest clouds, and it seldom lasts. Long autumn rain comes instead from wide sheets of cloud along a warm front, the kind that close in by degrees through a morning: first a haze across the sun, then a grey lid, then the first drops on the window. By evening the gauge may hold ten or fifteen millimetres. # Fog {kicker="Note 2" lead="Fog is a cloud that touches the ground. In autumn it fills the valley below the house after most clear nights, and it is usually gone by eleven."} Meteorologists call it fog when the visibility drops below one kilometre; above that, it is mist. Either way it is made of the same stuff as a cloud: droplets of water about a hundredth of a millimetre across, so small that they hang in the air instead of falling. A cubic metre of thick fog holds less than half a gram of water, a tenth of a teaspoon, and a layer a few dozen metres deep can hide a village up to its church spire, as :ref{id="valley"} shows. The fog of autumn mornings is usually radiation fog. On a clear, still night the ground gives up the day’s warmth to the open sky and chills the layer of air lying on it. When that air cools to its dew point, the vapour it carries condenses into droplets. Cold air is heavy, so it drains downhill and pools in hollows and river valleys, and at dawn the high ground stands in sunshine above a white, level sea. A wind of more than a few metres a second would stir the cold layer into the warmer air above it, and a cloudy night would keep the ground from cooling. As the sun climbs it warms the ground, the ground warms the air, and the fog thins from below, often rising into a low grey ceiling before it breaks up. Sea fog is another matter. It forms when warm, moist air drifts over cold water, and because the wind that brought it keeps feeding it, it can sit on a coast for days. Fog is white for the same reason a cloud is: its droplets scatter every colour of sunlight alike, red as much as blue. People who live in foggy valleys learn its habits. In ours the first fog of the year comes after the first long, clear night of September. It settles earliest by the river and lingers longest behind the mill. If the church tower comes out of it before nine, the afternoon will be fine. # Wind {kicker="Note 3" lead="We read the wind in what it moves: smoke, leaves, the sea, the washing on the line. In 1805 a naval officer gave it numbers."} Wind is air on its way from high pressure to low. It never takes the straight road: the turning of the Earth bends it aside, so that in the northern hemisphere it circles a low anticlockwise, and near the ground friction slows it and tilts it inwards. A wind is named for where it comes from, not where it goes, so a west wind blows from the west, off the ocean, and often brings the rain. In our garden it is the commonest wind of all, as the rose in :ref{id="rose"} makes plain. The rose is a year of mornings read from the vane on the shed roof. A vane turns its broad tail downwind, so its arrow always points into the wind, where the air is coming from. At eight each morning we note the nearest of its sixteen points, and each petal of the rose is as long as the share of mornings the wind blew from that point. Calm mornings, about one in eight, point nowhere and are left out. Checked against the gauge’s book, the vane stood between south-west and west on most wet mornings. Francis Beaufort, an officer of the Royal Navy, wrote his scale for ships’ logs and described each force by what it did to a warship’s sails. It was later rewritten for land, and the land version still works without an anemometer. At force 2 you feel the wind on your face and the leaves rustle. At force 4 it raises dust and scraps of paper and moves small branches. At force 6 large branches swing and an umbrella is hard to hold. At force 8 twigs break off the trees and walking into it is hard work. The scale stops at force 12, hurricane force, a mean wind of 118 kilometres an hour or more. Every valley has its own winds and its own names for them. Sailors and farmers named the winds long before anyone measured them, and the names still say where a wind comes from and what it brings: the mistral that pours down the Rhône valley, cold and dry; the föhn that warms the northern slopes of the Alps; the bora that falls on the Adriatic in winter, in gusts that can overturn a lorry; the sea breeze that sets in on a summer afternoon and dies at dusk. In Provence the mistral is said to blow for three, six or nine days. Weather stations measure the wind ten metres above open ground and average it over ten minutes, because wind is never steady. It comes in gusts and lulls, eddies round buildings and hedges, and blows harder over the sea than over a town; a gust can be half as strong again as the average around it. That is why a forecast gives two figures, a mean speed and a gust speed, and on an open hill you plan for the second. :::paragraphs{style="colophon"} *Notes on Weather*, part I. Set in Newsreader, Gloock and Reddit Sans (SIL Open Font License). Text and drawings: original, CC BY 4.0. The photograph is generated. :::`; // content.<lang>.md, inlined by the Cookbook // #region figures: the same resources for both editions, each pointing at its edition's drawing // The valley is a photograph, one JPEG in assets/ that both editions share, at its pixels. const photo = { kind: 'bitmap', svg: undefined, bitmap: { fileId: 'valley-1610.jpg', format: 'jpeg', width: 1610, height: 690 } }; const figure = (id, edition, [width, height], placement, caption, alt) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement, svg: { fileId: `${id}-${edition}.svg`, width, height }, caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt> const figures = (edition) => [ figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, { en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.', es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.', }, { en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow ' + 'tube, half full, beside a graduated measuring stick.', es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un ' + 'tubo estrecho, medio lleno, junto a una regla graduada.' }), { ...figure('valley', edition, [1610, 690], { position: 'bottom', span: 'page' }, { en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.', es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.', }, { en: 'A valley between wooded hills lies under layers of fog at sunrise, with a church ' + 'tower, trees and a river showing through it, seen from a fenced bank.', es: 'Un valle entre lomas boscosas yace bajo la niebla al amanecer; asoman la torre de ' + 'una iglesia, árboles y un río, vistos desde un ribazo con una cerca.' }), ...photo }, figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, { en: 'Where a year of morning winds came from at the garden station: west and south-west.', es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.', }, { en: 'A wind rose of sixteen petals on three rings; the longest petals point west and ' + 'south-west, the shortest east.', es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos ' + 'apuntan al oeste y al suroeste, los más cortos al este.' }), figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered ]; // #endregion // #region art: a rain gauge in section, a wind rose, the part page's rain function mulberry(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${body}</svg>`; const f1 = (n) => n.toFixed(1); const between = (random, [a, b]) => a + random() * (b - a); // Slanted rain strokes; `clear` lists boxes [x0, y0, x1, y1] the strokes stay out of. const streaks = (random, n, { x, y, len, width, color, alpha, clear = [] }) => Array.from({ length: n }, () => { const [x0, y0, l] = [between(random, x), between(random, y), between(random, len)]; const [o, w] = [between(random, alpha).toFixed(2), between(random, width).toFixed(2)]; const hits = clear.some(([a, b, c, d]) => x0 > a && x0 - 0.28 * l < c && y0 + l > b && y0 < d); return hits ? '' : `<path d="M${f1(x0)} ${f1(y0)}l${f1(-0.28 * l)} ${f1(l)}" stroke="${color}" ` + `stroke-opacity="${o}" stroke-width="${w}" stroke-linecap="round"/>`; }).join(''); const shape = (tag, attrs) => `<${tag} ${Object.entries(attrs) .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}="${v}"`).join(' ')}/>`; function gauge(p) { // 300 × 420: rain, the funnel, the can sunk in the lawn, the tube, a stick const random = mulberry(7); const line = { stroke: p.ink, strokeWidth: 2 }; const ticks = Array.from({ length: 23 }, (_, i) => `M246 ${386 - i * 9}h${i % 5 === 0 ? 14 : 7}`).join(''); const grass = Array.from({ length: 42 }, (_, i) => `M${f1(3 + i * 7.2 + random() * 4)} 356` + `l${f1((random() - 0.5) * 7)} -${f1(5 + random() * 9)}`).join(''); return svg(300, 420, streaks(random, 44, { x: [40, 292], y: [0, 72], len: [12, 24], width: [1.6, 2.6], color: p.rain, alpha: [0.35, 0.95] }) + shape('path', { d: 'M70 104H230', stroke: p.muted, strokeWidth: 1.4 }) + shape('path', { d: 'M70 104l7 -4v8zM230 104l-7 -4v8z', fill: p.muted }) // arrowheads + shape('rect', { x: 0, y: 355, width: 300, height: 65, fill: p.rule, fillOpacity: 0.55 }) + shape('path', { d: grass, stroke: p.slate, strokeWidth: 1.6, strokeLinecap: 'round' }) + shape('rect', { x: 76, y: 128, width: 148, height: 268, rx: 5, fill: p.fog, ...line, strokeWidth: 2.5 }) + shape('rect', { x: 124, y: 196, width: 52, height: 192, fill: p.paper, ...line }) + shape('rect', { x: 126, y: 290, width: 48, height: 96, fill: p.rain, fillOpacity: 0.85 }) + shape('path', { d: 'M70 118h160v10H70z', fill: p.slate }) + shape('path', { d: 'M72 128h156l-72 60h-12z', fill: p.rule, ...line }) + shape('rect', { x: 144, y: 184, width: 12, height: 16, fill: p.rule, ...line }) + shape('rect', { x: 240, y: 176, width: 26, height: 214, fill: p.paper, ...line, strokeWidth: 1.6 }) + shape('rect', { x: 241, y: 290, width: 24, height: 99, fill: p.rain, fillOpacity: 0.3 }) + shape('path', { d: ticks, stroke: p.ink, strokeWidth: 1.2 })); } function rose(p) { // 300 × 300: where a year of morning winds came from, in sixteen petals const share = [6, 4, 3, 2, 2, 3, 4, 6, 9, 12, 19, 22, 24, 14, 9, 7]; // N first, clockwise const petal = (s, i) => { const [a, r] = [((i * 22.5 - 90) * Math.PI) / 180, s * 5.4]; // length ∝ share const end = (d) => `${f1(150 + r * Math.cos(a + d))} ${f1(150 + r * Math.sin(a + d))}`; return shape('path', { d: `M150 150L${end(-0.17)}A${f1(r)} ${f1(r)} 0 0 1 ${end(0.17)}Z`, fill: s > 12 ? p.rain : p.slate, fillOpacity: s > 12 ? 0.95 : 0.55 }); }; const rings = [40, 80, 120].map((r) => shape('circle', { cx: 150, cy: 150, r, fill: 'none', stroke: p.rule, strokeWidth: 1.2 })); return svg(300, 300, rings.join('') + share.map(petal).join('') + shape('path', { d: 'M150 18V282M18 150H282', stroke: p.rule, strokeWidth: 1 }) + shape('path', { d: 'M144 14V2L156 14V2', fill: 'none', stroke: p.ink, strokeWidth: 2 }) // N + shape('circle', { cx: 150, cy: 150, r: 5, fill: p.ink })); } async function drawFigures() { // the drawings in both palettes, the part page's rain, the photo const words = [[20, 26, 80, 36], [20, 100, 112, 128], [20, 206, 140, 238]]; // mm: the type await loadSvg('rain-field.svg', svg(225, 297, streaks(mulberry(19), 320, { x: [-10, 245], y: [-14, 292], len: [6, 18], width: [0.3, 0.8], color: day.paper, alpha: [0.12, 0.45], clear: words }))); for (const [edition, p] of [['day', day], ['night', night]]) { await loadSvg(`gauge-${edition}.svg`, gauge(p)); await loadSvg(`rose-${edition}.svg`, rose(p)); } await loadImage('valley-1610.jpg', asset('valley-1610.jpg')); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Newsreader: ['400', '400i', '700'], Gloock: ['400'], 'Reddit Sans': ['400', '400i', '600', '700'] }; // 400: captions and the colophon // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await drawFigures(); const doc = await buildWithFonts( // print: every page on the kit's desk, and one beside the screen () => buildDocument({ markdown, resources: figures('day') }, config()), markdown); showPages(doc, { title: t({ en: 'One source, print and screen editions', es: 'Un solo original, ediciones impresa y de pantalla' }) }); // The two editions side by side, above the desk. index.html and style.css lay them out; pasted // on its own, the script writes the same markup, and the screen region gives the pane a height. if (!document.getElementById('editions')) { document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions"> <figure class="edition"><canvas id="proof" role="img" style="width:300px"></canvas> <figcaption></figcaption></figure><figure class="edition screen"> <div id="screen" role="region" tabindex="0"></div><figcaption></figcaption></figure></section>`); } const [proof, pane] = [document.getElementById('proof'), document.getElementById('screen')]; const [proofLabel, screenLabel] = document.querySelectorAll('#editions figcaption'); const holds = (page, id) => [...(page.floats ?? []), ...page.columns.flatMap((c) => c.blocks)] .some((block) => block.resourceBlock?.resource.id === id); const note = doc.pages.find((page) => holds(page, 'gauge')) ?? doc.pages[0]; // Figure 1's page const density = Math.min(window.devicePixelRatio || 1, 2); renderPageToCanvas(note, doc, proof, { scale: (density * proof.clientWidth) / note.width }); const { width: trimW, height: trimH } = config().page; proofLabel.textContent = `${t({ en: 'Print', es: 'Impresa' })} · canvas · ` + `${trimW.value} × ${trimH.value} mm`; proof.setAttribute('aria-label', `${t({ en: 'Print edition, page', es: 'Edición impresa, página' })} ${note.index + 1}`); pane.setAttribute('aria-label', t({ en: 'Screen edition', es: 'Edición de pantalla' })); // #region screen: the HTML edition in a Shadow DOM, laid out again when its pane resizes const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but // not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column) pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both'; } pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but // inherited text properties (letter-spacing, text-transform…) can, and the lines were measured // without them: `all: initial` on the wrapper stops them at the edition's edge. const inShadow = `<style>:host>div{all:initial;display:block}` + `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue let size = ''; function showScreen() { const [width, height] = [pane.clientWidth, pane.clientHeight]; if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded const first = !size; // the first build opens on Figure 1's page, like the print proof size = `${width}×${height}`; const screenDoc = buildDocument({ markdown, resources: figures('night') }, screenConfig({ width, height })); const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc, { mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`; const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages screenLabel.textContent = `${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`; } showScreen(); let timer = 0; // debounced; its first call, on observe(), finds the size unchanged new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); }) .observe(pane); // #endregionキット · 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 ───────────────────────────────────────────────────────────────────────
<section id="editions">
<figure class="edition"><canvas id="proof" role="img"></canvas><figcaption></figcaption></figure>
<figure class="edition screen"><div id="screen" role="region" tabindex="0"></div><figcaption></figcaption></figure>
</section>
<main id="pages"></main>
/* The reading room: the printed page and the screen edition side by side, on a fog-grey desk. */
#editions {
display: flex; justify-content: center; align-items: center; gap: 32px;
box-sizing: border-box; min-height: min(75vw, 100vh); padding: 28px;
background: #dde3e9;
}
.edition { margin: 0; display: flex; flex-direction: column; gap: 14px; }
.edition figcaption {
font: 600 11px/1 system-ui, sans-serif; letter-spacing: .16em; text-transform: uppercase;
color: #4f5a66;
}
#proof {
aspect-ratio: 225 / 297;
display: block; height: min(580px, calc(100vh - 188px)); background: #fff;
box-shadow: 0 1px 2px rgb(20 30 40 / .25), 0 20px 40px -18px rgb(20 30 40 / .5);
}
.screen { flex: 0 1 580px; min-width: 0; }
/* The pane the edition fills: its size is the page size, so resizing it re-lays the text.
Drag its corner to try. Its background is the night paper, set by the script. At least
400 px tall: in a shorter pane postext 1.4.1 sets the text over the opener
(gotcha: opener-taller-than-column). */
#screen {
height: min(580px, calc(100vh - 188px)); overflow-y: auto; scrollbar-gutter: stable;
resize: both; min-width: 240px; min-height: 400px; max-width: 100%;
border-radius: 10px; box-shadow: 0 0 0 7px #1c2129, 0 26px 48px -20px rgb(10 14 20 / .7);
scrollbar-width: thin; scrollbar-color: #2e3742 transparent;
}
@media (max-width: 760px) {
#editions { flex-direction: column; min-height: 0; padding: 24px 16px; }
#proof { height: auto; width: min(420px, 100%); }
.screen { flex: none; width: 100%; }
#screen { height: 72vh; }
}
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#昼の配色で読む
夜のパレットを外し、ペインと図に昼の値を使います。画面版は白いページ、濃いインク、昼の図で表示されます。
- colorPalette: paletteOf(night), // arrays are replaced whole: the night values
parts: { page: false }, // no divider page; the part still names the notes after it
@@
-pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
+pane.style.background = day.paper; // the pane's own ground, beside the pages and the scrollbar
@@
- const screenDoc = buildDocument({ markdown, resources: figures('night') },
+ const screenDoc = buildDocument({ markdown, resources: figures('day') },#印刷版の章扉に色帯と章番号を付ける
彩度の高い帯と大きな章番号を持つ、より重厚な印刷版の章扉については裁ち落としの帯を敷いた章扉を参照してください。画面の上書きはそのまま使えます。
よくあるつまずき
つまずき
HTML出力には段間罫、グリッド、トンボがなく、背景は透明
renderToHtmlは段間罫、ベースライングリッド、トンボを描かず、背景を渡さない限りページは透明です。暗いページの上では、暗い文字が暗い背景に重なります。 HTMLの読書版 →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
つまずき
htmlViewerの設定にはSandboxにしかないものがある
htmlViewer.maxCharsPerLine、columnGap、optimalLineBreakingを読むのはSandboxのビューアーで、renderToHtmlではありません。自分のページでは、ページの大きさを自分で決め、modeとcolumnGapをrenderToHtmlに渡してください。上書きはapplyHtmlViewerOverridesで適用されます。 HTMLの読書版 →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
章扉はページに固定した要素のうち最も下のものまで高さを確保する
詳細デザインの章扉は、最も下にある要素までの高さを確保します。見出しより下にあるページや裁ち落としに固定した要素もこれに含まれるため、ページの下端に置いた装飾があると本文が次のページへ押し出されます。こうした装飾は見出しより上に置くか、ヘッダーやフッターのスロットに移すか、minHeightで確保する高さを指定してください。 デザインした章扉 →
つまずき
Figure/TableはdefaultResourceTypes(locale)でローカライズする
設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。 「図」と「表」を文書の言語で →
つまずき
ハイフネーションできるのは8つのロケールだけで、コードは完全一致
ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →
つまずき
デザインのテキストのoverflowの既定値は'ellipsis-end'
幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
最終ページでは、topフロートが最後の段を押し下げることがある
postext 1.4.1では、章や話がページ幅のtopフロートで始まるページで終わり、その行が段の間で不均等に分かれると、stretchAfterFloatsは短いほうの段を短いまま終わらせず、フロートの下に空行を1行加えます。そのため、2つの段の始まりの行がそろわなくなります。headings.balancing.stretchAfterFloatsをfalseにするか、原稿を偶数行に収めてください。 段末そろえ →
つまずき
行末不ぞろいのテキストでは、太字や:refと隣の約物の間で改行されることがある
postext 1.4.1では、両端そろえでないテキスト(囲みの本文、左そろえの段落)は、太字やイタリックの部分、あるいは:refと、それに接する約物の間で改行されることがあります。ピリオドが次の行の頭に来たり、参照の前の「(」が前の行の末尾に残ったりします。両端そろえのテキストではそこで改行されません。すべての版の囲みを読み、そうなった文は、その部分が行の途中に来るように書き換えてください。 太字・イタリックとその色 →
- 編集したあとは、すべての記事の終わりを確かめてください。記事の最後の段落が2行の末尾を新しいページへ押し出すと(ウィドウ制御が2行をまとめて保つため)、postext 1.4.1はそのページの段に1行分の高さしか与えず、Canvasは2行目を切り取ってしまいます。2つのサンプルは、どの記事もそのような終わり方をしないように原稿の量を調整してあります。
- 左そろえ(行末不ぞろい)のテキストでは、1.4.1は参照の前の「(」で行を終えることがあり、読者がサイズを変えられるペインでは、どこかの幅でその改行が起こります。これらの記事では「(Fig. 2)」とは書かず、「as Fig. 2 shows」と書いています。
- 画面の段落がページの最終行で始まることを許してください(
avoidWidows: false)。この規則が有効だと、1.4.1はページより高い段落をまるごと図の下の隙間に押し込むことがあり、段落はページの地からはみ出します。240 × 460 pxや260 × 500 pxのペインでこれが見られます。 - ペインの高さは400 px以上に保ってください(CodePenのサンプルの
min-height)。高さが約350 px未満の狭いペインでは、画面の章扉がページの内容領域より高くなります。するとpostext 1.4.1は確保した領域をすべて捨て、最初の段落を題の上に重ねて組みます。 - レイアウトする前にペインに大きさを与えてください。非表示になったり、flexの行で押しつぶされたりして幅のないペインは、幅0のページになります。そのため
showScreen()は、幅か高さがFOLDEDの200 pxに満たないペインを飛ばします。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- 画像
- Radiation fog in a valley at dawn (Figure 2) · Generated With Diffusion Models · オリジナル
- The rain gauge, the wind rose and the part page's rain, drawn in code in both palettes · Ignacio Ferro · CC BY 4.0
- フォント
- Newsreader (SIL OFL 1.1) · Gloock (SIL OFL 1.1) · Reddit Sans (SIL OFL 1.1)


