跳到主要内容
食谱编号24

排版食谱 · 第10章 · 输出与集成

可付印的PDF:出血、裁切线与CMYK

一份展览导览单,带3 mm出血和裁切线,导出为CMYK PDF,其中的地图和平面图取自印刷母版。

本页内容
输出
Canvas · PDF
难度
高级
Postext
已用Postext 1.4.1测试
需要≥ 1.4.1 · postext-pdf ≥ 1.4.1
许可证
更新于2026年9月26日
代码MIT · 文本CC BY 4.0
  • 西班牙文样例:尚无中文版本
  • 成品尺寸170 × 227 mm
  • 1栏
  • Karla 9.6/13.6
  • Space Grotesk
  • Space Mono
  • 4页
  • 难度
  • Postext 1.4.1
  • 排版用时11 ms
  • 250行代码

成品一览

《Cartografías imaginarias》是一个虚构的展览,展出想象之地的地图。这里做的是它交付印刷厂的展厅说明单,本页两种语言版本上都是西班牙文。每个170 × 227 mm的页面放在一张大22 mm的纸上,多出的部分承载出血和裁切线。封面上,一条橙色路线穿过层层渐变绿色的群岛,越过裁切边伸进出血区。内页中,四个展厅用齐左的Karla排,各在一个路标式眉题下开头,一个橙色标签把页码带到书口外侧;封底在一条地形色带上方放一张平面图。示例导出两份文件:印刷用PDF,采用CMYK,带字体、书签和页面标签;另一份是灰度校样。印刷用PDF中的地图和平面图取自手工选定油墨的印刷母版;文字、标签和路标圆环则按普通公式转换。

这道食谱解答

  • 怎样制作带出血、裁切标记、书签、页码标签和色彩空间的可付印PDF?
  • 怎样在浏览器中导出嵌入字体的真正PDF?
  • 怎样给每一页加水印、背景底色或装饰图片?

简短回答

script.js · 第37–59行在完整代码中
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
}

用料

类型
Karla, Space Grotesk, Space Mono(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 按印版挑选屏幕颜色

script.js · 第18–33行在完整代码中
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特性文件:黑版等于1减去RGB三个通道中的最大值,青、品红、黄各由自己的通道和这个黑版算出。因此中性灰只印在黑版上(#161616变成K91),一个通道满值的颜色则完全不用黑版:橙色#ff6626在标签、圆环和路线上变成C0 M60 Y85 K0。只要绿色是最高的通道,品红就是0,所以森林绿变成C82 M0 Y25 K76,校样上是一种石油蓝绿色。正因如此,地图和平面图取自印刷母版(第5步),这种三色叠印只留给眉题、图引用和题注标签中那些小号粗体的森林绿文字。

#2 · 经得起裁切刀的书口标签

script.js · 第69–90行在完整代码中
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,所以即使裁切落在成品线外1 mm,切口处仍有橙色;页码挂在页面框,也就是成品线上,在剩下的10 mm内居中。parity决定每个元素出现在哪些页上,edge随之改变,好让元素抵达书口;pages: 'body'让标签和书眉(取自frontmatter的{title}和{subtitle})不出现在封面和封底上。封面和封底都算章首页,因为它们各以一个通栏或另起一页的标题开头。任何页眉或页脚元素都能这样重复,所以一个带parity和pages的image可以在每页放一个水印或装饰图。这些槽位画在文字上方,文字下方的底色要放在page.backgroundColor里。

#3 · 图稿到出血,文字在成品线内

script.js · 第108–152行在完整代码中
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在页面每边加上出血、标记偏移和5 mm长的裁切线,这里合计11 mm。由于简短回答把偏移设得与出血相等,裁切线从出血边开始向外延伸,印在黑版上。一个锚定在出血框、设了width: 'fill'的图片元素取该框的宽度,并保持自身比例:封面地图按176 × 233 mm绘制,即成品尺寸每边加3 mm,所以它恰好到达裁切线起点所在的出血边,而文字挂在版心上,远在成品线以内。封底是一个标题样式,它的页脚取代文档的页脚:同一个生成器画出的色带锚定在出血框底部,下页边距让正文止于色带上方8 mm。

#4 · 变成书签的标题

script.js · 第171–182行在完整代码中
  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根据标题生成书签,所以每个展厅都是一个一级标题,接排而不分页;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 · 用准确油墨制作印刷母版

script.js · 第377–409行在完整代码中
// 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,这个区域则把同样的路径写进一个单页CMYK PDF,所选的叠印让校样与屏幕一致:海面是C100 M45 Y80 K55,而不是普通转换得到的C82 M0 Y25 K76。每个资源在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.mdtitle: "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上的食谱文件夹 ↗

变化

#印刷厂要求5 mm出血

图稿、标签和比例尺都以BLEED定尺寸,裁切线通过markOffset跟随它,所以要5 mm出血只需改这一处,每张纸随之变为200 × 257 mm。

-const BLEED = 3; // 图稿越过成品线的毫米数:封面、书口标签和封底色带都延伸到这里
+const BLEED = 5; // 图稿越过成品线的毫米数:封面、书口标签和封底色带都延伸到这里

#用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会向字体提供函数请求任何块可能用到的每个字族的粗体、斜体和粗斜体,哪怕从来没有印出来,只要有一次请求被拒绝,导出就会中止。提供函数必须就近匹配该字族实际提供的字重,没有斜体时退回正体。 嵌入PDF的字体 →

易错点

SVG插图中不要用<marker>或滤镜(会退回位图)

SVG图只有在不含<marker>、滤镜和蒙版时,才能在PDF中保持矢量;否则会退回位图,而且层层嵌套的滤镜可能让它在Chrome中变成空白。箭头用路径来画。 作为资源的图和表 →

易错点

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

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

易错点

Fontsource的latin文件不含拉丁字母以外的字形

PDF字体提供函数嵌入的是Fontsource的latin文件,它们覆盖西班牙语和西欧文字,但不包括→、≈、✓、★、希腊字母或中欧字母;这些字形在PDF中会缺失。PDF中的文字要保持在latin范围内。 嵌入PDF的字体 →

易错点

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

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

易错点

齐左文字从不断词

断词只用于两端对齐的文字;左对齐不齐行的文字在词间断行,所以窄栏的齐左文字行尾会很参差。把这段改为两端对齐,或加大行长。 断词与文档语言 →

易错点

用defaultResourceTypes(locale)本地化Figure/Table

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

易错点

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

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

易错点

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

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

易错点

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

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

易错点

配置按对象身份缓存:每次新建一个对象

引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

  • postext-pdf 1.4.1把每页写成单一的MediaBox,即整张192 × 249 mm的纸,没有TrimBox或BleedBox,裁切线只印在黑版上。请把成品尺寸170 × 227 mm告诉印刷厂;如果对方要求PDF/X,就在印前工具里补上这些框。
  • 在1.4.1中,裁切线从成品线外markOffset处开始,而不是从出血边外开始,所以出血比默认的3 mm偏移宽时,裁切线会压在图稿上。让偏移等于出血,就像这里的cutLines那样。
  • renderToPdf 1.4.1不读取config.pdfGeneration。pressPdf()把色彩空间作为选项传入,书签和标签结构(tagging)默认开启。
  • 生成之后,每个按钮会变成一个“Open the PDF ↗”链接和一个下载链接,只有下载链接带文件名。

致谢

文本
原创文字, CC BY 4.0
字体
Karla (SIL OFL 1.1) · Space Grotesk (SIL OFL 1.1) · Space Mono (SIL OFL 1.1)
沙盒PDF