Zum Hauptinhalt springen
Rezept Nummer 151

Rezepte · Kapitel 11 · Comics und Manga

Zwei Yonkoma-Strips auf einer Seite

Eine Seite mit Gag-Strips aus vier Panels: zwei Spalten zu vier Panels, der rechte Strip zuerst gelesen, jeder mit Titel in schwarzem Kasten im ersten Panel.

Auf dieser Seite
Gattung
Comics
Ausgabe
Canvas · PDF
Stufe
Mittel
Postext
Getestet mit Postext 1.33.7
Erfordert ≥ 1.25.1 · postext-pdf ≥ 1.25.1
Lizenz
Aktualisiert am 11. Okt. 2026
Code MIT · Text CC BY 4.0

S. 1–1 · 1–2 von 2

  • Endformat 148 × 210 mm
  • 2 Spalten, 7,5 mm Spaltenabstand
  • Zen Antique 9/15
  • 2 Seiten
  • Stufe
  • Postext 1.33.7
  • In 306 ms gesetzt
  • 140 Zeilen Code

Kurz gesagt

Eine Seite kurzer japanischer Comicstrips mit je vier Panels, nebeneinander gesetzt wie in einem Manga-Magazin: Man liest den rechten Strip von oben nach unten, dann den linken.

Was Sie setzen

Eine Seite aus einer Yonkoma-Anthologie, ねこ喫茶まどろみ (Café Schlummer), für das Rezept geschrieben und in einem weichen Chibi-Stil gezeichnet: Mugi betreibt ein Katzencafé mit einer weißen, einer schwarzen und einer dreifarbigen Katze. Yonkoma, Strips aus vier Panels, sind der hochkant gestellte Gag-Strip der Zeitung: vier gleich große Panels übereinander, Einleitung, Entwicklung, Wendung, Pointe. Eine Magazinseite trägt zwei davon nebeneinander, und weil sich die Seite von rechts öffnet, kommt der rechte Strip zuerst. Die Seite ist A5, 148 × 210 mm, mit dem japanischen Original auf der einen Seite der Doppelseite und der Ausgabe in der Sprache dieser Seite auf der anderen (Englisch auf der japanischen Seite).

Dieses Rezept beantwortet

  • Wie setze ich einen Yonkoma-Strip mit vier Panels?

Die kurze Antwort

script.js · Zeilen 46–64im vollständigen Code
// 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(),
});

Zutaten

Schrift
Zen Antique, Dela Gothic One, Comic Neue, Bangers, Noto Sans SC, ZCOOL KuaiLe, Playpen Sans Arabic, Lalezar (SIL OFL 1.1)
Dateien
  • 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 unlocking the cat café's door, three cats waiting inside (Generated With Diffusion Models, eigener Text)
  • Mugi setting out food bowls for the cats (Generated With Diffusion Models, eigener Text)
  • Three cats eating from one bowl (Generated With Diffusion Models, eigener Text)
  • The cats staring at Mugi's toast (Generated With Diffusion Models, eigener Text)
  • A tired office worker arriving at the café (Generated With Diffusion Models, eigener Text)
  • The three cats asleep on one cushion (Generated With Diffusion Models, eigener Text)
  • The office worker opening his laptop (Generated With Diffusion Models, eigener Text)
  • The cats sitting on the keyboard, the worker in happy tears (Generated With Diffusion Models, eigener Text)

Zubereitung

#1 · Die Seite in zwei Spalten zu vier teilen

Der Code steht oben in der kurzen Antwort. Die ganze Seite ist ein einziges :::page mit split="* [* / * / * / *] | * [* / * / * / *]": zwei gleich breite Spalten, jede in vier gleich hohe Reihen geteilt. Die acht Zeilen ::panel füllen die Zellen in Lesereihenfolge, sodass die ersten vier zum ersten Strip gehören, und bei Leserichtung von rechts nach links liegt die erste Spalte rechts (Seitenaufteilung). Der Zwischenraum zwischen den Strips misst 9 mm und der zwischen den Panels 3 mm, sodass das Auge einen Strip hinuntergeht, bevor es zum nächsten wechselt.

#2 · Jeden Strip im ersten Panel betiteln

script.js · Zeilen 68–75im vollständigen Code
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
];

Ein Yonkoma hat einen Titel, und eine Anthologie druckt ihn in einem Kasten am Kopf des Strips. Hier ist der Titel eine Skriptzeile, caption{title}: 朝ごはん, und title ist ein eigenes Sprechblasenformat: weiß auf schwarz, bündig an den Panelrand in der oberen Ecke am Zeilenanfang gesetzt, in diesem Buch oben rechts. Er wird in jeder Ausgabe wie jede andere Zeile gelettert, sodass auf der deutschen Seite Frühstück und Therapie in denselben Kästen stehen.

#3 · Auch die Katzen als Sprecher markieren

script.js · Zeilen 209–350im vollständigen Code
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匹の猫が待っている。',
      fr: 'Mugi ouvre la porte vitrée du café ; à l’intérieur, les trois chats attendent.',
      de: 'Mugi schließt die Glastür des Cafés auf; drinnen warten die drei Katzen.',
      it: 'Mugi apre la porta a vetri del caffè; dentro aspettano i tre gatti.',
      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匹の猫の前に、ご飯を盛った器を並べるむぎ。',
      fr: 'Mugi pose des bols pleins de nourriture devant les trois chats.',
      de: 'Mugi stellt den drei Katzen volle Futternäpfe hin.',
      it: 'Mugi mette le ciotole piene di cibo davanti ai tre gatti.',
      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匹そろって同じ器に頭を突っこみ、ほかの器は手つかず。',
      fr: 'Les trois chats plongent la tête dans le même bol ; les autres restent pleins.',
      de: 'Alle drei Katzen stecken den Kopf in denselben Napf; die anderen bleiben voll.',
      it: 'I tre gatti infilano la testa nella stessa ciotola; le altre restano piene.',
      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匹がきらきらした目で見つめる。',
      fr: 'Mugi croque sa tartine tandis que les trois chats la fixent, les yeux brillants.',
      de: 'Mugi beißt in ihren Toast, während die drei Katzen ihn mit leuchtenden Augen '
        + 'anstarren.',
      it: 'Mugi addenta il suo toast e i tre gatti lo fissano con gli occhi che brillano.',
      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: '両手を広げて入ってくる疲れた会社員と、カウンターから迎えるむぎ。',
      fr: 'Un employé de bureau fatigué entre les bras ouverts ; Mugi l’accueille depuis le '
        + 'comptoir.',
      de: 'Ein müder Büroangestellter kommt mit offenen Armen herein; Mugi begrüßt ihn '
        + 'von der Theke aus.',
      it: 'Un impiegato stanco entra a braccia aperte; Mugi lo accoglie dal bancone.',
      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匹。',
      fr: 'Les trois chats dorment en tas sur un coussin rond.',
      de: 'Die drei Katzen schlafen auf einem Haufen auf einem runden Kissen.',
      it: 'I tre gatti dormono ammucchiati su un cuscino rotondo.',
      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: 'カフェのテーブルでため息をつき、ノートパソコンを開く会社員。',
      fr: 'L’employé soupire à une table du café et ouvre son ordinateur portable.',
      de: 'Der Büroangestellte seufzt an einem Cafétisch und klappt seinen Laptop auf.',
      it: 'L’impiegato sospira a un tavolino del caffè e apre il portatile.',
      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匹と、うれし泣きする会社員。',
      fr: 'Les trois chats s’assoient sur le clavier ; l’employé pleure de joie.',
      de: 'Die drei Katzen setzen sich auf die Tastatur; der Angestellte weint vor Freude.',
      it: 'I tre gatti si siedono sulla tastiera; l’impiegato piange di gioia.',
      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),
]);

Die Katzen sprechen, kuro: にゃ, darum markiert jedes Bild ihre Mäuler und Gesichter, wie es die von Mugi markiert. Zwei kleine Hilfsfunktionen halten die Liste kurz: cat() für eine Katze, die zum Leser schaut, und nose() für eine, deren Gesicht in einem Napf oder einem Kissen steckt. Im dritten Panel ist Mugi nicht im Bild, und ihre Zeile mugi{tail=top} bekommt einen Dorn zum oberen Rand.

#4 · Die Schriften jeder Ausgabe mit ihren Wörtern laden

script.js · Zeilen 354–366im vollständigen Code
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
}

Jede Schrift bekommt die Wörter, die sie setzt: Zen Antique die Sprechblasen, Dela Gothic One die Soundeffekte (ガツガツ, じーっ, すやぁ, ずしっ). Ein Yonkoma-Panel ist breiter als hoch, darum enden die japanischen Spalten nach sechs Zeichen (maxColumnChars: 6), und das Lettering ist einen halben Punkt kleiner als auf einer Erzählseite.

#5 · Das Original neben jede Ausgabe stellen

script.js · Zeilen 371–381im vollständigen Code
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 withLoadedFonts(() => buildDocument({ markdown: TEXT[lang], resources,
    continuation: { pageIndexOffset: lang === 'ja' ? 1 : 2 } }, edition(lang)),
    { ...kitFonts(FONTS), text: TEXT[lang] });
};
const docs = [await build('ja'), await build(FACING)];

Jede Sprache der Website lettert die Strips in ihrer eigenen Datei, dem japanischen Original über die Doppelseite gegenüber; die japanische Seite steht der englischen Ausgabe gegenüber. Die Reihenfolge bleibt in jeder Sprache: der rechte Strip zuerst.

Das ganze Rezept

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.25.1
// A page of a cat-café yonkoma: two four-panel strips side by side, the right one first.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'de'; // @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'] },
  fr: { locale: 'fr', faces: ['Comic Neue', 'Bangers'] },
  de: { locale: 'de', faces: ['Comic Neue', 'Bangers'] },
  it: { locale: 'it', 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 = () => ({
  // 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',
    fr: 'fr', de: 'de', it: 'it' }),
  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`---
Markdown-Beispiel · 30 Zeilen · content.de.mdtitle: "Café Schlummer" author: "Postext Cookbook" --- :::page{split="* [* / * / * / *] | * [* / * / * / *]"} ::panel{art=door} caption{title}: Frühstück mugi: Guten Morgen, ihr alle! kuro: Mrrp. ::panel{art=bowls} mugi: Jeder einen Napf, ja? ::panel{art=onebowl} sfx: MAMPF MAMPF mugi{tail=top}: …Wieso alle aus demselben? ::panel{art=toast} sfx: STARR mugi: Ihr habt doch gerade gefressen! ::panel{art=guest} caption{title}: Therapie kyaku: Ich will mich von Katzen heilen lassen… mugi: Willkommen! ::panel{art=nap} sfx: zzz kyaku{tail=top}: …Die schlafen ja alle. ::panel{art=laptop} kyaku: Na gut. Dann arbeite ich eben… ::panel{art=keyboard} sfx{at="70% 80%" rotate=-10}: PLUMPS kyaku{shout}: Kann nicht arbeiten. So glücklich!! :::
`; // content.<lang>.md: this edition's lettering const original = String.raw`---
Markdown-Beispiel · 30 Zeilen · 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`---
Markdown-Beispiel · 30 Zeilen · 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匹の猫が待っている。', fr: 'Mugi ouvre la porte vitrée du café ; à l’intérieur, les trois chats attendent.', de: 'Mugi schließt die Glastür des Cafés auf; drinnen warten die drei Katzen.', it: 'Mugi apre la porta a vetri del caffè; dentro aspettano i tre gatti.', 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匹の猫の前に、ご飯を盛った器を並べるむぎ。', fr: 'Mugi pose des bols pleins de nourriture devant les trois chats.', de: 'Mugi stellt den drei Katzen volle Futternäpfe hin.', it: 'Mugi mette le ciotole piene di cibo davanti ai tre gatti.', 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匹そろって同じ器に頭を突っこみ、ほかの器は手つかず。', fr: 'Les trois chats plongent la tête dans le même bol ; les autres restent pleins.', de: 'Alle drei Katzen stecken den Kopf in denselben Napf; die anderen bleiben voll.', it: 'I tre gatti infilano la testa nella stessa ciotola; le altre restano piene.', 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匹がきらきらした目で見つめる。', fr: 'Mugi croque sa tartine tandis que les trois chats la fixent, les yeux brillants.', de: 'Mugi beißt in ihren Toast, während die drei Katzen ihn mit leuchtenden Augen ' + 'anstarren.', it: 'Mugi addenta il suo toast e i tre gatti lo fissano con gli occhi che brillano.', 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: '両手を広げて入ってくる疲れた会社員と、カウンターから迎えるむぎ。', fr: 'Un employé de bureau fatigué entre les bras ouverts ; Mugi l’accueille depuis le ' + 'comptoir.', de: 'Ein müder Büroangestellter kommt mit offenen Armen herein; Mugi begrüßt ihn ' + 'von der Theke aus.', it: 'Un impiegato stanco entra a braccia aperte; Mugi lo accoglie dal bancone.', 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匹。', fr: 'Les trois chats dorment en tas sur un coussin rond.', de: 'Die drei Katzen schlafen auf einem Haufen auf einem runden Kissen.', it: 'I tre gatti dormono ammucchiati su un cuscino rotondo.', 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: 'カフェのテーブルでため息をつき、ノートパソコンを開く会社員。', fr: 'L’employé soupire à une table du café et ouvre son ordinateur portable.', de: 'Der Büroangestellte seufzt an einem Cafétisch und klappt seinen Laptop auf.', it: 'L’impiegato sospira a un tavolino del caffè e apre il portatile.', 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匹と、うれし泣きする会社員。', fr: 'Les trois chats s’assoient sur le clavier ; l’employé pleure de joie.', de: 'Die drei Katzen setzen sich auf die Tastatur; der Angestellte weint vor Freude.', it: 'I tre gatti si siedono sulla tastiera; l’impiegato piange di gioia.', 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 withLoadedFonts(() => buildDocument({ markdown: TEXT[lang], resources, continuation: { pageIndexOffset: lang === 'ja' ? 1 : 2 } }, edition(lang)), { ...kitFonts(FONTS), text: 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', fr: 'Deux strips yonkoma sur une page', de: 'Zwei Yonkoma-Strips auf einer Seite', it: 'Due strisce yonkoma su una pagina' }); showBook(docs, { title: TITLE }); offerPdf(() => renderToPdf(docs, { fontProvider: comicPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, images, cjk, arabic, comics: in jedem Rezept gleich · 740 Zeilen// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { 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', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `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. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; 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 at = fontsourceAt(id); 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 = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** 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, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** 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 v2 ── 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. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its 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 files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 v2 ── 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 = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { let at; cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1))) .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], at).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` the screen sets code point `cp` in, if any: the * last one loadCjkFonts adds whose range holds it. A numbered file that * repeats a letter of latin is added after latin, and wins it. */ function cjkSliceFor(slices, cp) { const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined); } /** 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 the engine's later round * 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 the * screen set its pages' characters in (`request.codePoints`), each marked * with its unicode-range and its place in the order loadCjkFonts added * them: postext-pdf sets a character two files hold in the one added * last, as the browser does. 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.map(async (slice, order) => { if (!picked.has(slice)) return null; const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: slice.range, order }); })).then((files) => files.filter(Boolean)); } /** 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, each marked with its unicode-range and its place in that order: * the PDF sets a character in the file the browser took it from. 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 = `${await fontsourceAt(id)}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)]); const ranges = kitRanges(); return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }), Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : 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 v2 ── 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. */ async function arabicFileUrl(family, weight, style) { const id = fontsourceId(family); return `${await fontsourceAt(id)}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 the engine's later round 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(${await 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: ā ḥ ʿ), each marked with its * unicode-range and its place in the order the screen added them (latin * and latin-ext by loadFonts, then arabic): the arabic file also has a * space and brackets, which the browser takes from latin, the file whose * range holds them, and postext-pdf follows it. 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 subsets = !wanted.length || wanted.some(inArabicRange) ? ['arabic', 'latin'] : ['latin']; if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) { subsets.push('latin-ext'); } const at = await fontsourceAt(id); return Promise.all(subsets.map(async (subset) => { const url = `${at}files/${id}-${subset}-${w}-${s}.woff2`; const res = await fetch(url); if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: subset === 'arabic' ? arabicRange() : kitRanges()[subset], order: ['latin', 'latin-ext', 'greek', 'arabic'].indexOf(subset) }); })); } // ─── Kit · comics v2 ── 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 at = await fontsourceAt(id); const file = (subset) => `${at}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, faces }. The safe area * is what every crop keeps, the anchors the speakers' mouths, heads and * faces, the avoid zones what no balloon covers unless a panel has no * other room, and `faces` the faces of the people who do not speak, which * no balloon ever 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 }), ...(meta.faces?.length && { faces: meta.faces }), }; } // ─── /Kit ───────────────────────────────────────────────────────────────────────

Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)

Häufige Fehler

Häufiger Fehler

Zeichnen Sie japanischen Text mit 'ja' aus, nie mit zh-Hans oder LANG

Die Ausgaben eines Rezepts sind en und es, ein japanisches Beispiel ist aber in beiden Japanisch: `locale: LANG` würde es als Englisch oder Spanisch auszeichnen, und eine chinesische Auszeichnung würde es nach chinesischen Regeln setzen (Kaiming-Interpunktion, kleine Kana dürfen eine Zeile beginnen, 图 statt 図, chinesische Glyphenformen im PDF). Seit postext 1.30 ist die Auszeichnung auch die Sprache, in der der Text geformt wird (gemessen, gezeichnet und eingebettet). Jede Auszeichnung außer 'ja' liefert daher andere Glyphenformen als die japanischen (unter einer chinesischen Auszeichnung die chinesischen) und andere Zeilenumbrüche. Schreiben Sie 'ja': Das wählt die Region Japan (JLReq-Zeilenumbruch und -Interpunktion, Sesam-Betonungszeichen, Furigana-Abstände, die Beschriftungen 図 und 表) und schaltet die Silbentrennung ab. Der Lint schlägt bei einem Text mit Kana unter einer zh- oder ko-Auszeichnung fehl. Japanischer Zeilenumbruch (Kinsoku) →

Häufiger Fehler

Chinesische Schnitte werden in Teilstücken geladen, über den Block cjk

Fontsource liefert eine chinesische, japanische oder koreanische Familie als etwa hundert Dateien pro Schriftstärke aus, jede für einen Zeichenbereich. loadFonts holt nur die Datei latin, am Bildschirm stammen die Han-Zeichen also aus einer Systemschrift und werden falsch gemessen, und fontsourceProvider gibt dem PDF diese latin-Datei, die sie als leere Kästchen druckt. Führen Sie den Kit-Block cjk auf, rufen Sie loadCjkFonts(FONTS, markdown) nach loadFonts auf (einmal pro Stimme, mit dem Text, den sie setzt, wenn das Buch mehrere CJK-Schriften verwendet), und geben Sie renderToPdf fontProvider: cjkPdfProvider: Beide nehmen die Dateien, die die Zeichen des Textes enthalten. Chinesische, japanische und koreanische Schriften →

  • In einem kleinen Panel geht einer vertikalen Sprechblase die Höhe aus, bevor ihr die Breite ausgeht: Die Engine setzt sie in mehr, kürzere Spalten und bricht zwischen Wörtern um. Der letzte Schrei der Büroangestellten ist auf Japanisch trotzdem zu hoch für das untere Panel, darum ist er mit break markiert (kyaku{shout break}): Er bricht nach oben durch den Rahmen ins Panel darüber, statt die Katzen zu verdecken, und bleibt aus dem Seitenrand heraus.
  • Ein großer Soundeffekt wird wie eine Sprechblase von Gesichtern ferngehalten, und sein festgesteckter Punkt liegt im Sicherheitsbereich des Panels, sodass jeder Ausschnitt ihn behält: PLUMPS sitzt bei 70% 80%, auf den Pfoten der Katzen. Das japanische ずしっ ist vertikal gesetzt und höher; an diesem Punkt verdeckte es das Gesicht der dreifarbigen Katze, und der Build warnte comicBalloonOverflow, darum sitzt es dort bei 86% 84%, neben ihr.

Nachweise

Text
  • ねこ喫茶まどろみ (Café Doze), two yonkoma strips written for this recipe, with their English, Spanish, Catalan, Chinese and Arabic letterings · Postext Cookbook · CC BY 4.0
Bilder
  • Mugi unlocking the cat café's door, three cats waiting inside · Generated With Diffusion Models · eigener Text
  • Mugi setting out food bowls for the cats · Generated With Diffusion Models · eigener Text
  • Three cats eating from one bowl · Generated With Diffusion Models · eigener Text
  • The cats staring at Mugi's toast · Generated With Diffusion Models · eigener Text
  • A tired office worker arriving at the café · Generated With Diffusion Models · eigener Text
  • The three cats asleep on one cushion · Generated With Diffusion Models · eigener Text
  • The office worker opening his laptop · Generated With Diffusion Models · eigener Text
  • The cats sitting on the keyboard, the worker in happy tears · Generated With Diffusion Models · eigener Text
Schriften
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