かんたんな説明
古い潮力水車小屋を紹介する両面のリーフレットを、英語とスペイン語で作ります。文書をフォントや絵といっしょに1つのファイルに保存し、そのファイルから同じページを取り戻す方法を示します。
できあがり
架空の入り江にある潮力水車小屋の来館者用DLリーフレット(99 × 210 mm)の両面です。おもて面は1枚の絵です。水際に水車があり、上半分は砂の上、下半分は青い水の下で淡く描かれます。表題は50 ptのDM Serif Displayイタリック、青いタブには版の言語(ENまたはES)が入ります。裏面は水車小屋の断面図で始まり、両端そろえのDM Sansの本文、青い見出し行の付いた開館時間の表、Instrument Sansのキャプション、奥付、発行者名の入った青い帯と続きます。各版は.postextファイルに書き出され、ここに載せたページはすべて、そのファイルのバイト列から、ファイルに入った書体と図で組んだものです。
このレシピが答える質問
- .postextファイルを開き、同梱のフォント・画像・設定で描画するには?
- コードから.postextバンドルを作り、文書をSandboxやほかのプログラムに渡すには?
- 1つのプロジェクトから、同じ本を2つの言語で出版するには?
- 「図」や「表」のラベルを文書の言語で表示するには?
手短な答え
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
markdown, config: config(), resources,
files: { ...drawings, ...faceFiles },
thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why
// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
材料
- 機能
- .postextバンドル「図」と「表」を文書の言語で独自のフォントハイフネーションと文書の言語番号付きキャプション図を配置する参照その場に置く図明示的な縦のアキ章ごとに組む本表紙、扉、奥付見出しスタイル見出しの属性デザインした章扉ページデザインのテキスト・罫・ボックスページデザインの画像文書のメタデータキャプションのスタイル表スタイルPDFの書き出しPDFに埋め込むフォント
- 種類
- DM Sans, DM Serif Display, Instrument Sans (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 版を書き出し、バイト列だけを読み戻す
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
markdown, config: config(), resources,
files: { ...drawings, ...faceFiles },
thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why
// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
createBundleは章、設定、リソース、そしてそれらが参照するすべてのファイルをzipにまとめます。openBundleが受け取るのはそのバイト列だけです。書体はloadBundleFontsから、図はregisterBundleImagesから、デザインはbundle.configから取ります。filesに入れ忘れた書体はブラウザーの代替フォントで印字され、入れ忘れた図は警告とともに省かれ、その参照は(?)と印字されます。zipの中では各fileIdがパスになり(mill.svgはresources/mill.svgになります)、openBundleが返すリソースとcustomFontsはすでに新しい名前を使っています(バンドルを開く)。
#2 · 書体をファイルに入れる
const customFonts = Object.entries(FONTS).map(([name, specs]) => ({ name,
variants: specs.map((spec) => ({ weight: parseInt(spec, 10), format: 'woff2',
style: spec.endsWith('i') ? 'italic' : 'normal', fileId: `${fontsourceId(name)}-${spec}` })),
}));
// The bytes: Fontsource's static woff2 files, latin subset, which covers the Spanish text too.
const faceFiles = Object.fromEntries(await Promise.all(customFonts.flatMap(({ name, variants }) =>
variants.map(async ({ weight, style, fileId }) => {
const id = fontsourceId(name);
const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/`
+ `${id}-latin-${weight}-${style}.woff2`);
if (!res.ok) throw new Error(`Fontsource has no ${name} ${weight} ${style}`);
return [fileId, new Uint8Array(await res.arrayBuffer())];
}))));
バンドルが書体を持つのは、customFontsがその書体を指定し、filesにそのバリエーションのfileIdでバイト列が入っているときです。createBundleはDM Sans 400をfonts/dm-sans-400-normal.woff2として格納します。CodePenのサンプルはFontsourceから7つのwoff2ファイルをダウンロードしますが、どれも登録しません。buildBundleが行を測る前にloadBundleFontsがバンドル内の複製をdocument.fontsに加え、PDFも同じ7ファイルから書体を取ります。
#3 · どのファイルもfileIdで名付ける
const svg = (id, w, h, altText, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...extra });
const row = (...cells) => cells.map((content) => ({ content }));
const head = (...cells) => cells.map((content) => ({ content, isHeader: true }));
const resources = [
svg('cover', PAGE.w, PAGE.h, t({ en: 'A mill wheel on the waterline, its lower half pale '
+ 'under the estuary', es: 'Una rueda de molino en la línea del agua, con la mitad '
+ 'inferior pálida bajo la ría' })),
svg('mill', SECTION.w, SECTION.h, t({
en: 'The mill in section: the pond at high level on the left, the mill house on the dam '
+ 'with its millstones, the horizontal wheel in the vaulted pit, and the estuary on the '
+ 'right below a dashed high-water line',
es: 'El molino en sección: el estanque a nivel alto a la izquierda, la casa del molino sobre '
+ 'la presa con sus muelas, el rodezno en el cárcavo abovedado y la ría a la derecha, bajo '
+ 'una línea discontinua de pleamar' }), {
placement: { position: 'here' },
caption: t({ en: 'Two hours after high water: the pond turns the wheel, and the estuary '
+ 'has fallen below the dashed line.',
es: 'Dos horas tras la pleamar: el estanque mueve el rodezno y la ría ha quedado por debajo '
+ 'de la línea discontinua.' }) }),
{ id: 'hours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
placement: { position: 'here' },
caption: t({ en: 'Opening hours. Last entry 45 minutes before closing.',
es: 'Horario. Última entrada 45 minutos antes del cierre.' }),
table: { model: { headerRowCount: 1, columnWidths: [1.55, 0.9, 1.55], rows: t({
en: [head('Season', 'Days', 'Hours'),
row('April–June', 'Tue–Sun', '10:00–14:00, 16:00–19:00'),
row('July–August', 'Mon–Sun', '10:00–20:00'),
row('September–March', 'Fri–Sun', '10:30–14:30')],
es: [head('Temporada', 'Días', 'Horario'),
row('Abril–junio', 'Mar.–dom.', '10:00–14:00 y 16:00–19:00'),
row('Julio–agosto', 'Lun.–dom.', '10:00–20:00'),
row('Septiembre–marzo', 'Vie.–dom.', '10:30–14:30')] }) } } },
];
2点の図はCodePenのサンプルが生成するSVGマークアップで、それぞれのリソースのsvg.fileIdの名前でfilesに渡します。表はデータなので、キャプションといっしょにpreset.jsonに入って運ばれます。断面図と表はposition: 'here'で配置します。断面図は裏面の先頭に、開館時間の表はそれを引用する段落の下に置かれます。1.4.1では行内のリソースの下に間隔がとられないので、表のあとに:::space{lines=0.5}の行を入れて、キャプションの下に3.7 mmをあけています。これがないと、次の段落はキャプションの1.4 mm下から始まります。
#4 · ラベルをファイルに書き込む
// Hyphenation patterns and the PDF's /Lang, by exact code (gotcha: hyphenation-locales).
locale: t({ en: 'en-us', es: 'es' }),
// Figura and Tabla travel inside the Spanish file. Left out, they follow whoever opens it:
// the Sandbox at /en/sandbox prints Figure 1.1 (gotcha: bundle-labels-reader-locale).
// '{n}' numbers them 1, 2, 3: a leaflet has no chapters to number its figures by.
resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
ファイルにresourceTypesがないと、openBundleはファイルの言語ではなく、読み手が求めた言語でそれを組み立てます。スペイン語のリーフレットを{ locale: 'en' }で開いたり、/en/sandboxのSandboxに読み込んだりすると、スペイン語の本文の上にFigure 1.1と印字されます。設定に書き込んだ種類はこの既定を置き換えるので、スペイン語のファイルはどこで開いてもFigura 1とTabla 1を印字します。'{n}'は章番号を落とします。2ページのリーフレットに章番号は不要です(リソースの種類)。同じconfig()でlocaleも設定し、これでスペイン語のハイフネーションパターン(裏面のcompuer-tas)と、PDFが宣言する言語が決まります。
#5 · 同じバイト列を渡す
const file = `tide-mill-${LANG}.postext`;
document.getElementById('pt-actions').append(Object.assign(document.createElement('a'), {
href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), download: file,
textContent: `Download ${file} · ${Math.round(bytes.length / 1024)} KB` }));
// The PDF embeds the faces the bundle carries, and draws the figures from its files.
offerPdf(() => renderToPdf(docs, {
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2 }),
resourceBytes: bundleResourceBytes(bundle),
}), `${RECIPE}-${LANG}.pdf`);
リンクからは、ページを組んだのと同じbytes、フォント込みで132 KBのファイルを取得できます。Sandboxで読み込むと(本 → 新規 → .postextファイルを開く…)、ファイルはスペイン語または英語の本になり、thumbnailから取った水車の絵がその本の画像になります。PDFは書体をbundleFontProviderから取ります。これはバンドル内でウェイトが最も近い書体を、同じスタイルがあればそのスタイルで返します。図はbundleResourceBytesから取るので、フォントも画像も2度ダウンロードされることはありません(バンドルを組んで描画する)。
#6 · おもて面を見出しひとつで描く
const at = (x, y, width, edge = 'top-left') => ({ anchor: { to: 'page', edge },
offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } });
const text = (id, content, family, size, placement, look) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col('estuary'), overflow: 'wrap', // gotcha:
placement, ...look }); // overflow-ellipsis-default
const caps = { fontFamily: LABEL, fontWeight: 700, textTransform: 'uppercase',
letterSpacing: pt(1.15) };
const [MEASURE, EDGE] = [PAGE.w - 2 * PAGE.side, 17]; // mm; EDGE: trim to kicker and facts
const cover = { id: 'cover', advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'page',
edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(PAGE.h) } } },
text('kicker', '{attr.kicker}', LABEL, 7.5, at(PAGE.side, EDGE, MEASURE), caps),
// The language tab: the edition's code on a blue flap hanging from the top edge.
text('edition', '{attr.edition}', LABEL, 8, { anchor: { to: 'page', edge: 'top-right' },
offset: { x: mm(-PAGE.side) } }, { ...caps, color: col('foam'), box: {
backgroundColor: col('estuary'), padding: { top: mm(8), right: mm(2.4), bottom: mm(2.2),
left: mm(2.4) } } }),
text('title', '{titleText}', DISPLAY, 50, at(PAGE.side - 0.8, 25, MEASURE + 2),
{ italic: true, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple)
text('lead', '{attr.lead}', TEXT, 11, at(PAGE.side + 2, WATER + 50, MEASURE - 4),
{ color: col('foam'), italic: true, lineHeight: 1.4 }),
text('facts', '{attr.facts}', LABEL, 7.5, at(PAGE.side, -EDGE, MEASURE, 'bottom-left'),
{ ...caps, color: col('sand') }),
] } } };
おもて面は表紙用の見出しのデザインです。図を原寸でページに固定し、見出しの文字は{titleText}で、キッカー、リード文、案内の1行は見出しの属性から取り、言語のタブはボックス付きのテキスト要素で描きます。H1のレベルはspan: 'page'なので、図は用紙の端から始まります。見出しのデザインを段の中に置いたままにすると、版面の上端と下端で切られます。図は上から12 mm下がった位置で始まり、下端の13 mm手前で止まり、言語のタブは文字の入らない細い帯に縮んでしまいます。
レシピの全体
// ═══ Postext Cookbook · Nº 041 · .postext round trip in two languages ════════════ // https://postext.dev/en/cookbook/bundle-round-trip // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: DM Sans, DM Serif Display, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { createBundle, openBundle, loadBundleFonts, registerBundleImages, buildBundle, bundleFontProvider, bundleResourceBytes, defaultResourceTypes, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'bundle-round-trip'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#172130', muted: '#56606c', // text; the colophon estuary: '#25476a', mud: '#8a6f4d', // the one accent; the wheel's wood in the drawings sand: '#e9dcc4', foam: '#eef2f3', rule: '#c4ced6', paper: '#ffffff' }; // The hex rides along: design elements read it, not the palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the estuary blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.estuary }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['DM Sans', 'DM Serif Display', 'Instrument Sans']; const PAGE = { w: 99, h: 210, top: 12, bottom: 13, side: 10 }; // mm: a DL leaflet, both sides const [BODY, LEAD] = [9.4, 13.4]; // pt const WATER = 98; // mm from the top of the cover: where the sand ends and the estuary begins // #region cover: the front of the leaflet, a heading drawn over one picture const at = (x, y, width, edge = 'top-left') => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }); const text = (id, content, family, size, placement, look) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('estuary'), overflow: 'wrap', // gotcha: placement, ...look }); // overflow-ellipsis-default const caps = { fontFamily: LABEL, fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.15) }; const [MEASURE, EDGE] = [PAGE.w - 2 * PAGE.side, 17]; // mm; EDGE: trim to kicker and facts const cover = { id: 'cover', advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(PAGE.h) } } }, text('kicker', '{attr.kicker}', LABEL, 7.5, at(PAGE.side, EDGE, MEASURE), caps), // The language tab: the edition's code on a blue flap hanging from the top edge. text('edition', '{attr.edition}', LABEL, 8, { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-PAGE.side) } }, { ...caps, color: col('foam'), box: { backgroundColor: col('estuary'), padding: { top: mm(8), right: mm(2.4), bottom: mm(2.2), left: mm(2.4) } } }), text('title', '{titleText}', DISPLAY, 50, at(PAGE.side - 0.8, 25, MEASURE + 2), { italic: true, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple) text('lead', '{attr.lead}', TEXT, 11, at(PAGE.side + 2, WATER + 50, MEASURE - 4), { color: col('foam'), italic: true, lineHeight: 1.4 }), text('facts', '{attr.facts}', LABEL, 7.5, at(PAGE.side, -EDGE, MEASURE, 'bottom-left'), { ...caps, color: col('sand') }), ] } } }; // #endregion const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) // #region labels: the edition's language, written into the file with the rest of the config // Hyphenation patterns and the PDF's /Lang, by exact code (gotcha: hyphenation-locales). locale: t({ en: 'en-us', es: 'es' }), // Figura and Tabla travel inside the Spanish file. Left out, they follow whoever opens it: // the Sandbox at /en/sandbox prints Figure 1.1 (gotcha: bundle-labels-reader-locale). // '{n}' numbers them 1, 2, 3: a leaflet has no chapters to number its figures by. resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })), // #endregion colorPalette, customFonts, page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.side), right: mm(PAGE.side) } }, // a flyer printed both sides: nothing to mirror layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.75, maxWordSpacing: 1.6 }, headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ // in main-color: the estuary // The H1 break, restated (gotcha: headings-drop-h1-break). In the column, the cover design // is cut at the text block's top and bottom edges; span: 'page' paints it from the trim. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } }, { level: 2, fontSize: pt(15), lineHeight: pt(LEAD * 1.25), marginTop: pt(LEAD * 0.5), marginBottom: pt(LEAD * 0.25) }, ] }, headingStyles: [cover], captionStyle: { fontFamily: LABEL, fontSize: pt(7.8), labelColor: col('estuary'), gap: mm(1.8) }, tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('estuary'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(7.6), bodyFontSize: pt(8.2), cellPadding: mm(1.3) }, paragraphStyles: [{ id: 'colophon', fontSize: pt(6.6), lineHeight: pt(8.8), color: col('muted'), textAlign: 'left', firstLineIndent: mm(0), marginTop: pt(LEAD) }], header: { elements: [] }, // The back's foot: a strip of estuary with the publisher, the frontmatter's author. footer: { elements: [ { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('estuary') }, placement: { anchor: { to: 'page', edge: 'bottom-left' }, size: { width: 'fill', height: mm(7) } } }, text('foot', '{author}', LABEL, 7.5, { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(PAGE.side), y: mm(-2.4) } }, { ...caps, color: col('foam'), pages: 'body', overflow: 'clip' }), ] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 25行 · content.en.md
title: "The Tide Mill of Arenal" author: "Arenal Estuary Trust" --- # The Tide Mill {style="cover" kicker="Arenal estuary · Visitor leaflet 3" lead="For 165 years the tide turned its four wheels. Walk the dam and look down into the wheel pit, where the pond empties twice a day." facts="Open all year · Free on Sundays" edition="EN"} :::pagebreak ::resource{id="mill"} ## Two tides a day On the flood tide the sea pushes open the gates in the dam and fills the millpond behind it, six hectares of salt water; when the tide turns, the water inside presses them shut. Two hours after high water, the estuary has fallen far enough for the miller to open the chutes (:ref{id="mill" style="full"}). The water drops onto a horizontal wheel in the vaulted pit under the floor, and an upright shaft turns the millstones above it. The mill ground maize and wheat for the farms of the valley from 1791 until 1956. Restored in 2004, it grinds again on demonstration days, two hours after high water (:ref{id="hours" style="full"}). ::resource{id="hours"} :::space{lines=0.5} The path on the dam is flat enough for wheelchairs, and eleven steps lead down to the wheel pit. Tickets cost €3; entry is free on Sundays. :::paragraphs{style="colophon"} Leaflet 3, English edition · Text and drawings CC BY 4.0 · Set in DM Sans, DM Serif Display and Instrument Sans (SIL Open Font License). :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the cover's wheel in the estuary, and the mill in section // No words in the drawings: an SVG drawn as an image cannot use web fonts (gotcha: // svg-no-webfonts). Every length is in millimetres of the printed page. const SECTION = { w: 79, h: 35 }; // the mill in section, as wide as the text const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const line = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`); const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) ` + `rotate(${n(turn)})">${body}</g>`; // A wave line across the page: cubic arcs of wavelength `len`, `amp` high. const wave = (y, len, amp, phase, width) => { let d = `M${n(-phase)} ${n(y)}`; for (let x = -phase; x < width + len; x += len) { const [q, h] = [x + len / 4, x + 3 * len / 4]; d += `C${n(q)} ${n(y - amp)} ${n(q)} ${n(y - amp)} ${n(x + len / 2)} ${n(y)}` + `C${n(h)} ${n(y + amp)} ${n(h)} ${n(y + amp)} ${n(x + len)} ${n(y)}`; } return d; }; // The wheel: a hub and eighteen blades, each a spoon on a spoke, the spoon bent back against // the turn; the square end of the shaft at the centre. function wheel(cx, cy, r, color, extra = '') { const spoke = `M${n(r * 0.28)} ${n(-r * 0.018)}H${n(r * 0.54)}V${n(r * 0.018)}H${n(r * 0.28)}Z`; const spoon = `M0 0C${n(r * 0.1)} ${n(-r * 0.08)} ${n(r * 0.36)} ${n(-r * 0.12)} ${n(r * 0.46)} ` + `${n(-r * 0.05)}C${n(r * 0.5)} ${n(-r * 0.01)} ${n(r * 0.44)} ${n(r * 0.06)} ${n(r * 0.3)} ` + `${n(r * 0.06)}C${n(r * 0.18)} ${n(r * 0.06)} ${n(r * 0.06)} ${n(r * 0.03)} 0 0Z`; const blade = path(spoke, color) + group(r * 0.52, 0, -16, path(spoon, color)); let out = ''; for (let i = 0; i < 18; i++) out += group(cx, cy, i * 20, blade); const ring = ` stroke="${palette.sand}" stroke-width="${n(r * 0.03)}"`; return `<g${extra}>${out}${circle(cx, cy, r * 0.31, color)}` + `${circle(cx, cy, r * 0.22, 'none', ring)}` + `<rect x="${n(cx - r * 0.06)}" y="${n(cy - r * 0.06)}" width="${n(r * 0.12)}" ` + `height="${n(r * 0.12)}" fill="${palette.sand}"/></g>`; } function coverArt() { const [cx, r] = [PAGE.w / 2, 37]; let body = `<rect width="${PAGE.w}" height="${WATER}" fill="${palette.sand}"/>`; // The mud flat the ebb leaves: three bands above the waterline, darker towards the water. for (const [y, h, o] of [[WATER - 15, 3, 0.1], [WATER - 10, 4, 0.16], [WATER - 5, 5, 0.24]]) { body += `<rect y="${y}" width="${PAGE.w}" height="${h}" fill="${palette.mud}" ` + `fill-opacity="${o}"/>`; } body += wheel(cx, WATER, r, palette.estuary); body += `<rect y="${WATER}" width="${PAGE.w}" height="${PAGE.h - WATER}" ` + `fill="${palette.estuary}"/>`; // Under the water the wheel shows as a pale ghost: the same drawing, clipped to the water. body += `<clipPath id="under"><rect y="${WATER}" width="${PAGE.w}" height="${PAGE.h}"/>` + `</clipPath>${wheel(cx, WATER, r, palette.foam, ' clip-path="url(#under)" opacity=".2"')}`; for (const [dy, phase, o] of [[3, 0, 0.5], [10, 4, 0.3], [18, 8, 0.2], [28, 2, 0.12]]) { body += line(wave(WATER + dy, 11, 0.9, phase, PAGE.w), palette.foam, 0.7, ` stroke-opacity="${o}"`); } return svgDoc(PAGE.w, PAGE.h, body); } // A level mark: the surveyor's triangle standing on a water surface. const level = (x, y, fill) => path(`M${n(x - 1.4)} ${n(y - 2.2)}H${n(x + 1.4)}L${n(x)} ${n(y)}Z`, fill, fill === 'none' ? ` stroke="${palette.estuary}" stroke-width=".3"` : ''); const arrow = (d, tip, turn, color) => line(d, color, 0.55) + group(...tip, turn, line('M-1.6-1L0 0-1.6 1', color, 0.55)); function sectionArt() { const { w, h } = SECTION; const [HIGH, LOW, FLOOR, WHEEL] = [10, 26.5, 15.5, 27]; // mm: levels, floor and wheel heights const P = palette; let b = ''; // Water first: the pond held at high tide, the estuary fallen to low water. b += path(`M0 ${HIGH}H31V33H0Z`, P.estuary); b += path(`M52 ${LOW}H${w}V${h}H52Z`, P.estuary); b += line(`M52 ${HIGH}H${w - 1}`, P.estuary, 0.35, ' stroke-dasharray="1.4 1"'); // The ground: the pond's bed and the estuary's mud bank. b += path(`M0 33L31 32V${h}H0Z`, P.mud); b += path(`M52 32.5L${w} 34V${h}H52Z`, P.mud); // The dam and the mill house on it, in sand with a mud outline; the roof in mud. const stroke = ` stroke="${P.mud}" stroke-width=".45"`; b += path(`M30 ${h}V5.6H54V${h}Z`, P.sand, stroke); b += path('M28.5 6L42 0.4L55.5 6Z', P.mud); // The wheel pit: a vaulted opening through the dam, with the ebb running out of it. b += path(`M33.5 ${h}V25A8 8 0 0 1 49.5 25V${h}Z`, P.paper, stroke); b += path('M33.5 30.5H55V33.5H33.5Z', P.estuary); // The chute from the pond onto the wheel, and the gate lifted above its mouth. b += path(`M30 22L36.4 ${WHEEL - 1.2}`, 'none', ` stroke="${P.estuary}" stroke-width="1.8"`); b += `<rect x="29.2" y="16.8" width="1.6" height="4" fill="${P.ink}"/>`; // The horizontal wheel, and its shaft up through the floor to the runner stone. b += line(`M41.5 ${FLOOR}V${WHEEL + 1}`, P.ink, 0.6); b += `<rect x="35.8" y="${WHEEL - 0.8}" width="11.4" height="1.6" rx=".5" fill="${P.mud}"/>`; for (let x = 36.6; x < 47; x += 1.6) { b += line(`M${n(x)} ${WHEEL - 1.4}V${WHEEL + 1.2}`, P.mud, 0.45); // the blades, edge-on } // The milling floor, the runner stone on the bed stone, and the hopper above them. b += line(`M31 ${FLOOR}H53`, P.mud, 0.45); const stone = (x, y, sw) => `<rect x="${x}" y="${n(y)}" width="${sw}" height="1.6" ` + `fill="${P.rule}" stroke="${P.ink}" stroke-width=".3"/>`; b += stone(36.5, FLOOR - 3.2, 10) + stone(36, FLOOR - 1.6, 11); b += path(`M38.6 8H44.4L42.6 ${FLOOR - 4.2}H40.4Z`, P.mud); // Level marks, and the way the water goes. b += level(6, HIGH, P.estuary) + level(73, LOW, P.estuary) + level(73, HIGH, 'none'); b += arrow('M9 27C16 26 22 24.4 27.4 23', [27.4, 23], -15, P.foam); b += arrow('M50.5 32H63', [63, 32], 0, P.foam); return svgDoc(w, h, b); } // fileId → markup: the files the resources below name. const drawings = { 'cover.svg': coverArt(), 'mill.svg': sectionArt() }; // #endregion // #region resources: the drawings name their files by fileId; the table carries its own data const svg = (id, w, h, altText, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...extra }); const row = (...cells) => cells.map((content) => ({ content })); const head = (...cells) => cells.map((content) => ({ content, isHeader: true })); const resources = [ svg('cover', PAGE.w, PAGE.h, t({ en: 'A mill wheel on the waterline, its lower half pale ' + 'under the estuary', es: 'Una rueda de molino en la línea del agua, con la mitad ' + 'inferior pálida bajo la ría' })), svg('mill', SECTION.w, SECTION.h, t({ en: 'The mill in section: the pond at high level on the left, the mill house on the dam ' + 'with its millstones, the horizontal wheel in the vaulted pit, and the estuary on the ' + 'right below a dashed high-water line', es: 'El molino en sección: el estanque a nivel alto a la izquierda, la casa del molino sobre ' + 'la presa con sus muelas, el rodezno en el cárcavo abovedado y la ría a la derecha, bajo ' + 'una línea discontinua de pleamar' }), { placement: { position: 'here' }, caption: t({ en: 'Two hours after high water: the pond turns the wheel, and the estuary ' + 'has fallen below the dashed line.', es: 'Dos horas tras la pleamar: el estanque mueve el rodezno y la ría ha quedado por debajo ' + 'de la línea discontinua.' }) }), { id: 'hours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, caption: t({ en: 'Opening hours. Last entry 45 minutes before closing.', es: 'Horario. Última entrada 45 minutos antes del cierre.' }), table: { model: { headerRowCount: 1, columnWidths: [1.55, 0.9, 1.55], rows: t({ en: [head('Season', 'Days', 'Hours'), row('April–June', 'Tue–Sun', '10:00–14:00, 16:00–19:00'), row('July–August', 'Mon–Sun', '10:00–20:00'), row('September–March', 'Fri–Sun', '10:30–14:30')], es: [head('Temporada', 'Días', 'Horario'), row('Abril–junio', 'Mar.–dom.', '10:00–14:00 y 16:00–19:00'), row('Julio–agosto', 'Lun.–dom.', '10:00–20:00'), row('Septiembre–marzo', 'Vie.–dom.', '10:30–14:30')] }) } } }, ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages use. They travel inside the bundle, so the reader loads them from // there, before the layout (gotcha: fonts-first). const FONTS = { 'DM Sans': ['400', '400i', '700'], 'DM Serif Display': ['400', '400i'], 'Instrument Sans': ['400', '700'] }; // #region faces: FONTS as customFonts, each face a woff2 file named by its fileId const customFonts = Object.entries(FONTS).map(([name, specs]) => ({ name, variants: specs.map((spec) => ({ weight: parseInt(spec, 10), format: 'woff2', style: spec.endsWith('i') ? 'italic' : 'normal', fileId: `${fontsourceId(name)}-${spec}` })), })); // The bytes: Fontsource's static woff2 files, latin subset, which covers the Spanish text too. const faceFiles = Object.fromEntries(await Promise.all(customFonts.flatMap(({ name, variants }) => variants.map(async ({ weight, style, fileId }) => { const id = fontsourceId(name); const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/` + `${id}-latin-${weight}-${style}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${name} ${weight} ${style}`); return [fileId, new Uint8Array(await res.arrayBuffer())]; })))); // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region answer: write this edition to a .postext file, then lay it out from those bytes alone // The writer: text, design, resources and every file they name, zipped. createBundle looks // up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`. const { bytes, warnings } = await createBundle({ name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }), locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations markdown, config: config(), resources, files: { ...drawings, ...faceFiles }, thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture }); if (warnings.length) console.warn(warnings); // what was left out, and why // The reader has nothing but the bytes. Each fileId is now the file's path inside the zip: // mill.svg is resources/mill.svg, and the faces sit under fonts/. const bundle = await openBundle(bytes); await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts() await registerBundleImages(bundle); // the drawings, for the canvas const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one // #endregion showPages(docs, { title: t({ en: 'The Tide Mill · English edition', es: 'El molino de mareas · edición en español' }) }); // #region handoff: the same bytes as a download for the Sandbox, and a PDF from the bundle const file = `tide-mill-${LANG}.postext`; document.getElementById('pt-actions').append(Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), download: file, textContent: `Download ${file} · ${Math.round(bytes.length / 1024)} KB` })); // The PDF embeds the faces the bundle carries, and draws the figures from its files. offerPdf(() => renderToPdf(docs, { fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2 }), resourceBytes: bundleResourceBytes(bundle), }), `${RECIPE}-${LANG}.pdf`); // #endregionキット · core, fonts, viewer, pdf:全レシピ共通 · 275行
// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#2言語のバンドルを読む
Postextガイドは、英語とスペイン語の章、設定、キャプションを1つのファイルに収めています。{ locale }で版を選ぶと、どちらの言語でも48ページ、12章として開きます。このときリンクから取得できるのはリーフレットのファイルのままで、PDFボタンはガイドを組みます。
-const bundle = await openBundle(bytes);
+const guide = await fetch('https://postext.dev/bundles/postext-guide.postext');
+const bundle = await openBundle(await guide.arrayBuffer(), { locale: LANG });#ラベルを読み手に選ばせる
種類を指定しないと、ラベルはファイルを開いた言語に従い、図は表紙の見出しから数えられます。スペイン語版はここではFigura 1.1と印字し、/en/sandboxのSandboxに読み込むとFigure 1.1になります。
- resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),よくあるつまずき
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
つまずき
resourceTypesのないバンドルは、読者の言語で図の表示名を付ける
postext 1.4.1のopenBundleは、.postextファイルにresourceTypesがないと、ファイル自身のロケールではなく読者が求めるロケールでFigureとTableを作ります。Sandboxもすべてのファイルを画面の言語で読み込みます。スペイン語のバンドルを{ locale: 'en' }で開くか、/en/sandboxで読み込むと、bundle.localeは'es'のままなのに、スペイン語の本文の上にFigure 1.1と印字されます。createBundleに渡す設定にresourceTypes: defaultResourceTypes(lang)を書いてください。 .postextバンドル →
つまずき
ハイフネーションできるのは8つのロケールだけで、コードは完全一致
ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
インラインの図は上に空きがあり、下にはない
postext 1.4.1では、::resourceで位置'here'に置いた図は、上にグリッド1行分の空きを取りますが、下には次の行がベースライングリッドにそろうときの残りしか取りません。1行分からほぼゼロまでばらつくため、次の段落がキャプションのすぐ下から始まることがあります。::resourceの行のあとに:::space{lines=1}を置いてください。ほかの:::spaceと同じく、段の頭では捨てられます。 その場に置く図 →
つまずき
SVGの<img>内のテキストはWebフォントを使えない
SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
デザインのテキストのlineHeightは倍率で、寸法ではない
デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です(lineHeight: 1.05)。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。 ページデザインのテキスト・罫・ボックス →
つまずき
デザインのテキストのoverflowの既定値は'ellipsis-end'
幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
- postext 1.4.1の
createBundleは、1ファイルに1言語しか書き出しません。多言語のバンドルで説明しているlocalizedの入力はこのリリースにはありません。ただしopenBundle1.4.1はそうしたファイルを読めます。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- フォント
- DM Sans (SIL OFL 1.1) · DM Serif Display (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)


