かんたんな説明
地図の展覧会のリーフレットを、印刷所にそのまま渡せる形にします。ページの端の外側に絵をはみ出させる余白と断裁の目印を付け、印刷機が使う色でPDFを保存する方法を示します。
できあがり
架空の土地の地図を集めた架空の展覧会『Cartografías imaginarias』の展示解説シートを、印刷所に渡す状態で組みます。このページはどの言語版でも見本はスペイン語です。170×227mmの各ページは、四辺とも11mm大きい紙に載り、そこに裁ち落としとトンボが入ります。表紙では、オレンジの航路が段階的な緑の群島を横切り、仕上がり線を越えて裁ち落としまで延びます。中面では、左そろえのKarlaで組んだ4つの展示室がウェイポイント形のキッカーの下で始まり、オレンジの爪がノンブルを小口の外へ運びます。裏表紙は起伏の帯の上に平面図を置きます。サンプルのコードは、フォント、しおり、ページラベル付きのCMYKの入稿用PDFと、グレースケールの校正用PDFを書き出します。入稿用PDFでは、地図と平面図は手で選んだインキの印刷用マスターから取り、文字、爪、ウェイポイントの輪は単純な変換に任せます。
このレシピが答える質問
- 裁ち落とし、トンボ、しおり、ページラベル、色空間を備えた入稿用PDFを作るには?
- フォントを埋め込んだ本物のPDFをブラウザーで書き出すには?
- すべてのページに透かし、背景の色、装飾画像を加えるには?
手短な答え
const BLEED = 3; // mm of artwork past the trim: the cover, the tab and the back band reach it
// Hook-up: config().page.cutLines. A mark starts markOffset outside the trim and runs 5 mm
// (markLength): an offset equal to the bleed keeps the marks off the artwork whatever BLEED
// is. Each page grows by doc.trimOffset a side, bleed + offset + mark: 11 mm here.
const cutLines = { enabled: true, bleed: mm(BLEED), markOffset: mm(BLEED) };
// renderToPdf ignores config.pdfGeneration, so every PDF setting goes in its options.
// `masters` holds print-master bytes by fileId (section 2 writes them).
function pressPdf(doc, colorSpace, { resources, masters }) {
// A resource names its master in svg.pdfFileId, but outside bundles renderToPdf only asks
// for svg.fileId: answer that id with the master (gotcha: pdf-master-resourcebytes).
const masterOf = new Map(resources.filter((r) => r.svg?.pdfFileId)
.map((r) => [r.svg.fileId, r.svg.pdfFileId]));
return renderToPdf(doc, {
// The kit's provider snaps weights and falls back to upright, since the PDF asks for every
// style of every family (gotcha: pdf-provider-all-styles); TrueType faces are subset.
fontProvider: fontsourceProvider,
colorSpace, // 'cmyk' for the press, 'grayscale' for a proof: a naive conversion, no ICC
// The proof keeps the SVGs, which its grey conversion can reach; masters stay in CMYK.
resourceBytes: (fileId) => (colorSpace === 'cmyk' && masters.get(masterOf.get(fileId)))
|| imageBytes(fileId),
}); // bookmarks (from the headings) and /PageLabels (from the folios) come by default
}
材料
- 機能
- 裁ち落としとトンボCMYKとグレースケールのPDFSVGの印刷用マスターPDFの書き出しPDFに埋め込むフォントPDFのしおり裁ち落としの帯とツメデザイン要素の基準位置ページデザインの画像ページの役割ごとの柱節ごとの柱見出しスタイル表紙、扉、奥付デザインした章扉見出しの属性文書のメタデータ柱とノンブルリソースとしての図と表「図」と「表」を文書の言語でセマンティックカラーパレット
- 種類
- Karla, Space Grotesk, Space Mono (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 画面の色を版で選ぶ
const palette = {
ink: '#161616', // text: a neutral grey prints on the black plate alone (K91)
muted: '#666666', // the running heads, on black alone for the same reason (K60)
forest: '#0b3d2e', // the sea, the kickers, the back band: its plain build is a petrol teal
signal: '#ff6626', // route, waypoints, tab: red at full strength, so no black (C0 M60 Y85)
sage: '#9fb8a8', // high ground; small type on forest
paper: '#f4f1ea', // type on forest
};
// col(id): a palette-linked colour. It carries the hex too, because 1.4.1 paints design
// elements from the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
// The engine's defaults link to 'main-color': point it at the forest, so nothing prints blue.
{ id: 'main-color', name: 'forest (defaults)', value: { hex: palette.forest, model: 'hex' } },
];
colorSpace: 'cmyk'のとき、postext-pdfはICCプロファイルを使わず、各16進値を単純な式で変換します。墨は1からRGBの最大のチャンネルを引いた値で、シアン、マゼンタ、イエローはそれぞれ自分のチャンネルとその墨から求めます。そのため無彩色のグレーは墨版だけで刷られ(#161616はK91になります)、1つのチャンネルが最大の色は墨なしで刷られます。オレンジ#ff6626は、爪、輪、航路でC0 M60 Y85 K0になります。緑が最大のチャンネルのときマゼンタは必ず0になるので、深緑はC82 M0 Y25 K76となり、校正ではペトロール系のティールに見えます。地図と平面図を印刷用マスターから取るのはこのためです(手順5)。3色の掛け合わせが残るのは、キッカー、図の参照、キャプションのラベルに使う小さな太字の深緑の文字だけです。
#2 · 断裁でずれても残る爪
const TAB = { w: 10, h: 24 }; // the tab as it will be trimmed, mm; its foot is level with the text
const tab = (parity) => {
const edge = parity === 'even' ? 'bottom-left' : 'bottom-right'; // the fore-edge
const from = (to, y) => ({ anchor: { to, edge }, offset: { y: mm(y) } });
return [
// The box hangs from the bleed frame and BLEED of its width is trimmed off, so a cut that
// lands a millimetre outside the trim still leaves orange at the edge.
{ kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('signal') },
placement: { ...from('bleed', -(FOOT + BLEED)), size: { width: mm(TAB.w + BLEED),
height: mm(TAB.h) } } },
// The folio hangs from the trim frame ('page'), so it centres on the part left after the cut.
{ kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...label, align: 'center',
fontSize: pt(11), letterSpacing: pt(0), color: col('ink'),
placement: { ...from('page', -FOOT), size: { width: mm(TAB.w), height: mm(TAB.h) } } },
].map((element) => ({ ...element, parity, pages: 'body' })); // never on the covers
};
const head = (parity, content) => ({ kind: 'text', id: `head-${parity}`, content, ...label,
color: col('muted'), parity, pages: 'body',
placement: { anchor: { to: 'container', edge: parity === 'even' ? 'top-left' : 'top-right' },
offset: { y: mm(11) } } });
const header = { elements: [head('even', '{title}'), head('odd', '{subtitle}')] };
const footer = { elements: [...tab('even'), ...tab('odd')] };
爪の箱は裁ち落としの枠から下げ、仕上がりの爪よりBLEEDだけ幅を広げます。こうしておけば、断裁が仕上がり線から1mm外にずれても、端にオレンジが残ります。ノンブルはページの枠、つまり仕上がり線から下げ、残る10mmの中央に置きます。parityは各要素が出るページを選び、edgeはそれに合わせて変わって小口に届きます。pages: 'body'は爪と柱(フロントマターの{title}と{subtitle})を両方の表紙から外します。表紙はどちらもページ全幅の見出し、または改ページする見出しで始まるので、扉として数えられるからです。ヘッダーとフッターの要素はどれもこうして繰り返されるので、imageにparityとpagesを付ければ、透かしや装飾の絵を全ページに置けます。スロットは本文の上に描かれるので、本文の背後に敷く色はpage.backgroundColorに入れます。
#3 · 絵柄は裁ち落としまで、文字は仕上がりの内側に
const BAND = 44; // mm from the trim foot to the top of the back band
const onForest = { color: col('paper'), align: 'left', overflow: 'wrap' };
const art = (resourceId, edge) => ({ kind: 'image', id: resourceId, resourceId,
placement: { anchor: { to: 'bleed', edge }, size: { width: 'fill' } } }); // height: its ratio
// The map reserves no height (gotcha: opener-image-no-reserve), so the text puts a
// :::pagebreak right after the cover heading: without it the lead would start on the map.
// span: 'page' makes the cover an opener, so furniture set to pages: 'body' skips it.
const cover = { id: 'cover', numbered: false, span: 'page', advancedDesign: { enabled: true,
slot: { elements: [ // paint order: the map first, the type on top
art('cubierta', 'top-left'),
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('sage'),
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Space Grotesk',
fontWeight: 700, fontSize: pt(50), lineHeight: 0.98, ...onForest,
placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(4) },
size: { width: mm(132) } } },
{ kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Karla', fontSize: pt(13),
...onForest, placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } },
] } },
footer: { elements: [ // the cover's own foot: dates and place, over open sea
{ kind: 'text', id: 'dates', content: '{attr.fechas}', ...label, color: col('paper'),
placement: { anchor: { to: 'container', edge: 'bottom-left' }, offset: { y: mm(-15) } } },
{ kind: 'text', id: 'place', content: '{attr.lugar}', ...label, color: col('sage'),
placement: { anchor: { to: '#dates', edge: 'below' }, offset: { y: mm(1.6) } } },
] } };
const back = { id: 'back', numbered: false, breakBefore: { enabled: true, parity: 'any' },
advancedDesign: room('{attr.kicker}'),
margins: { bottom: mm(BAND + 8) }, // the text stops 8 mm above the band
footer: { elements: [
art('banda', 'bottom-left'),
{ kind: 'text', id: 'venue', content: '{attr.lugar}', fontFamily: 'Space Grotesk',
fontWeight: 700, fontSize: pt(20), ...onForest, // on the band, level with the text edge
placement: { anchor: { to: '#banda', edge: 'align-top' }, offset: { x: mm(BLEED + OUTER),
y: mm(6) } } },
{ kind: 'text', id: 'address', content: '{attr.direccion}', fontFamily: 'Karla',
fontSize: pt(10), ...onForest,
placement: { anchor: { to: '#venue', edge: 'below' }, offset: { y: mm(1.5) } } },
{ kind: 'text', id: 'when', content: '{attr.fechas}', ...label, color: col('sage'),
placement: { anchor: { to: '#address', edge: 'below' }, offset: { y: mm(4) } } },
{ kind: 'text', id: 'colophon', content: '{attr.colofon}', ...label, fontWeight: 400,
fontSize: pt(6.3), letterSpacing: pt(0), textTransform: 'none', lineHeight: 1.4,
color: col('sage'), overflow: 'wrap',
placement: { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(OUTER),
y: mm(-6) }, size: { width: mm(78) } } }, // 57 monospaced characters a line
] } };
cutLinesは、裁ち落とし、トンボのオフセット、5mmのトンボをページの各辺に足します。ここでは11mmです。手短な答えではオフセットを裁ち落としと同じにしているので、トンボは裁ち落としの縁から外へ向かって、墨版に引かれます。裁ち落としに固定しwidth: 'fill'とした画像要素は、その枠の幅を取り、自分の縦横比を保ちます。表紙の地図は176×233mm、仕上がりに片側3mmを足した大きさで描かれるので、トンボが始まる裁ち落としの縁まで届きます。文字は版面から下げるので、仕上がり線のかなり内側に収まります。裏表紙はフッターが文書のフッターを置き換える見出しスタイルです。同じ生成コードで作った帯を裁ち落としの下端に固定し、地の余白で本文をその8mm上に止めます。
#4 · しおりになる見出し
headings: {
// A designed heading keeps its own text, hidden, for the bookmarks and the tags. Set it in
// a face the pages already load, or the PDF embeds Open Sans for that text alone.
fontFamily: 'Space Grotesk',
levels: [
// Rooms follow on: any headings object drops the H1 break (gotcha: headings-drop-h1-break),
// so it is stated off. The back cover breaks through its style, a :::pagebreak ends the
// front one. The template numbers kicker and bookmark: "Sala 1 Una isla en ninguna parte".
{ level: 1, numberingTemplate: 'Sala {1}', breakBefore: { enabled: false },
marginTop: pt(LEAD), advancedDesign: room('{number} · {attr.fecha}') },
],
},
renderToPdfは見出しからしおりを作ります。そこで各展示室を改ページなしで続くレベル1の見出しにし、numberingTemplateでキッカーに{number}を、しおりに接頭辞を与えます。PDFのしおりには、表紙、「Sala 1 Una isla en ninguna parte」から「Sala 4 La isla del tesoro」まで、そして来館案内が並びます。デザインした見出しは自分のテキストを隠しますが、しおりとタグのためにそれを保持します。fontFamilyがないと、その隠れたテキストはOpen Sans 700で組まれ、ページはそのためだけにこのフォントを読み込み、PDFはそれを埋め込みます。ページラベルは既定のままのpage.pageNumbering、つまり1からのアラビア数字に従うので、PDFビューアーの3ページは爪に3とあるページです。
#5 · 正確なインキで作る印刷用マスター
// C, M, Y, K in %, one build per colour of the drawings, matched to the screen colours on a
// proof. The plain conversion would print the sea C82 M0 Y25 K76, a petrol teal; here it is
// a four-ink green. The orange keeps its plain build, so the route matches the tab.
const INKS = { forest: [100, 45, 80, 55], shallows: [100, 50, 85, 40], coast: [90, 50, 85, 20],
lowland: [80, 40, 75, 5], upland: [50, 15, 45, 20], sage: [40, 20, 35, 0],
summit: [10, 5, 10, 0], tint: [5, 0, 5, 5], signal: [0, 60, 85, 0], paper: [0, 0, 0, 0] };
function pdfPage(w, h, shapes) { // w, h in mm; the content stream flips y to match the SVG
const s = 72 / 25.4; // pt per mm
const ink = (id, op) => `${INKS[id].map((v) => v / 100).join(' ')} ${op}`;
const ops = (d) => d.replace(/([MLCZ])([^MLCZ]*)/g,
(_, c, v) => `${v.trim()} ${{ M: 'm', L: 'l', C: 'c', Z: 'h' }[c]} `.trimStart());
const body = shapes.map(({ d, fill, stroke, width, dash }) => [
'q', fill && ink(fill, 'k'), stroke && ink(stroke, 'K'), stroke && `${width} w 1 J 1 j`,
dash && `[${dash.join(' ')}] 0 d`, ops(d), fill ? 'f' : 'S', 'Q',
].filter(Boolean).join(' ')).join('\n');
const stream = `${s} 0 0 ${-s} 0 ${h * s} cm\n${body}`;
const objects = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${n(w * s)} ${n(h * s)}] /Contents 4 0 R >>`,
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`];
let pdf = '%PDF-1.4\n';
const offsets = objects.map((object, i) => {
const offset = pdf.length;
pdf += `${i + 1} 0 obj\n${object}\nendobj\n`;
return offset;
});
const xref = pdf.length;
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
+ offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')
+ `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return new TextEncoder().encode(pdf); // ASCII only, so string length = byte length
}
const masters = new Map(Object.entries(DRAWINGS) // by fileId: cubierta.pdf, banda.pdf, plano.pdf
.map(([id, [size, shapes]]) => [`${id}.pdf`, pdfPage(...size, shapes)]));
各図版はパスとして一度だけ定義します。artの領域はそれをCanvasが描くSVGにし、この領域は同じパスを1ページのCMYKのPDFに書き込みます。掛け合わせは校正が画面と合うように選んであり、海は単純変換のC82 M0 Y25 K76ではなくC100 M45 Y80 K55になります。各リソースはsvg.pdfFileIdでマスターを指定し、手短な答えのpressPdf()がマスターのバイト列をSVGのidでrenderToPdfに渡します。postext-pdfはそのページを手を加えずに、マスター自身のインキのベクターとして埋め込みます。これは図である平面図にも、デザイン画像である表紙の地図と裏表紙の帯にも当てはまります。グレースケールの校正にはSVGのほうを渡します。埋め込んだマスターは変換されないので、カラーのまま残ってしまうからです。
レシピの全体
// ═══ Postext Cookbook · Nº 024 · Print-ready PDF: bleed, crop marks and CMYK ═══════════ // https://postext.dev/en/cookbook/print-ready-pdf // Code: MIT · Text: original (CC BY 4.0) · Maps: generated in code (CC BY 4.0) // Fonts: Karla, Space Grotesk, Space Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // An exhibition leaflet set up for the press: bleed and crop marks on every page, and a CMYK // PDF that embeds its fonts and takes the maps and the floor plan from print masters. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only) const RECIPE = 'print-ready-pdf'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: screen colours picked for the plates the CMYK file prints them on const palette = { ink: '#161616', // text: a neutral grey prints on the black plate alone (K91) muted: '#666666', // the running heads, on black alone for the same reason (K60) forest: '#0b3d2e', // the sea, the kickers, the back band: its plain build is a petrol teal signal: '#ff6626', // route, waypoints, tab: red at full strength, so no black (C0 M60 Y85) sage: '#9fb8a8', // high ground; small type on forest paper: '#f4f1ea', // type on forest }; // col(id): a palette-linked colour. It carries the hex too, because 1.4.1 paints design // elements from the hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the forest, so nothing prints blue. { id: 'main-color', name: 'forest (defaults)', value: { hex: palette.forest, model: 'hex' } }, ]; // #endregion // #region answer: bleed and crop marks on every page; a CMYK PDF that swaps in print masters const BLEED = 3; // mm of artwork past the trim: the cover, the tab and the back band reach it // Hook-up: config().page.cutLines. A mark starts markOffset outside the trim and runs 5 mm // (markLength): an offset equal to the bleed keeps the marks off the artwork whatever BLEED // is. Each page grows by doc.trimOffset a side, bleed + offset + mark: 11 mm here. const cutLines = { enabled: true, bleed: mm(BLEED), markOffset: mm(BLEED) }; // renderToPdf ignores config.pdfGeneration, so every PDF setting goes in its options. // `masters` holds print-master bytes by fileId (section 2 writes them). function pressPdf(doc, colorSpace, { resources, masters }) { // A resource names its master in svg.pdfFileId, but outside bundles renderToPdf only asks // for svg.fileId: answer that id with the master (gotcha: pdf-master-resourcebytes). const masterOf = new Map(resources.filter((r) => r.svg?.pdfFileId) .map((r) => [r.svg.fileId, r.svg.pdfFileId])); return renderToPdf(doc, { // The kit's provider snaps weights and falls back to upright, since the PDF asks for every // style of every family (gotcha: pdf-provider-all-styles); TrueType faces are subset. fontProvider: fontsourceProvider, colorSpace, // 'cmyk' for the press, 'grayscale' for a proof: a naive conversion, no ICC // The proof keeps the SVGs, which its grey conversion can reach; masters stay in CMYK. resourceBytes: (fileId) => (colorSpace === 'cmyk' && masters.get(masterOf.get(fileId))) || imageBytes(fileId), }); // bookmarks (from the headings) and /PageLabels (from the folios) come by default } // #endregion const TRIM = [170, 227]; // mm: the leaflet as it leaves the guillotine const [TOP, FOOT, INNER, OUTER] = [20, 22, 18, 34]; // margins, mm, mirrored; the tab is outside const LEAD = 13.6; // body leading, pt const label = { fontFamily: 'Space Mono', fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.3), textTransform: 'uppercase', align: 'left' }; // default: centred // #region tab: a thumb tab off the fore-edge that carries the folio, on body pages only const TAB = { w: 10, h: 24 }; // the tab as it will be trimmed, mm; its foot is level with the text const tab = (parity) => { const edge = parity === 'even' ? 'bottom-left' : 'bottom-right'; // the fore-edge const from = (to, y) => ({ anchor: { to, edge }, offset: { y: mm(y) } }); return [ // The box hangs from the bleed frame and BLEED of its width is trimmed off, so a cut that // lands a millimetre outside the trim still leaves orange at the edge. { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('signal') }, placement: { ...from('bleed', -(FOOT + BLEED)), size: { width: mm(TAB.w + BLEED), height: mm(TAB.h) } } }, // The folio hangs from the trim frame ('page'), so it centres on the part left after the cut. { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...label, align: 'center', fontSize: pt(11), letterSpacing: pt(0), color: col('ink'), placement: { ...from('page', -FOOT), size: { width: mm(TAB.w), height: mm(TAB.h) } } }, ].map((element) => ({ ...element, parity, pages: 'body' })); // never on the covers }; const head = (parity, content) => ({ kind: 'text', id: `head-${parity}`, content, ...label, color: col('muted'), parity, pages: 'body', placement: { anchor: { to: 'container', edge: parity === 'even' ? 'top-left' : 'top-right' }, offset: { y: mm(11) } } }); const header = { elements: [head('even', '{title}'), head('odd', '{subtitle}')] }; const footer = { elements: [...tab('even'), ...tab('odd')] }; // #endregion const room = (kicker) => ({ enabled: true, slot: { elements: [ // a waypoint, kicker and title { kind: 'box', id: 'stop', style: { backgroundColor: col('paper'), borderColor: col('signal'), borderWidth: mm(0.9), borderRadius: mm(1.7) }, placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: mm(3.4), height: mm(3.4) } } }, { kind: 'text', id: 'kicker', content: kicker, ...label, color: col('forest'), placement: { anchor: { to: '#stop', edge: 'right-of' }, offset: { x: mm(2.2) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Space Grotesk', fontWeight: 700, fontSize: pt(17), lineHeight: 1.08, color: col('ink'), align: 'left', overflow: 'wrap', // design text ends in '…' by default (gotcha: overflow-ellipsis-default) placement: { anchor: { to: '#stop', edge: 'below' }, offset: { y: mm(1.2) }, size: { width: 'fill' } } }, ] } }); // #region covers: the front art runs bleed to bleed; the back band bleeds on three sides const BAND = 44; // mm from the trim foot to the top of the back band const onForest = { color: col('paper'), align: 'left', overflow: 'wrap' }; const art = (resourceId, edge) => ({ kind: 'image', id: resourceId, resourceId, placement: { anchor: { to: 'bleed', edge }, size: { width: 'fill' } } }); // height: its ratio // The map reserves no height (gotcha: opener-image-no-reserve), so the text puts a // :::pagebreak right after the cover heading: without it the lead would start on the map. // span: 'page' makes the cover an opener, so furniture set to pages: 'body' skips it. const cover = { id: 'cover', numbered: false, span: 'page', advancedDesign: { enabled: true, slot: { elements: [ // paint order: the map first, the type on top art('cubierta', 'top-left'), { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('sage'), placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Space Grotesk', fontWeight: 700, fontSize: pt(50), lineHeight: 0.98, ...onForest, placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(4) }, size: { width: mm(132) } } }, { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Karla', fontSize: pt(13), ...onForest, placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } }, ] } }, footer: { elements: [ // the cover's own foot: dates and place, over open sea { kind: 'text', id: 'dates', content: '{attr.fechas}', ...label, color: col('paper'), placement: { anchor: { to: 'container', edge: 'bottom-left' }, offset: { y: mm(-15) } } }, { kind: 'text', id: 'place', content: '{attr.lugar}', ...label, color: col('sage'), placement: { anchor: { to: '#dates', edge: 'below' }, offset: { y: mm(1.6) } } }, ] } }; const back = { id: 'back', numbered: false, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: room('{attr.kicker}'), margins: { bottom: mm(BAND + 8) }, // the text stops 8 mm above the band footer: { elements: [ art('banda', 'bottom-left'), { kind: 'text', id: 'venue', content: '{attr.lugar}', fontFamily: 'Space Grotesk', fontWeight: 700, fontSize: pt(20), ...onForest, // on the band, level with the text edge placement: { anchor: { to: '#banda', edge: 'align-top' }, offset: { x: mm(BLEED + OUTER), y: mm(6) } } }, { kind: 'text', id: 'address', content: '{attr.direccion}', fontFamily: 'Karla', fontSize: pt(10), ...onForest, placement: { anchor: { to: '#venue', edge: 'below' }, offset: { y: mm(1.5) } } }, { kind: 'text', id: 'when', content: '{attr.fechas}', ...label, color: col('sage'), placement: { anchor: { to: '#address', edge: 'below' }, offset: { y: mm(4) } } }, { kind: 'text', id: 'colophon', content: '{attr.colofon}', ...label, fontWeight: 400, fontSize: pt(6.3), letterSpacing: pt(0), textTransform: 'none', lineHeight: 1.4, color: col('sage'), overflow: 'wrap', placement: { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(OUTER), y: mm(-6) }, size: { width: mm(78) } } }, // 57 monospaced characters a line ] } }; // #endregion const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) locale: LANG, // hyphenation (for justified text only) and the PDF's /Lang // "Figura 1": Spanish names by hand (gotcha: resource-types-locale), one running count resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' })), colorPalette, headingStyles: [cover, back], page: { width: mm(TRIM[0]), height: mm(TRIM[1]), cutLines, margins: { top: mm(TOP), bottom: mm(FOOT), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Karla', fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('forest'), // Ragged, like the labels. Ragged text is never hyphenated (gotcha: ragged-no-hyphenation); // the 118 mm measure keeps the rag shallow. textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true }, // #region levels: every room a numbered H1 with no page break, so each is a PDF bookmark headings: { // A designed heading keeps its own text, hidden, for the bookmarks and the tags. Set it in // a face the pages already load, or the PDF embeds Open Sans for that text alone. fontFamily: 'Space Grotesk', levels: [ // Rooms follow on: any headings object drops the H1 break (gotcha: headings-drop-h1-break), // so it is stated off. The back cover breaks through its style, a :::pagebreak ends the // front one. The template numbers kicker and bookmark: "Sala 1 Una isla en ninguna parte". { level: 1, numberingTemplate: 'Sala {1}', breakBefore: { enabled: false }, marginTop: pt(LEAD), advancedDesign: room('{number} · {attr.fecha}') }, ], }, // #endregion paragraphStyles: [ // Only size and face change: styles inherit the rest. 'ficha' sets each room's object label. { id: 'lead', fontFamily: 'Space Grotesk', fontSize: pt(12), lineHeight: pt(16.5) }, { id: 'ficha', fontFamily: 'Space Mono', fontSize: pt(7), lineHeight: pt(10.5) }, ], // Captions in the label face, like the object labels; the label in forest (orange is 2.9:1). captionStyle: { fontFamily: 'Space Mono', fontSize: pt(7.5), labelColor: col('forest') }, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 56行 · content.es.md
title: "Cartografías imaginarias" subtitle: "Mapas de lugares que nunca existieron" author: "Biblioteca del Faro" --- # Cartografías imaginarias {style="cover" kicker="Exposición temporal · Ala Norte" fechas="Del 14 de enero al 29 de abril de 2027" lugar="Biblioteca del Faro · Puerto Alba"} :::pagebreak :::paragraphs{style="lead"} La exposición reúne en facsímil cuatro mapas de lugares inventados, impresos entre 1516 y 1883. Tres dibujan las costas de tierras que nadie ha pisado: la isla de Utopía, el país de la Ternura y la isla del tesoro. El cuarto, la carta marina de un poema de Lewis Carroll, deja el mar en blanco. Las salas siguen el orden de las fechas y se recorren en el sentido de las agujas del reloj. ::: # Una isla en ninguna parte {fecha="1516"} En 1516 Tomás Moro publicó en Lovaina un librito en latín sobre el mejor estado de una república y sobre una isla nueva llamada Utopía. El nombre viene del griego *ou tópos*, «ningún lugar». La isla tiene forma de media luna, doscientas millas de anchura en su parte central y cincuenta y cuatro ciudades casi idénticas. La capital, Amauroto, «la ciudad oscura», se levanta junto al Anidro, «el río sin agua». Moro disfraza de geografía un tratado político, y el grabado de la primera edición rotula en latín la capital, el nacimiento del Anidro y su desembocadura. :::paragraphs{style="ficha"} Tomás Moro, *Libellus… deque nova insula Utopia*. Lovaina: Dirk Martens, 1516. Facsímil. «Utopiae insulae figura», grabado de la primera edición. ::: # El país de la Ternura {fecha="1654"} Madeleine de Scudéry publicó en 1654 el primero de los diez tomos de *Clélie*, una novela ambientada en la Roma antigua, e incluyó en él un mapa grabado, la *Carte de Tendre*. Desde Nueva Amistad salen tres caminos hacia tres ciudades llamadas Ternura: la de la Inclinación, la de la Estima y la del Reconocimiento. El primero sigue un río y es el más rápido; los otros dos atraviesan aldeas con nombres de virtudes, como Sinceridad o Generosidad. Quien se desvía acaba en el lago de la Indiferencia, y más allá del mar Peligroso el grabado deja unas Tierras Desconocidas. :::paragraphs{style="ficha"} Madeleine de Scudéry, *Clélie, histoire romaine*, tomo I. París: Augustin Courbé, 1654. Facsímil. «Carte de Tendre», grabado de François Chauveau. ::: # El océano en blanco {fecha="1876"} En *La caza del Snark*, el poema que Lewis Carroll publicó en 1876 con el subtítulo «Una agonía en ocho cantos», el Campanero, que capitanea a ocho hombres y un castor, compra una gran carta del mar sin el menor rastro de tierra, y la tripulación la celebra porque, por fin, todos la entienden. Cuando el capitán pregunta de qué sirven los polos, los trópicos y los meridianos de Mercator, los marineros contestan que son simples signos convencionales. La carta es «un vacío perfecto y absoluto», y el libro la imprimió tal cual en el segundo canto: un rectángulo en blanco con unos pocos rótulos en el marco. El Campanero, para quien todo lo que dice tres veces es verdad, no tiene otro método para cruzar el océano que tocar la campana, y en la travesía el bauprés se confunde a veces con el timón, algo que según él ocurre a menudo en los climas tropicales. :::paragraphs{style="ficha"} Lewis Carroll, *The Hunting of the Snark*, con ilustraciones de Henry Holiday. Londres: Macmillan, 1876. Facsímil. Carta del océano, a tamaño real. ::: # La isla del tesoro {fecha="1881"} En el verano de 1881, en Braemar, en las Tierras Altas de Escocia, Robert Louis Stevenson dibujó y coloreó una isla para entretener a su hijastro, Lloyd Osbourne, de trece años. De aquel mapa salió la novela, titulada al principio *The Sea Cook*, que se publicó por entregas en la revista juvenil *Young Folks* entre octubre de 1881 y enero de 1882, firmada por un tal «capitán George North», y como libro en 1883. En 1894 contó en la revista *The Idler* que el mapa original, enviado con el manuscrito a la editorial Cassell, nunca llegó, y que tuvo que rehacerlo al revés, a partir del libro: hizo inventario de cada alusión y ajustó con un compás la isla a esos datos. El nuevo mapa se dibujó en el despacho de su padre, con ballenas y barcos, y Thomas Stevenson, ingeniero de faros, falsificó con esmero la firma del capitán Flint y las instrucciones de navegación de Billy Bones. Es el que abre, como frontispicio, la primera edición, y se expone junto al número de *The Idler* en el que Stevenson cuenta la pérdida. :::paragraphs{style="ficha"} Robert Louis Stevenson, *Treasure Island*. Londres: Cassell & Company, 1883. Facsímil. «My First Book: Treasure Island», en *The Idler*, 1894. ::: # Información práctica {style="back" kicker="Visita" lugar="Biblioteca del Faro" direccion="Paseo del Muelle, 12 · Puerto Alba" fechas="Del 14 de enero al 29 de abril de 2027" colofon="Compuesto en Karla, Space Grotesk y Space Mono (SIL OFL). Textos: CC BY 4.0; mapas dibujados en código."} El plano de la :ref{id="plano" style="full" case="lower"} traza el recorrido, que empieza y acaba en el vestíbulo. **Horario.** De martes a sábado, de 10:00 a 14:00 y de 17:00 a 20:00; domingos y festivos, de 10:00 a 14:00. Los lunes, cerrado. **Entrada libre.** Visitas guiadas los sábados a las 12:00, sin reserva, en grupos de hasta veinte personas. **Taller familiar.** «Dibuja tu isla», para niños de 6 a 12 años, los domingos a las 11:00. Inscripción en la recepción. **Accesibilidad.** Todas las salas están a pie de calle. En la recepción hay lupas y los textos de sala en letra grande.`; // content.<lang>.md, inlined by the Cookbook // #region art: an invented archipelago and a floor plan, drawn as paths // Paths and flat fills only: no <marker>, filter or mask, so the PDF keeps every drawing // vector (gotcha: svg-no-marker-filters). const PX = 12; // declared pixels per mm. Only the ratio counts: SVG figures fill their frame const rng = (seed) => () => { // Mulberry32: the same maps on every run seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const n = (v) => Math.round(v * 100) / 100; const xy = ([x, y]) => `${n(x)} ${n(y)}`; const spline = (pts, closed) => { // Catmull-Rom through the points, as cubic Béziers const last = pts.length - 1; const at = (i) => pts[closed ? (i + pts.length) % pts.length : Math.max(0, Math.min(last, i))]; const segs = pts.slice(0, closed ? pts.length : -1).map((p, i) => { const [a, b, c, d] = [at(i - 1), p, at(i + 1), at(i + 2)]; return `C${xy([b[0] + (c[0] - a[0]) / 6, b[1] + (c[1] - a[1]) / 6])} ` + `${xy([c[0] - (d[0] - b[0]) / 6, c[1] - (d[1] - b[1]) / 6])} ${xy(c)}`; }); return `M${xy(pts[0])}${segs.join('')}${closed ? 'Z' : ''}`; }; const disc = (x, y, r, k = 0.5523 * r) => `M${xy([x - r, y])}C${xy([x - r, y - k])} ` + `${xy([x - k, y - r])} ${xy([x, y - r])}C${xy([x + k, y - r])} ${xy([x + r, y - k])} ` + `${xy([x + r, y])}C${xy([x + r, y + k])} ${xy([x + k, y + r])} ${xy([x, y + r])}` + `C${xy([x - k, y + r])} ${xy([x - r, y + k])} ${xy([x - r, y])}Z`; const poly = (...pts) => `M${pts.map(xy).join('L')}`; const box = (x, y, w, h) => `${poly([x, y], [x + w, y], [x + w, y + h], [x, y + h])}Z`; // The drawings' own colours: the plan's floor, the sea near a coast, the relief bands. const ART = { tint: '#e3ebe4', shallows: '#0f4a37', coast: '#1f5c45', lowland: '#3f7d5f', upland: '#6f9a80', summit: '#dfe7dc' }; const hex = (id) => palette[id] ?? ART[id]; const toSvg = (w, h, shapes) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" ` + `height="${h * PX}" viewBox="0 0 ${w} ${h}">${shapes.map(({ d, fill, stroke, width, dash }) => `<path d="${d}" fill="${fill ? hex(fill) : 'none'}"${stroke ? ` stroke="${hex(stroke)}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"` : ''}${dash ? ` stroke-dasharray="${dash.join(' ')}"` : ''}/>`).join('')}</svg>`; // Elevation: filled bands from the coast to the summit (thin contour lines alias in thumbnails). const RELIEF = ['coast', 'lowland', 'upland', 'sage', 'summit']; // An island is a few overlapping lobes. Each band is filled for every lobe, so shapes of one // colour merge into a single coast with saddles and more than one summit. function island(lobes, seed, aspect = 0.8) { const rand = rng(seed); return lobes.map(([cx, cy, r]) => { const waves = [2, 3, 4, 6].map((k) => [k, (rand() * 0.26) / Math.sqrt(k), rand() * 6.28]); const peak = [cx + (rand() - 0.5) * r * 0.6, cy + (rand() - 0.5) * r * 0.5]; // The contour at s × r (plus `grow` mm); higher contours drift towards the summit. return (s, grow = 0, drift = 0) => Array.from({ length: 64 }, (_, i) => { const a = (i / 64) * Math.PI * 2; const bump = waves.reduce((sum, [k, amp, ph]) => sum + amp * Math.sin(k * a + ph + drift), 0); const [ox, oy] = [peak[0] + (cx - peak[0]) * s, peak[1] + (cy - peak[1]) * s]; const rr = r * s * (1 + bump) + grow; return [ox + Math.cos(a) * rr, oy + Math.sin(a) * rr * aspect]; }); }); } function terrain(w, h, isles, route = []) { const lobes = isles.flat(); const fillAll = (fill, s, grow = 0, drift = 0) => lobes.map((f) => ({ d: spline(f(s, grow, drift), true), fill })); const shapes = [{ d: box(0, 0, w, h), fill: 'forest' }, ...fillAll(RELIEF[0], 1.55, 1, 0.3), ...fillAll('forest', 1.55, 0, 0.3), // a 1 mm sea contour ...fillAll('shallows', 1.28), ...RELIEF.flatMap((fill, i) => fillAll(fill, 1 - i * 0.19, 0, i * 0.22))]; // coast to summit if (route.length) { shapes.push({ d: spline(route, false), stroke: 'signal', width: 1.5, dash: [3.2, 2.4] }); for (const [x, y] of route.slice(1, -1)) { shapes.push({ d: disc(x, y, 2.7), fill: 'signal' }, { d: disc(x, y, 1.1), fill: 'paper' }); } } return shapes; } // The cover: the trim plus the bleed on every side, 176 × 233 mm. The route enters from the // bleed and leaves through it, past four stops, one per room. const COVER = TRIM.map((side) => side + 2 * BLEED); const SCALE = [BLEED + TRIM[0] - OUTER - 20, BLEED + 209]; // the scale bar's corner const coverShapes = [...terrain(...COVER, [ island([[112, 152, 32], [140, 170, 24], [128, 128, 18]], 7), island([[36, 134, 14], [49, 145, 9]], 11), island([[151, 77, 10]], 3)], [[-4, 202], [40, 137], [98, 160], [136, 150], [151, 78], [182, 36]]), // A scale bar ending on the recto's text edge, level with the dates (trim → bleed frame). ...[0, 2].map((i) => ({ d: box(SCALE[0] + i * 5, SCALE[1], 5, 1.4), fill: 'paper' })), { d: box(...SCALE, 20, 1.4), stroke: 'paper', width: 0.3 }]; const BANDART = [COVER[0], BAND + BLEED]; // the back band, from the bleed's foot const bandShapes = terrain(...BANDART, [ // the back band: the same sea, another coast island([[160, 46, 24], [140, 56, 12], [176, 30, 14]], 5, 0.62), island([[4, 26, 13]], 9, 0.7)]); // an islet cut by the trim: its bleed is on the sheet // The floor plan, 118 × 54 mm: walls with doorways, cases, and the route with numbered stops. // Stroked numerals in a 0.6 × 1 box: SVG text gets none of the web fonts (gotcha: svg-no-webfonts). const DIGITS = { 1: 'M0.14 0.22L0.36 0L0.36 1', 2: 'M0.04 0.24C0.08 -0.06 0.58 -0.06 0.56 0.28C0.54 0.52 0.04 0.7 0.04 1L0.58 1', 3: 'M0.06 0L0.56 0L0.28 0.38C0.64 0.36 0.66 1 0.28 1C0.16 1 0.06 0.96 0.02 0.88', 4: 'M0.44 1L0.44 0L0.02 0.66L0.6 0.66', }; const glyph = (k, x, y, size) => DIGITS[k].replace(/(-?[\d.]+) (-?[\d.]+)/g, (_, a, b) => xy([x + (a - 0.3) * size, y + (b - 0.5) * size])); const PLAN = [118, 54]; const STOPS = [[20, 37], [20, 13], [79, 13], [98, 37]]; // rooms 1 to 4, clockwise const planShapes = [ { d: box(1, 1, 116, 48), fill: 'tint' }, // the floor: four rooms round the vestibule ...[[6, 42, 14, 4], [23, 42, 12, 4], [5, 4, 4, 14], [50, 17, 22, 4], [86, 4, 24, 4], [110, 30, 4, 14], [84, 44, 22, 3]].map((r) => ({ d: box(...r), fill: 'sage' })), // cases ...[[[52, 49], [1, 49], [1, 1], [117, 1], [117, 49], [66, 49]], // walls; the gaps are doors [[1, 25], [15, 25]], [[25, 25], [40, 25]], [[40, 1], [40, 8]], [[40, 17], [40, 33]], [[40, 42], [40, 49]], [[40, 25], [93, 25]], [[103, 25], [117, 25]], [[78, 25], [78, 33]], [[78, 42], [78, 49]]].map((pts) => ({ d: poly(...pts), stroke: 'forest', width: 1.2 })), // The route starts and ends in the vestibule, just inside the door. { d: poly([56, 45], [56, 37], [20, 37], [20, 13], [98, 13], [98, 37], [62, 37], [62, 45]), stroke: 'signal', width: 0.9, dash: [2.2, 1.7] }, { d: `${poly([53, 54], [56, 50.4], [59, 54])}Z`, fill: 'signal' }, // the way in ...STOPS.flatMap(([x, y], i) => [{ d: disc(x, y, 3.3), fill: 'signal' }, { d: glyph(i + 1, x, y, 3.4), stroke: 'paper', width: 0.5 }]), ]; // Each drawing by resource id: its size in mm and its shapes. const DRAWINGS = { cubierta: [COVER, coverShapes], banda: [BANDART, bandShapes], plano: [PLAN, planShapes] }; // #endregion // #region master: print masters, one-page PDFs written in the inks the designer chose // C, M, Y, K in %, one build per colour of the drawings, matched to the screen colours on a // proof. The plain conversion would print the sea C82 M0 Y25 K76, a petrol teal; here it is // a four-ink green. The orange keeps its plain build, so the route matches the tab. const INKS = { forest: [100, 45, 80, 55], shallows: [100, 50, 85, 40], coast: [90, 50, 85, 20], lowland: [80, 40, 75, 5], upland: [50, 15, 45, 20], sage: [40, 20, 35, 0], summit: [10, 5, 10, 0], tint: [5, 0, 5, 5], signal: [0, 60, 85, 0], paper: [0, 0, 0, 0] }; function pdfPage(w, h, shapes) { // w, h in mm; the content stream flips y to match the SVG const s = 72 / 25.4; // pt per mm const ink = (id, op) => `${INKS[id].map((v) => v / 100).join(' ')} ${op}`; const ops = (d) => d.replace(/([MLCZ])([^MLCZ]*)/g, (_, c, v) => `${v.trim()} ${{ M: 'm', L: 'l', C: 'c', Z: 'h' }[c]} `.trimStart()); const body = shapes.map(({ d, fill, stroke, width, dash }) => [ 'q', fill && ink(fill, 'k'), stroke && ink(stroke, 'K'), stroke && `${width} w 1 J 1 j`, dash && `[${dash.join(' ')}] 0 d`, ops(d), fill ? 'f' : 'S', 'Q', ].filter(Boolean).join(' ')).join('\n'); const stream = `${s} 0 0 ${-s} 0 ${h * s} cm\n${body}`; const objects = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${n(w * s)} ${n(h * s)}] /Contents 4 0 R >>`, `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`]; let pdf = '%PDF-1.4\n'; const offsets = objects.map((object, i) => { const offset = pdf.length; pdf += `${i + 1} 0 obj\n${object}\nendobj\n`; return offset; }); const xref = pdf.length; pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('') + `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; return new TextEncoder().encode(pdf); // ASCII only, so string length = byte length } const masters = new Map(Object.entries(DRAWINGS) // by fileId: cubierta.pdf, banda.pdf, plano.pdf .map(([id, [size, shapes]]) => [`${id}.pdf`, pdfPage(...size, shapes)])); // #endregion // Every drawing is an SVG for the screen and names its print master for the press. const svgResource = (id, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`, pdfFileId: `${id}.pdf`, width: DRAWINGS[id][0][0] * PX, height: DRAWINGS[id][0][1] * PX } }); const resources = [ svgResource('cubierta', { altText: 'Islas inventadas en verdes escalonados sobre un mar ' + 'verde oscuro, cruzadas por una ruta naranja con cuatro paradas.' }), svgResource('banda', { altText: 'La costa de una isla inventada.' }), svgResource('plano', { caption: 'Plano de la exposición, con el itinerario en naranja.', altText: 'Plano: cuatro salas alrededor del vestíbulo y un itinerario ' + 'naranja que las recorre en el sentido de las agujas del reloj.' }), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages use. Layout measures with the browser's fonts, so the kit loads them // from Fontsource before the first build (gotcha: fonts-first); the PDF embeds the same files, // Fontsource's latin subsets, which cover Spanish (gotcha: latin-subset). const FONTS = { Karla: ['400', '400i', '700'], 'Space Grotesk': ['400', '700'], 'Space Mono': ['400', '400i', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all(Object.entries(DRAWINGS) .map(([id, [size, shapes]]) => loadSvg(`${id}.svg`, toSvg(...size, shapes)))); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Cartografías imaginarias · PDF listo para imprenta' }); const inputs = { resources, masters }; offerPdf(() => pressPdf(doc, 'cmyk', inputs), `${RECIPE}.pdf`); // the file for the press offerPdf(() => pressPdf(doc, 'grayscale', inputs), `${RECIPE}-proof.pdf`); // a proof to read // The kit names both buttons alike; once built, each download link carries its file name. const button = (file) => document.querySelector(`[data-postext-pdf="${file}"]`); button(`${RECIPE}.pdf`).textContent = 'Press PDF (CMYK)'; button(`${RECIPE}-proof.pdf`).textContent = 'Greyscale proof';キット · core, fonts, viewer, pdf, images:全レシピ共通 · 310行
// ─── 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#5mmの裁ち落としにする
絵柄、爪、縮尺バーはBLEEDから寸法を取り、トンボもmarkOffsetを通じてそれに従います。5mmを求める印刷所に合わせるには、この変更だけで済み、各用紙は200×257mmになります。
-const BLEED = 3; // mm of artwork past the trim: the cover, the tab and the back band reach it
+const BLEED = 5; // mm of artwork past the trim: the cover, the tab and the back band reach it#絵柄をSVGから刷る
pdfFileIdがないと、入稿用ファイルは各図版をSVGから描きます。ベクターのままですが単純変換を通るので、海はC82 M0 Y25 K76のペトロール系ティールで刷られます。
- updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`, pdfFileId: `${id}.pdf`,
+ updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`,よくあるつまずき
つまずき
renderToPdfはsvg.pdfFileIdを無視する。印刷用マスターはresourceBytesで渡す
バンドルを使わない場合、renderToPdfがresourceBytesに求めるのはSVG自身のfileIdで、svg.pdfFileIdは求めません。resourceBytesでは、SVGのfileIdに対して印刷用マスターのバイト列を返してください。 SVGの印刷用マスター →
つまずき
PDFはすべてのファミリーのすべてのウェイトとスタイルを要求する
renderToPdfは、ブロックが使う可能性のあるすべてのファミリーについて、実際には印字されないものも含め、ボールド、イタリック、ボールドイタリックのフォントをフォントプロバイダーに求めます。1つでも拒否されると書き出しが止まります。プロバイダーは、そのファミリーが持つ最も近いウェイトを返し、イタリックがなければ立体にフォールバックする必要があります。 PDFに埋め込むフォント →
つまずき
SVGの図に<marker>やフィルターを使わない(ラスターにフォールバックする)
SVGの図がPDFでベクターのまま残るのは、<marker>、フィルター、マスクを使っていない場合だけです。使っているとラスターにフォールバックし、深く入れ子にしたフィルターはChromeで図を真っ白にすることがあります。矢じりはパスで描いてください。 リソースとしての図と表 →
つまずき
SVGの<img>内のテキストはWebフォントを使えない
SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →
つまずき
Fontsourceのlatinファイルにはラテン文字以外のグリフがない
PDFプロバイダーが埋め込むのはFontsourceのlatinファイルです。スペイン語や西欧語のテキストは収まりますが、→、≈、✓、★、ギリシャ文字、中欧語の文字は含まれず、PDFではそれらのグリフが欠けます。PDFのテキストはlatinの範囲に収めてください。 PDFに埋め込むフォント →
つまずき
章扉の画像は確保する高さに数えられない
postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。 デザインした章扉 →
つまずき
行末不ぞろいのテキストはハイフネーションされない
ハイフネーションが効くのは両端そろえのテキストだけです。左そろえのテキストは語と語の間で改行するため、幅の狭い左そろえの段では行末の凹凸が大きくなります。その箇所を両端そろえにするか、行長を広げてください。 ハイフネーションと文書の言語 →
つまずき
Figure/TableはdefaultResourceTypes(locale)でローカライズする
設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。 「図」と「表」を文書の言語で →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
デザインのテキストのoverflowの既定値は'ellipsis-end'
幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
- postext-pdf 1.4.1は各ページをMediaBox 1つ、つまり192×249mmの用紙全体として書き出し、TrimBoxもBleedBoxも持ちません。トンボも墨版にだけ刷ります。印刷所には仕上がりサイズ170×227mmを伝えるか、PDF/Xを求められたらプリプレスのツールでボックスを加えます。
- 1.4.1ではトンボは裁ち落としの外ではなく、仕上がり線から
markOffsetだけ外で始まります。そのため裁ち落としが既定のオフセット3mmより広いと、トンボが絵柄の上に刷られます。ここでのcutLinesのように、オフセットを裁ち落としと同じにしておきます。 renderToPdf1.4.1はconfig.pdfGenerationを読みません。pressPdf()はカラースペースをオプションとして渡し、しおりとタグ付けは既定で有効です。- 生成が終わると、各ボタンは「Open the PDF ↗」のリンクとダウンロードのリンクに変わります。ファイル名が付くのはダウンロードのリンクだけです。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- フォント
- Karla (SIL OFL 1.1) · Space Grotesk (SIL OFL 1.1) · Space Mono (SIL OFL 1.1)


