Pular para o conteúdo principal
Receita número 24

Receitas · Capítulo 10 · Saída e integração

PDF para gráfica: sangria, marcas de corte e PDF/X

Um folheto de exposição com 3 mm de sangria e marcas de corte, exportado em PDF/X-4 sobre FOGRA51 com matrizes de impressão e preflight limpo.

Nesta página
Saída
Canvas · PDF
Nível
Avançado
Postext
Testada com o Postext 1.22.0
Requer ≥ 1.22.0 · postext-pdf ≥ 1.22.0
Licença
Atualizada em 7 de out. de 2026
Código MIT · Texto CC BY 4.0
  • Amostra em espanhol: ainda sem edição em português
  • Refile 170 × 227 mm
  • 1 coluna
  • Karla 9,6/13,6
  • Space Grotesk
  • Space Mono
  • 4 páginas
  • Nível
  • Postext 1.22.0
  • Diagramado em 11 ms
  • 283 linhas de código

Em poucas palavras

A folha de sala de uma exposição de mapas, pronta para mandar à gráfica. Mostra como deixar uma sobra além da borda da página, pôr marcas de corte e salvar um PDF nas cores que a gráfica usa.

O que você vai compor

A folha de sala de Cartografías imaginarias, uma exposição inventada de mapas de lugares imaginários, do jeito que segue para a gráfica, em espanhol nas duas edições desta página. Cada página de 170 × 227 mm fica numa folha 22 mm maior, que leva a sangria e as marcas de corte. Na capa, uma rota laranja atravessa um arquipélago em verdes escalonados e passa do refile para a sangria. Por dentro, quatro salas em Karla em bandeira se abrem sob antetítulos com uma parada da rota, e uma aba laranja leva o fólio na borda externa; a quarta capa põe a planta sobre uma faixa de relevo. O código exporta o arquivo de impressão, um PDF/X-4 separado para FOGRA51 (a condição PSO Coated v3 do couché de 150 g) com fontes, marcadores e rótulos de página, e uma prova em tons de cinza, e passa o preflight no layout antes de oferecê-los. No arquivo de impressão, os mapas e a planta vêm de matrizes com tintas escolhidas a dedo, e o texto, a aba e os anéis da rota usam as mesmas tintas por meio de uma paleta definida em CMYK.

Esta receita responde a

  • Como faço um PDF pronto para impressão, com sangria, marcas de corte, marcadores, rótulos de página e espaço de cor?
  • Como faço para exportar um PDF de verdade no navegador, com as fontes incorporadas?
  • Como acrescento uma marca-d'água, um fundo de cor ou uma imagem decorativa em todas as páginas?

A resposta curta

script.js · linhas 47–82no código completo
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) };

// Hook-up: config().print. PDF/X-4 for the press on FOGRA51 (PSO Coated v3, the leaflet's
// 150 g coated matte): every colour separated through the profile (the authored ones as
// written), 100 % K text overprinting, the output intent, TrimBox and BleedBox on each page.
// The 7.5 pt bold labels in forest hold in four inks on coated stock: the small-text check
// starts at 7 pt instead of 9.
const PRINT = { standard: 'pdfx4', outputProfile: 'fogra51', preflight: { smallTextSize: pt(7) } };
// Where the profiles are fetched from: postext.dev serves postext's icc/ folder at /icc/ (any
// copy of it works, and so does the npm CDN's postext/icc/).
const PROFILES = 'https://postext.dev/icc/';

// `masters` holds print-master bytes by fileId (section 2 writes them). The proof is an
// ordinary greyscale PDF: no PDF/X, which would force CMYK.
function pressPdf(doc, kind, { resources, masters }) {
  const press = kind === 'press';
  // 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,
    // The press file takes config().print, its profile fetched from PROFILES
    // (outputProfile: the .icc bytes, to hand it over yourself).
    profileBaseUrl: PROFILES,
    ...(press ? {} : { print: { standard: 'none' }, colorSpace: 'grayscale' }),
    // The proof keeps the SVGs, which its grey conversion can reach; masters stay in CMYK.
    resourceBytes: (fileId) => (press && masters.get(masterOf.get(fileId))) || imageBytes(fileId),
  }); // bookmarks (from the headings) and /PageLabels (from the folios) come by default
}

Ingredientes

Tipografia
Karla, Space Grotesk, Space Mono (SIL OFL 1.1)
Materiais
Nenhum: todas as imagens são desenhadas em código

Preparo

#1 · Cores de tela escolhidas pela chapa

script.js · linhas 19–43no código completo
const palette = {
  ink: '#161616', // text: a neutral grey prints on the black plate alone (K95 on FOGRA51)
  muted: '#666666', // the running heads, on black alone for the same reason (K66)
  forest: '#0b3d2e', // the sea, the kickers, the back band
  signal: '#ff6626', // route, waypoints, tab
  sage: '#9fb8a8', // high ground; small type on forest
  paper: '#f4f1ea', // type on forest: reversed out, it prints no ink at all
};
// The colours authored in CMYK (%), the same builds as the print masters (section 2), so the
// type, the tab and the drawings share their inks. Without them the profile would separate
// the forest as C90 M43 Y71 K62 and the orange as C0 M70 Y90 K0, a shade off the route.
const AUTHORED = { forest: [100, 45, 80, 55], signal: [0, 60, 85, 0], sage: [40, 20, 35, 0],
  paper: [0, 0, 0, 0] };
const cmyk = ([c, m, y, k]) => ({ c, m, y, k });
const value = (id) => (AUTHORED[id]
  ? { hex: palette[id], model: 'cmyk', cmyk: cmyk(AUTHORED[id]) }
  : { hex: palette[id], model: 'hex' });
// col(id): a palette-linked colour. It carries the hex too, because postext paints design
// elements from the hex (gotcha: palette-skips-designs).
const col = (id) => ({ ...value(id), paletteId: id });
const colorPalette = [
  ...Object.keys(palette).map((id) => ({ id, name: id, value: value(id) })),
  // 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' } },
];

print: { standard: 'pdfx4', outputProfile: 'fogra51' } na configuração separa cada cor pelo perfil FOGRA51 que vem com o postext. Os cinzas neutros imprimem só na chapa do preto, com o K escolhido para manter o tom: #161616 vira K95 e o #666666 dos cabeços, K66, e o texto em 100 % K sai em sobreimpressão. O perfil imprimiria o verde-escuro como C90 M43 Y71 K62 e o laranja como C0 M70 Y90 K0, um tom diferente da rota das matrizes (passo 5), por isso esses dois, o verde-sálvia e a cor do papel são definidos em CMYK: ColorValue.cmyk guarda a mistura, o hexadecimal fica para a tela, e todo uso ligado à paleta imprime a mistura tal como foi escrita. A cor do papel é C0 M0 Y0 K0: o texto vazado no verde-escuro não leva tinta nenhuma, e assim nenhuma chapa pode deslocá-lo.

#2 · Uma aba que sobrevive à guilhotina

script.js · linhas 92–113no código completo
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')] };

A caixa da aba pende do quadro da sangria e tem BLEED a mais que a aba já refilada, de modo que um corte que caia um milímetro fora do refile ainda deixa laranja na borda; o fólio pende do quadro da página, que é o refile, e se centraliza nos 10 mm que sobram. parity escolhe as páginas em que cada elemento aparece, e edge muda junto para chegar à borda externa, enquanto pages: 'body' deixa a aba e os cabeços ({title} e {subtitle}, do frontmatter) fora das duas capas, que contam como aberturas porque cada uma começa com um título que ocupa a página ou salta para uma nova. Qualquer elemento do cabeçalho ou do rodapé se repete do mesmo jeito, então uma image com parity e pages põe uma marca-d'água ou uma imagem decorativa em todas as páginas; os encaixes são pintados por cima do texto, e um fundo de cor por trás dele vai em page.backgroundColor.

#3 · A arte até a sangria, o texto dentro do refile

script.js · linhas 131–177no código completo
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,
      // 6.3 pt in sage would be three inks on a four-ink band: reversed to paper, it prints
      // no ink and no plate can shift it (the preflight's small-text check).
      fontSize: pt(6.3), letterSpacing: pt(0), textTransform: 'none', lineHeight: 1.4,
      color: col('paper'), 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 soma a cada lado da página a sangria, o afastamento da marca e a marca de 5 mm, 11 mm aqui, e como a resposta curta iguala o afastamento à sangria, as marcas começam na borda da sangria e vão para fora, na chapa do preto. Um elemento de imagem ancorado à sangria com width: 'fill' toma a largura desse quadro e mantém a própria proporção: o mapa da capa é desenhado com 176 × 233 mm, o formato refilado mais 3 mm de cada lado, e por isso chega à borda da sangria, onde começam as marcas, enquanto o texto pende da mancha, bem dentro do refile. A quarta capa é um estilo de título cujo rodapé substitui o do documento, com uma faixa do mesmo gerador ancorada ao pé da sangria e uma margem inferior que para o texto 8 mm acima dela.

#4 · Títulos que viram marcadores

script.js · linhas 197–208no código completo
  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 monta os marcadores a partir dos títulos, então cada sala é um título de nível 1 que segue o anterior sem quebra de página, e numberingTemplate dá ao antetítulo o seu {number} e ao marcador o seu prefixo: o PDF lista a capa, de “Sala 1 Una isla en ninguna parte” a “Sala 4 La isla del tesoro”, e as informações práticas. Um título desenhado esconde o próprio texto, mas o guarda para os marcadores e as tags; sem fontFamily, esse texto oculto sai em Open Sans 700, que as páginas carregariam e o PDF incorporaria só para ele. Os rótulos de página seguem page.pageNumbering, mantido no padrão, decimal a partir de 1, e assim a página 3 no visualizador de PDF é a que traz 3 na aba.

#5 · Matrizes de impressão com tintas exatas

script.js · linhas 403–435no código completo
// C, M, Y, K in %, one build per colour of the drawings, matched to the screen colours on a
// proof. The palette authors forest, signal, sage and paper with these same builds, so the
// route matches the tab and the kickers match the sea.
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)]));

Cada desenho é definido uma única vez, como traçados. A região da arte os transforma no SVG que o canvas pinta, e esta região escreve os mesmos traçados num PDF CMYK de uma página, com misturas escolhidas para que a prova se pareça com a tela; assim o mar fica em C100 M45 Y80 K55, o verde-escuro da paleta. Cada recurso indica sua matriz em svg.pdfFileId, pressPdf(), na resposta curta, entrega os bytes da matriz a renderToPdf com o id do SVG, e o postext-pdf incorpora essa página sem alteração, em vetores e com as tintas da matriz. Isso vale para a planta, que é uma figura, e para o mapa da capa e a faixa da quarta capa, que são imagens de design. A prova em tons de cinza recebe os SVGs, porque uma matriz incorporada nunca é convertida e continuaria em cores.

A receita completa

Sandbox
// ═══ 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.22.0
// An exhibition leaflet set up for the press: bleed and crop marks on every page, and a
// PDF/X-4 file separated for FOGRA51 that embeds its fonts and takes the maps and the floor
// plan from print masters, checked by the preflight before it is offered.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, preflightDocument, loadOutputProfile, outputTransform,
} 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, and for four of them the inks the press prints them in
const palette = {
  ink: '#161616', // text: a neutral grey prints on the black plate alone (K95 on FOGRA51)
  muted: '#666666', // the running heads, on black alone for the same reason (K66)
  forest: '#0b3d2e', // the sea, the kickers, the back band
  signal: '#ff6626', // route, waypoints, tab
  sage: '#9fb8a8', // high ground; small type on forest
  paper: '#f4f1ea', // type on forest: reversed out, it prints no ink at all
};
// The colours authored in CMYK (%), the same builds as the print masters (section 2), so the
// type, the tab and the drawings share their inks. Without them the profile would separate
// the forest as C90 M43 Y71 K62 and the orange as C0 M70 Y90 K0, a shade off the route.
const AUTHORED = { forest: [100, 45, 80, 55], signal: [0, 60, 85, 0], sage: [40, 20, 35, 0],
  paper: [0, 0, 0, 0] };
const cmyk = ([c, m, y, k]) => ({ c, m, y, k });
const value = (id) => (AUTHORED[id]
  ? { hex: palette[id], model: 'cmyk', cmyk: cmyk(AUTHORED[id]) }
  : { hex: palette[id], model: 'hex' });
// col(id): a palette-linked colour. It carries the hex too, because postext paints design
// elements from the hex (gotcha: palette-skips-designs).
const col = (id) => ({ ...value(id), paletteId: id });
const colorPalette = [
  ...Object.keys(palette).map((id) => ({ id, name: id, value: value(id) })),
  // 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) };

// Hook-up: config().print. PDF/X-4 for the press on FOGRA51 (PSO Coated v3, the leaflet's
// 150 g coated matte): every colour separated through the profile (the authored ones as
// written), 100 % K text overprinting, the output intent, TrimBox and BleedBox on each page.
// The 7.5 pt bold labels in forest hold in four inks on coated stock: the small-text check
// starts at 7 pt instead of 9.
const PRINT = { standard: 'pdfx4', outputProfile: 'fogra51', preflight: { smallTextSize: pt(7) } };
// Where the profiles are fetched from: postext.dev serves postext's icc/ folder at /icc/ (any
// copy of it works, and so does the npm CDN's postext/icc/).
const PROFILES = 'https://postext.dev/icc/';

// `masters` holds print-master bytes by fileId (section 2 writes them). The proof is an
// ordinary greyscale PDF: no PDF/X, which would force CMYK.
function pressPdf(doc, kind, { resources, masters }) {
  const press = kind === 'press';
  // 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,
    // The press file takes config().print, its profile fetched from PROFILES
    // (outputProfile: the .icc bytes, to hand it over yourself).
    profileBaseUrl: PROFILES,
    ...(press ? {} : { print: { standard: 'none' }, colorSpace: 'grayscale' }),
    // The proof keeps the SVGs, which its grey conversion can reach; masters stay in CMYK.
    resourceBytes: (fileId) => (press && 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,
      // 6.3 pt in sage would be three inks on a four-ink band: reversed to paper, it prints
      // no ink and no plate can shift it (the preflight's small-text check).
      fontSize: pt(6.3), letterSpacing: pt(0), textTransform: 'none', lineHeight: 1.4,
      color: col('paper'), 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,
  print: PRINT,
  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`---
Amostra em Markdown · 56 linhas · 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 palette authors forest, signal, sage and paper with these same builds, so the // route matches the tab and the kickers match the sea. 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, 'press', inputs), `${RECIPE}.pdf`); // the file for the press offerPdf(() => pressPdf(doc, 'proof', 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 (PDF/X-4)'; button(`${RECIPE}-proof.pdf`).textContent = 'Greyscale proof'; // The preflight: what a printer would reject, checked with the same profile. const transform = outputTransform(await loadOutputProfile(PRINT.outputProfile, PROFILES)); const findings = preflightDocument(doc, { resources, transform }); const report = document.createElement('p'); report.textContent = findings.length === 0 ? `Preflight (${PRINT.standard}, ${PRINT.outputProfile}): no findings` : `Preflight: ${findings.map((f) => `${f.kind} on page ${f.pageIndex + 1}`).join('; ')}`; button(`${RECIPE}.pdf`).after(report);
Kit · core, fonts, viewer, pdf, images: igual em todas as receitas · 316 linhas// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { 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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), 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 /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ 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 v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { 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 text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no 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 ───────────────────────────────────────────────────────────────────────

O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)

Variações

#Peça 5 mm de sangria

A arte, a aba e a escala gráfica são medidas a partir de BLEED, e as marcas de corte acompanham por meio de markOffset; para uma gráfica que peça 5 mm, basta esta mudança, e cada folha passa a medir 200 × 257 mm.

-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

#Imprima a arte a partir dos SVGs

Sem pdfFileId, o arquivo de impressão desenha cada imagem a partir do seu SVG, ainda em vetores e passando pelo perfil: o verde-escuro, definido em CMYK, mantém C100 M45 Y80 K55, enquanto os outros verdes das ilhas recebem as separações do perfil em vez das escolhidas a dedo.

-  updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`, pdfFileId: `${id}.pdf`,
+  updatedAt: 0, ...extra, svg: { fileId: `${id}.svg`,

Erros comuns

Erro comum

renderToPdf ignora svg.pdfFileId: entregue o arquivo-mestre por resourceBytes

Fora dos pacotes, renderToPdf pede a resourceBytes o fileId do próprio SVG e nunca o svg.pdfFileId. No seu resourceBytes, devolva os bytes do arquivo-mestre de impressão para o fileId do SVG. Matrizes de impressão para SVG →

Erro comum

O PDF pede todos os pesos e estilos de cada família

renderToPdf pede ao provedor de fontes o negrito, o itálico e o negrito itálico de cada família que um bloco poderia usar, mesmo os que nunca são impressos, e uma única recusa interrompe a exportação. O provedor deve se ajustar ao peso mais próximo que a família oferece e voltar ao romano quando não houver itálico. Fontes incorporadas ao PDF →

Erro comum

Sem <marker> nem filtros na arte SVG (vira bitmap)

Uma figura SVG só continua vetorial no PDF sem <marker>, filtros e máscaras; caso contrário, é rasterizada, e filtros muito aninhados podem deixá-la em branco no Chrome. Desenhe as pontas de seta como caminhos (paths). Figuras e tabelas como recursos →

Erro comum

O texto dentro de um SVG <img> não pode usar fontes web

Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →

Erro comum

Os arquivos do Fontsource cobrem letras, não símbolos

O kit incorpora o arquivo latin do Fontsource de cada fonte e, quando o texto usa esses caracteres, o arquivo latin-ext para č, ł, † e o arquivo greek para α, χ (em uma família que o tenha: Lora e Gelasio não têm), na tela e no PDF. Símbolos como →, ≈, ✓ e ★ não estão em nenhum desses arquivos e somem no PDF: desenhe-os, componha-os como matemática ou escolha uma fonte cujos arquivos os tenham. Fontes incorporadas ao PDF →

Erro comum

As imagens de uma abertura nunca contam para a altura que ela reserva

No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar. Aberturas desenhadas →

Erro comum

Texto alinhado à esquerda nunca é hifenizado

A hifenização só se aplica ao texto justificado; o texto alinhado à esquerda quebra entre palavras, então uma coluna estreita em bandeira fica muito irregular. Justifique o trecho ou aumente a medida. Hifenização e idioma do documento →

Erro comum

Traduza Figura e Tabela com defaultResourceTypes(locale)

O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes. Figura e Tabela no seu idioma →

Erro comum

Qualquer objeto headings desativa a quebra de página do H1

Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →

Erro comum

Uma paleta trocada não chega aos elementos de design nem à cor das referências

postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →

Erro comum

O excesso de texto de design é 'ellipsis-end' por padrão

Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →

Erro comum

A configuração fica em cache pela identidade: crie um objeto novo

O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →

Erro comum

Carregue todas as fontes antes do layout

O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →

  • Um padrão PDF/X sempre grava em CMYK, diga o que disser colorSpace, por isso a prova em tons de cinza substitui print por { standard: 'none' } ao lado de colorSpace: 'grayscale'.
  • Uma renderização PDF/X falha sem o perfil de saída. renderToPdf busca fogra51.icc em profileBaseUrl (aqui, o /icc/ de postext.dev); para servir o perfil você mesmo, passe os bytes do arquivo em outputProfile.
  • O preflight acusa texto com menos de 9 pt composto em mais de uma tinta. Os rótulos em negrito de 7,5 pt no verde-escuro aguentam quatro tintas em papel couché, por isso print.preflight.smallTextSize fica em 7 pt; o colofão de 6,3 pt, que em verde-sálvia seria três tintas sobre uma faixa de quatro, sai vazado na cor do papel.
  • Um arquivo PDF/X não leva anotações de link, porque um arquivo para impressão não pode tê-las dentro da caixa de sangria; os marcadores continuam.
  • Uma marca de corte começa a markOffset do refile, não da sangria, então com uma sangria maior que o afastamento padrão de 3 mm as marcas saem impressas sobre a arte. Mantenha o afastamento igual à sangria, como cutLines faz aqui.
  • Depois de gerado, cada botão vira um link “Open the PDF ↗” e um link de download, e só os de download trazem o nome do arquivo.

Créditos

Texto
Texto original, CC BY 4.0
Fontes
Karla (SIL OFL 1.1) · Space Grotesk (SIL OFL 1.1) · Space Mono (SIL OFL 1.1)
SandboxPDF