En poques paraules
Un fulletó de dues cares d'un antic molí de marea, en anglès i en castellà. Ensenya a desar un document amb les seves fonts i imatges en un sol fitxer i a treure'n les mateixes pàgines.
Què compondràs
Les dues cares d'un fulletó DL de visita, de 99 × 210 mm, per a un molí de marea en una ria inventada. La portada és un sol dibuix: una roda de molí a la línia de l'aigua, amb la meitat superior sobre la sorra i la inferior pàl·lida sota el blau de l'aigua, el títol en DM Serif Display cursiva de 50 pt i una pestanya blava amb el codi de l'edició, ES o EN. El revers comença amb el molí en secció i continua amb el text en DM Sans justificada, l'horari en una taula amb la capçalera en blau, peus en Instrument Sans, un colofó i una franja blava amb la fundació que l'edita. Cada edició s'escriu en un fitxer .postext, i cada pàgina que veus es compon a partir dels bytes d'aquest fitxer, amb les fonts i els dibuixos que porta a dins.
Aquesta recepta respon a
- Com obro un fitxer .postext i el pinto amb les seves pròpies fonts, imatges i configuració?
- Com creo un paquet .postext des del codi per passar un document al Sandbox o a un altre programa?
- Com publico el mateix llibre en dues llengües des d'un sol projecte?
- Com aconsegueixo les etiquetes «Figura» i «Taula» en la llengua del meu document?
La resposta curta
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
markdown, config: config(), resources,
files: { ...drawings, ...faceFiles },
thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why
// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
Ingredients
- Funcions
- Paquets .postextFigura i Taula en la teva llenguaLes teves pròpies fontsPartició de mots i llengua del documentPeus numeratsCites que col·loquen les figuresFigures just aquíEspai vertical explícitLlibres construïts capítol a capítolCobertes, portades i colofonsEstils de títolAtributs de títolObertures dissenyadesTextos, filets i caixes als dissenys de pàginaImatges als dissenys de pàginaMetadades del documentEstil dels peusEstil de taulesExportació a PDFFonts incrustades al PDF
- També fa servir
- Banda de capítol d'amplada completaSalts de pàgina i de columnaCapçaleres segons el tipus de pàginaEstils de paràgrafTipus de recurs propis
- Tipografia
- DM Sans, DM Serif Display, Instrument Sans (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Escriu l'edició i torna a llegir només els bytes
// The writer: text, design, resources and every file they name, zipped. createBundle looks
// up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`.
const { bytes, warnings } = await createBundle({
name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }),
locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations
markdown, config: config(), resources,
files: { ...drawings, ...faceFiles },
thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture
});
if (warnings.length) console.warn(warnings); // what was left out, and why
// The reader has nothing but the bytes. Each fileId is now the file's path inside the zip:
// mill.svg is resources/mill.svg, and the faces sit under fonts/.
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts()
await registerBundleImages(bundle); // the drawings, for the canvas
const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one
createBundle comprimeix en un zip el capítol, la configuració, els recursos i tots els fitxers que anomenen, i openBundle no rep res més que aquests bytes. Les fonts surten de loadBundleFonts, els dibuixos de registerBundleImages i el disseny de bundle.config. Una font que no sigui a files surt amb la font de reserva del navegador, i un dibuix que falti es descarta amb un avís i la seva remissió s'imprimeix com a (?). Dins el zip, cada fileId passa a ser una ruta (mill.svg ara és resources/mill.svg), i els recursos i el customFonts que retorna openBundle ja fan servir els noms nous (obrir un paquet).
#2 · Posa les fonts dins el fitxer
const customFonts = Object.entries(FONTS).map(([name, specs]) => ({ name,
variants: specs.map((spec) => ({ weight: parseInt(spec, 10), format: 'woff2',
style: spec.endsWith('i') ? 'italic' : 'normal', fileId: `${fontsourceId(name)}-${spec}` })),
}));
// The bytes: Fontsource's static woff2 files, latin subset, which covers the Spanish text too.
const faceFiles = Object.fromEntries(await Promise.all(customFonts.flatMap(({ name, variants }) =>
variants.map(async ({ weight, style, fileId }) => {
const id = fontsourceId(name);
const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/`
+ `${id}-latin-${weight}-${style}.woff2`);
if (!res.ok) throw new Error(`Fontsource has no ${name} ${weight} ${style}`);
return [fileId, new Uint8Array(await res.arrayBuffer())];
}))));
Un paquet porta una font quan customFonts l'anomena i files en desa els bytes amb el fileId de la variant; createBundle desa la DM Sans 400 com a fonts/dm-sans-400-normal.woff2. El pen baixa de Fontsource els set woff2, però no en registra cap: loadBundleFonts afegeix a document.fonts les còpies del paquet abans que buildBundle mesuri una sola línia, i el PDF treu les seves fonts d'aquests mateixos set fitxers.
#3 · Anomena cada fitxer pel seu fileId
const svg = (id, w, h, altText, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...extra });
const row = (...cells) => cells.map((content) => ({ content }));
const head = (...cells) => cells.map((content) => ({ content, isHeader: true }));
const resources = [
svg('cover', PAGE.w, PAGE.h, t({ en: 'A mill wheel on the waterline, its lower half pale '
+ 'under the estuary', es: 'Una rueda de molino en la línea del agua, con la mitad '
+ 'inferior pálida bajo la ría' })),
svg('mill', SECTION.w, SECTION.h, t({
en: 'The mill in section: the pond at high level on the left, the mill house on the dam '
+ 'with its millstones, the horizontal wheel in the vaulted pit, and the estuary on the '
+ 'right below a dashed high-water line',
es: 'El molino en sección: el estanque a nivel alto a la izquierda, la casa del molino sobre '
+ 'la presa con sus muelas, el rodezno en el cárcavo abovedado y la ría a la derecha, bajo '
+ 'una línea discontinua de pleamar' }), {
placement: { position: 'here' },
caption: t({ en: 'Two hours after high water: the pond turns the wheel, and the estuary '
+ 'has fallen below the dashed line.',
es: 'Dos horas tras la pleamar: el estanque mueve el rodezno y la ría ha quedado por debajo '
+ 'de la línea discontinua.' }) }),
{ id: 'hours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
placement: { position: 'here' },
caption: t({ en: 'Opening hours. Last entry 45 minutes before closing.',
es: 'Horario. Última entrada 45 minutos antes del cierre.' }),
table: { model: { headerRowCount: 1, columnWidths: [1.55, 0.9, 1.55], rows: t({
en: [head('Season', 'Days', 'Hours'),
row('April–June', 'Tue–Sun', '10:00–14:00, 16:00–19:00'),
row('July–August', 'Mon–Sun', '10:00–20:00'),
row('September–March', 'Fri–Sun', '10:30–14:30')],
es: [head('Temporada', 'Días', 'Horario'),
row('Abril–junio', 'Mar.–dom.', '10:00–14:00 y 16:00–19:00'),
row('Julio–agosto', 'Lun.–dom.', '10:00–20:00'),
row('Septiembre–marzo', 'Vie.–dom.', '10:30–14:30')] }) } } },
];
Els dos dibuixos són SVG que genera el mateix pen i que es lliuren a files amb el svg.fileId del seu recurs; la taula són dades, així que viatja a preset.json amb el seu peu. La secció i la taula es col·loquen amb position: 'here': la secció al capdamunt del revers i l'horari sota el paràgraf que el cita. A la versió 1.4.1, un recurs en línia no porta aire a sota, així que una línia :::space{lines=0.5} després de la taula deixa 3,7 mm sota el peu; sense aquesta línia, el paràgraf següent comença a 1,4 mm del peu.
#4 · Escriu les etiquetes al fitxer
// Hyphenation patterns and the PDF's /Lang, by exact code (gotcha: hyphenation-locales).
locale: t({ en: 'en-us', es: 'es' }),
// Figura and Tabla travel inside the Spanish file. Left out, they follow whoever opens it:
// the Sandbox at /en/sandbox prints Figure 1.1 (gotcha: bundle-labels-reader-locale).
// '{n}' numbers them 1, 2, 3: a leaflet has no chapters to number its figures by.
resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
Quan un fitxer no porta resourceTypes, openBundle els crea en la llengua que demana qui l'obre, no en la del fitxer: el fulletó en castellà obert amb { locale: 'en' }, o importat al Sandbox des de /en/sandbox, imprimeix Figure 1.1 sobre text en castellà. Els tipus escrits a la configuració substitueixen aquests, així que el fitxer en castellà imprimeix Figura 1 i Tabla 1 l'obri qui l'obri. '{n}' treu el número de capítol, que un fulletó de dues pàgines no necessita (tipus de recurs). La mateixa funció config() fixa locale, que tria els patrons de partició de mots del castellà (compuer-tas al revers) i la llengua que declara el PDF.
#5 · Lliura els mateixos bytes
const file = `tide-mill-${LANG}.postext`;
document.getElementById('pt-actions').append(Object.assign(document.createElement('a'), {
href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), download: file,
textContent: `Download ${file} · ${Math.round(bytes.length / 1024)} KB` }));
// The PDF embeds the faces the bundle carries, and draws the figures from its files.
offerPdf(() => renderToPdf(docs, {
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2 }),
resourceBytes: bundleResourceBytes(bundle),
}), `${RECIPE}-${LANG}.pdf`);
L'enllaç ofereix els mateixos bytes des dels quals es van compondre les pàgines: 132 KB amb les fonts. En importar-lo al Sandbox (Llibres → Nou → Obrir un fitxer .postext…), el fitxer es converteix en un llibre en castellà o en anglès que porta com a imatge la roda de thumbnail. El PDF pren les seves fonts de bundleFontProvider, que retorna la del paquet més propera en pes, amb el mateix estil si n'hi ha, i els seus dibuixos de bundleResourceBytes, així que no es baixa dues vegades cap font ni cap imatge (compondre i renderitzar un paquet).
#6 · Dibuixa la portada amb un sol títol
const at = (x, y, width, edge = 'top-left') => ({ anchor: { to: 'page', edge },
offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } });
const text = (id, content, family, size, placement, look) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col('estuary'), overflow: 'wrap', // gotcha:
placement, ...look }); // overflow-ellipsis-default
const caps = { fontFamily: LABEL, fontWeight: 700, textTransform: 'uppercase',
letterSpacing: pt(1.15) };
const [MEASURE, EDGE] = [PAGE.w - 2 * PAGE.side, 17]; // mm; EDGE: trim to kicker and facts
const cover = { id: 'cover', advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'page',
edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(PAGE.h) } } },
text('kicker', '{attr.kicker}', LABEL, 7.5, at(PAGE.side, EDGE, MEASURE), caps),
// The language tab: the edition's code on a blue flap hanging from the top edge.
text('edition', '{attr.edition}', LABEL, 8, { anchor: { to: 'page', edge: 'top-right' },
offset: { x: mm(-PAGE.side) } }, { ...caps, color: col('foam'), box: {
backgroundColor: col('estuary'), padding: { top: mm(8), right: mm(2.4), bottom: mm(2.2),
left: mm(2.4) } } }),
text('title', '{titleText}', DISPLAY, 50, at(PAGE.side - 0.8, 25, MEASURE + 2),
{ italic: true, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple)
text('lead', '{attr.lead}', TEXT, 11, at(PAGE.side + 2, WATER + 50, MEASURE - 4),
{ color: col('foam'), italic: true, lineHeight: 1.4 }),
text('facts', '{attr.facts}', LABEL, 7.5, at(PAGE.side, -EDGE, MEASURE, 'bottom-left'),
{ ...caps, color: col('sand') }),
] } } };
La portada és el disseny del títol de coberta: el dibuix ancorat a la pàgina a mida completa, el text del títol amb {titleText}, l'avanttítol, l'entradeta i la línia de dades, que surten dels seus atributs, i un text amb caixa per a la pestanya de la llengua. El nivell 1 dels títols porta span: 'page', així que el dibuix comença a la vora del paper. Si es queda a la columna, el disseny es talla a les vores superior i inferior de la caixa de text: el dibuix començaria 12 mm més avall i acabaria 13 mm abans de la vora inferior, i la pestanya de la llengua quedaria en una tira sense lletres.
La recepta completa
// ═══ Postext Cookbook · Nº 041 · .postext round trip in two languages ════════════ // https://postext.dev/en/cookbook/bundle-round-trip // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: DM Sans, DM Serif Display, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { createBundle, openBundle, loadBundleFonts, registerBundleImages, buildBundle, bundleFontProvider, bundleResourceBytes, defaultResourceTypes, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'bundle-round-trip'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#172130', muted: '#56606c', // text; the colophon estuary: '#25476a', mud: '#8a6f4d', // the one accent; the wheel's wood in the drawings sand: '#e9dcc4', foam: '#eef2f3', rule: '#c4ced6', paper: '#ffffff' }; // The hex rides along: design elements read it, not the palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the estuary blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.estuary }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['DM Sans', 'DM Serif Display', 'Instrument Sans']; const PAGE = { w: 99, h: 210, top: 12, bottom: 13, side: 10 }; // mm: a DL leaflet, both sides const [BODY, LEAD] = [9.4, 13.4]; // pt const WATER = 98; // mm from the top of the cover: where the sand ends and the estuary begins // #region cover: the front of the leaflet, a heading drawn over one picture const at = (x, y, width, edge = 'top-left') => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }); const text = (id, content, family, size, placement, look) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('estuary'), overflow: 'wrap', // gotcha: placement, ...look }); // overflow-ellipsis-default const caps = { fontFamily: LABEL, fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.15) }; const [MEASURE, EDGE] = [PAGE.w - 2 * PAGE.side, 17]; // mm; EDGE: trim to kicker and facts const cover = { id: 'cover', advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: mm(PAGE.w), height: mm(PAGE.h) } } }, text('kicker', '{attr.kicker}', LABEL, 7.5, at(PAGE.side, EDGE, MEASURE), caps), // The language tab: the edition's code on a blue flap hanging from the top edge. text('edition', '{attr.edition}', LABEL, 8, { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-PAGE.side) } }, { ...caps, color: col('foam'), box: { backgroundColor: col('estuary'), padding: { top: mm(8), right: mm(2.4), bottom: mm(2.2), left: mm(2.4) } } }), text('title', '{titleText}', DISPLAY, 50, at(PAGE.side - 0.8, 25, MEASURE + 2), { italic: true, lineHeight: 0.96 }), // a multiple (gotcha: design-lineheight-multiple) text('lead', '{attr.lead}', TEXT, 11, at(PAGE.side + 2, WATER + 50, MEASURE - 4), { color: col('foam'), italic: true, lineHeight: 1.4 }), text('facts', '{attr.facts}', LABEL, 7.5, at(PAGE.side, -EDGE, MEASURE, 'bottom-left'), { ...caps, color: col('sand') }), ] } } }; // #endregion const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) // #region labels: the edition's language, written into the file with the rest of the config // Hyphenation patterns and the PDF's /Lang, by exact code (gotcha: hyphenation-locales). locale: t({ en: 'en-us', es: 'es' }), // Figura and Tabla travel inside the Spanish file. Left out, they follow whoever opens it: // the Sandbox at /en/sandbox prints Figure 1.1 (gotcha: bundle-labels-reader-locale). // '{n}' numbers them 1, 2, 3: a leaflet has no chapters to number its figures by. resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })), // #endregion colorPalette, customFonts, page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.side), right: mm(PAGE.side) } }, // a flyer printed both sides: nothing to mirror layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.75, maxWordSpacing: 1.6 }, headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ // in main-color: the estuary // The H1 break, restated (gotcha: headings-drop-h1-break). In the column, the cover design // is cut at the text block's top and bottom edges; span: 'page' paints it from the trim. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } }, { level: 2, fontSize: pt(15), lineHeight: pt(LEAD * 1.25), marginTop: pt(LEAD * 0.5), marginBottom: pt(LEAD * 0.25) }, ] }, headingStyles: [cover], captionStyle: { fontFamily: LABEL, fontSize: pt(7.8), labelColor: col('estuary'), gap: mm(1.8) }, tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('estuary'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(7.6), bodyFontSize: pt(8.2), cellPadding: mm(1.3) }, paragraphStyles: [{ id: 'colophon', fontSize: pt(6.6), lineHeight: pt(8.8), color: col('muted'), textAlign: 'left', firstLineIndent: mm(0), marginTop: pt(LEAD) }], header: { elements: [] }, // The back's foot: a strip of estuary with the publisher, the frontmatter's author. footer: { elements: [ { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('estuary') }, placement: { anchor: { to: 'page', edge: 'bottom-left' }, size: { width: 'fill', height: mm(7) } } }, text('foot', '{author}', LABEL, 7.5, { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(PAGE.side), y: mm(-2.4) } }, { ...caps, color: col('foam'), pages: 'body', overflow: 'clip' }), ] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 25 línies · content.es.md
title: "El molino de mareas de Arenal" author: "Fundación Ría de Arenal" --- # El molino de mareas {style="cover" kicker="Ría de Arenal · Folleto de visita 3" lead="Durante 165 años, la marea movió sus cuatro rodeznos. Recorre la presa y asómate al cárcavo, por donde se vacía el estanque dos veces al día." facts="Abierto todo el año · Gratis los domingos" edition="ES"} :::pagebreak ::resource{id="mill"} ## Dos mareas al día Cuando la marea sube, el mar empuja las compuertas de la presa y llena el estanque, seis hectáreas de agua salada; cuando baja, el agua del estanque las cierra. Pasadas dos horas de la pleamar, la ría está lo bastante baja para que el molinero abra los saetines (:ref{id="mill" style="full" case="lower"}). El agua cae sobre un rodezno, una rueda horizontal que gira en el cárcavo, bajo el suelo, y un eje vertical mueve las muelas de arriba. El molino hizo harina de maíz y trigo para el valle desde 1791 hasta 1956. Restaurado en 2004, vuelve a moler los días de demostración, dos horas tras la pleamar (:ref{id="hours" style="full" case="lower"}). ::resource{id="hours"} :::space{lines=0.5} El camino de la presa es llano y apto para sillas de ruedas, y al cárcavo se baja por once peldaños. La entrada cuesta 3 euros y es gratuita los domingos. :::paragraphs{style="colophon"} Folleto 3, edición en español · Texto y dibujos CC BY 4.0 · Compuesto en DM Sans, DM Serif Display e Instrument Sans (SIL Open Font License). :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the cover's wheel in the estuary, and the mill in section // No words in the drawings: an SVG drawn as an image cannot use web fonts (gotcha: // svg-no-webfonts). Every length is in millimetres of the printed page. const SECTION = { w: 79, h: 35 }; // the mill in section, as wide as the text const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const line = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`); const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) ` + `rotate(${n(turn)})">${body}</g>`; // A wave line across the page: cubic arcs of wavelength `len`, `amp` high. const wave = (y, len, amp, phase, width) => { let d = `M${n(-phase)} ${n(y)}`; for (let x = -phase; x < width + len; x += len) { const [q, h] = [x + len / 4, x + 3 * len / 4]; d += `C${n(q)} ${n(y - amp)} ${n(q)} ${n(y - amp)} ${n(x + len / 2)} ${n(y)}` + `C${n(h)} ${n(y + amp)} ${n(h)} ${n(y + amp)} ${n(x + len)} ${n(y)}`; } return d; }; // The wheel: a hub and eighteen blades, each a spoon on a spoke, the spoon bent back against // the turn; the square end of the shaft at the centre. function wheel(cx, cy, r, color, extra = '') { const spoke = `M${n(r * 0.28)} ${n(-r * 0.018)}H${n(r * 0.54)}V${n(r * 0.018)}H${n(r * 0.28)}Z`; const spoon = `M0 0C${n(r * 0.1)} ${n(-r * 0.08)} ${n(r * 0.36)} ${n(-r * 0.12)} ${n(r * 0.46)} ` + `${n(-r * 0.05)}C${n(r * 0.5)} ${n(-r * 0.01)} ${n(r * 0.44)} ${n(r * 0.06)} ${n(r * 0.3)} ` + `${n(r * 0.06)}C${n(r * 0.18)} ${n(r * 0.06)} ${n(r * 0.06)} ${n(r * 0.03)} 0 0Z`; const blade = path(spoke, color) + group(r * 0.52, 0, -16, path(spoon, color)); let out = ''; for (let i = 0; i < 18; i++) out += group(cx, cy, i * 20, blade); const ring = ` stroke="${palette.sand}" stroke-width="${n(r * 0.03)}"`; return `<g${extra}>${out}${circle(cx, cy, r * 0.31, color)}` + `${circle(cx, cy, r * 0.22, 'none', ring)}` + `<rect x="${n(cx - r * 0.06)}" y="${n(cy - r * 0.06)}" width="${n(r * 0.12)}" ` + `height="${n(r * 0.12)}" fill="${palette.sand}"/></g>`; } function coverArt() { const [cx, r] = [PAGE.w / 2, 37]; let body = `<rect width="${PAGE.w}" height="${WATER}" fill="${palette.sand}"/>`; // The mud flat the ebb leaves: three bands above the waterline, darker towards the water. for (const [y, h, o] of [[WATER - 15, 3, 0.1], [WATER - 10, 4, 0.16], [WATER - 5, 5, 0.24]]) { body += `<rect y="${y}" width="${PAGE.w}" height="${h}" fill="${palette.mud}" ` + `fill-opacity="${o}"/>`; } body += wheel(cx, WATER, r, palette.estuary); body += `<rect y="${WATER}" width="${PAGE.w}" height="${PAGE.h - WATER}" ` + `fill="${palette.estuary}"/>`; // Under the water the wheel shows as a pale ghost: the same drawing, clipped to the water. body += `<clipPath id="under"><rect y="${WATER}" width="${PAGE.w}" height="${PAGE.h}"/>` + `</clipPath>${wheel(cx, WATER, r, palette.foam, ' clip-path="url(#under)" opacity=".2"')}`; for (const [dy, phase, o] of [[3, 0, 0.5], [10, 4, 0.3], [18, 8, 0.2], [28, 2, 0.12]]) { body += line(wave(WATER + dy, 11, 0.9, phase, PAGE.w), palette.foam, 0.7, ` stroke-opacity="${o}"`); } return svgDoc(PAGE.w, PAGE.h, body); } // A level mark: the surveyor's triangle standing on a water surface. const level = (x, y, fill) => path(`M${n(x - 1.4)} ${n(y - 2.2)}H${n(x + 1.4)}L${n(x)} ${n(y)}Z`, fill, fill === 'none' ? ` stroke="${palette.estuary}" stroke-width=".3"` : ''); const arrow = (d, tip, turn, color) => line(d, color, 0.55) + group(...tip, turn, line('M-1.6-1L0 0-1.6 1', color, 0.55)); function sectionArt() { const { w, h } = SECTION; const [HIGH, LOW, FLOOR, WHEEL] = [10, 26.5, 15.5, 27]; // mm: levels, floor and wheel heights const P = palette; let b = ''; // Water first: the pond held at high tide, the estuary fallen to low water. b += path(`M0 ${HIGH}H31V33H0Z`, P.estuary); b += path(`M52 ${LOW}H${w}V${h}H52Z`, P.estuary); b += line(`M52 ${HIGH}H${w - 1}`, P.estuary, 0.35, ' stroke-dasharray="1.4 1"'); // The ground: the pond's bed and the estuary's mud bank. b += path(`M0 33L31 32V${h}H0Z`, P.mud); b += path(`M52 32.5L${w} 34V${h}H52Z`, P.mud); // The dam and the mill house on it, in sand with a mud outline; the roof in mud. const stroke = ` stroke="${P.mud}" stroke-width=".45"`; b += path(`M30 ${h}V5.6H54V${h}Z`, P.sand, stroke); b += path('M28.5 6L42 0.4L55.5 6Z', P.mud); // The wheel pit: a vaulted opening through the dam, with the ebb running out of it. b += path(`M33.5 ${h}V25A8 8 0 0 1 49.5 25V${h}Z`, P.paper, stroke); b += path('M33.5 30.5H55V33.5H33.5Z', P.estuary); // The chute from the pond onto the wheel, and the gate lifted above its mouth. b += path(`M30 22L36.4 ${WHEEL - 1.2}`, 'none', ` stroke="${P.estuary}" stroke-width="1.8"`); b += `<rect x="29.2" y="16.8" width="1.6" height="4" fill="${P.ink}"/>`; // The horizontal wheel, and its shaft up through the floor to the runner stone. b += line(`M41.5 ${FLOOR}V${WHEEL + 1}`, P.ink, 0.6); b += `<rect x="35.8" y="${WHEEL - 0.8}" width="11.4" height="1.6" rx=".5" fill="${P.mud}"/>`; for (let x = 36.6; x < 47; x += 1.6) { b += line(`M${n(x)} ${WHEEL - 1.4}V${WHEEL + 1.2}`, P.mud, 0.45); // the blades, edge-on } // The milling floor, the runner stone on the bed stone, and the hopper above them. b += line(`M31 ${FLOOR}H53`, P.mud, 0.45); const stone = (x, y, sw) => `<rect x="${x}" y="${n(y)}" width="${sw}" height="1.6" ` + `fill="${P.rule}" stroke="${P.ink}" stroke-width=".3"/>`; b += stone(36.5, FLOOR - 3.2, 10) + stone(36, FLOOR - 1.6, 11); b += path(`M38.6 8H44.4L42.6 ${FLOOR - 4.2}H40.4Z`, P.mud); // Level marks, and the way the water goes. b += level(6, HIGH, P.estuary) + level(73, LOW, P.estuary) + level(73, HIGH, 'none'); b += arrow('M9 27C16 26 22 24.4 27.4 23', [27.4, 23], -15, P.foam); b += arrow('M50.5 32H63', [63, 32], 0, P.foam); return svgDoc(w, h, b); } // fileId → markup: the files the resources below name. const drawings = { 'cover.svg': coverArt(), 'mill.svg': sectionArt() }; // #endregion // #region resources: the drawings name their files by fileId; the table carries its own data const svg = (id, w, h, altText, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...extra }); const row = (...cells) => cells.map((content) => ({ content })); const head = (...cells) => cells.map((content) => ({ content, isHeader: true })); const resources = [ svg('cover', PAGE.w, PAGE.h, t({ en: 'A mill wheel on the waterline, its lower half pale ' + 'under the estuary', es: 'Una rueda de molino en la línea del agua, con la mitad ' + 'inferior pálida bajo la ría' })), svg('mill', SECTION.w, SECTION.h, t({ en: 'The mill in section: the pond at high level on the left, the mill house on the dam ' + 'with its millstones, the horizontal wheel in the vaulted pit, and the estuary on the ' + 'right below a dashed high-water line', es: 'El molino en sección: el estanque a nivel alto a la izquierda, la casa del molino sobre ' + 'la presa con sus muelas, el rodezno en el cárcavo abovedado y la ría a la derecha, bajo ' + 'una línea discontinua de pleamar' }), { placement: { position: 'here' }, caption: t({ en: 'Two hours after high water: the pond turns the wheel, and the estuary ' + 'has fallen below the dashed line.', es: 'Dos horas tras la pleamar: el estanque mueve el rodezno y la ría ha quedado por debajo ' + 'de la línea discontinua.' }) }), { id: 'hours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, caption: t({ en: 'Opening hours. Last entry 45 minutes before closing.', es: 'Horario. Última entrada 45 minutos antes del cierre.' }), table: { model: { headerRowCount: 1, columnWidths: [1.55, 0.9, 1.55], rows: t({ en: [head('Season', 'Days', 'Hours'), row('April–June', 'Tue–Sun', '10:00–14:00, 16:00–19:00'), row('July–August', 'Mon–Sun', '10:00–20:00'), row('September–March', 'Fri–Sun', '10:30–14:30')], es: [head('Temporada', 'Días', 'Horario'), row('Abril–junio', 'Mar.–dom.', '10:00–14:00 y 16:00–19:00'), row('Julio–agosto', 'Lun.–dom.', '10:00–20:00'), row('Septiembre–marzo', 'Vie.–dom.', '10:30–14:30')] }) } } }, ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages use. They travel inside the bundle, so the reader loads them from // there, before the layout (gotcha: fonts-first). const FONTS = { 'DM Sans': ['400', '400i', '700'], 'DM Serif Display': ['400', '400i'], 'Instrument Sans': ['400', '700'] }; // #region faces: FONTS as customFonts, each face a woff2 file named by its fileId const customFonts = Object.entries(FONTS).map(([name, specs]) => ({ name, variants: specs.map((spec) => ({ weight: parseInt(spec, 10), format: 'woff2', style: spec.endsWith('i') ? 'italic' : 'normal', fileId: `${fontsourceId(name)}-${spec}` })), })); // The bytes: Fontsource's static woff2 files, latin subset, which covers the Spanish text too. const faceFiles = Object.fromEntries(await Promise.all(customFonts.flatMap(({ name, variants }) => variants.map(async ({ weight, style, fileId }) => { const id = fontsourceId(name); const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/` + `${id}-latin-${weight}-${style}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${name} ${weight} ${style}`); return [fileId, new Uint8Array(await res.arrayBuffer())]; })))); // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region answer: write this edition to a .postext file, then lay it out from those bytes alone // The writer: text, design, resources and every file they name, zipped. createBundle looks // up each fileId (a drawing's svg.fileId, a face's variant fileId) in `files`. const { bytes, warnings } = await createBundle({ name: t({ en: 'The Tide Mill of Arenal', es: 'El molino de mareas de Arenal' }), locale: LANG, // one language per bundle: createBundle 1.4.1 writes no translations markdown, config: config(), resources, files: { ...drawings, ...faceFiles }, thumbnail: { data: drawings['cover.svg'], mime: 'image/svg+xml' }, // the book's picture }); if (warnings.length) console.warn(warnings); // what was left out, and why // The reader has nothing but the bytes. Each fileId is now the file's path inside the zip: // mill.svg is resources/mill.svg, and the faces sit under fonts/. const bundle = await openBundle(bytes); await loadBundleFonts(bundle); // one FontFace per face from the file, in place of loadFonts() await registerBundleImages(bundle); // the drawings, for the canvas const docs = buildBundle(bundle); // one VDTDocument per chapter: a leaflet has one // #endregion showPages(docs, { title: t({ en: 'The Tide Mill · English edition', es: 'El molino de mareas · edición en español' }) }); // #region handoff: the same bytes as a download for the Sandbox, and a PDF from the bundle const file = `tide-mill-${LANG}.postext`; document.getElementById('pt-actions').append(Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), download: file, textContent: `Download ${file} · ${Math.round(bytes.length / 1024)} KB` })); // The PDF embeds the faces the bundle carries, and draws the figures from its files. offerPdf(() => renderToPdf(docs, { fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2 }), resourceBytes: bundleResourceBytes(bundle), }), `${RECIPE}-${LANG}.pdf`); // #endregionKit · core, fonts, viewer, pdf: igual a totes les receptes · 275 línies
// ─── 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Llegeix un paquet bilingüe
La guia de Postext porta en un sol fitxer els seus capítols, la seva configuració i els seus peus en anglès i en castellà; { locale } tria l'edició, que s'obre com a dotze capítols en 48 pàgines en qualsevol de les dues llengües, mentre l'enllaç continua oferint el fitxer del fulletó i el botó del PDF compon la guia.
-const bundle = await openBundle(bytes);
+const guide = await fetch('https://postext.dev/bundles/postext-guide.postext');
+const bundle = await openBundle(await guide.arrayBuffer(), { locale: LANG });#Deixa que el lector triï les etiquetes
Sense els tipus, les etiquetes segueixen la llengua amb què s'obre el fitxer i les figures compten des del títol de coberta: l'edició castellana imprimeix aquí Figura 1.1, i Figure 1.1 quan la importa el Sandbox des de /en/sandbox.
- resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),Errors freqüents
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
Error freqüent
Un paquet sense resourceTypes retola les figures en l'idioma de qui l'obre
Quan un fitxer .postext no porta resourceTypes, openBundle a postext 1.4.1 crea Figura i Taula en l'idioma que demana qui l'obre, no en el del fitxer, i el Sandbox importa cada fitxer en l'idioma de la seva interfície. Un paquet en castellà obert amb { locale: 'en' }, o importat des de /en/sandbox, imprimeix Figure 1.1 sobre text en castellà encara que bundle.locale continuï dient 'es'. Escriu resourceTypes: defaultResourceTypes(idioma) a la configuració que passes a createBundle. Paquets .postext →
Error freqüent
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
Una figura en línia porta aire a sobre, però no a sota
A postext 1.4.1, una figura que ::resource col·loca amb la posició 'here' porta una línia de la retícula de base d'aire a sobre, però a sota només el que sobra quan la línia següent s'ajusta a la retícula: des d'una línia sencera fins a gairebé res, de manera que el paràgraf següent pot començar enganxat al peu. Posa :::space{lines=1} després de la línia ::resource; com tot :::space, es descarta al capdamunt d'una columna. Figures just aquí →
Error freqüent
El text dins d'un SVG <img> no pot fer servir fonts web
Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
El lineHeight d'un text de disseny és un múltiple, mai una mesura
En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
El desbordament del text de disseny és 'ellipsis-end' per defecte
Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Una configuració es desa a la memòria cau per identitat: crea un objecte nou
El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →
- A postext 1.4.1,
createBundleescriu una sola llengua per fitxer. L'entradalocalizedque descriu Paquets bilingües no és en aquesta versió, tot i queopenBundle1.4.1 ja llegeix aquests fitxers.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Fonts
- DM Sans (SIL OFL 1.1) · DM Serif Display (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


