Saltar al contenido principal
Receta número 151

Recetario · Capítulo 11 · Cómic y manga

Dos tiras yonkoma en una página

Una página de tiras cómicas de cuatro viñetas: dos columnas de cuatro, la tira de la derecha primero y el título de cada tira en un recuadro negro.

En esta página
Género
Cómics
Salida
Canvas · PDF
Postext
Probada con Postext 1.21.0
Requiere ≥ 1.21.0 · postext-pdf ≥ 1.21.0
Licencia
Actualizada el 7 oct 2026
Código MIT · Texto CC BY 4.0

pp. 1–1 · 1–2 de 2

  • Formato 148 × 210 mm
  • 2 columnas, medianil de 7,5 mm
  • Zen Antique 9/15
  • 2 páginas
  • Nivel
  • Postext 1.21.0
  • Compuesto en 412 ms
  • 134 líneas de código

En pocas palabras

Una página de tiras cómicas japonesas de cuatro viñetas, una junto a otra como en una revista de manga: se lee la tira de la derecha de arriba abajo y después la de la izquierda.

Lo que vas a componer

Una página de una antología de yonkoma, ねこ喫茶まどろみ (Café Modorra), escrita para esta receta y dibujada en un estilo chibi suave: Mugi lleva un café de gatos con una gata blanca, un gato negro y una gata tricolor. El yonkoma, la tira de cuatro viñetas, es la tira cómica de periódico puesta de pie: cuatro viñetas iguales apiladas de arriba abajo, planteamiento, desarrollo, giro y remate. Una página de revista lleva dos, una al lado de otra, y como la página se abre por la derecha, la tira de la derecha va primero. La página es A5, 148 × 210 mm, con el original japonés en un lado del pliego y la edición en la lengua de esta página en el otro (la inglesa en la página japonesa).

Esta receta responde a

  • ¿Cómo compongo una tira yonkoma de cuatro viñetas?

La respuesta corta

script.js · líneas 44–62en el código completo
// content.<lang>.md splits the page `* [* / * / * / *] | * [* / * / * / *]`: two equal
// columns, each cut into four equal panels. The reading direction lays the first column
// out on the right, so the strip that opens the page is the right-hand one, as in a
// yonkoma magazine, and each strip still reads top to bottom. The gutter between the
// strips is wider than the gutters between their panels, so the eye drops down a strip
// before it crosses to the next.
const comics = (lang) => ({
  artDirection: 'rtl', // the right strip first, in every edition
  mirrorArt: false,
  gutter: { horizontal: mm(3), vertical: mm(9) }, // 3 mm between panels, 9 mm between strips
  panel: { borderWidth: pt(0.9), borderColor: col('ink'), background: col('paper') },
  lettering: {
    fontFamily: EDITIONS[lang].faces[0], color: col('ink'),
    fontSize: pt(['ja', 'zh'].includes(lang) ? 8 : 7), // a yonkoma letters smaller than a story
    writingMode: lang === 'zh' ? 'vertical' : 'auto',
    maxColumnChars: 6, // short columns: a yonkoma panel is wider than it is tall
  },
  balloonStyles: balloons(),
});

Ingredientes

Tipografía
Zen Antique, Dela Gothic One, Comic Neue, Bangers, Noto Sans SC, ZCOOL KuaiLe, Playpen Sans Arabic, Lalezar (SIL OFL 1.1)
Recursos
  • neko-bowls.jpg
  • neko-door.jpg
  • neko-guest.jpg
  • neko-keyboard.jpg
  • neko-laptop.jpg
  • neko-nap.jpg
  • neko-onebowl.jpg
  • neko-toast.jpg
  • Mugi abre la puerta del café de gatos; tres gatos esperan dentro (Generated With Diffusion Models, original)
  • Mugi pone los cuencos de comida a los gatos (Generated With Diffusion Models, original)
  • Tres gatos comiendo del mismo cuenco (Generated With Diffusion Models, original)
  • Los gatos miran la tostada de Mugi (Generated With Diffusion Models, original)
  • Un oficinista cansado llega al café (Generated With Diffusion Models, original)
  • Los tres gatos dormidos en un cojín (Generated With Diffusion Models, original)
  • El oficinista abre el portátil (Generated With Diffusion Models, original)
  • Los gatos sentados en el teclado, el oficinista llorando de alegría (Generated With Diffusion Models, original)

Elaboración

#1 · Divide la página en dos columnas de cuatro

El código es la respuesta corta de arriba. Toda la página es un :::page con split="* [* / * / * / *] | * [* / * / * / *]": dos columnas iguales, cada una cortada en cuatro franjas iguales. Las ocho líneas ::panel llenan las celdas en orden de lectura, así que las cuatro primeras son de la primera tira, y con la lectura de derecha a izquierda la primera columna se coloca a la derecha (división de la página). La calle entre las tiras mide 9 mm y la calle entre viñetas, 3 mm, así que la mirada baja por una tira antes de pasar a la siguiente.

#2 · Pon el título de cada tira en su primera viñeta

script.js · líneas 66–73en el código completo
const balloons = () => [
  { id: 'speech', fill: col('paper'), stroke: col('ink'), strokeWidth: pt(0.6), roundness: 2.4 },
  { id: 'shout', fill: col('paper'), stroke: col('ink'), burstPoints: 16 },
  // caption{title}: the strip's title, white on black in the first panel's corner
  { id: 'title', shape: 'rectangle', fill: col('ink'), stroke: col('ink'), color: col('paper'),
    bold: true, tail: 'none', position: 'top-start', butt: true, align: 'center' },
  { id: 'sfx', color: col('ink'), haloColor: col('paper'), fontScale: 1.8 }, // chibi: small effects
];

Un yonkoma tiene título, y las antologías lo imprimen en un recuadro a la cabeza de la tira. Aquí el título es una línea del guion, caption{title}: 朝ごはん, y title es un estilo de bocadillo propio: blanco sobre negro, pegado al borde de la viñeta en la esquina superior del lado de inicio, que en este libro es la derecha. Se rotula en cada edición como cualquier otra línea, y en la página española los mismos recuadros dicen El desayuno y Terapia.

#3 · Marca también a los gatos como personajes

script.js · líneas 206–320en el código completo
const cat = (id, x, y, fx, fy, w = 0.12, h = 0.15) => ({ id, x, y,
  face: { x: fx, y: fy, width: w, height: h } });
const nose = (id, x, y) => ({ id, x, y, head: { x, y: y - 0.04 } }); // cats asleep or eating
const ART = { // fractions of the picture: the same in every language
  door: {
    alt: t({ en: "Mugi unlocks the café's glass door; the white, black and calico cats wait.",
      es: 'Mugi abre la puerta de cristal del café; dentro esperan los tres gatos.',
      ca: 'La Mugi obre la porta de vidre del cafè; a dins esperen els tres gats.',
      zh: '麦打开猫咖的玻璃门,三只猫在里面等着。',
      ar: 'موغي تفتح باب المقهى الزجاجي، والقطط الثلاث تنتظر في الداخل.',
      ja: '猫カフェのガラス戸を開けるむぎ。中で3匹の猫が待っている。',
      pt: 'Mugi abre a porta de vidro do café; lá dentro, os três gatos esperam.' }),
  safeArea: { x: 0.09, y: 0.19, width: 0.81, height: 0.52 },
  anchors: [{ id: 'mugi', x: 0.205, y: 0.385, head: { x: 0.17, y: 0.25 },
    face: { x: 0.1, y: 0.2, width: 0.2, height: 0.23 } },
  cat('shiro', 0.5, 0.66, 0.44, 0.55, 0.13), cat('kuro', 0.66, 0.62, 0.61, 0.52, 0.12, 0.14),
  cat('mike', 0.83, 0.645, 0.77, 0.53, 0.12, 0.16)] },
  bowls: {
    alt: t({ en: 'Mugi sets out full food bowls in front of the three cats.',
      es: 'Mugi pone cuencos llenos delante de los tres gatos.',
      ca: 'La Mugi posa bols plens de menjar davant dels tres gats.',
      zh: '麦把装满猫粮的碗摆在三只猫面前。',
      ar: 'موغي تضع صحون طعام ممتلئة أمام القطط الثلاث.',
      ja: '3匹の猫の前に、ご飯を盛った器を並べるむぎ。',
      pt: 'Mugi põe tigelas cheias de comida na frente dos três gatos.' }),
  safeArea: { x: 0.16, y: 0.26, width: 0.74, height: 0.65 },
  anchors: [{ id: 'mugi', x: 0.27, y: 0.465, head: { x: 0.25, y: 0.3 },
    face: { x: 0.17, y: 0.26, width: 0.16, height: 0.24 } },
  cat('shiro', 0.37, 0.63, 0.31, 0.52, 0.13, 0.16), cat('kuro', 0.6, 0.64, 0.55, 0.52, 0.11, 0.16),
  cat('mike', 0.82, 0.62, 0.76, 0.52, 0.12, 0.14)] },
  onebowl: {
    alt: t({ en: 'All three cats push their heads into the same bowl; the others stay full.',
      es: 'Los tres gatos meten la cabeza en el mismo cuenco; los otros siguen llenos.',
      ca: 'Els tres gats fiquen el cap al mateix bol; els altres continuen plens.',
      zh: '三只猫把头挤进同一个碗里,另外两个碗还满着。',
      ar: 'القطط الثلاث تدسّ رؤوسها في الصحن نفسه، والصحنان الآخران ممتلئان.',
      ja: '3匹そろって同じ器に頭を突っこみ、ほかの器は手つかず。',
      pt: 'Os três gatos enfiam a cabeça na mesma tigela; as outras continuam cheias.' }),
  safeArea: { x: 0.05, y: 0.27, width: 0.88, height: 0.66 },
  anchors: [nose('shiro', 0.33, 0.66), nose('kuro', 0.5, 0.6), nose('mike', 0.66, 0.68),
    { id: 'sfx', x: 0.5, y: 0.3 }] },
  toast: {
    alt: t({ en: 'Mugi bites her toast while the three cats stare at it with shining eyes.',
      es: 'Mugi muerde su tostada mientras los tres gatos la miran con ojos brillantes.',
      ca: 'La Mugi mossega la torrada mentre els tres gats la miren amb ulls brillants.',
      zh: '麦咬着吐司,三只猫眼睛发亮地盯着。',
      ar: 'موغي تقضم خبزها المحمّص والقطط الثلاث تحدّق فيه بعيون لامعة.',
      ja: 'トーストをかじるむぎを、3匹がきらきらした目で見つめる。',
      pt: 'Mugi morde a torrada enquanto os três gatos olham para ela com olhos brilhando.' }),
  safeArea: { x: 0.09, y: 0.22, width: 0.85, height: 0.56 },
  avoid: [{ x: 0.6, y: 0.52, width: 0.15, height: 0.1 }],
  anchors: [{ id: 'mugi', x: 0.8, y: 0.5, head: { x: 0.85, y: 0.32 },
    face: { x: 0.75, y: 0.25, width: 0.2, height: 0.33 } },
  cat('shiro', 0.17, 0.71, 0.1, 0.6, 0.14, 0.16), cat('kuro', 0.32, 0.73, 0.26, 0.62, 0.12, 0.14),
  cat('mike', 0.46, 0.66, 0.4, 0.56, 0.12, 0.14)] },
  guest: {
    alt: t({ en: 'A tired office worker walks in, arms out; Mugi greets him from the counter.',
      es: 'Un oficinista cansado entra con los brazos abiertos; Mugi lo saluda desde la barra.',
      ca: 'Un oficinista cansat entra amb els braços oberts; la Mugi el saluda des de la barra.',
      zh: '一个疲惫的上班族张着双臂走进来,麦在柜台后招呼他。',
      ar: 'موظف متعب يدخل فاتحًا ذراعيه، وموغي ترحّب به من خلف المنضدة.',
      ja: '両手を広げて入ってくる疲れた会社員と、カウンターから迎えるむぎ。',
      pt: 'Um funcionário de escritório cansado entra de braços abertos; Mugi o recebe do '
        + 'balcão.' }),
  safeArea: { x: 0.13, y: 0.14, width: 0.74, height: 0.51 },
  anchors: [{ id: 'kyaku', x: 0.31, y: 0.42, head: { x: 0.28, y: 0.3 },
    face: { x: 0.19, y: 0.22, width: 0.21, height: 0.26 } },
  { id: 'mugi', x: 0.76, y: 0.39, head: { x: 0.76, y: 0.28 },
    face: { x: 0.68, y: 0.2, width: 0.18, height: 0.25 } }] },
  nap: {
    alt: t({ en: 'The three cats asleep in one pile on a round cushion.',
      es: 'Los tres gatos dormidos en un montón sobre un cojín redondo.',
      ca: 'Els tres gats adormits en un munt sobre un coixí rodó.',
      zh: '三只猫挤成一团睡在圆垫子上。',
      ar: 'القطط الثلاث نائمة كومةً واحدة على وسادة مستديرة.',
      ja: '丸いクッションの上で、ひとかたまりになって眠る3匹。',
      pt: 'Os três gatos dormem amontoados numa almofada redonda.' }),
  safeArea: { x: 0.27, y: 0.21, width: 0.52, height: 0.55 },
  anchors: [nose('shiro', 0.4, 0.42), nose('kuro', 0.41, 0.7), nose('mike', 0.72, 0.58),
    { id: 'sfx', x: 0.3, y: 0.25 }] },
  laptop: {
    alt: t({ en: 'The office worker sighs at a café table and opens his laptop.',
      es: 'El oficinista suspira en una mesa del café y abre el portátil.',
      ca: 'L’oficinista sospira en una taula del cafè i obre el portàtil.',
      zh: '上班族在咖啡桌前叹了口气,打开笔记本电脑。',
      ar: 'الموظف يتنهّد إلى طاولة في المقهى ويفتح حاسوبه.',
      ja: 'カフェのテーブルでため息をつき、ノートパソコンを開く会社員。',
      pt: 'O funcionário suspira numa mesa do café e abre o notebook.' }),
  safeArea: { x: 0.22, y: 0.25, width: 0.56, height: 0.55 },
  anchors: [{ id: 'kyaku', x: 0.45, y: 0.62, head: { x: 0.39, y: 0.4 },
    face: { x: 0.3, y: 0.3, width: 0.22, height: 0.38 } }] },
  keyboard: {
    alt: t({ en: 'The three cats sit on the keyboard; the worker cries tears of joy.',
      es: 'Los tres gatos se sientan en el teclado; el oficinista llora de alegría.',
      ca: 'Els tres gats seuen damunt del teclat; l’oficinista plora d’alegria.',
      zh: '三只猫坐在键盘上,上班族喜极而泣。',
      ar: 'القطط الثلاث تجلس على لوحة المفاتيح، والموظف يبكي من الفرح.',
      ja: 'キーボードの上に座る3匹と、うれし泣きする会社員。',
      pt: 'Os três gatos sentam no teclado; o funcionário chora de alegria.' }),
  safeArea: { x: 0.07, y: 0.1, width: 0.81, height: 0.78 },
  anchors: [{ id: 'kyaku', x: 0.29, y: 0.42, head: { x: 0.25, y: 0.2 },
    face: { x: 0.15, y: 0.1, width: 0.27, height: 0.4 } },
  cat('shiro', 0.42, 0.57, 0.36, 0.48, 0.13, 0.14), cat('kuro', 0.59, 0.6, 0.53, 0.5, 0.12, 0.14),
  cat('mike', 0.74, 0.62, 0.68, 0.52, 0.12, 0.14)] },
};
const panels = () => Promise.all([
  comicPanel('door', asset('neko-door.jpg'), ART.door),
  comicPanel('bowls', asset('neko-bowls.jpg'), ART.bowls),
  comicPanel('onebowl', asset('neko-onebowl.jpg'), ART.onebowl),
  comicPanel('toast', asset('neko-toast.jpg'), ART.toast),
  comicPanel('guest', asset('neko-guest.jpg'), ART.guest),
  comicPanel('nap', asset('neko-nap.jpg'), ART.nap),
  comicPanel('laptop', asset('neko-laptop.jpg'), ART.laptop),
  comicPanel('keyboard', asset('neko-keyboard.jpg'), ART.keyboard),
]);

Los gatos hablan, kuro: にゃ, así que cada imagen marca su boca y su cara igual que las de Mugi. Dos funciones pequeñas acortan la lista: cat() para un gato que mira al lector y nose() para uno con la cara metida en el cuenco o en el cojín. En la tercera viñeta Mugi no sale en el dibujo, y su línea mugi{tail=top} lleva el rabillo al borde superior.

#4 · Carga las fuentes de cada edición con sus palabras

script.js · líneas 324–336en el código completo
const SFX = /^sfx[^::\n]*[::](.*)$/gm; // sound-effect lines: the second face sets them
async function loadEdition(lang) {
  const text = TEXT[lang];
  const [face, sfx] = EDITIONS[lang].faces;
  const faces = { [face]: FONTS[face], [sfx]: FONTS[sfx] };
  if (['ja', 'zh'].includes(lang)) { // CJK: only the files that hold these characters
    const effects = [...text.matchAll(SFX)].map((m) => m[1]).join('');
    await loadCjkFonts({ [face]: FONTS[face] }, text.replace(SFX, ''), { vertical: true });
    await loadCjkFonts({ [sfx]: FONTS[sfx] }, effects, { vertical: true });
  }
  if (lang === 'ar') await loadArabicFonts(faces, text);
  await loadComicFonts(faces, text); // the bold and italic the faces do not ship
}

Cada fuente recibe las palabras que compone: Zen Antique los bocadillos y Dela Gothic One las onomatopeyas (ガツガツ, じーっ, すやぁ, ずしっ). Una viñeta de yonkoma es más ancha que alta, así que las columnas japonesas se cortan a los seis caracteres (maxColumnChars: 6) y la rotulación es medio punto más pequeña que en una página de historia.

#5 · Pon el original junto a cada edición

script.js · líneas 341–350en el código completo
const FACING = LANG === 'ja' ? 'en' : LANG; // Japanese readers see the English edition
const TEXT = { ja: original, [FACING]: LANG === 'ja' ? english : markdown };
await loadFonts(FONTS, Object.values(TEXT).join('\n'));
const resources = await panels();
const build = async (lang) => { // the same pictures and page; this language's lettering
  await loadEdition(lang);
  return buildWithFonts(() => buildDocument({ markdown: TEXT[lang], resources,
    continuation: { pageIndexOffset: lang === 'ja' ? 1 : 2 } }, edition(lang)), TEXT[lang]);
};
const docs = [await build('ja'), await build(FACING)];

Cada idioma del sitio rotula las tiras en su propio archivo, frente al original japonés al otro lado del pliego; la página japonesa se empareja con la edición inglesa. En todos los idiomas se sigue leyendo primero la tira de la derecha.

La receta completa

Sandbox
// ═══ Postext Cookbook · Nº 151 · Two yonkoma strips on a page ═════════════════════════
// https://postext.dev/en/cookbook/yonkoma
// Code: MIT · Story: written for the recipe (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Zen Antique, Comic Neue and six more (SIL OFL 1.1) · Needs postext ≥ 1.21.0
// A page of a cat-café yonkoma: two four-panel strips side by side, the right one first.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: this edition's language; the Japanese original faces it
const RECIPE = 'yonkoma';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black ink on a warm white, one screen-tone grey
const palette = {
  ink: '#151413', // panel borders, balloon outlines, the lettering
  paper: '#fdfcf9', // the page and the balloons
  tone: '#e8e6e1', // the inner-voice box, a light screen tone
  muted: '#6f6b64',
  accent: '#a8322b', // the cover red (the Folio case in recipe.json)
};
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: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion

// The engine's lettering and sound-effect faces per language (defaultComicFont and
// defaultComicSfxFont), written out so FONTS and the PDF know them.
const EDITIONS = {
  ja: { locale: 'ja', faces: ['Zen Antique', 'Dela Gothic One'] },
  en: { locale: 'en-us', faces: ['Comic Neue', 'Bangers'] },
  es: { locale: 'es', faces: ['Comic Neue', 'Bangers'] },
  ca: { locale: 'ca', faces: ['Comic Neue', 'Bangers'] },
  zh: { locale: 'zh-Hans', faces: ['Noto Sans SC', 'ZCOOL KuaiLe'] },
  ar: { locale: 'ar', faces: ['Playpen Sans Arabic', 'Lalezar'] },
  pt: { locale: 'pt-BR', faces: ['Comic Neue', 'Bangers'] },
};

// #region answer: two columns of four, read from the right
// content.<lang>.md splits the page `* [* / * / * / *] | * [* / * / * / *]`: two equal
// columns, each cut into four equal panels. The reading direction lays the first column
// out on the right, so the strip that opens the page is the right-hand one, as in a
// yonkoma magazine, and each strip still reads top to bottom. The gutter between the
// strips is wider than the gutters between their panels, so the eye drops down a strip
// before it crosses to the next.
const comics = (lang) => ({
  artDirection: 'rtl', // the right strip first, in every edition
  mirrorArt: false,
  gutter: { horizontal: mm(3), vertical: mm(9) }, // 3 mm between panels, 9 mm between strips
  panel: { borderWidth: pt(0.9), borderColor: col('ink'), background: col('paper') },
  lettering: {
    fontFamily: EDITIONS[lang].faces[0], color: col('ink'),
    fontSize: pt(['ja', 'zh'].includes(lang) ? 8 : 7), // a yonkoma letters smaller than a story
    writingMode: lang === 'zh' ? 'vertical' : 'auto',
    maxColumnChars: 6, // short columns: a yonkoma panel is wider than it is tall
  },
  balloonStyles: balloons(),
});
// #endregion

// #region balloons: round speech, and a black box for each strip's title
const balloons = () => [
  { id: 'speech', fill: col('paper'), stroke: col('ink'), strokeWidth: pt(0.6), roundness: 2.4 },
  { id: 'shout', fill: col('paper'), stroke: col('ink'), burstPoints: 16 },
  // caption{title}: the strip's title, white on black in the first panel's corner
  { id: 'title', shape: 'rectangle', fill: col('ink'), stroke: col('ink'), color: col('paper'),
    bold: true, tail: 'none', position: 'top-start', butt: true, align: 'center' },
  { id: 'sfx', color: col('ink'), haloColor: col('paper'), fontScale: 1.8 }, // chibi: small effects
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  // Each edition's language, written out (gotcha: ja-locale-tag); the original is built
  // with 'ja' in every edition (edition('ja') below).
  locale: t({ ja: 'ja', en: 'en-us', es: 'es', ca: 'ca', zh: 'zh-Hans', ar: 'ar', pt: 'pt-BR' }),
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150, // A5, a yonkoma anthology
    binding: 'right', backgroundColor: col('paper'), // manga open from the right, in any language
    margins: { top: mm(15), bottom: mm(15), left: mm(14), right: mm(14), mirror: true },
  },
  bodyText: {
    fontFamily: 'Zen Antique', fontSize: pt(9), lineHeight: pt(15), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  },
  headings: { fontFamily: 'Zen Antique', color: col('ink'),
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }] }, // the H1 break
  header: { elements: [] }, // comic pages are all panels: no running heads, no folios
  footer: { elements: [] },
  comics: comics(LANG),
});
// Another edition changes the language and the faces; the strips and their order stay.
const edition = (lang) => ({ ...config(), locale: EDITIONS[lang].locale, comics: comics(lang) });

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 30 líneas · content.es.mdtitle: "Café Modorra" author: "Postext Cookbook" --- :::page{split="* [* / * / * / *] | * [* / * / * / *]"} ::panel{art=door} caption{title}: El desayuno mugi: ¡Buenos días a todos! kuro: Miau. ::panel{art=bowls} mugi: Un cuenco para cada uno, ¿eh? ::panel{art=onebowl} sfx: ÑAM ÑAM mugi{tail=top}: …¿Por qué los tres en el mismo? ::panel{art=toast} sfx: MIRADA FIJA mugi: ¡Si acabáis de comer! ::panel{art=guest} caption{title}: Terapia kyaku: Vengo a que me curen los gatos… mugi: ¡Bienvenido! ::panel{art=nap} sfx: zzz kyaku{tail=top}: …Están todos dormidos. ::panel{art=laptop} kyaku: Bueno, pues me pongo a trabajar… ::panel{art=keyboard} sfx{at="70% 80%" rotate=-10}: ¡PLOF! kyaku{shout}: ¡¡No puedo trabajar y soy feliz!! :::
`; // content.<lang>.md: this edition's lettering const original = String.raw`---
Muestra en Markdown · 30 líneas · content.ja.mdtitle: "ねこ喫茶まどろみ" author: "Postext Cookbook" --- :::page{split="* [* / * / * / *] | * [* / * / * / *]"} ::panel{art=door} caption{title}: 朝ごはん mugi: おはよー、みんな! kuro: にゃ ::panel{art=bowls} mugi: ひとり一個ずつね ::panel{art=onebowl} sfx: ガツガツ mugi{tail=top}: ……なんで一個に集まるの ::panel{art=toast} sfx: じーっ mugi: さっき食べたでしょ! ::panel{art=guest} caption{title}: いやし kyaku: 猫ちゃんに癒されに来ました…… mugi: いらっしゃいませー ::panel{art=nap} sfx: すやぁ kyaku{tail=top}: ……全員寝てる ::panel{art=laptop} kyaku: しかたない、仕事するか…… ::panel{art=keyboard} sfx{at="86% 84%" rotate=-10}: ずしっ kyaku{shout break}: 仕事できないけど幸せーっ!! :::
`; // content.ja.md, the original, in every edition const english = String.raw`---
Muestra en Markdown · 30 líneas · content.en.mdtitle: "Café Doze" author: "Postext Cookbook" --- :::page{split="* [* / * / * / *] | * [* / * / * / *]"} ::panel{art=door} caption{title}: Breakfast mugi: Morning, everyone! kuro: Mrrp. ::panel{art=bowls} mugi: One bowl each, okay? ::panel{art=onebowl} sfx: NOM NOM mugi{tail=top}: …Why all in the same one? ::panel{art=toast} sfx: STARE mugi: You just ate! ::panel{art=guest} caption{title}: Therapy kyaku: I'm here to be healed by cats… mugi: Welcome! ::panel{art=nap} sfx: zzz kyaku{tail=top}: …They're all asleep. ::panel{art=laptop} kyaku: Fine. I'll get some work done… ::panel{art=keyboard} sfx{at="70% 80%" rotate=-10}: THUMP kyaku{shout}: Can't work. So happy!! :::
`; // the Japanese edition faces the original with it // The same panels and speaker ids in every file; only the words change. // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Zen Antique': ['400'], 'Dela Gothic One': ['400'], 'Comic Neue': ['400', '700', '400i', '700i'], Bangers: ['400'], 'Noto Sans SC': ['400', '700'], 'ZCOOL KuaiLe': ['400'], 'Playpen Sans Arabic': ['400', '700'], Lalezar: ['400'], }; // #region art: eight chibi panels drawn for the recipe, speakers and faces marked once const cat = (id, x, y, fx, fy, w = 0.12, h = 0.15) => ({ id, x, y, face: { x: fx, y: fy, width: w, height: h } }); const nose = (id, x, y) => ({ id, x, y, head: { x, y: y - 0.04 } }); // cats asleep or eating const ART = { // fractions of the picture: the same in every language door: { alt: t({ en: "Mugi unlocks the café's glass door; the white, black and calico cats wait.", es: 'Mugi abre la puerta de cristal del café; dentro esperan los tres gatos.', ca: 'La Mugi obre la porta de vidre del cafè; a dins esperen els tres gats.', zh: '麦打开猫咖的玻璃门,三只猫在里面等着。', ar: 'موغي تفتح باب المقهى الزجاجي، والقطط الثلاث تنتظر في الداخل.', ja: '猫カフェのガラス戸を開けるむぎ。中で3匹の猫が待っている。', pt: 'Mugi abre a porta de vidro do café; lá dentro, os três gatos esperam.' }), safeArea: { x: 0.09, y: 0.19, width: 0.81, height: 0.52 }, anchors: [{ id: 'mugi', x: 0.205, y: 0.385, head: { x: 0.17, y: 0.25 }, face: { x: 0.1, y: 0.2, width: 0.2, height: 0.23 } }, cat('shiro', 0.5, 0.66, 0.44, 0.55, 0.13), cat('kuro', 0.66, 0.62, 0.61, 0.52, 0.12, 0.14), cat('mike', 0.83, 0.645, 0.77, 0.53, 0.12, 0.16)] }, bowls: { alt: t({ en: 'Mugi sets out full food bowls in front of the three cats.', es: 'Mugi pone cuencos llenos delante de los tres gatos.', ca: 'La Mugi posa bols plens de menjar davant dels tres gats.', zh: '麦把装满猫粮的碗摆在三只猫面前。', ar: 'موغي تضع صحون طعام ممتلئة أمام القطط الثلاث.', ja: '3匹の猫の前に、ご飯を盛った器を並べるむぎ。', pt: 'Mugi põe tigelas cheias de comida na frente dos três gatos.' }), safeArea: { x: 0.16, y: 0.26, width: 0.74, height: 0.65 }, anchors: [{ id: 'mugi', x: 0.27, y: 0.465, head: { x: 0.25, y: 0.3 }, face: { x: 0.17, y: 0.26, width: 0.16, height: 0.24 } }, cat('shiro', 0.37, 0.63, 0.31, 0.52, 0.13, 0.16), cat('kuro', 0.6, 0.64, 0.55, 0.52, 0.11, 0.16), cat('mike', 0.82, 0.62, 0.76, 0.52, 0.12, 0.14)] }, onebowl: { alt: t({ en: 'All three cats push their heads into the same bowl; the others stay full.', es: 'Los tres gatos meten la cabeza en el mismo cuenco; los otros siguen llenos.', ca: 'Els tres gats fiquen el cap al mateix bol; els altres continuen plens.', zh: '三只猫把头挤进同一个碗里,另外两个碗还满着。', ar: 'القطط الثلاث تدسّ رؤوسها في الصحن نفسه، والصحنان الآخران ممتلئان.', ja: '3匹そろって同じ器に頭を突っこみ、ほかの器は手つかず。', pt: 'Os três gatos enfiam a cabeça na mesma tigela; as outras continuam cheias.' }), safeArea: { x: 0.05, y: 0.27, width: 0.88, height: 0.66 }, anchors: [nose('shiro', 0.33, 0.66), nose('kuro', 0.5, 0.6), nose('mike', 0.66, 0.68), { id: 'sfx', x: 0.5, y: 0.3 }] }, toast: { alt: t({ en: 'Mugi bites her toast while the three cats stare at it with shining eyes.', es: 'Mugi muerde su tostada mientras los tres gatos la miran con ojos brillantes.', ca: 'La Mugi mossega la torrada mentre els tres gats la miren amb ulls brillants.', zh: '麦咬着吐司,三只猫眼睛发亮地盯着。', ar: 'موغي تقضم خبزها المحمّص والقطط الثلاث تحدّق فيه بعيون لامعة.', ja: 'トーストをかじるむぎを、3匹がきらきらした目で見つめる。', pt: 'Mugi morde a torrada enquanto os três gatos olham para ela com olhos brilhando.' }), safeArea: { x: 0.09, y: 0.22, width: 0.85, height: 0.56 }, avoid: [{ x: 0.6, y: 0.52, width: 0.15, height: 0.1 }], anchors: [{ id: 'mugi', x: 0.8, y: 0.5, head: { x: 0.85, y: 0.32 }, face: { x: 0.75, y: 0.25, width: 0.2, height: 0.33 } }, cat('shiro', 0.17, 0.71, 0.1, 0.6, 0.14, 0.16), cat('kuro', 0.32, 0.73, 0.26, 0.62, 0.12, 0.14), cat('mike', 0.46, 0.66, 0.4, 0.56, 0.12, 0.14)] }, guest: { alt: t({ en: 'A tired office worker walks in, arms out; Mugi greets him from the counter.', es: 'Un oficinista cansado entra con los brazos abiertos; Mugi lo saluda desde la barra.', ca: 'Un oficinista cansat entra amb els braços oberts; la Mugi el saluda des de la barra.', zh: '一个疲惫的上班族张着双臂走进来,麦在柜台后招呼他。', ar: 'موظف متعب يدخل فاتحًا ذراعيه، وموغي ترحّب به من خلف المنضدة.', ja: '両手を広げて入ってくる疲れた会社員と、カウンターから迎えるむぎ。', pt: 'Um funcionário de escritório cansado entra de braços abertos; Mugi o recebe do ' + 'balcão.' }), safeArea: { x: 0.13, y: 0.14, width: 0.74, height: 0.51 }, anchors: [{ id: 'kyaku', x: 0.31, y: 0.42, head: { x: 0.28, y: 0.3 }, face: { x: 0.19, y: 0.22, width: 0.21, height: 0.26 } }, { id: 'mugi', x: 0.76, y: 0.39, head: { x: 0.76, y: 0.28 }, face: { x: 0.68, y: 0.2, width: 0.18, height: 0.25 } }] }, nap: { alt: t({ en: 'The three cats asleep in one pile on a round cushion.', es: 'Los tres gatos dormidos en un montón sobre un cojín redondo.', ca: 'Els tres gats adormits en un munt sobre un coixí rodó.', zh: '三只猫挤成一团睡在圆垫子上。', ar: 'القطط الثلاث نائمة كومةً واحدة على وسادة مستديرة.', ja: '丸いクッションの上で、ひとかたまりになって眠る3匹。', pt: 'Os três gatos dormem amontoados numa almofada redonda.' }), safeArea: { x: 0.27, y: 0.21, width: 0.52, height: 0.55 }, anchors: [nose('shiro', 0.4, 0.42), nose('kuro', 0.41, 0.7), nose('mike', 0.72, 0.58), { id: 'sfx', x: 0.3, y: 0.25 }] }, laptop: { alt: t({ en: 'The office worker sighs at a café table and opens his laptop.', es: 'El oficinista suspira en una mesa del café y abre el portátil.', ca: 'L’oficinista sospira en una taula del cafè i obre el portàtil.', zh: '上班族在咖啡桌前叹了口气,打开笔记本电脑。', ar: 'الموظف يتنهّد إلى طاولة في المقهى ويفتح حاسوبه.', ja: 'カフェのテーブルでため息をつき、ノートパソコンを開く会社員。', pt: 'O funcionário suspira numa mesa do café e abre o notebook.' }), safeArea: { x: 0.22, y: 0.25, width: 0.56, height: 0.55 }, anchors: [{ id: 'kyaku', x: 0.45, y: 0.62, head: { x: 0.39, y: 0.4 }, face: { x: 0.3, y: 0.3, width: 0.22, height: 0.38 } }] }, keyboard: { alt: t({ en: 'The three cats sit on the keyboard; the worker cries tears of joy.', es: 'Los tres gatos se sientan en el teclado; el oficinista llora de alegría.', ca: 'Els tres gats seuen damunt del teclat; l’oficinista plora d’alegria.', zh: '三只猫坐在键盘上,上班族喜极而泣。', ar: 'القطط الثلاث تجلس على لوحة المفاتيح، والموظف يبكي من الفرح.', ja: 'キーボードの上に座る3匹と、うれし泣きする会社員。', pt: 'Os três gatos sentam no teclado; o funcionário chora de alegria.' }), safeArea: { x: 0.07, y: 0.1, width: 0.81, height: 0.78 }, anchors: [{ id: 'kyaku', x: 0.29, y: 0.42, head: { x: 0.25, y: 0.2 }, face: { x: 0.15, y: 0.1, width: 0.27, height: 0.4 } }, cat('shiro', 0.42, 0.57, 0.36, 0.48, 0.13, 0.14), cat('kuro', 0.59, 0.6, 0.53, 0.5, 0.12, 0.14), cat('mike', 0.74, 0.62, 0.68, 0.52, 0.12, 0.14)] }, }; const panels = () => Promise.all([ comicPanel('door', asset('neko-door.jpg'), ART.door), comicPanel('bowls', asset('neko-bowls.jpg'), ART.bowls), comicPanel('onebowl', asset('neko-onebowl.jpg'), ART.onebowl), comicPanel('toast', asset('neko-toast.jpg'), ART.toast), comicPanel('guest', asset('neko-guest.jpg'), ART.guest), comicPanel('nap', asset('neko-nap.jpg'), ART.nap), comicPanel('laptop', asset('neko-laptop.jpg'), ART.laptop), comicPanel('keyboard', asset('neko-keyboard.jpg'), ART.keyboard), ]); // #endregion // #region fonts: each edition loads its two faces with the words they set const SFX = /^sfx[^::\n]*[::](.*)$/gm; // sound-effect lines: the second face sets them async function loadEdition(lang) { const text = TEXT[lang]; const [face, sfx] = EDITIONS[lang].faces; const faces = { [face]: FONTS[face], [sfx]: FONTS[sfx] }; if (['ja', 'zh'].includes(lang)) { // CJK: only the files that hold these characters const effects = [...text.matchAll(SFX)].map((m) => m[1]).join(''); await loadCjkFonts({ [face]: FONTS[face] }, text.replace(SFX, ''), { vertical: true }); await loadCjkFonts({ [sfx]: FONTS[sfx] }, effects, { vertical: true }); } if (lang === 'ar') await loadArabicFonts(faces, text); await loadComicFonts(faces, text); // the bold and italic the faces do not ship } // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region editions: the original on the right-hand page, this edition's lettering facing it const FACING = LANG === 'ja' ? 'en' : LANG; // Japanese readers see the English edition const TEXT = { ja: original, [FACING]: LANG === 'ja' ? english : markdown }; await loadFonts(FONTS, Object.values(TEXT).join('\n')); const resources = await panels(); const build = async (lang) => { // the same pictures and page; this language's lettering await loadEdition(lang); return buildWithFonts(() => buildDocument({ markdown: TEXT[lang], resources, continuation: { pageIndexOffset: lang === 'ja' ? 1 : 2 } }, edition(lang)), TEXT[lang]); }; const docs = [await build('ja'), await build(FACING)]; // #endregion const TITLE = t({ en: 'Two yonkoma strips on a page', es: 'Dos tiras yonkoma en una página', ca: 'Dues tires yonkoma en una pàgina', zh: '一页并排两篇四格漫画', ar: 'شريطا يونكوما في صفحة واحدة', ja: '1ページに四コマを2本並べる', pt: 'Duas tiras yonkoma numa página' }); showBook(docs, { title: TITLE }); offerPdf(() => renderToPdf(docs, { fontProvider: comicPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, images, cjk, arabic, comics: igual en todas las recetas · 722 líneas// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · 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 · arabic v1 ── Arabic-script faces · postext.dev/cookbook // Fontsource ships an Arabic family as one file per subset and weight: the // `arabic` file holds the letters, the harakat, the Arabic-Indic digits, the // Arabic punctuation and the presentation forms; `latin` and `latin-ext` // hold the rest. loadFonts loads the latin files; this block adds the arabic // file of every Arabic family, for the canvas and for the PDF, which shapes // the letters with HarfBuzz from the same bytes. /** The code points of Fontsource's `arabic` subset, as its stylesheets * declare them (the same unicode-range the browser picks the file by). A * function, not a const: the kit is inlined after the recipe's top-level * awaits, and a const read before its line throws, where a function * declaration is hoisted. */ function arabicRange() { return 'U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,' + 'U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC,U+102E0-102FB,' + 'U+10E60-10E7E,U+10EC2-10EC4,U+10EFC-10EFF,U+1EE00-1EEFF'; } /** Whether code point `cp` is in the arabic file. */ function inArabicRange(cp) { inArabicRange.ranges ??= arabicRange().split(',').map((part) => { const [lo, hi = lo] = part.slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return inArabicRange.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); } /** Whether Fontsource serves `family` with an `arabic` subset. Fails when * the API does not answer: an Arabic face taken for a Latin one would set * its letters in a system face. */ async function isArabicFamily(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?.includes('arabic'); } /** The arabic file of a face. */ function arabicFileUrl(family, weight, style) { const id = fontsourceId(family); return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-arabic-${weight}-${style}.woff2`; } /** faces = { Amiri: ['400', '700'] }, as for loadFonts, after it: the whole * FONTS object may be passed, its families without an arabic subset are * left alone. Adds the arabic file of every listed weight of each Arabic * family (the latin files come from loadFonts) and loads it. `text` is * the sample: fails when it holds an Arabic-script character the arabic * file does not cover. List every weight the pages set in Arabic: a weight * left to buildWithFonts gets the latin file only, and its Arabic letters * fall back to a system face. Resolves to the number of files loaded. */ async function loadArabicFonts(faces, text = '') { kitStatus('Loading fonts…'); let loaded = 0; try { const outside = [...new Set(text)].filter((ch) => /\p{Script=Arabic}/u.test(ch) && !inArabicRange(ch.codePointAt(0))); if (outside.length) throw new Error(`Fontsource's arabic files have no ${outside.slice(0, 12).join(' ')}`); for (const [family, specs] of Object.entries(faces)) { if (!(await isArabicFamily(family))) continue; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const face = new FontFace(family, `url(${arabicFileUrl(family, weight, style)}) format('woff2')`, { weight: String(weight), style, unicodeRange: arabicRange() }); document.fonts.add(await face.load().catch(() => { throw new Error(`Fontsource has no arabic file for ${family} ${weight} ${style}`); })); loaded++; } } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with Arabic faces: a family with an * arabic subset gets its arabic file when its pages set Arabic letters * (`request.codePoints`), then its latin file, and its latin-ext file for * the letters beyond latin (transliteration: ā ḥ ʿ). The arabic file comes * first: it also holds the space and the brackets, so a line of Arabic is * shaped as one run and not cut at every space. Any other family goes to * fontsourceProvider (the "pdf" block). */ async function arabicPdfProvider(family, weight, style, request) { if (!(await isArabicFamily(family))) return fontsourceProvider(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 wanted = [...(request?.codePoints ?? [])]; const id = fontsourceId(family); const urls = []; if (!wanted.length || wanted.some(inArabicRange)) urls.push(arabicFileUrl(family, w, s)); urls.push(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) { urls.push(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`); } return Promise.all(urls.map(async (url) => { const res = await fetch(url); if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } // ─── Kit · comics v1 ── comic pages · postext.dev/cookbook // The engine letters a comic in a face per language (defaultComicFont: // Comic Neue, Zen Antique for Japanese, Noto Sans SC, LXGW WenKai TC, // Playpen Sans Arabic; defaultComicSfxFont: Bangers, Dela Gothic One, // ZCOOL KuaiLe, Lalezar) and asks for the bold of a shout or a sound effect // and the italic of an inner voice. Most of these faces ship one weight and // no italic: this block declares the file Fontsource does ship for those // variants, so the canvas measures and paints the letters the PDF embeds // (the providers snap to the shipped file) and not a bold or a slant the // browser makes up. It also turns the art manifest into panel resources. /** faces = { 'Comic Neue': ['400', '700'], Bangers: ['400'] }, as for * loadFonts, after it (and after loadCjkFonts or loadArabicFonts for the * faces of those scripts); the whole FONTS object may be passed. List in * FONTS only what Fontsource ships (Bangers: ['400']): loadFonts fails on * the rest. For each family, the regular, bold, italic and bold italic it * lacks are declared with its nearest file (the regular for the bold, the * upright for the italic) and loaded for `text`. A CJK face needs the cjk * block; an Arabic face setting Arabic, the arabic block. Resolves to the * number of variants added. */ async function loadComicFonts(faces, text = '') { kitStatus('Loading fonts…'); let added = 0; try { for (const family of Object.keys(faces)) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); const weights = meta.weights?.length ? meta.weights : [400, 700]; const styles = meta.styles?.length ? meta.styles : ['normal']; for (const [weight, style] of [[400, 'normal'], [700, 'normal'], [400, 'italic'], [700, 'italic']]) { if ((weights.includes(weight) && styles.includes(style)) || hasFace(family, weight, style)) continue; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && styles.includes('italic') ? 'italic' : 'normal'; for (const { url, range } of await comicFaceFiles(family, w, s, meta, text)) { document.fonts.add(new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: range })); } await document.fonts.load(`${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`, text || 'A'); added++; } } } catch (error) { kitFail(error); throw error; } return added; } /** The files of a shipped face ({ url, range }): a CJK face's slices (the * cjk block reads them from its stylesheet), else latin, the latin-ext * and greek files `text` needs, and the arabic file when `text` holds * Arabic. */ async function comicFaceFiles(family, weight, style, meta, text) { if (meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset))) { if (typeof cjkSlices !== 'function') throw new Error(`${family} is a CJK face: list the cjk kit block`); return (await cjkSlices(family, weight, style)).map(({ url, range }) => ({ url, range })); } const id = fontsourceId(family); const file = (subset) => `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const files = ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ url: file(subset), range: comicRange(subset) })); if (meta.subsets?.includes('arabic') && /\p{Script=Arabic}/u.test(text)) { if (typeof arabicRange !== 'function') throw new Error(`${family} sets Arabic: list the arabic kit block`); files.unshift({ url: file('arabic'), range: arabicRange() }); } return files; } /** The unicode-range loadFonts gives a Fontsource file. A function, not a * const: the kit is inlined after the recipe's top-level awaits. */ function comicRange(subset) { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }[subset]; } /** The PDF font provider of a comic in any script: a CJK face goes to * cjkPdfProvider, an Arabic face to arabicPdfProvider, any other to * fontsourceProvider (the "pdf" block). Each embeds the shipped weight * and style nearest to the one asked for, the file loadComicFonts * declared for the canvas. */ async function comicPdfProvider(family, weight, style, request) { const subsets = (await fontsourceMeta(family))?.subsets ?? []; if (subsets.some((subset) => /^(chinese|japanese|korean)/.test(subset))) { if (typeof cjkPdfProvider !== 'function') throw new Error(`${family} is a CJK face: list the cjk kit block`); return cjkPdfProvider(family, weight, style, request); } if (subsets.includes('arabic') && typeof arabicPdfProvider === 'function') { return arabicPdfProvider(family, weight, style, request); } return fontsourceProvider(family, weight, style, request); } /** A panel picture as a resource for art= (and pop=): loads `url` * (write asset('lh-arrive.jpg') in the call, so the lint checks the file) * and declares it with `meta`, its entry in the art manifest: * { width, height, alt, safeArea, anchors, avoid }. The safe area is what * every crop keeps, the anchors the speakers' mouths, heads and faces, * the avoid zones what no balloon covers; all in fractions of the picture, * the same in every language. Without width and height, the picture's own * size. Needs the images block. Resolves to the resource. */ async function comicPanel(id, url, meta = {}) { const fileId = decodeURIComponent(url.split('/').pop()); await loadImage(fileId, url); let { width, height } = meta; if (!width || !height) { const bitmap = await createImageBitmap(new Blob([imageBytes(fileId)])); ({ width, height } = bitmap); bitmap.close(); } return { id, typeId: meta.typeId ?? 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId, format: /\.png$/i.test(fileId) ? 'png' : 'jpeg', width, height }, ...(meta.alt && { altText: meta.alt }), ...(meta.safeArea && { safeArea: meta.safeArea }), ...(meta.anchors?.length && { anchors: meta.anchors }), ...(meta.avoid?.length && { avoid: meta.avoid }), }; } // ─── /Kit ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)

Errores frecuentes

Error frecuente

Etiqueta un texto japonés 'ja', nunca zh-Hans ni LANG

Las ediciones de una receta son en y es, pero una muestra japonesa es japonesa en las dos: `locale: LANG` la etiquetaría como inglés o español, y una etiqueta china la compondría con las reglas chinas (puntuación Kaiming, kana pequeño libre de empezar línea, 图 en lugar de 図, formas chinas de los caracteres en el PDF). Escribe 'ja': elige la región de Japón (corte y puntuación de la JLReq, puntos de sésamo, reparto de los furigana, etiquetas 図 y 表) y desactiva la separación silábica. El lint falla un texto con kana bajo una etiqueta zh o ko. Corte de líneas japonés (kinsoku) →

Error frecuente

Las fuentes chinas se cargan por fragmentos, con el bloque cjk

Fontsource sirve una familia china, japonesa o coreana en un centenar de archivos por peso, cada uno con un intervalo de caracteres. loadFonts solo descarga el archivo latin, así que en pantalla los caracteres chinos salen de una fuente del sistema y se miden mal, y fontsourceProvider entrega al PDF ese archivo latin, que los imprime como cajas vacías. Añade el bloque cjk del kit, llama a loadCjkFonts(FONTS, markdown) después de loadFonts (una vez por voz, con el texto que compone, si el libro usa varias fuentes chinas) y pasa a renderToPdf fontProvider: cjkPdfProvider: ambos toman los archivos que contienen los caracteres del texto. Fuentes chinas, japonesas y coreanas →

Error frecuente

Una configuración se cachea por identidad: crea un objeto nuevo

El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

  • En una viñeta pequeña, un bocadillo vertical se queda sin altura antes que sin anchura: el motor lo compone en más columnas, más cortas, cortando entre palabras. El último grito del oficinista sigue siendo demasiado alto para la viñeta de abajo en japonés, así que se marca con break (kyaku{shout break}): rompe el marco hacia la viñeta de arriba en lugar de tapar a los gatos, sin salirse al margen de la página.
  • Una onomatopeya grande se aparta de las caras como un bocadillo, y su punto fijo cae dentro de la zona segura de la viñeta, así que todo recorte lo conserva: ¡PLOF! va en 70% 80%, sobre las patas de los gatos. La ずしっ japonesa va en vertical y es más alta; en ese punto tapaba la cara de la gata tricolor y la construcción avisaba comicBalloonOverflow, así que allí va en 86% 84%, a su lado.

Créditos

Texto
  • ねこ喫茶まどろみ (Café Modorra), dos tiras yonkoma escritas para esta receta, con sus rotulaciones en inglés, español, catalán, chino y árabe · Postext Cookbook · CC BY 4.0
Imágenes
  • Mugi abre la puerta del café de gatos; tres gatos esperan dentro · Generated With Diffusion Models · original
  • Mugi pone los cuencos de comida a los gatos · Generated With Diffusion Models · original
  • Tres gatos comiendo del mismo cuenco · Generated With Diffusion Models · original
  • Los gatos miran la tostada de Mugi · Generated With Diffusion Models · original
  • Un oficinista cansado llega al café · Generated With Diffusion Models · original
  • Los tres gatos dormidos en un cojín · Generated With Diffusion Models · original
  • El oficinista abre el portátil · Generated With Diffusion Models · original
  • Los gatos sentados en el teclado, el oficinista llorando de alegría · Generated With Diffusion Models · original
Fuentes
Zen Antique (SIL OFL 1.1) · Dela Gothic One (SIL OFL 1.1) · Comic Neue (SIL OFL 1.1) · Bangers (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · ZCOOL KuaiLe (SIL OFL 1.1) · Playpen Sans Arabic (SIL OFL 1.1) · Lalezar (SIL OFL 1.1)
SandboxPDF