En poques paraules
Un llibre il·lustrat japonès per a infants, tot en hiragana, amb un espai després de cada grup de paraules i línies que no el parteixen mai, sota un dibuix a cada pàgina.
Què compondràs
Un llibre il·lustrat de deu pàgines, かみの ふね (El vaixell de paper), escrit i dibuixat per a la recepta: després de la pluja una nena plega un vaixell de paper vermell, i el vaixell baixa pel riu, deixa enrere una granota, un bernat i la lluna, i arriba al mar. Està compost com els llibres japonesos per als lectors més petits: només hiragana, a 20 pt en Klee One, una lletra feta a partir del model dels llibres de text escolars, i un espai entre frases, 分かち書き (wakachigaki), perquè l'infant llegeixi frase a frase. Les pàgines són apaïsades, de 260 × 200 mm, en vertical i enquadernades per la dreta: el dibuix a dalt, a sota línies curtes de vuit caràcters, i el vaixell avança cap a l'esquerra, al ritme de les pàgines.
Aquesta recepta respon a
- Com separo per frases un llibre il·lustrat japonès (wakachigaki), amb línies que només es tallen als espais?
- Per què el kana petit, ー i els tancaments no poden començar una línia en japonès, i com trio com d'estricta és la regla?
- Quines fonts componen el japonès, amb els kanji en les seves formes japoneses, a la pantalla i al PDF?
La resposta curta
// The story is typed with an ordinary space (U+0020) after each phrase (文節). Japanese
// text may break between any two kana, so the space alone does not keep a phrase whole:
// keep-all drops the breaks between two letters and keeps those at a space, after 、。」
// and before 「. Kinsoku still applies.
const phrases = { wordBreak: 'keep-all' }; // cjk.wordBreak
const bodyText = {
fontFamily: HAND, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
// Ragged: a justified line would stretch its spaces and its kana to fill the tier.
textAlign: 'left', firstLineIndent: em(0),
};
Ingredients
- Funcions
- Kana, kanji i rōmajiText verticalTall de línies japonès (kinsoku)Llibres enquadernats per la dretaRetícula de caràctersFonts xineses, japoneses i coreanesColumna i mitjaColumna al marge per a flotantsTipus de recurs propisImatges als dissenys de pàginaCobertes, portades i colofonsEstils de títolAtributs de títolEstils de paràgrafSalts de pàgina i de columnaColor del paperPaleta de color semànticaExportació a PDFFonts incrustades al PDF
- També fa servir
- Figures just aquíObertures dissenyadesFigures i taules com a recursosCapçaleres per seccióCapítols sense número
- Tipografia
- Klee One, Zen Maru Gothic (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Mantén sencera cada frase
El codi és la resposta curta de més amunt. El japonès es pot tallar entre dos kana qualssevol, així que un espai entre frases no fa més que afegir aire: no impedeix que la línia acabi dins de あか|い. cjk.wordBreak: 'keep-all' només conserva els talls en un espai i al costat de la puntuació, després de 、。」 o davant de 「, com el word-break: keep-all de CSS, i el kinsoku continua valent (tipografia de l'Àsia oriental). L'espai és un U+0020 corrent, de 0,35 quadratins en Klee One, i la línia el suprimeix quan cau al final. Un U+3000 també separa frases, però és un caràcter fix d'un quadratí que es queda al final de la línia.

El text va en bandera. Justificada, una línia curta estiraria els espais i els buits entre els kana fins a arribar al peu de la franja; un llibre il·lustrat deixa cada línia on acaba la seva última frase.
#2 · Posa els dibuixos en una franja sobre el text
// In vertical text 'left' is the top of the page: the side column is a tier that takes
// the pictures (floats), and the text runs in the tier under it, right to left.
const layout = {
layoutType: 'oneAndHalf', writingMode: 'vertical-rl', sideColumnRole: 'floats',
sideColumnSide: 'left', sideColumnPercent: 58, gutterWidth: pt(2 * BODY),
};
const cjk = { ...phrases, grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
const resourceTypes = [{ id: 'picture', name: 'え', shortLabel: 'え', numberingTemplate: '{n}',
resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // pictures, no captions
En el text vertical 'left' és el capdamunt de la pàgina, així que una maqueta de columna i mitja amb la columna lateral a l'esquerra són dues franges: els dibuixos floten a la de dalt (14 caràcters d'alt) i el text corre per la de baix (8 caràcters). Cada pàgina cita el seu dibuix amb ::resource{id="…"} i acaba amb :::pagebreak. El tipus de recurs dels dibuixos no porta prefix al peu, i els dibuixos són 2,186 vegades més amples que alts, la forma de la franja, així que l'omplen (escriptura vertical).
#3 · Fes de la portada un dibuix a tota pàgina
// In the flow frame of a vertical page x runs down the sheet and y leftward from its right
// edge; a box's width runs down the sheet.
const at = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(down), y: mm(across) } });
const cover = {
id: 'cover', numbered: false, toc: false, span: 'page', header: { elements: [] },
breakBefore: { enabled: true, parity: 'any' },
advancedDesign: { enabled: true, minHeight: mm(TRIM.w - 40), slot: { elements: [
{ kind: 'image', id: 'art', resourceId: 'cover', decorative: true,
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: mm(TRIM.h), height: mm(TRIM.w) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontWeight: 700,
fontSize: pt(60), lineHeight: 1, letterSpacing: pt(6), color: col('boat'),
placement: at(22, 34) },
{ kind: 'text', id: 'latin', content: '{attr.latin}', fontFamily: ROUND, fontWeight: 700,
fontSize: pt(9), lineHeight: 1.4, color: col('ink'), placement: at(22, 62) },
] } },
};
La portada és un disseny de títol a tota la pàgina: el dibuix s'ancora al sagnat amb la mida de la pàgina (en el flux vertical el seu width baixa pel full), i el títol baixa pel cel, a la dreta, en Zen Maru Gothic de 60 pt.
#4 · Dibuixa les escenes en codi
const W = 2186; // the art's width in its own units; its height is 1000 (ART is 2.186 : 1)
const COVER = Math.round((W * TRIM.h) / TRIM.w); // 1682: the cover is the page's shape
const P = palette;
function mulberry32(seed) {
return () => {
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 svgDoc = (body, h = 1000, w = W) => `<svg xmlns="http://www.w3.org/2000/svg" `
+ `width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const rect = (fill, y = 0, h = 1000, x = 0, w = W) =>
`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`;
const wave = (y, amp, fill, seed, h = 1000, w = W) => { // a band whose top edge undulates
const r = mulberry32(seed);
let d = `M0 ${h} L0 ${y}`;
for (let x = 0; x <= w; x += 120) d += ` Q${x + 60} ${y + (r() - 0.5) * 2 * amp} ${x + 120} ${y}`;
return `<path d="${d} L${w} ${h} Z" fill="${fill}"/>`;
};
const ripples = (y0, y1, n, seed, stroke = '#ffffff') => {
const r = mulberry32(seed);
return Array.from({ length: n }, () => {
const x = r() * (W - 160) + 40; const y = y0 + r() * (y1 - y0); const l = 40 + r() * 70;
return `<path d="M${x} ${y} q${l / 2} -14 ${l} 0" fill="none" stroke="${stroke}" `
+ 'stroke-width="7" stroke-linecap="round" opacity=".7"/>';
}).join('');
};
const boat = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">`
+ `<path d="M-95 0 L95 0 L66 42 L-66 42 Z" fill="${P.boat}"/>`
+ `<path d="M-70 0 L0 -88 L70 0 Z" fill="#e0624c"/><path d="M0 -88 L0 0" stroke="#a83322" `
+ 'stroke-width="4"/></g>';
const girl = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">`
+ '<path d="M-14 120 L-14 170 M14 120 L14 170" stroke="#2e2a26" stroke-width="9" '
+ 'stroke-linecap="round"/><path d="M0 40 L-55 130 L55 130 Z" fill="#e7a83c"/>'
+ '<circle cx="0" cy="0" r="40" fill="#f6d8c2"/><path d="M-42 4 Q-44 -46 0 -46 Q44 -46 42 4 '
+ 'L30 -12 Q0 -30 -30 -12 Z" fill="#2e2a26"/><circle cx="-14" cy="6" r="4" fill="#2e2a26"/>'
+ '<circle cx="14" cy="6" r="4" fill="#2e2a26"/></g>';
const frog = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">`
+ '<ellipse cx="0" cy="40" rx="130" ry="44" fill="#a9a39a"/>'
+ '<ellipse cx="0" cy="0" rx="62" ry="42" fill="#5f9e47"/><circle cx="-34" cy="-38" r="20" '
+ 'fill="#5f9e47"/><circle cx="34" cy="-38" r="20" fill="#5f9e47"/><circle cx="-34" cy="-40" '
+ 'r="9" fill="#2e2a26"/><circle cx="34" cy="-40" r="9" fill="#2e2a26"/>'
+ '<path d="M-26 6 Q0 22 26 6" fill="none" stroke="#2e2a26" stroke-width="5"/></g>';
const fish = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">`
+ '<ellipse cx="0" cy="0" rx="42" ry="20" fill="#e8893a"/><path d="M38 0 L72 -22 L72 22 Z" '
+ 'fill="#e8893a"/><circle cx="-22" cy="-4" r="5" fill="#2e2a26"/></g>';
const heron = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">`
+ '<path d="M0 60 L0 230" stroke="#6e675f" stroke-width="8"/>'
+ '<ellipse cx="10" cy="40" rx="70" ry="38" fill="#ffffff" stroke="#c9c2b8" stroke-width="4"/>'
+ '<path d="M-48 30 Q-80 -40 -40 -110" fill="none" stroke="#ffffff" stroke-width="20" '
+ 'stroke-linecap="round"/><circle cx="-40" cy="-114" r="20" fill="#ffffff"/>'
+ '<path d="M-58 -116 L-118 -104 L-58 -104 Z" fill="#e3b33c"/><circle cx="-44" cy="-118" '
+ 'r="4" fill="#2e2a26"/></g>';
const gull = (x, y, s = 1) => `<path d="M${x - 50 * s} ${y} q${25 * s} ${-30 * s} ${50 * s} 0 `
+ `q${25 * s} ${-30 * s} ${50 * s} 0" fill="none" stroke="#2e2a26" stroke-width="7" `
+ 'stroke-linecap="round"/>';
const disc = (x, y, r, fill) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}"/>`;
const stars = (n, seed) => {
const r = mulberry32(seed);
return Array.from({ length: n }, () => disc(r() * W, r() * 420, 3 + r() * 5, '#f7e7a8')).join('');
};
const hills = (y, fill, seed) => wave(y, 60, fill, seed);
const SCENES = {
// The cover is the whole page, 260 × 200 mm: 2186 × 1682 units. The sky on the right
// takes the title.
cover: svgDoc(rect('#d9ecf2', 0, COVER) + disc(560, 260, 110, '#f6d27a')
+ wave(980, 70, P.grass, 3, COVER) + wave(1180, 24, P.river, 4, COVER)
+ ripples(1250, 1640, 22, 5) + boat(1000, 1330, 2.2), COVER),
rain: svgDoc(rect('#e6eef0') + ripples(60, 360, 10, 7, '#9fb9c4') + rect('#f1e3c8', 640, 360)
+ `<rect x="760" y="560" width="660" height="90" rx="12" fill="#b07d53"/>`
+ girl(1260, 380, 1.4) + boat(880, 520, 0.9)),
stream: svgDoc(rect('#e3f0f4') + hills(420, P.grass, 11) + wave(600, 22, P.river, 12)
+ ripples(650, 960, 14, 13) + girl(1800, 300, 1.5) + boat(1300, 700, 1)),
frog: svgDoc(rect('#e3f0f4') + hills(380, P.grass, 21) + wave(560, 22, P.river, 22)
+ ripples(620, 960, 14, 23) + frog(1480, 600, 1.6) + boat(820, 760, 1)),
bridge: svgDoc(rect('#e3f0f4') + hills(400, P.grass, 31) + wave(540, 20, P.river, 32)
+ '<path d="M300 560 Q1093 120 1886 560" fill="none" stroke="#9a6b45" stroke-width="60"/>'
+ ripples(600, 960, 12, 33) + boat(1093, 720, 1) + fish(1450, 860, 1.1) + fish(1680, 800, 0.9)
+ fish(1300, 930, 0.8)),
heron: svgDoc(rect('#e3f0f4') + hills(300, '#a7cc8a', 41) + wave(420, 16, '#7aaec4', 42)
+ ripples(480, 960, 18, 43) + heron(1700, 230, 1.3) + boat(700, 700, 0.8)),
dusk: svgDoc(rect('#f4c9b5') + disc(500, 520, 110, '#f29a5c') + hills(470, '#c79c87', 51)
+ wave(560, 18, '#c98f8a', 52) + ripples(600, 960, 14, 53, '#f6ddd0') + boat(1300, 760, 0.9)),
night: svgDoc(rect('#2f3e5c') + stars(40, 61) + disc(1500, 200, 80, '#f7e7a8')
+ hills(470, '#253247', 62) + wave(560, 14, '#3f5878', 63)
+ '<path d="M1460 600 L1540 600 L1580 980 L1420 980 Z" fill="#f7e7a8" opacity=".35"/>'
+ ripples(600, 960, 12, 64, '#8aa3c2') + boat(900, 760, 0.9)),
sea: svgDoc(rect('#d6ecf4') + disc(1700, 200, 70, '#f6d27a') + wave(420, 8, '#4f88a6', 71)
+ ripples(470, 960, 22, 72) + gull(900, 200, 1.2) + gull(1150, 140, 0.9) + boat(700, 650, 0.8)),
window: svgDoc(rect('#efe2c8') + rect('#d6ecf4', 90, 640, 420, 1100)
+ rect('#4f88a6', 470, 260, 420, 1100) + boat(1250, 500, 0.4) + `<rect x="420" y="90" `
+ 'width="1100" height="640" fill="none" stroke="#b07d53" stroke-width="40"/><path '
+ 'd="M970 90 L970 730" stroke="#b07d53" stroke-width="24"/>' + rect('#b07d53', 730, 60)
+ girl(1750, 470, 1.6)),
};
Les deu escenes són SVG fets amb unes poques formes, el vaixell, la nena, la granota, el bernat, i un generador amb llavor per a les ones i les estrelles, de manera que totes les captures són idèntiques. Al PDF també són dibuixos vectorials.
La recepta completa
// ═══ Postext Cookbook · Nº 127 · A hiragana picture book, spaced by phrase ══════════ // https://postext.dev/en/cookbook/hiragana-picture-book // Code: MIT · Story and pictures: written and drawn for the recipe (CC BY 4.0) // Fonts: Klee One, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.16.1 // A picture book for small readers: hiragana only, a space between phrases, vertical. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, loadVerticalAlternates, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'es'; // @lang: the language of the frame; the story is Japanese in both editions const RECIPE = 'hiragana-picture-book'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: the paper boat's red, river blues, a warm paper const palette = { ink: '#2e2a26', // the text: a soft near-black boat: '#cf4430', // the boat, the title (4.6:1 on the paper) river: '#5d93ad', grass: '#86b665', muted: '#6e675f', // the colophon paper: '#fbf6ea', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'boat (defaults)', value: { hex: palette.boat, model: 'hex' } }, ]; // #endregion const HAND = 'Klee One'; // 教科書体: the hand a child learns to write kana from const ROUND = 'Zen Maru Gothic'; // the cover's title const [BODY, LEAD] = [20, 36]; // pt: large kana, a line gap of 0.8 em const PT = 25.4 / 72; // mm in a point const TRIM = { w: 260, h: 200 }; // mm: a landscape picture book (横長) const [CHARS, PICTURE, LINES] = [8, 14, 17]; // characters: text tier, picture tier; lines const ART = { w: LINES * LEAD * PT, h: PICTURE * BODY * PT }; // mm: 215.9 × 98.8 // #region answer: わかち書き — a space between phrases, and lines that break only there // The story is typed with an ordinary space (U+0020) after each phrase (文節). Japanese // text may break between any two kana, so the space alone does not keep a phrase whole: // keep-all drops the breaks between two letters and keeps those at a space, after 、。」 // and before 「. Kinsoku still applies. const phrases = { wordBreak: 'keep-all' }; // cjk.wordBreak const bodyText = { fontFamily: HAND, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), // Ragged: a justified line would stretch its spaces and its kana to fill the tier. textAlign: 'left', firstLineIndent: em(0), }; // #endregion // #region layout: the picture tier above, eight characters of text below it // In vertical text 'left' is the top of the page: the side column is a tier that takes // the pictures (floats), and the text runs in the tier under it, right to left. const layout = { layoutType: 'oneAndHalf', writingMode: 'vertical-rl', sideColumnRole: 'floats', sideColumnSide: 'left', sideColumnPercent: 58, gutterWidth: pt(2 * BODY), }; const cjk = { ...phrases, grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } }; const resourceTypes = [{ id: 'picture', name: 'え', shortLabel: 'え', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // pictures, no captions // #endregion // #region cover: the first picture across the page, the title down its sky // In the flow frame of a vertical page x runs down the sheet and y leftward from its right // edge; a box's width runs down the sheet. const at = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(down), y: mm(across) } }); const cover = { id: 'cover', numbered: false, toc: false, span: 'page', header: { elements: [] }, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: { enabled: true, minHeight: mm(TRIM.w - 40), slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', decorative: true, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: mm(TRIM.h), height: mm(TRIM.w) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontWeight: 700, fontSize: pt(60), lineHeight: 1, letterSpacing: pt(6), color: col('boat'), placement: at(22, 34) }, { kind: 'text', id: 'latin', content: '{attr.latin}', fontFamily: ROUND, fontWeight: 700, fontSize: pt(9), lineHeight: 1.4, color: col('ink'), placement: at(22, 62) }, ] } }, }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag) colorPalette, resourceTypes, page: { sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(14), bottom: mm(16), left: mm(22), right: mm(22), mirror: true }, }, layout, cjk, bodyText, headings: { fontFamily: ROUND, color: col('boat'), levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }] }, // the H1 break headingStyles: [cover], paragraphStyles: [ { id: 'end', fontFamily: ROUND, fontWeight: 700, color: col('boat'), textAlign: 'right', marginTop: pt(LEAD) }, // おしまい at the foot of its line { id: 'colophon', fontFamily: HAND, fontSize: pt(7.5), lineHeight: pt(LEAD), color: col('muted'), textAlign: 'left', marginTop: pt(LEAD) }, ], header: { elements: [] }, // a picture book has no running heads and no folios footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 65 línies · content.es.md
title: "かみの ふね" author: "Postext Cookbook" --- # かみの ふね {style="cover" latin="El barco de papel: un libro ilustrado en hiragana"} ::resource{id="rain"} あめが やんだ あさ、みいちゃんは あかい かみで ふねを おりました。 :::pagebreak ::resource{id="stream"} 「いって らっしゃい。」みいちゃんが そっと てを はなすと、ふねは すいすい ながれて いきました。 :::pagebreak ::resource{id="frog"} いしの うえで、かえるが ききました。「どこへ いくの。」「うみまで。」と、ふねは こたえました。 :::pagebreak ::resource{id="bridge"} はしの したを くぐると、ちいさな さかなが ついて きました。ぴちゃん、ぴちゃん。 :::pagebreak ::resource{id="heron"} かわは だんだん ひろく なりました。きしでは さぎが いっぽんあしで みおくって います。 :::pagebreak ::resource{id="dusk"} ゆうがた、そらが ももいろに そまりました。かぜの なかに、うみの においが します。 :::pagebreak ::resource{id="night"} よるに なりました。おつきさまが みずに うつって、ふねの みちを てらして くれました。 :::pagebreak ::resource{id="sea"} あさ、ふねは ひろい うみに でました。かもめが「ようこそ。」と いいました。 :::pagebreak ::resource{id="window"} とおくの まちで、みいちゃんも まどを あけて、うみの ほうを みて いました。 :::paragraphs{style="end"} おしまい ::: :::paragraphs{style="colophon"} Cuento e ilustraciones escritos y dibujados para esta receta (CC BY 4.0) · Compuesto en Klee One y Zen Maru Gothic (SIL OFL) :::`; // content.<lang>.md, a space after each phrase // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Klee One': ['400'], 'Zen Maru Gothic': ['700'] }; // #region art: ten scenes drawn in code, a seeded hand for the waves and the stars const W = 2186; // the art's width in its own units; its height is 1000 (ART is 2.186 : 1) const COVER = Math.round((W * TRIM.h) / TRIM.w); // 1682: the cover is the page's shape const P = palette; function mulberry32(seed) { return () => { 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 svgDoc = (body, h = 1000, w = W) => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const rect = (fill, y = 0, h = 1000, x = 0, w = W) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`; const wave = (y, amp, fill, seed, h = 1000, w = W) => { // a band whose top edge undulates const r = mulberry32(seed); let d = `M0 ${h} L0 ${y}`; for (let x = 0; x <= w; x += 120) d += ` Q${x + 60} ${y + (r() - 0.5) * 2 * amp} ${x + 120} ${y}`; return `<path d="${d} L${w} ${h} Z" fill="${fill}"/>`; }; const ripples = (y0, y1, n, seed, stroke = '#ffffff') => { const r = mulberry32(seed); return Array.from({ length: n }, () => { const x = r() * (W - 160) + 40; const y = y0 + r() * (y1 - y0); const l = 40 + r() * 70; return `<path d="M${x} ${y} q${l / 2} -14 ${l} 0" fill="none" stroke="${stroke}" ` + 'stroke-width="7" stroke-linecap="round" opacity=".7"/>'; }).join(''); }; const boat = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">` + `<path d="M-95 0 L95 0 L66 42 L-66 42 Z" fill="${P.boat}"/>` + `<path d="M-70 0 L0 -88 L70 0 Z" fill="#e0624c"/><path d="M0 -88 L0 0" stroke="#a83322" ` + 'stroke-width="4"/></g>'; const girl = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">` + '<path d="M-14 120 L-14 170 M14 120 L14 170" stroke="#2e2a26" stroke-width="9" ' + 'stroke-linecap="round"/><path d="M0 40 L-55 130 L55 130 Z" fill="#e7a83c"/>' + '<circle cx="0" cy="0" r="40" fill="#f6d8c2"/><path d="M-42 4 Q-44 -46 0 -46 Q44 -46 42 4 ' + 'L30 -12 Q0 -30 -30 -12 Z" fill="#2e2a26"/><circle cx="-14" cy="6" r="4" fill="#2e2a26"/>' + '<circle cx="14" cy="6" r="4" fill="#2e2a26"/></g>'; const frog = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">` + '<ellipse cx="0" cy="40" rx="130" ry="44" fill="#a9a39a"/>' + '<ellipse cx="0" cy="0" rx="62" ry="42" fill="#5f9e47"/><circle cx="-34" cy="-38" r="20" ' + 'fill="#5f9e47"/><circle cx="34" cy="-38" r="20" fill="#5f9e47"/><circle cx="-34" cy="-40" ' + 'r="9" fill="#2e2a26"/><circle cx="34" cy="-40" r="9" fill="#2e2a26"/>' + '<path d="M-26 6 Q0 22 26 6" fill="none" stroke="#2e2a26" stroke-width="5"/></g>'; const fish = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">` + '<ellipse cx="0" cy="0" rx="42" ry="20" fill="#e8893a"/><path d="M38 0 L72 -22 L72 22 Z" ' + 'fill="#e8893a"/><circle cx="-22" cy="-4" r="5" fill="#2e2a26"/></g>'; const heron = (x, y, s = 1) => `<g transform="translate(${x} ${y}) scale(${s})">` + '<path d="M0 60 L0 230" stroke="#6e675f" stroke-width="8"/>' + '<ellipse cx="10" cy="40" rx="70" ry="38" fill="#ffffff" stroke="#c9c2b8" stroke-width="4"/>' + '<path d="M-48 30 Q-80 -40 -40 -110" fill="none" stroke="#ffffff" stroke-width="20" ' + 'stroke-linecap="round"/><circle cx="-40" cy="-114" r="20" fill="#ffffff"/>' + '<path d="M-58 -116 L-118 -104 L-58 -104 Z" fill="#e3b33c"/><circle cx="-44" cy="-118" ' + 'r="4" fill="#2e2a26"/></g>'; const gull = (x, y, s = 1) => `<path d="M${x - 50 * s} ${y} q${25 * s} ${-30 * s} ${50 * s} 0 ` + `q${25 * s} ${-30 * s} ${50 * s} 0" fill="none" stroke="#2e2a26" stroke-width="7" ` + 'stroke-linecap="round"/>'; const disc = (x, y, r, fill) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}"/>`; const stars = (n, seed) => { const r = mulberry32(seed); return Array.from({ length: n }, () => disc(r() * W, r() * 420, 3 + r() * 5, '#f7e7a8')).join(''); }; const hills = (y, fill, seed) => wave(y, 60, fill, seed); const SCENES = { // The cover is the whole page, 260 × 200 mm: 2186 × 1682 units. The sky on the right // takes the title. cover: svgDoc(rect('#d9ecf2', 0, COVER) + disc(560, 260, 110, '#f6d27a') + wave(980, 70, P.grass, 3, COVER) + wave(1180, 24, P.river, 4, COVER) + ripples(1250, 1640, 22, 5) + boat(1000, 1330, 2.2), COVER), rain: svgDoc(rect('#e6eef0') + ripples(60, 360, 10, 7, '#9fb9c4') + rect('#f1e3c8', 640, 360) + `<rect x="760" y="560" width="660" height="90" rx="12" fill="#b07d53"/>` + girl(1260, 380, 1.4) + boat(880, 520, 0.9)), stream: svgDoc(rect('#e3f0f4') + hills(420, P.grass, 11) + wave(600, 22, P.river, 12) + ripples(650, 960, 14, 13) + girl(1800, 300, 1.5) + boat(1300, 700, 1)), frog: svgDoc(rect('#e3f0f4') + hills(380, P.grass, 21) + wave(560, 22, P.river, 22) + ripples(620, 960, 14, 23) + frog(1480, 600, 1.6) + boat(820, 760, 1)), bridge: svgDoc(rect('#e3f0f4') + hills(400, P.grass, 31) + wave(540, 20, P.river, 32) + '<path d="M300 560 Q1093 120 1886 560" fill="none" stroke="#9a6b45" stroke-width="60"/>' + ripples(600, 960, 12, 33) + boat(1093, 720, 1) + fish(1450, 860, 1.1) + fish(1680, 800, 0.9) + fish(1300, 930, 0.8)), heron: svgDoc(rect('#e3f0f4') + hills(300, '#a7cc8a', 41) + wave(420, 16, '#7aaec4', 42) + ripples(480, 960, 18, 43) + heron(1700, 230, 1.3) + boat(700, 700, 0.8)), dusk: svgDoc(rect('#f4c9b5') + disc(500, 520, 110, '#f29a5c') + hills(470, '#c79c87', 51) + wave(560, 18, '#c98f8a', 52) + ripples(600, 960, 14, 53, '#f6ddd0') + boat(1300, 760, 0.9)), night: svgDoc(rect('#2f3e5c') + stars(40, 61) + disc(1500, 200, 80, '#f7e7a8') + hills(470, '#253247', 62) + wave(560, 14, '#3f5878', 63) + '<path d="M1460 600 L1540 600 L1580 980 L1420 980 Z" fill="#f7e7a8" opacity=".35"/>' + ripples(600, 960, 12, 64, '#8aa3c2') + boat(900, 760, 0.9)), sea: svgDoc(rect('#d6ecf4') + disc(1700, 200, 70, '#f6d27a') + wave(420, 8, '#4f88a6', 71) + ripples(470, 960, 22, 72) + gull(900, 200, 1.2) + gull(1150, 140, 0.9) + boat(700, 650, 0.8)), window: svgDoc(rect('#efe2c8') + rect('#d6ecf4', 90, 640, 420, 1100) + rect('#4f88a6', 470, 260, 420, 1100) + boat(1250, 500, 0.4) + `<rect x="420" y="90" ` + 'width="1100" height="640" fill="none" stroke="#b07d53" stroke-width="40"/><path ' + 'd="M970 90 L970 730" stroke="#b07d53" stroke-width="24"/>' + rect('#b07d53', 730, 60) + girl(1750, 470, 1.6)), }; // #endregion const pictures = Object.keys(SCENES).map((id) => ({ id, typeId: 'picture', kind: 'svg', createdAt: 0, updatedAt: 0, placement: { span: 'side' }, svg: { fileId: `${id}.svg`, width: W, height: id === 'cover' ? COVER : 1000 } })); // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadCjkFonts({ [HAND]: FONTS[HAND] }, markdown, { vertical: true }); await loadCjkFonts({ [ROUND]: FONTS[ROUND] }, 'かみのふねおしまい', { vertical: true }); await Promise.all(Object.entries(SCENES).map(([id, svg]) => loadSvg(`${id}.svg`, svg))); const doc = await buildWithFonts( () => buildDocument({ markdown, resources: pictures }, config()), markdown); showBook(doc, { title: t({ en: 'A hiragana picture book', es: 'Un libro ilustrado en hiragana' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, images, cjk: igual a totes les receptes · 488 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 · 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 · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook ─ // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to buildWithFonts gets * the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files that * hold the characters its pages set (`request.codePoints`); any other * family gets the latin file fontsourceProvider fetches (the "pdf" block) * and, when the face sets letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); 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.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them. Latin comes first: postext-pdf draws a character from the first * file that has it, as the browser takes a character both files hold from * latin. A face Fontsource ships without latin-ext, or whose file does * not come, gets latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); 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.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); return ext ? [latin, ext] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] 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); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /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
#Justifica les línies
Justificada, cada línia arriba al peu de la franja: primer s'estiren els espais entre frases, fins a mig quadratí, i després els buits entre els kana (JLReq §3.8). Una línia curta de kana de 20 pt mostra llavors buits amples; per això la recepta deixa les línies en bandera.
- textAlign: 'left', firstLineIndent: em(0),
+ textAlign: 'justify', firstLineIndent: em(0),Errors freqüents
Error freqüent
Etiqueta un text japonès 'ja', mai zh-Hans ni LANG
Les edicions d'una recepta són en i es, però una mostra japonesa és japonesa en totes dues: `locale: LANG` l'etiquetaria com a anglès o castellà, i una etiqueta xinesa la compondria amb les regles xineses (puntuació Kaiming, kana petit lliure de començar línia, 图 en lloc de 図, formes xineses dels caràcters al PDF). Escriu 'ja': tria la regió del Japó (tall i puntuació de la JLReq, punts de sèsam, repartiment dels furigana, etiquetes 図 i 表) i desactiva la partició de mots. El lint falla un text amb kana sota una etiqueta zh o ko. Tall de línies japonès (kinsoku) →
Error freqüent
Compon el japonès amb una font japonesa
Noto Serif SC i TC tenen kana, però dibuixen els kanji amb formes xineses (直, 骨 i 角 canvien) i el kana amb un disseny xinès. Compon el text en Noto Serif JP o Shippori Mincho B1 i els títols en Noto Sans JP, carregades amb loadCjkFonts. Els fitxers japonesos de Fontsource no tenen hentaigana ni altres kana històrics (U+1B000–1B16F): loadCjkFonts hi falla i el PDF imprimeix caixes, així que escriu el kana modern o porta als recursos de la recepta una font que els tingui. Shippori Mincho tampoc no té les vocals amb macró ō i ū: compon el rōmaji amb una font llatina. Kana, kanji i rōmaji →
Error freqüent
Les fonts xineses es carreguen per fragments, amb el bloc cjk
Fontsource serveix una família xinesa, japonesa o coreana en un centenar de fitxers per pes, cadascun amb un interval de caràcters. loadFonts només baixa el fitxer latin, així que a la pantalla els caràcters xinesos surten d'una font del sistema i es mesuren malament, i fontsourceProvider lliura al PDF aquest fitxer latin, que els imprimeix com a caixes buides. Afegeix el bloc cjk del kit, crida loadCjkFonts(FONTS, markdown) després de loadFonts (una vegada per veu, amb el text que compon, si el llibre fa servir diverses fonts xineses) i passa a renderToPdf fontProvider: cjkPdfProvider: tots dos agafen els fitxers que contenen els caràcters del text. Fonts xineses, japoneses i coreanes →
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 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 →
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 →
keep-allnomés conserva els talls que el text ja porta: Postext no busca les frases, així que els espais els escriu l'autor. Una frase més llarga que la línia es continua tallant per dins, en l'últim punt que permet el kinsoku.- El xinès no separa mai les paraules, i el llibre de lectura xinès Un llibre de lectura vertical amb zhuyin a la dreta ajuda els lectors amb lectures als caràcters; un llibre il·lustrat japonès no fa servir kanji, així que no necessita furigana, i els espais entre frases guien la lectura.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- かみの ふね (The Paper Boat), a picture-book story in hiragana written for this recipe · Postext Cookbook · CC BY 4.0
- Fonts
- Klee One (SIL OFL 1.1) · Zen Maru Gothic (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)


