Vai al contenuto principale
Ricetta numero 127

Ricettario · Capitolo 2 · Caratteri e testo

Un albo illustrato in hiragana, spaziato per frasi

Un albo illustrato verticale in hiragana con uno spazio tra le frasi (wakachigaki): keep-all tiene intera ogni frase e le righe vanno a capo solo agli spazi.

In questa pagina
Output
Canvas · PDF
Livello
Intermedio
Postext
Provata con Postext 1.31.0
Richiede ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Licenza
Aggiornata il 10 ott 2026
Codice MIT · Testo CC BY 4.0
  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 260 × 200 mm
  • Colonna e mezza, Intercolonna di 14,1 mm
  • Klee One 20/36
  • Zen Maru Gothic
  • 10 pagine
  • Livello
  • Postext 1.30.0
  • Impaginato in 60 ms
  • 136 righe di codice

In breve

Un albo illustrato giapponese per bambini piccoli, tutto in hiragana, con uno spazio dopo ogni gruppo di parole e righe che non ne spezzano mai uno, sotto un’illustrazione in ogni pagina.

Che cosa comporrai

Un albo illustrato di dieci pagine, かみの ふね (La barchetta di carta), scritto per la ricetta e illustrato con dieci dipinti: una bambina piega una barchetta di carta rossa dopo la pioggia, e la barchetta scende lungo il fiume oltre una rana, un airone e la luna fino al mare. È composto come si compongono i libri giapponesi per i lettori più piccoli: solo hiragana, 20 pt in Klee One, una scrittura modellata sul carattere dei libri di scuola, e uno spazio tra le frasi, 分かち書き (wakachigaki), così un bambino legge una frase alla volta. Le pagine sono orizzontali, 260 × 200 mm, verticali e rilegate a destra: l’immagine nella parte alta della pagina, brevi righe di otto caratteri sotto, e la barchetta che viaggia verso sinistra man mano che si girano le pagine.

Questa ricetta risponde a

  • Come spazio un libro illustrato giapponese per frasi (wakachigaki), con righe che vanno a capo solo negli spazi?
  • Perché i kana piccoli, ー e le parentesi di chiusura non possono aprire una riga in giapponese, e come scelgo quanto è severa la regola?
  • Quali font compongono il giapponese, con i kanji nelle loro forme giapponesi, a schermo e nel PDF?

La risposta breve

script.js · righe 40–50nel codice completo
// The story is typed with an ordinary space (U+0020) after each phrase (文節). Japanese
// text may break between any two kana, so the space alone does not keep a phrase whole:
// keep-all drops the breaks between two letters and keeps those at a space, after 、。」
// and before 「. Kinsoku still applies.
const phrases = { wordBreak: 'keep-all' }; // cjk.wordBreak
const bodyText = {
  fontFamily: HAND, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  // Ragged: a justified line would stretch its spaces and its kana to fill the tier.
  textAlign: 'left', firstLineIndent: em(0),
};

Ingredienti

Tipo
Klee One, Zen Maru Gothic (SIL OFL 1.1)
Risorse
  • bridge-1400.jpg
  • cover-1430.jpg
  • dusk-1400.jpg
  • frog-1400.jpg
  • heron-1400.jpg
  • night-1400.jpg
  • rain-1400.jpg
  • sea-1400.jpg
  • stream-1400.jpg
  • window-1400.jpg
  • The cover: a red paper boat on a river through green meadows, a clear sky on the right for the title (Generated With Diffusion Models, originale)
  • Mii-chan folding the red paper boat at a low table by the window, after the rain (Generated With Diffusion Models, originale)
  • Mii-chan waving goodbye to the boat on the stream (Generated With Diffusion Models, originale)
  • A frog on a stone talking to the boat (Generated With Diffusion Models, originale)
  • The boat under a wooden footbridge, little fish following it (Generated With Diffusion Models, originale)
  • A white heron on the bank watching the boat on the wide river (Generated With Diffusion Models, originale)
  • Dusk: a pink sky over the river mouth and the sea beyond (Generated With Diffusion Models, originale)
  • Night: the full moon's reflection lighting the boat's way (Generated With Diffusion Models, originale)
  • The boat at sea, greeted by seagulls (Generated With Diffusion Models, originale)
  • Mii-chan at her window, looking towards the distant sea (Generated With Diffusion Models, originale)

Preparazione

#1 · Tieni intera ogni frase

Il codice è la risposta breve qui sopra. Il testo giapponese può andare a capo tra due kana qualsiasi, quindi uno spazio tra le frasi aggiunge solo spazio; da solo non impedisce a una riga di finire dentro あか|い. cjk.wordBreak: 'keep-all' mantiene solo le interruzioni a uno spazio e accanto alla punteggiatura, dopo 、。」 o prima di 「, come fa word-break: keep-all in CSS, e le regole kinsoku restano valide (East Asian typography). Lo spazio è un normale U+0020, 0,35 em in Klee One, e una riga lo elimina alla sua fine. Anche un U+3000 è un’interruzione di frase, ma è un carattere fisso largo un em, che resta alla fine della riga.

Pagina 3: otto righe, ognuna che finisce a uno spazio o dopo 」 e 、: 「いって / らっしゃい。」 / みいちゃんが / そっと てを…

Il testo è composto a bandiera. Giustificata, una riga corta allargherebbe i suoi spazi e gli intervalli tra i kana per raggiungere il piede della fascia; un albo illustrato chiude ogni riga dove finisce la sua ultima frase.

#2 · Metti le immagini in una fascia sopra il testo

script.js · righe 54–62nel codice completo
// In vertical text 'left' is the top of the page: the side column is a tier that takes
// the pictures (floats), and the text runs in the tier under it, right to left.
const layout = {
  layoutType: 'oneAndHalf', writingMode: 'vertical-rl', sideColumnRole: 'floats',
  sideColumnSide: 'left', sideColumnPercent: 58, gutterWidth: pt(2 * BODY),
};
const cjk = { ...phrases, grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
const resourceTypes = [{ id: 'picture', name: 'え', shortLabel: 'え', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // pictures, no captions

Nel testo verticale 'left' è la testa della pagina, quindi un layout a colonna e mezza con la colonna laterale a sinistra diventa due fasce: le immagini flottano nella fascia superiore (profonda 14 caratteri) e il testo scorre in quella inferiore (8 caratteri). Ogni pagina cita la sua immagine con ::resource{id="…"} e finisce con :::pagebreak. Il tipo immagine non ha prefisso di didascalia, e le immagini sono 2,186 volte più larghe che alte, la forma della fascia, così ognuna la riempie (vertical writing).

#3 · Fai della copertina un’immagine a tutta pagina

script.js · righe 66–83nel codice completo
// In the flow frame of a vertical page x runs down the sheet and y leftward from its right
// edge; a box's width runs down the sheet.
const at = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const cover = {
  id: 'cover', numbered: false, toc: false, span: 'page', header: { elements: [] },
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: mm(TRIM.w - 40), slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover', decorative: true,
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(TRIM.h), height: mm(TRIM.w) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontWeight: 700,
      fontSize: pt(60), lineHeight: 1, letterSpacing: pt(6), color: col('boat'),
      placement: at(22, 34) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', fontFamily: ROUND, fontWeight: 700,
      fontSize: pt(9), lineHeight: 1.4, color: col('ink'), placement: at(22, 62) },
  ] } },
};

La copertina è un design di titolo a tutta pagina: l’immagine è ancorata all’abbondanza con le dimensioni della pagina (nel flusso verticale la sua width scorre lungo il foglio), e il titolo è composto lungo il cielo a destra in Zen Maru Gothic, 60 pt.

#4 · Carica i dipinti come bitmap

script.js · righe 188–203nel codice completo
// The scenes have the tier's shape, 612 : 280 (ART); the cover has the page's, 260 : 200.
const PICTURES = { // id: [file, width, height, alt text in the book's language]
  cover: ['cover-1430.jpg', 1430, 1100, 'あおい かわに あかい かみの ふねが うかんで いる。'],
  rain: ['rain-1400.jpg', 1400, 641, 'まどべの ちゃぶだいで、みいちゃんが あかい かみで ふねを おって いる。'],
  stream: ['stream-1400.jpg', 1400, 641, 'みいちゃんが おがわに ふねを ながし、てを ふって いる。'],
  frog: ['frog-1400.jpg', 1400, 641, 'かわの いしの うえの かえるが、ながれて いく ふねに はなしかけて いる。'],
  bridge: ['bridge-1400.jpg', 1400, 641, 'きの はしの したを ふねが くぐり、ちいさな さかなが ついて くる。'],
  heron: ['heron-1400.jpg', 1400, 641, 'ひろい かわの きしで、しろい さぎが ふねを みおくって いる。'],
  dusk: ['dusk-1400.jpg', 1400, 641, 'ももいろの ゆうやけの かわに ふねが うかび、とおくに うみが みえる。'],
  night: ['night-1400.jpg', 1400, 641, 'まんげつが かわに うつり、ひかりの みちの そばを ふねが すすむ。'],
  sea: ['sea-1400.jpg', 1400, 641, 'あおい うみに でた ふねの うえを、かもめが とんで いる。'],
  window: ['window-1400.jpg', 1400, 641, 'みいちゃんが まどから、まちの むこうの うみを みて いる。'],
};
const pictures = Object.entries(PICTURES).map(([id, [fileId, w, h, altText]]) => ({
  id, typeId: 'picture', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText,
  placement: { span: 'side' }, bitmap: { fileId, format: 'jpeg', width: w, height: h } }));

Le dieci immagini sono dipinti, file JPEG in assets/ della ricetta. Le nove scene sono tagliate sulla forma della fascia, 612 : 280, e la copertina su quella della pagina, 260 : 200, così ognuna riempie la sua cornice e nulla viene ritagliato. Ognuna è una risorsa bitmap dichiarata alla sua dimensione in pixel, con il testo alternativo nella lingua del libro, e il suo file si carica sotto il fileId della risorsa prima della composizione. Le scene larghe 1.400 px si stampano a circa 165 dpi sulla fascia (resource types).

La ricetta completa

Sandbox
// ═══ Postext Cookbook · Nº 127 · A hiragana picture book, spaced by phrase ══════════
// https://postext.dev/en/cookbook/hiragana-picture-book
// Code: MIT · Story: written for the recipe (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Klee One, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// A picture book for small readers: hiragana only, a space between phrases, vertical.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the frame; the story is Japanese in both editions
const RECIPE = 'hiragana-picture-book';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the paper boat's red, river blues, a warm paper
const palette = {
  ink: '#2e2a26', // the text: a soft near-black
  boat: '#cf4430', // the boat, the title (4.6:1 on the paper)
  river: '#5d93ad',
  grass: '#86b665',
  muted: '#6e675f', // the colophon
  paper: '#fbf6ea',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'boat (defaults)', value: { hex: palette.boat, model: 'hex' } },
];
// #endregion

const HAND = 'Klee One'; // 教科書体: the hand a child learns to write kana from
const ROUND = 'Zen Maru Gothic'; // the cover's title
const [BODY, LEAD] = [20, 36]; // pt: large kana, a line gap of 0.8 em
const PT = 25.4 / 72; // mm in a point
const TRIM = { w: 260, h: 200 }; // mm: a landscape picture book (横長)
const [CHARS, PICTURE, LINES] = [8, 14, 17]; // characters: text tier, picture tier; lines
const ART = { w: LINES * LEAD * PT, h: PICTURE * BODY * PT }; // mm: 215.9 × 98.8

// #region answer: わかち書き — a space between phrases, and lines that break only there
// The story is typed with an ordinary space (U+0020) after each phrase (文節). Japanese
// text may break between any two kana, so the space alone does not keep a phrase whole:
// keep-all drops the breaks between two letters and keeps those at a space, after 、。」
// and before 「. Kinsoku still applies.
const phrases = { wordBreak: 'keep-all' }; // cjk.wordBreak
const bodyText = {
  fontFamily: HAND, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  // Ragged: a justified line would stretch its spaces and its kana to fill the tier.
  textAlign: 'left', firstLineIndent: em(0),
};
// #endregion

// #region layout: the picture tier above, eight characters of text below it
// In vertical text 'left' is the top of the page: the side column is a tier that takes
// the pictures (floats), and the text runs in the tier under it, right to left.
const layout = {
  layoutType: 'oneAndHalf', writingMode: 'vertical-rl', sideColumnRole: 'floats',
  sideColumnSide: 'left', sideColumnPercent: 58, gutterWidth: pt(2 * BODY),
};
const cjk = { ...phrases, grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
const resourceTypes = [{ id: 'picture', name: 'え', shortLabel: 'え', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // pictures, no captions
// #endregion

// #region cover: the first picture across the page, the title down its sky
// In the flow frame of a vertical page x runs down the sheet and y leftward from its right
// edge; a box's width runs down the sheet.
const at = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const cover = {
  id: 'cover', numbered: false, toc: false, span: 'page', header: { elements: [] },
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: mm(TRIM.w - 40), slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover', decorative: true,
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(TRIM.h), height: mm(TRIM.w) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontWeight: 700,
      fontSize: pt(60), lineHeight: 1, letterSpacing: pt(6), color: col('boat'),
      placement: at(22, 34) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', fontFamily: ROUND, fontWeight: 700,
      fontSize: pt(9), lineHeight: 1.4, color: col('ink'), placement: at(22, 62) },
  ] } },
};
// #endregion

const config = () => ({
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(14), bottom: mm(16), left: mm(22), right: mm(22), mirror: true },
  },
  layout,
  cjk,
  bodyText,
  headings: { fontFamily: ROUND, color: col('boat'),
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }] }, // the H1 break
  headingStyles: [cover],
  paragraphStyles: [
    { id: 'end', fontFamily: ROUND, fontWeight: 700, color: col('boat'), textAlign: 'right',
      marginTop: pt(LEAD) }, // おしまい at the foot of its line
    { id: 'colophon', fontFamily: HAND, fontSize: pt(7.5), lineHeight: pt(LEAD),
      color: col('muted'), textAlign: 'left', marginTop: pt(LEAD) },
  ],
  header: { elements: [] }, // a picture book has no running heads and no folios
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Esempio Markdown · 69 righe · content.en.mdtitle: "かみの ふね" author: "Postext Cookbook" --- # かみの ふね {style="cover" latin="The Paper Boat: a picture book in hiragana"} ::resource{id="rain"} あめが やんだ あさ、みいちゃんは あかい かみで ふねを おりました。 :::pagebreak ::resource{id="stream"} 「いって らっしゃい。」みいちゃんが そっと てを はなすと、ふねは すいすい ながれて いきました。 :::pagebreak ::resource{id="frog"} いしの うえで、かえるが ききました。「どこへ いくの。」「うみまで。」と、ふねは こたえました。 :::pagebreak ::resource{id="bridge"} はしの したを くぐると、ちいさな さかなが ついて きました。ぴちゃん、ぴちゃん。 :::pagebreak ::resource{id="heron"} かわは だんだん ひろく なりました。きしでは さぎが いっぽんあしで みおくって います。 :::pagebreak ::resource{id="dusk"} ゆうがた、そらが ももいろに そまりました。かぜの なかに、うみの においが します。 :::pagebreak ::resource{id="night"} よるに なりました。おつきさまが みずに うつって、ふねの みちを てらして くれました。 :::pagebreak ::resource{id="sea"} あさ、ふねは ひろい うみに でました。かもめが「ようこそ。」と いいました。 :::pagebreak ::resource{id="window"} とおくの まちで、みいちゃんも まどを あけて、うみの ほうを みて いました。 :::paragraphs{style="end"} おしまい ::: :::paragraphs{style="colophon"} Story written for this recipe (CC BY 4.0) Pictures: paintings Fonts: Klee One, Zen Maru Gothic (SIL OFL) :::
`; // content.<lang>.md, a space after each phrase // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Klee One': ['400'], 'Zen Maru Gothic': ['700'] }; // #region pictures: ten paintings, declared at their pixel size under the file ids they load as // The scenes have the tier's shape, 612 : 280 (ART); the cover has the page's, 260 : 200. const PICTURES = { // id: [file, width, height, alt text in the book's language] cover: ['cover-1430.jpg', 1430, 1100, 'あおい かわに あかい かみの ふねが うかんで いる。'], rain: ['rain-1400.jpg', 1400, 641, 'まどべの ちゃぶだいで、みいちゃんが あかい かみで ふねを おって いる。'], stream: ['stream-1400.jpg', 1400, 641, 'みいちゃんが おがわに ふねを ながし、てを ふって いる。'], frog: ['frog-1400.jpg', 1400, 641, 'かわの いしの うえの かえるが、ながれて いく ふねに はなしかけて いる。'], bridge: ['bridge-1400.jpg', 1400, 641, 'きの はしの したを ふねが くぐり、ちいさな さかなが ついて くる。'], heron: ['heron-1400.jpg', 1400, 641, 'ひろい かわの きしで、しろい さぎが ふねを みおくって いる。'], dusk: ['dusk-1400.jpg', 1400, 641, 'ももいろの ゆうやけの かわに ふねが うかび、とおくに うみが みえる。'], night: ['night-1400.jpg', 1400, 641, 'まんげつが かわに うつり、ひかりの みちの そばを ふねが すすむ。'], sea: ['sea-1400.jpg', 1400, 641, 'あおい うみに でた ふねの うえを、かもめが とんで いる。'], window: ['window-1400.jpg', 1400, 641, 'みいちゃんが まどから、まちの むこうの うみを みて いる。'], }; const pictures = Object.entries(PICTURES).map(([id, [fileId, w, h, altText]]) => ({ id, typeId: 'picture', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText, placement: { span: 'side' }, bitmap: { fileId, format: 'jpeg', width: w, height: h } })); // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadCjkFonts({ [HAND]: FONTS[HAND] }, markdown, { vertical: true }); await loadCjkFonts({ [ROUND]: FONTS[ROUND] }, 'かみのふねおしまい', { vertical: true }); // A design slot or ::resource names a resource, the resource a file id: load each file. await Promise.all(Object.values(PICTURES).map(([file]) => loadImage(file, asset(file)))); const doc = await withLoadedFonts( () => buildDocument({ markdown, resources: pictures }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'A hiragana picture book', es: 'Un libro ilustrado en hiragana' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, images, cjk: uguale in ogni ricetta · 504 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 ───────────────────────────────────────────────────────────────────────

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)

Varianti

#Giustifica le righe

Giustificata, ogni riga raggiunge il piede della fascia: prima si allargano gli spazi tra le frasi, fino a mezzo em, poi gli intervalli tra i kana (JLReq §3.8). Una riga corta di kana da 20 pt mostra allora intervalli larghi, ed è per questo che la ricetta lascia le righe a bandiera.

-  textAlign: 'left', firstLineIndent: em(0),
+  textAlign: 'justify', firstLineIndent: em(0),

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

Componi il giapponese in un carattere giapponese

Noto Serif SC e TC hanno i kana, ma disegnano i kanji in forme cinesi (直, 骨 e 角 sono diversi) e i kana con un disegno cinese. Componi il testo in Noto Serif JP o Shippori Mincho B1 e i titoli in Noto Sans JP, caricati con loadCjkFonts. I file giapponesi di Fontsource non contengono hentaigana né altri kana storici (U+1B000–1B16F): loadCjkFonts fallisce su di essi e il PDF stampa riquadri, quindi scrivi i kana moderni o includi negli asset della ricetta un carattere che li abbia. A Shippori Mincho mancano anche le vocali con macron ō e ū: componi il rōmaji in un carattere latino. Kana, kanji e rōmaji →

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 →

  • keep-all mantiene le interruzioni che il testo ha già: Postext non trova le frasi, quindi è l’autore a digitare gli spazi. Una frase più lunga della riga si spezza comunque al suo interno, nell’ultimo punto che le regole kinsoku consentono.
  • Il cinese non spazia mai le parole, e il sillabario cinese A vertical reader with zhuyin to the right aiuta invece i suoi giovani lettori con le letture; un albo illustrato giapponese non usa kanji, quindi non ha bisogno di furigana, e sono gli spazi tra le frasi a guidare la lettura.

Crediti

Testo
  • かみの ふね (The Paper Boat), a picture-book story in hiragana written for this recipe · Postext Cookbook · CC BY 4.0
Immagini
  • The cover: a red paper boat on a river through green meadows, a clear sky on the right for the title · Generated With Diffusion Models · originale
  • Mii-chan folding the red paper boat at a low table by the window, after the rain · Generated With Diffusion Models · originale
  • Mii-chan waving goodbye to the boat on the stream · Generated With Diffusion Models · originale
  • A frog on a stone talking to the boat · Generated With Diffusion Models · originale
  • The boat under a wooden footbridge, little fish following it · Generated With Diffusion Models · originale
  • A white heron on the bank watching the boat on the wide river · Generated With Diffusion Models · originale
  • Dusk: a pink sky over the river mouth and the sea beyond · Generated With Diffusion Models · originale
  • Night: the full moon's reflection lighting the boat's way · Generated With Diffusion Models · originale
  • The boat at sea, greeted by seagulls · Generated With Diffusion Models · originale
  • Mii-chan at her window, looking towards the distant sea · Generated With Diffusion Models · originale
Font
Klee One (SIL OFL 1.1) · Zen Maru Gothic (SIL OFL 1.1)
SandboxPDF