In breve
Due pagine di un fumetto giapponese. Le pagine giapponesi sono letterate dall’alto in basso in colonne strette; le altre edizioni mantengono l’ordine giapponese delle vignette, a partire da quella in alto a destra.
Che cosa comporrai
Le due pagine d’apertura di un manga di kendo, 前の晩 (La vigilia), scritto per la ricetta e disegnato in bianco e nero: Hana si allena da sola la sera prima del torneo della prefettura, Sora le porta un amuleto del santuario, il loro allenatore li manda a casa. Prima viene l’originale giapponese, letterato come lo lettera un settimanale: caratteri antichi (kana in un taglio Mincho, kanji in uno Gothic), nuvolette più alte che larghe, nessun punto alla fine di una nuvoletta. Di fronte, le stesse due pagine nella lingua di questa pagina (inglese sulla pagina giapponese), che si leggono ancora a partire dalla vignetta in alto a destra, come i manga in licenza fin dai primi anni 2000. Il formato è B6, 128 × 182 mm, quello del tankōbon.
Questa ricetta risponde a
- Come impagino un manga da leggere da destra a sinistra?
- Come scrivo il giapponese in verticale dentro le nuvolette?
La risposta breve
const comics = (lang) => ({
// The pages were drawn for right-to-left reading: panel 1 sits top right. The
// reading direction stays 'auto', which follows the art in a left-to-right
// language too: the English page keeps the Japanese panel order.
artDirection: 'rtl',
mirrorArt: false, // never flop the art: Hana's grip on the sword, the kimono's fold
gutter: { horizontal: mm(4.5), vertical: mm(2) }, // a tier gap twice the panel gap
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.5 : 7.5), // one size per edition
// 'auto' sets Japanese in columns of 8 characters at most and drops the final 。.
// Chinese goes in columns too: a book bound on the right never mixes the two.
writingMode: lang === 'zh' ? 'vertical' : 'auto',
},
balloonStyles: balloons(),
});
Ingredienti
- Funzionalità
- Direzione di lettura e ordine mangaLettering dei fumettiRifare il lettering in altre lingueTesto verticaleFont cinesi, giapponesi e coreaniLibri rilegati a destraDivisione in vignetteDisegni nelle vignette e ritagliParlanti e ancoraggiPagine a fumettiStili delle nuvoletteEffetti sonoriTavolozza di colori semanticaPagine su un canvasEsportazione in PDF
- Usa anche
- Divisione in righe del cineseBordi e spazi delle vignetteFurigana: letture in kana sopra i kanjiColore della cartaFont incorporati nel PDFFigure e tabelle come risorseRuby: letture in pinyin e zhuyinNumeri diritti nel testo verticale
- Tipo
- Zen Antique, Dela Gothic One, Comic Neue, Bangers, Noto Sans SC, ZCOOL KuaiLe, Playpen Sans Arabic, Lalezar (SIL OFL 1.1)
- Risorse
kendo-charm.jpgkendo-closeup.jpgkendo-coach.jpgkendo-dojo.jpgkendo-doorway.jpgkendo-kneel.jpgkendo-roof.jpgkendo-smile.jpgkendo-swing.jpg- The school's old wooden kendo dojo at dusk, its doors open and lit (Generated With Diffusion Models, originale)
- Hana alone in the dojo, swinging her bamboo sword overhead (Generated With Diffusion Models, originale)
- Sora peeking round the sliding door with a shrine charm (Generated With Diffusion Models, originale)
- Close-up of Hana's sweating face (Generated With Diffusion Models, originale)
- Sora holding out the charm to Hana (Generated With Diffusion Models, originale)
- Coach Mori in the doorway, backlit, arms crossed (Generated With Diffusion Models, originale)
- Close-up of Coach Mori's small smile (Generated With Diffusion Models, originale)
- Hana tying the charm to her armour while Sora scratches his head (Generated With Diffusion Models, originale)
- Hana and Sora on the school roof at sunset, seen from behind (Generated With Diffusion Models, originale)
Preparazione
#1 · Leggi nella direzione in cui è stata disegnata la tavola
Il codice è nella risposta breve qui sopra. Un mangaka compone ogni pagina a partire dall’alto a destra: la prima vignetta, il primo a parlare e il percorso dell’occhio lungo una striscia cominciano lì. artDirection: 'rtl' lo dice al motore, e con la direzione di lettura lasciata su 'auto' ogni edizione segue il disegno; un’edizione araba si leggerebbe comunque da destra a sinistra. La tavola non viene mai specchiata. Gli editori occidentali hanno ribaltato i manga fino al 2002 circa, e i lettori vedevano ogni spadaccino diventare mancino e ogni kimono chiudersi dalla parte sbagliata; lasciare le pagine come sono state disegnate costa al lettore un’abitudine, mentre ribaltarle costa il disegno. Ogni :::page porta la sua suddivisione, 30 / 34 [46 | *] / * [44 | *], e la prima cella di ogni striscia si impagina a destra (direzione di lettura).
#2 · Segna ogni personaggio una volta sola, sull’immagine
const ART = { // fractions of the picture: the same in every language
dojo: {
alt: t({ en: 'The old wooden kendo dojo at dusk, its doors open and lit.',
es: 'El viejo dojo de madera al anochecer, con las puertas abiertas e iluminadas.',
ca: 'L’antic dojo de fusta al capvespre, amb les portes obertes i il·luminades.',
fr: 'Le vieux dojo en bois au crépuscule, portes ouvertes et éclairées.',
de: 'Das alte Kendo-Dojo aus Holz in der Abenddämmerung, die Türen offen und erleuchtet.',
it: 'Il vecchio dojo di legno all’imbrunire, con le porte aperte e illuminate.',
pt: 'O velho dojô de madeira ao anoitecer, com as portas abertas e iluminadas.',
zh: '黄昏时分的老木造剑道场,门敞开着,里面亮着灯。',
ar: 'قاعة الكندو الخشبية القديمة عند الغسق، أبوابها مفتوحة ومضاءة.',
ja: '夕暮れの古い木造の剣道場。戸が開いて、中に明かりがともっている。' }),
safeArea: { x: 0.45, y: 0.32, width: 0.5, height: 0.51 },
anchors: [{ id: 'dojo', x: 0.66, y: 0.63 }] },
swing: {
alt: t({ en: 'Hana, alone in the dojo, swings her bamboo sword overhead.',
es: 'Hana, sola en el dojo, alza la espada de bambú sobre la cabeza.',
ca: 'La Hana, sola al dojo, aixeca l’espasa de bambú per sobre del cap.',
fr: 'Hana, seule dans le dojo, lève son sabre de bambou au-dessus de sa tête.',
de: 'Hana, allein im Dojo, schwingt ihr Bambusschwert über dem Kopf.',
it: 'Hana, sola nel dojo, alza la spada di bambù sopra la testa.',
pt: 'Hana, sozinha no dojô, ergue a espada de bambu acima da cabeça.',
zh: '花独自在道场里,把竹剑高举过头。',
ar: 'هانا وحدها في القاعة ترفع سيف الخيزران فوق رأسها.',
ja: 'ひとり道場で、竹刀を頭上に振りかぶる花。' }),
safeArea: { x: 0.36, y: 0, width: 0.31, height: 0.5 },
avoid: [{ x: 0.43, y: 0, width: 0.17, height: 0.12 }],
anchors: [{ id: 'hana', x: 0.535, y: 0.28, head: { x: 0.53, y: 0.22 },
face: { x: 0.49, y: 0.14, width: 0.11, height: 0.17 } }] },
doorway: {
alt: t({ en: 'Sora peeks round the sliding door, holding a shrine charm.',
es: 'Sora se asoma por la puerta corredera con un amuleto en la mano.',
ca: 'En Sora treu el cap per la porta corredissa amb un amulet a la mà.',
fr: 'Sora passe la tête par la porte coulissante, une amulette à la main.',
de: 'Sora späht mit einem Schreinamulett in der Hand um die Schiebetür.',
it: 'Sora si affaccia dalla porta scorrevole, con un amuleto in mano.',
pt: 'Sora espia pela porta de correr com um amuleto na mão.',
zh: '空从拉门后探出头,手里攥着护身符。',
ar: 'سورا يطلّ من وراء الباب المنزلق وفي يده تميمة.',
ja: 'お守りを手に、引き戸の陰からのぞく空。' }),
safeArea: { x: 0.37, y: 0.09, width: 0.34, height: 0.5 },
avoid: [{ x: 0.49, y: 0.41, width: 0.08, height: 0.1 }],
anchors: [{ id: 'sora', x: 0.545, y: 0.31, head: { x: 0.55, y: 0.22 },
face: { x: 0.47, y: 0.16, width: 0.2, height: 0.18 } }] },
closeup: {
alt: t({ en: "Close-up of Hana's sweating, startled face.",
es: 'Primer plano del rostro sudoroso y sobresaltado de Hana.',
ca: 'Primer pla de la cara suada i sobresaltada de la Hana.',
fr: 'Gros plan sur le visage en sueur et surpris de Hana.',
de: 'Nahaufnahme von Hanas verschwitztem, erschrockenem Gesicht.',
it: 'Primo piano del volto sudato e sorpreso di Hana.',
pt: 'Primeiro plano do rosto suado e assustado de Hana.',
zh: '花满脸是汗、吃了一惊的特写。',
ar: 'لقطة قريبة لوجه هانا المتعرّق المذعور.',
ja: '汗をかき、はっとした花の顔のアップ。' }),
safeArea: { x: 0.27, y: 0.28, width: 0.43, height: 0.51 },
anchors: [{ id: 'hana', x: 0.475, y: 0.665, head: { x: 0.45, y: 0.35 },
face: { x: 0.3, y: 0.3, width: 0.4, height: 0.45 } }] },
charm: {
alt: t({ en: 'Sora holds out the charm; Hana looks at it, sword on her shoulder.',
es: 'Sora le tiende el amuleto; Hana lo mira con la espada al hombro.',
ca: 'En Sora li allarga l’amulet; la Hana el mira amb l’espasa a l’espatlla.',
fr: 'Sora tend l’amulette ; Hana la regarde, le sabre sur l’épaule.',
de: 'Sora hält ihr das Amulett hin; Hana betrachtet es, das Schwert auf der Schulter.',
it: 'Sora porge l’amuleto; Hana lo guarda, con la spada sulla spalla.',
pt: 'Sora estende o amuleto; Hana olha para ele com a espada no ombro.',
zh: '空递出护身符,花扛着竹剑看着它。',
ar: 'سورا يمدّ التميمة، وهانا تنظر إليها والسيف على كتفها.',
ja: '空がお守りを差し出し、竹刀を肩にかついだ花がそれを見る。' }),
safeArea: { x: 0.21, y: 0.09, width: 0.56, height: 0.53 },
avoid: [{ x: 0.43, y: 0.47, width: 0.06, height: 0.09 }],
anchors: [{ id: 'sora', x: 0.325, y: 0.335, head: { x: 0.29, y: 0.22 },
face: { x: 0.26, y: 0.17, width: 0.11, height: 0.2 } },
{ id: 'hana', x: 0.675, y: 0.335, head: { x: 0.665, y: 0.22 },
face: { x: 0.62, y: 0.17, width: 0.11, height: 0.2 } }] },
coach: {
alt: t({ en: 'Coach Mori in the doorway, backlit, arms crossed.',
es: 'El entrenador Mori en la puerta, a contraluz, de brazos cruzados.',
ca: 'L’entrenador Mori a la porta, a contrallum, de braços plegats.',
fr: 'L’entraîneur Mori sur le seuil, à contre-jour, bras croisés.',
de: 'Trainer Mori in der Tür, im Gegenlicht, mit verschränkten Armen.',
it: 'L’allenatore Mori sulla porta, in controluce, a braccia conserte.',
pt: 'O treinador Mori na porta, contra a luz, de braços cruzados.',
zh: '森老师站在门口,背着光,双臂抱在胸前。',
ar: 'المدرّب موري عند الباب، والضوء خلفه، عاقدًا ذراعيه.',
ja: '逆光の戸口で腕を組んで立つ森先生。' }),
safeArea: { x: 0.33, y: 0.13, width: 0.3, height: 0.32 },
anchors: [{ id: 'mori', x: 0.425, y: 0.215, head: { x: 0.42, y: 0.17 },
face: { x: 0.37, y: 0.13, width: 0.1, height: 0.11 } }] },
smile: {
alt: t({ en: "Close-up of Mori's stern face breaking into a small smile.",
es: 'Primer plano del rostro severo de Mori, que esboza una sonrisa.',
ca: 'Primer pla de la cara severa d’en Mori, que esbossa un somriure.',
fr: 'Gros plan sur le visage sévère de Mori, qui esquisse un sourire.',
de: 'Nahaufnahme von Moris strengem Gesicht, das sich zu einem kleinen Lächeln löst.',
it: 'Primo piano del volto severo di Mori, che accenna un sorriso.',
pt: 'Primeiro plano do rosto severo de Mori, que esboça um sorriso.',
zh: '森老师严肃的脸上露出一丝笑意的特写。',
ar: 'لقطة قريبة لوجه موري الصارم وقد ارتسمت عليه ابتسامة.',
ja: '厳しい顔がふっとゆるむ森先生のアップ。' }),
safeArea: { x: 0.43, y: 0.21, width: 0.51, height: 0.53 },
anchors: [{ id: 'mori', x: 0.69, y: 0.52, head: { x: 0.6, y: 0.25 },
face: { x: 0.45, y: 0.28, width: 0.45, height: 0.32 } }] },
kneel: {
alt: t({ en: 'Kneeling, Hana ties the charm to her armour; Sora scratches his head.',
es: 'De rodillas, Hana se ata el amuleto a la armadura; Sora se rasca la cabeza.',
ca: 'De genolls, la Hana es lliga l’amulet a l’armadura; en Sora es grata el cap.',
fr: 'À genoux, Hana attache l’amulette à son armure ; Sora se gratte la tête.',
de: 'Kniend bindet Hana das Amulett an ihre Rüstung; Sora kratzt sich am Kopf.',
it: 'In ginocchio, Hana lega l’amuleto all’armatura; Sora si gratta la testa.',
pt: 'De joelhos, Hana amarra o amuleto na armadura; Sora coça a cabeça.',
zh: '花跪坐着把护身符系在护具上,空挠着头。',
ar: 'هانا جاثية تربط التميمة بدرعها، وسورا يحكّ رأسه.',
ja: '正座してお守りを防具に結ぶ花と、頭をかく空。' }),
safeArea: { x: 0.21, y: 0.08, width: 0.61, height: 0.48 },
avoid: [{ x: 0.27, y: 0.43, width: 0.06, height: 0.12 }],
anchors: [{ id: 'hana', x: 0.305, y: 0.245, head: { x: 0.3, y: 0.17 },
face: { x: 0.25, y: 0.1, width: 0.11, height: 0.17 } },
{ id: 'sora', x: 0.7, y: 0.25, head: { x: 0.71, y: 0.15 },
face: { x: 0.64, y: 0.1, width: 0.13, height: 0.18 } }] },
roof: {
alt: t({ en: 'Seen from behind, Hana and Sora sit on the school roof at sunset.',
es: 'De espaldas, Hana y Sora sentados en la azotea del instituto al atardecer.',
ca: 'D’esquena, la Hana i en Sora asseguts al terrat de l’institut a la posta.',
fr: 'De dos, Hana et Sora assis sur le toit du lycée au coucher du soleil.',
de: 'Von hinten: Hana und Sora sitzen bei Sonnenuntergang auf dem Schuldach.',
it: 'Di spalle, Hana e Sora seduti sul tetto della scuola al tramonto.',
pt: 'De costas, Hana e Sora sentados no terraço da escola ao pôr do sol.',
zh: '夕阳下,花和空坐在学校楼顶的背影。',
ar: 'هانا وسورا جالسان على سطح المدرسة عند الغروب، من الخلف.',
ja: '夕焼けの屋上に並んで座る花と空の後ろ姿。' }),
safeArea: { x: 0.24, y: 0.41, width: 0.47, height: 0.31 },
anchors: [{ id: 'hana', x: 0.36, y: 0.52, head: { x: 0.36, y: 0.48 },
face: { x: 0.31, y: 0.43, width: 0.11, height: 0.14 } },
{ id: 'sora', x: 0.63, y: 0.52, head: { x: 0.625, y: 0.47 },
face: { x: 0.58, y: 0.41, width: 0.1, height: 0.15 } }] },
};
const panels = () => Promise.all([
comicPanel('dojo', asset('kendo-dojo.jpg'), ART.dojo),
comicPanel('swing', asset('kendo-swing.jpg'), ART.swing),
comicPanel('doorway', asset('kendo-doorway.jpg'), ART.doorway),
comicPanel('closeup', asset('kendo-closeup.jpg'), ART.closeup),
comicPanel('charm', asset('kendo-charm.jpg'), ART.charm),
comicPanel('coach', asset('kendo-coach.jpg'), ART.coach),
comicPanel('smile', asset('kendo-smile.jpg'), ART.smile),
comicPanel('kneel', asset('kendo-kneel.jpg'), ART.kneel),
comicPanel('roof', asset('kendo-roof.jpg'), ART.roof),
]);
Le parole cambiano con la lingua; il disegno no. Ogni immagine di vignetta porta una zona sicura che ogni ritaglio conserva, e per ogni personaggio una bocca (dove punta la coda di una battuta), una testa (per le nuvolette di pensiero) e un volto che nessuna nuvoletta può coprire, tutto in frazioni dell’immagine. Una riga del copione nomina chi parla, sora: …, e il motore colloca la nuvoletta vicino a quel personaggio in qualunque lingua. Chi parla senza essere nell’immagine, come Sora quando l’allenatore compare sulla soglia, riceve una coda verso il bordo della vignetta: sora{tail=bottom}.
#3 · Dai a ogni lingua le sue nuvolette
const balloons = () => [
{ id: 'speech', fill: col('paper'), stroke: col('ink'), strokeWidth: pt(0.6), roundness: 2 },
{ id: 'whisper', fill: col('paper'), stroke: col('ink'), roundness: 2 },
{ id: 'thought', fill: col('paper'), stroke: col('ink') },
{ id: 'inner', fill: col('tone'), stroke: col('tone'), strokeWidth: pt(0), italic: false },
{ id: 'caption', fill: col('paper'), stroke: col('ink'), strokeWidth: pt(0.5) },
{ id: 'sfx', color: col('ink'), haloColor: col('paper') },
];
Gli stili dicono che tipo di voce porta una nuvoletta: un ovale semplice per il parlato, un tratteggio per il sussurro, una nuvola per il pensiero, un riquadro grigio per la voce interiore di Hana. Come si dispongono le parole dentro è affare dell’edizione. In giapponese il motore compone colonne di al massimo otto caratteri, toglie il 。 che chiude una nuvoletta e mantiene il 、, mette in piedi le due cifre di 第42回 in un em (:tcy[42]), e compone !? affiancati in una sola cella. Il copione dà a ogni nome la sua lettura dove compare la prima volta, {一ノ瀬|いちのせ}, come i manga stampano il furigana sopra i nomi. In inglese le stesse nuvolette diventano ovali attorno a righe orizzontali, e il loro ordine parte ancora da destra.
#4 · Carica i caratteri di ogni edizione con le sue parole
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
}
Zen Antique e Dela Gothic One sono distribuiti in circa cento file ciascuno, uno per intervallo di caratteri: ogni carattere si carica con le parole che compone, le nuvolette per l’uno e gli effetti sonori per l’altro, così la pagina scarica solo i file che le servono. loadComicFonts dichiara il grassetto di un grido e il corsivo di una voce interiore a partire dai file che questi caratteri distribuiscono davvero, così lo schermo e il PDF disegnano le stesse lettere.
#5 · Metti l’originale accanto a ogni edizione
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: 1 } }, edition(lang)),
{ ...kitFonts(FONTS), text: TEXT[lang] });
};
const docs = [await build('ja'), await build(FACING)];
Ogni lingua di questo sito ha la sua edizione delle due pagine, scritta nel suo content.<lang>.md, e ogni edizione sta di fronte all’originale giapponese: /* @content@ja */ inserisce content.ja.md in ogni edizione, e la pagina giapponese affianca l’originale al lettering inglese. Anche l’edizione cinese è composta in colonne, perché un libro che si apre da destra non mescola nuvolette verticali e orizzontali; quella araba si legge da destra a sinistra per la sua stessa direzione.
La ricetta completa
// ═══ Postext Cookbook · Nº 149 · Manga read right to left, lettered vertically ══════ // https://postext.dev/en/cookbook/manga-right-to-left // 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.0 // Two pages of a kendo manga: the Japanese original, then the same pages lettered again. import { buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, loadVerticalAlternates, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'it'; // @lang: this edition's language; the Japanese original faces it const RECIPE = 'manga-right-to-left'; // ─── 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'] }, 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'] }, zh: { locale: 'zh-Hans', faces: ['Noto Sans SC', 'ZCOOL KuaiLe'] }, ar: { locale: 'ar', faces: ['Playpen Sans Arabic', 'Lalezar'] }, }; // #region answer: the art reads right to left, so every edition does const comics = (lang) => ({ // The pages were drawn for right-to-left reading: panel 1 sits top right. The // reading direction stays 'auto', which follows the art in a left-to-right // language too: the English page keeps the Japanese panel order. artDirection: 'rtl', mirrorArt: false, // never flop the art: Hana's grip on the sword, the kimono's fold gutter: { horizontal: mm(4.5), vertical: mm(2) }, // a tier gap twice the panel gap 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.5 : 7.5), // one size per edition // 'auto' sets Japanese in columns of 8 characters at most and drops the final 。. // Chinese goes in columns too: a book bound on the right never mixes the two. writingMode: lang === 'zh' ? 'vertical' : 'auto', }, balloonStyles: balloons(), }); // #endregion // #region balloons: oval speech, a dashed whisper, a grey box for the inner voice const balloons = () => [ { id: 'speech', fill: col('paper'), stroke: col('ink'), strokeWidth: pt(0.6), roundness: 2 }, { id: 'whisper', fill: col('paper'), stroke: col('ink'), roundness: 2 }, { id: 'thought', fill: col('paper'), stroke: col('ink') }, { id: 'inner', fill: col('tone'), stroke: col('tone'), strokeWidth: pt(0), italic: false }, { id: 'caption', fill: col('paper'), stroke: col('ink'), strokeWidth: pt(0.5) }, { id: 'sfx', color: col('ink'), haloColor: col('paper') }, ]; // #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(128), height: mm(182), dpi: 150, // B6, a tankōbon binding: 'right', backgroundColor: col('paper'), // manga open from the right, in any language margins: { top: mm(13), bottom: mm(15), left: mm(11), right: mm(11), 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 with it the faces, the writing mode and the // house rules of the lettering. Nothing else: not the page, not the panels. const edition = (lang) => ({ ...config(), locale: EDITIONS[lang].locale, comics: comics(lang) }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Esempio Markdown · 40 righe · content.it.md
title: "La vigilia" author: "Postext Cookbook" --- :::page{split="30 / 34 [46 | *] / * [44 | *]"} ::panel{art=dojo} caption: Il giorno prima del 42º torneo di prefettura. dojo: …Yaaah! ::panel{art=swing} sfx: SWOOSH hana{inner}: Altri cinquanta…! ::panel{art=doorway} sora{whisper}: Wow, Ichinose si allena ancora… sora{thought}: Non posso piombare dentro così. ::panel{art=closeup} hana: …Chi c’è!? ::panel{art=charm} hana: …Aizawa? sora: S-scusa! È un amuleto del santuario di mia nonna. sora: Portalo domani. ::: :::page{split="38 [44 | *] / 34 / *"} ::panel{art=coach} sfx: CLAC mori{at="85% 19%"}: Ehi. mori: La scuola ha chiuso un’ora fa. sora{tail=bottom}: M-Mori-sensei… ::panel{art=smile} mori: Anche riposare è allenamento. mori: …Domani mi aspetto molto da te. hana{tail=bottom}: Sì, sensei! ::panel{art=kneel} sora{thought}: Non ci ha nemmeno sgridati… hana: Grazie, Aizawa. Lo porterò. ::panel{art=roof} hana: …Sinceramente? Ho un po’ paura. sora: Anch’io. E non devo nemmeno gareggiare. hana: Eh eh. Che c’entra? :::`; // content.<lang>.md: this edition's lettering const original = String.raw`---Esempio Markdown · 40 righe · content.ja.md
title: "前の晩" author: "Postext Cookbook" --- :::page{split="30 / 34 [46 | *] / * [44 | *]"} ::panel{art=dojo} caption: 第:tcy[42]回県大会の前日。 dojo: ……ヤアッ! ::panel{art=swing} sfx: ブンッ hana{inner}: あと:tcy[50]本……! ::panel{art=doorway} sora{whisper}: うわ、{一ノ瀬|いちのせ}さん、まだやってる…… sora{thought}: 声、かけづらいなあ ::panel{art=closeup} hana: ……誰!? ::panel{art=charm} hana: ……{相沢|あいざわ}くん? sora: ご、ごめん! これ、うちのばあちゃんの神社のお守り sora: 明日、持ってって ::: :::page{split="38 [44 | *] / 34 / *"} ::panel{art=coach} sfx: ガラッ mori: こら。 mori: 下校時刻、とっくに過ぎてるぞ。 sora{tail=bottom}: も、森先生…… ::panel{art=smile} mori: 試合の前の晩は、休むのも稽古だ。 mori: ……明日、期待してるぞ。 hana{tail=bottom}: はい! ::panel{art=kneel} sora{thought}: 怒られなかった…… hana: ありがとう、相沢くん。つけていくね ::panel{art=roof} hana: ……ほんとはね、ちょっと怖いんだ sora: 僕も。試合に出ないのに hana: ふふっ、なにそれ :::`; // content.ja.md, the original, in every edition const english = String.raw`---Esempio Markdown · 40 righe · content.en.md
title: "The Night Before" author: "Postext Cookbook" --- :::page{split="30 / 34 [46 | *] / * [44 | *]"} ::panel{art=dojo} caption: The day before the 42nd Prefectural Tournament. dojo: …Yaaah! ::panel{art=swing} sfx: FWOOSH hana{inner}: Fifty more…! ::panel{art=doorway} sora{whisper}: Wow, Ichinose's still at it… sora{thought}: I can't just barge in. ::panel{art=closeup} hana: …Who's there!? ::panel{art=charm} hana: …Aizawa? sora: S-sorry! It's a charm from my grandma's shrine. sora: Take it tomorrow. ::: :::page{split="38 [44 | *] / 34 / *"} ::panel{art=coach} sfx: SLIDE mori{at="85% 19%"}: Hey. mori: School closed an hour ago. sora{tail=bottom}: M-Mori-sensei… ::panel{art=smile} mori: Rest is training too. mori: …I'm expecting a lot tomorrow. hana{tail=bottom}: Yes, sensei! ::panel{art=kneel} sora{thought}: He didn't yell at us… hana: Thanks, Aizawa. I'll wear it. ::panel{art=roof} hana: …Honestly? I'm a little scared. sora: Me too. And I'm not even competing. hana: Heh. What's that got to do with it? :::`; // 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: nine panels, with each speaker's mouth, head and face marked once const ART = { // fractions of the picture: the same in every language dojo: { alt: t({ en: 'The old wooden kendo dojo at dusk, its doors open and lit.', es: 'El viejo dojo de madera al anochecer, con las puertas abiertas e iluminadas.', ca: 'L’antic dojo de fusta al capvespre, amb les portes obertes i il·luminades.', fr: 'Le vieux dojo en bois au crépuscule, portes ouvertes et éclairées.', de: 'Das alte Kendo-Dojo aus Holz in der Abenddämmerung, die Türen offen und erleuchtet.', it: 'Il vecchio dojo di legno all’imbrunire, con le porte aperte e illuminate.', pt: 'O velho dojô de madeira ao anoitecer, com as portas abertas e iluminadas.', zh: '黄昏时分的老木造剑道场,门敞开着,里面亮着灯。', ar: 'قاعة الكندو الخشبية القديمة عند الغسق، أبوابها مفتوحة ومضاءة.', ja: '夕暮れの古い木造の剣道場。戸が開いて、中に明かりがともっている。' }), safeArea: { x: 0.45, y: 0.32, width: 0.5, height: 0.51 }, anchors: [{ id: 'dojo', x: 0.66, y: 0.63 }] }, swing: { alt: t({ en: 'Hana, alone in the dojo, swings her bamboo sword overhead.', es: 'Hana, sola en el dojo, alza la espada de bambú sobre la cabeza.', ca: 'La Hana, sola al dojo, aixeca l’espasa de bambú per sobre del cap.', fr: 'Hana, seule dans le dojo, lève son sabre de bambou au-dessus de sa tête.', de: 'Hana, allein im Dojo, schwingt ihr Bambusschwert über dem Kopf.', it: 'Hana, sola nel dojo, alza la spada di bambù sopra la testa.', pt: 'Hana, sozinha no dojô, ergue a espada de bambu acima da cabeça.', zh: '花独自在道场里,把竹剑高举过头。', ar: 'هانا وحدها في القاعة ترفع سيف الخيزران فوق رأسها.', ja: 'ひとり道場で、竹刀を頭上に振りかぶる花。' }), safeArea: { x: 0.36, y: 0, width: 0.31, height: 0.5 }, avoid: [{ x: 0.43, y: 0, width: 0.17, height: 0.12 }], anchors: [{ id: 'hana', x: 0.535, y: 0.28, head: { x: 0.53, y: 0.22 }, face: { x: 0.49, y: 0.14, width: 0.11, height: 0.17 } }] }, doorway: { alt: t({ en: 'Sora peeks round the sliding door, holding a shrine charm.', es: 'Sora se asoma por la puerta corredera con un amuleto en la mano.', ca: 'En Sora treu el cap per la porta corredissa amb un amulet a la mà.', fr: 'Sora passe la tête par la porte coulissante, une amulette à la main.', de: 'Sora späht mit einem Schreinamulett in der Hand um die Schiebetür.', it: 'Sora si affaccia dalla porta scorrevole, con un amuleto in mano.', pt: 'Sora espia pela porta de correr com um amuleto na mão.', zh: '空从拉门后探出头,手里攥着护身符。', ar: 'سورا يطلّ من وراء الباب المنزلق وفي يده تميمة.', ja: 'お守りを手に、引き戸の陰からのぞく空。' }), safeArea: { x: 0.37, y: 0.09, width: 0.34, height: 0.5 }, avoid: [{ x: 0.49, y: 0.41, width: 0.08, height: 0.1 }], anchors: [{ id: 'sora', x: 0.545, y: 0.31, head: { x: 0.55, y: 0.22 }, face: { x: 0.47, y: 0.16, width: 0.2, height: 0.18 } }] }, closeup: { alt: t({ en: "Close-up of Hana's sweating, startled face.", es: 'Primer plano del rostro sudoroso y sobresaltado de Hana.', ca: 'Primer pla de la cara suada i sobresaltada de la Hana.', fr: 'Gros plan sur le visage en sueur et surpris de Hana.', de: 'Nahaufnahme von Hanas verschwitztem, erschrockenem Gesicht.', it: 'Primo piano del volto sudato e sorpreso di Hana.', pt: 'Primeiro plano do rosto suado e assustado de Hana.', zh: '花满脸是汗、吃了一惊的特写。', ar: 'لقطة قريبة لوجه هانا المتعرّق المذعور.', ja: '汗をかき、はっとした花の顔のアップ。' }), safeArea: { x: 0.27, y: 0.28, width: 0.43, height: 0.51 }, anchors: [{ id: 'hana', x: 0.475, y: 0.665, head: { x: 0.45, y: 0.35 }, face: { x: 0.3, y: 0.3, width: 0.4, height: 0.45 } }] }, charm: { alt: t({ en: 'Sora holds out the charm; Hana looks at it, sword on her shoulder.', es: 'Sora le tiende el amuleto; Hana lo mira con la espada al hombro.', ca: 'En Sora li allarga l’amulet; la Hana el mira amb l’espasa a l’espatlla.', fr: 'Sora tend l’amulette ; Hana la regarde, le sabre sur l’épaule.', de: 'Sora hält ihr das Amulett hin; Hana betrachtet es, das Schwert auf der Schulter.', it: 'Sora porge l’amuleto; Hana lo guarda, con la spada sulla spalla.', pt: 'Sora estende o amuleto; Hana olha para ele com a espada no ombro.', zh: '空递出护身符,花扛着竹剑看着它。', ar: 'سورا يمدّ التميمة، وهانا تنظر إليها والسيف على كتفها.', ja: '空がお守りを差し出し、竹刀を肩にかついだ花がそれを見る。' }), safeArea: { x: 0.21, y: 0.09, width: 0.56, height: 0.53 }, avoid: [{ x: 0.43, y: 0.47, width: 0.06, height: 0.09 }], anchors: [{ id: 'sora', x: 0.325, y: 0.335, head: { x: 0.29, y: 0.22 }, face: { x: 0.26, y: 0.17, width: 0.11, height: 0.2 } }, { id: 'hana', x: 0.675, y: 0.335, head: { x: 0.665, y: 0.22 }, face: { x: 0.62, y: 0.17, width: 0.11, height: 0.2 } }] }, coach: { alt: t({ en: 'Coach Mori in the doorway, backlit, arms crossed.', es: 'El entrenador Mori en la puerta, a contraluz, de brazos cruzados.', ca: 'L’entrenador Mori a la porta, a contrallum, de braços plegats.', fr: 'L’entraîneur Mori sur le seuil, à contre-jour, bras croisés.', de: 'Trainer Mori in der Tür, im Gegenlicht, mit verschränkten Armen.', it: 'L’allenatore Mori sulla porta, in controluce, a braccia conserte.', pt: 'O treinador Mori na porta, contra a luz, de braços cruzados.', zh: '森老师站在门口,背着光,双臂抱在胸前。', ar: 'المدرّب موري عند الباب، والضوء خلفه، عاقدًا ذراعيه.', ja: '逆光の戸口で腕を組んで立つ森先生。' }), safeArea: { x: 0.33, y: 0.13, width: 0.3, height: 0.32 }, anchors: [{ id: 'mori', x: 0.425, y: 0.215, head: { x: 0.42, y: 0.17 }, face: { x: 0.37, y: 0.13, width: 0.1, height: 0.11 } }] }, smile: { alt: t({ en: "Close-up of Mori's stern face breaking into a small smile.", es: 'Primer plano del rostro severo de Mori, que esboza una sonrisa.', ca: 'Primer pla de la cara severa d’en Mori, que esbossa un somriure.', fr: 'Gros plan sur le visage sévère de Mori, qui esquisse un sourire.', de: 'Nahaufnahme von Moris strengem Gesicht, das sich zu einem kleinen Lächeln löst.', it: 'Primo piano del volto severo di Mori, che accenna un sorriso.', pt: 'Primeiro plano do rosto severo de Mori, que esboça um sorriso.', zh: '森老师严肃的脸上露出一丝笑意的特写。', ar: 'لقطة قريبة لوجه موري الصارم وقد ارتسمت عليه ابتسامة.', ja: '厳しい顔がふっとゆるむ森先生のアップ。' }), safeArea: { x: 0.43, y: 0.21, width: 0.51, height: 0.53 }, anchors: [{ id: 'mori', x: 0.69, y: 0.52, head: { x: 0.6, y: 0.25 }, face: { x: 0.45, y: 0.28, width: 0.45, height: 0.32 } }] }, kneel: { alt: t({ en: 'Kneeling, Hana ties the charm to her armour; Sora scratches his head.', es: 'De rodillas, Hana se ata el amuleto a la armadura; Sora se rasca la cabeza.', ca: 'De genolls, la Hana es lliga l’amulet a l’armadura; en Sora es grata el cap.', fr: 'À genoux, Hana attache l’amulette à son armure ; Sora se gratte la tête.', de: 'Kniend bindet Hana das Amulett an ihre Rüstung; Sora kratzt sich am Kopf.', it: 'In ginocchio, Hana lega l’amuleto all’armatura; Sora si gratta la testa.', pt: 'De joelhos, Hana amarra o amuleto na armadura; Sora coça a cabeça.', zh: '花跪坐着把护身符系在护具上,空挠着头。', ar: 'هانا جاثية تربط التميمة بدرعها، وسورا يحكّ رأسه.', ja: '正座してお守りを防具に結ぶ花と、頭をかく空。' }), safeArea: { x: 0.21, y: 0.08, width: 0.61, height: 0.48 }, avoid: [{ x: 0.27, y: 0.43, width: 0.06, height: 0.12 }], anchors: [{ id: 'hana', x: 0.305, y: 0.245, head: { x: 0.3, y: 0.17 }, face: { x: 0.25, y: 0.1, width: 0.11, height: 0.17 } }, { id: 'sora', x: 0.7, y: 0.25, head: { x: 0.71, y: 0.15 }, face: { x: 0.64, y: 0.1, width: 0.13, height: 0.18 } }] }, roof: { alt: t({ en: 'Seen from behind, Hana and Sora sit on the school roof at sunset.', es: 'De espaldas, Hana y Sora sentados en la azotea del instituto al atardecer.', ca: 'D’esquena, la Hana i en Sora asseguts al terrat de l’institut a la posta.', fr: 'De dos, Hana et Sora assis sur le toit du lycée au coucher du soleil.', de: 'Von hinten: Hana und Sora sitzen bei Sonnenuntergang auf dem Schuldach.', it: 'Di spalle, Hana e Sora seduti sul tetto della scuola al tramonto.', pt: 'De costas, Hana e Sora sentados no terraço da escola ao pôr do sol.', zh: '夕阳下,花和空坐在学校楼顶的背影。', ar: 'هانا وسورا جالسان على سطح المدرسة عند الغروب، من الخلف.', ja: '夕焼けの屋上に並んで座る花と空の後ろ姿。' }), safeArea: { x: 0.24, y: 0.41, width: 0.47, height: 0.31 }, anchors: [{ id: 'hana', x: 0.36, y: 0.52, head: { x: 0.36, y: 0.48 }, face: { x: 0.31, y: 0.43, width: 0.11, height: 0.14 } }, { id: 'sora', x: 0.63, y: 0.52, head: { x: 0.625, y: 0.47 }, face: { x: 0.58, y: 0.41, width: 0.1, height: 0.15 } }] }, }; const panels = () => Promise.all([ comicPanel('dojo', asset('kendo-dojo.jpg'), ART.dojo), comicPanel('swing', asset('kendo-swing.jpg'), ART.swing), comicPanel('doorway', asset('kendo-doorway.jpg'), ART.doorway), comicPanel('closeup', asset('kendo-closeup.jpg'), ART.closeup), comicPanel('charm', asset('kendo-charm.jpg'), ART.charm), comicPanel('coach', asset('kendo-coach.jpg'), ART.coach), comicPanel('smile', asset('kendo-smile.jpg'), ART.smile), comicPanel('kneel', asset('kendo-kneel.jpg'), ART.kneel), comicPanel('roof', asset('kendo-roof.jpg'), ART.roof), ]); // #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: 1 } }, edition(lang)), { ...kitFonts(FONTS), text: TEXT[lang] }); }; const docs = [await build('ja'), await build(FACING)]; // #endregion const TITLE = t({ en: 'A manga read right to left', es: 'Un manga que se lee de derecha a izquierda', ca: 'Un manga que es llegeix de dreta a esquerra', zh: '从右往左读的漫画', fr: 'Un manga qui se lit de droite à gauche', de: 'Ein Manga, der von rechts nach links gelesen wird', it: 'Un manga che si legge da destra a sinistra', pt: 'Um mangá lido da direita para a esquerda', ar: 'مانغا تُقرأ من اليمين إلى اليسار', ja: '右から左へ読む漫画' }); showBook(docs, { title: TITLE }); offerPdf(() => renderToPdf(docs, { fontProvider: comicPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, images, cjk, arabic, comics: uguale in ogni ricetta · 740 righe
// ─── 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Errori frequenti
Errore frequente
Assegna a un testo giapponese il tag 'ja', mai zh-Hans o LANG
Le edizioni di una ricetta sono en ed es, ma un esempio giapponese è giapponese in entrambe: `locale: LANG` gli darebbe il tag inglese o spagnolo, e un tag cinese lo comporrebbe con le regole cinesi (punteggiatura Kaiming, kana piccoli liberi di aprire una riga, 图 per 図, forme cinesi dei glifi nel PDF). Da postext 1.30 il tag è anche la lingua usata per lo shaping del testo (misura, disegno e incorporamento), quindi qualunque tag diverso da 'ja' dà forme dei glifi diverse da quelle giapponesi (quelle cinesi con un tag cinese) e altri a capo. Scrivi 'ja': sceglie la regione Giappone (divisione in righe e punteggiatura JLReq, segni d’enfasi a sesamo, spaziatura dei furigana, etichette 図 e 表) e disattiva la sillabazione. Il lint fa fallire un testo con kana sotto un tag zh o ko. Divisione in righe del giapponese (kinsoku) →
Errore frequente
I caratteri cinesi si caricano a fette, tramite il blocco cjk
Fontsource serve una famiglia cinese, giapponese o coreana come un centinaio di file per peso, ognuno dei quali copre un intervallo di caratteri. loadFonts scarica solo il file latin, così a schermo i caratteri Han vengono da un font di sistema e sono misurati male, e fontsourceProvider passa al PDF quel file latin, che li stampa come riquadri vuoti. Elenca il blocco cjk del kit, chiama loadCjkFonts(FONTS, markdown) dopo loadFonts (una volta per voce tipografica, con il testo che compone, quando il libro usa più caratteri CJK) e passa a renderToPdf fontProvider: cjkPdfProvider: entrambi prendono i file che contengono i caratteri del testo. Font cinesi, giapponesi e coreani →
- Metti a destra il primo personaggio che parla in una vignetta. Le nuvolette seguono l’ordine di lettura, quindi nella vignetta in cui Sora consegna l’amuleto il copione dà la prima battuta a Hana, che sta a destra. Con Sora per primo, le sue nuvolette restano dalla sua parte della vignetta, ma le colonne giapponesi non lasciano spazio sotto di esse per la risposta di Hana: finisce accanto a loro sulla destra e viene letta per prima.
- Una nuvoletta che non trova spazio nella sua vignetta può superarne il bordo, e il motore lo segnala (
comicBalloonOverflow). Dai a una vignetta affollata una cella più grande nella suddivisione prima di accorciare il dialogo. - Una figura piccola in una vignetta alta non lascia spazio accanto a sé per due nuvolette impilate: nella vignetta della soglia la seconda battuta dell’allenatore gli finiva sopra dal petto alle ginocchia nelle edizioni composte in righe. I loro copioni fissano la sua prima battuta accanto alla testa (
mori{at="85% 19%"}), e la battuta unita a essa la segue. La pagina cinese spezza a mano una nuvoletta, con una barra rovesciata alla fine di ogni riga, dove le colonne tagliavano in due 其实.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- 前の晩 (The Night Before), a two-page kendo manga written for this recipe, with its English, Spanish, Catalan, Chinese and Arabic letterings · Postext Cookbook · CC BY 4.0
- Immagini
- The school's old wooden kendo dojo at dusk, its doors open and lit · Generated With Diffusion Models · originale
- Hana alone in the dojo, swinging her bamboo sword overhead · Generated With Diffusion Models · originale
- Sora peeking round the sliding door with a shrine charm · Generated With Diffusion Models · originale
- Close-up of Hana's sweating face · Generated With Diffusion Models · originale
- Sora holding out the charm to Hana · Generated With Diffusion Models · originale
- Coach Mori in the doorway, backlit, arms crossed · Generated With Diffusion Models · originale
- Close-up of Coach Mori's small smile · Generated With Diffusion Models · originale
- Hana tying the charm to her armour while Sora scratches his head · Generated With Diffusion Models · originale
- Hana and Sora on the school roof at sunset, seen from behind · Generated With Diffusion Models · originale
- Font
- 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)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


