Kurz gesagt
Ein japanisches Bilderbuch für kleine Kinder, ganz in Hiragana, mit einem Leerzeichen nach jeder Wortgruppe und Zeilen, die nie eine davon teilen, unter einem Bild auf jeder Seite.
Was Sie setzen
Ein zehnseitiges Bilderbuch, かみの ふね (Das Papierschiff), für das Rezept geschrieben und mit zehn Gemälden illustriert: Ein Mädchen faltet nach dem Regen ein rotes Papierschiff, und das Schiff reist flussabwärts an einem Frosch, einem Reiher und dem Mond vorbei bis zum Meer. Es ist so gesetzt, wie japanische Bücher für die jüngsten Leser gesetzt werden: nur Hiragana, 20 pt in der Klee One, einer Handschrift nach dem Vorbild der Schulbuchschrift, und ein Leerzeichen zwischen den Phrasen, 分かち書き (Wakachigaki), sodass ein Kind eine Phrase nach der anderen liest. Die Seiten sind quer, 260 × 200 mm, vertikal und rechts gebunden: das Bild über den oberen Teil der Seite, darunter kurze Zeilen zu acht Zeichen, und das Schiff reist beim Umblättern nach links.
Dieses Rezept beantwortet
- Wie setze ich ein japanisches Bilderbuch mit Abständen zwischen Wortgruppen (Wakachigaki), mit Zeilen, die nur an den Abständen umbrechen?
- Warum dürfen kleine Kana, ー und schließende Klammern keine japanische Zeile beginnen, und wie wähle ich, wie streng die Regel ist?
- Welche Schriften setzen Japanisch, mit Kanji in ihren japanischen Formen, am Bildschirm und im PDF?
Die kurze Antwort
// 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),
};
Zutaten
- Funktionen
- Kana, Kanji und RōmajiSenkrechter TextJapanischer Zeilenumbruch (Kinsoku)Rechts gebundene BücherZeichenrasterChinesische, japanische und koreanische SchriftenAnderthalb SpaltenRandspalte für GleitobjekteEigene RessourcentypenBilder in SeitengestaltungenUmschläge, Titelseiten und ImpressumÜberschriftenformateÜberschriftenattributeAbsatzformateSeiten- und SpaltenumbrüchePapierfarbeSemantische FarbpalettePDF-ExportIm PDF eingebettete Schriften
- Nutzt außerdem
- Abbildungen genau hierGestaltete AuftakteAbbildungen und Tabellen als RessourcenKolumnentitel je AbschnittKapitel ohne Nummer
- Schrift
- Klee One, Zen Maru Gothic (SIL OFL 1.1)
- Dateien
bridge-1400.jpgcover-1430.jpgdusk-1400.jpgfrog-1400.jpgheron-1400.jpgnight-1400.jpgrain-1400.jpgsea-1400.jpgstream-1400.jpgwindow-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, eigener Text)
- Mii-chan folding the red paper boat at a low table by the window, after the rain (Generated With Diffusion Models, eigener Text)
- Mii-chan waving goodbye to the boat on the stream (Generated With Diffusion Models, eigener Text)
- A frog on a stone talking to the boat (Generated With Diffusion Models, eigener Text)
- The boat under a wooden footbridge, little fish following it (Generated With Diffusion Models, eigener Text)
- A white heron on the bank watching the boat on the wide river (Generated With Diffusion Models, eigener Text)
- Dusk: a pink sky over the river mouth and the sea beyond (Generated With Diffusion Models, eigener Text)
- Night: the full moon's reflection lighting the boat's way (Generated With Diffusion Models, eigener Text)
- The boat at sea, greeted by seagulls (Generated With Diffusion Models, eigener Text)
- Mii-chan at her window, looking towards the distant sea (Generated With Diffusion Models, eigener Text)
Zubereitung
#1 · Jede Phrase ganz halten
Der Code steht oben in der kurzen Antwort. Japanischer Text darf zwischen zwei beliebigen Kana umbrechen, ein Leerzeichen zwischen Phrasen schafft also nur Raum; für sich allein hindert es eine Zeile nicht daran, innerhalb von あか|い zu enden. cjk.wordBreak: 'keep-all' behält nur die Umbrüche an einem Leerzeichen und neben Satzzeichen, nach 、。」 oder vor 「, wie es CSS mit word-break: keep-all tut, und die Kinsoku-Regeln gelten weiterhin (Ostasiatische Typografie). Das Leerzeichen ist ein gewöhnliches U+0020, in der Klee One 0,35 em breit, und eine Zeile lässt es an ihrem Ende fallen. Ein U+3000 ist ebenfalls ein Phrasenumbruch, aber ein festes Zeichen von einem Geviert Breite, das am Zeilenende stehen bleibt.

Der Text steht im Flattersatz. Im Blocksatz würde eine kurze Zeile ihre Leerzeichen und die Abstände zwischen ihren Kana dehnen, um den Fuß der Zone zu erreichen; ein Bilderbuch setzt jede Zeile dorthin, wo ihre letzte Phrase endet.
#2 · Die Bilder in eine Zone über dem Text setzen
// 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
Im vertikalen Satz ist 'left' der Kopf der Seite, also ergibt ein anderthalbspaltiges Layout mit der Seitenspalte links zwei Zonen: Die Bilder gleiten in die obere Zone (14 Zeichen tief), und der Text läuft in der unteren (8 Zeichen). Jede Seite zitiert ihr Bild mit ::resource{id="…"} und endet mit :::pagebreak. Der Bildtyp hat kein Legendenpräfix, und die Bilder sind 2,186-mal so breit wie hoch, das Format der Zone, sodass jedes sie füllt (Vertikaler Satz).
#3 · Den Umschlag zu einem ganzseitigen Bild machen
// 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) },
] } },
};
Der Umschlag ist eine Überschriftengestaltung über die ganze Seitenbreite: Das Bild ist mit der Größe der Seite selbst am Anschnitt verankert (im vertikalen Fluss läuft seine width das Blatt hinunter), und der Titel steht rechts den Himmel hinunter in der Zen Maru Gothic in 60 pt.
#4 · Die Gemälde als Bitmaps laden
// 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 } }));
Die zehn Bilder sind Gemälde, JPEG-Dateien in assets/ des Rezepts. Die neun Szenen sind auf das Format der Zone zugeschnitten, 612 : 280, der Umschlag auf das der Seite, 260 : 200, sodass jedes seinen Rahmen füllt und nichts beschnitten wird. Jedes ist eine Ressource bitmap, mit ihrer Pixelgröße deklariert und mit Alternativtext in der Sprache des Buchs, und ihre Datei wird vor dem Build unter der fileId der Ressource geladen. Szenen von 1.400 px Breite werden über die Zone mit etwa 165 dpi gedruckt (Ressourcentypen).
Das ganze Rezept
// ═══ 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`---Markdown-Beispiel · 69 Zeilen · content.en.md
title: "かみの ふね" 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: in jedem Rezept gleich · 504 Zeilen
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · cjk v2 ── Chinese, Japanese and Korean books · postext.dev/cookbook // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { let at; cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1))) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], at).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` the screen sets code point `cp` in, if any: the * last one loadCjkFonts adds whose range holds it. A numbered file that * repeats a letter of latin is added after latin, and wins it. */ function cjkSliceFor(slices, cp) { const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to the engine's later round * gets the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files the * screen set its pages' characters in (`request.codePoints`), each marked * with its unicode-range and its place in the order loadCjkFonts added * them: postext-pdf sets a character two files hold in the one added * last, as the browser does. Any other family gets the latin file * fontsourceProvider fetches (the "pdf" block) and, when the face sets * letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.map(async (slice, order) => { if (!picked.has(slice)) return null; const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: slice.range, order }); })).then((files) => files.filter(Boolean)); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them, each marked with its unicode-range and its place in that order: * the PDF sets a character in the file the browser took it from. A face * Fontsource ships without latin-ext, or whose file does not come, gets * latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `${await fontsourceAt(id)}files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); const ranges = kitRanges(); return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }), Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Die Zeilen im Blocksatz setzen
Im Blocksatz reicht jede Zeile bis zum Fuß der Zone: Zuerst dehnen sich die Leerzeichen zwischen den Phrasen, bis zu einem halben Geviert, dann die Abstände zwischen den Kana (JLReq §3.8). Eine kurze Zeile aus Kana zu 20 pt zeigt dann weite Lücken, weshalb das Rezept seine Zeilen im Flattersatz lässt.
- textAlign: 'left', firstLineIndent: em(0),
+ textAlign: 'justify', firstLineIndent: em(0),Häufige Fehler
Häufiger Fehler
Zeichnen Sie japanischen Text mit 'ja' aus, nie mit zh-Hans oder LANG
Die Ausgaben eines Rezepts sind en und es, ein japanisches Beispiel ist aber in beiden Japanisch: `locale: LANG` würde es als Englisch oder Spanisch auszeichnen, und eine chinesische Auszeichnung würde es nach chinesischen Regeln setzen (Kaiming-Interpunktion, kleine Kana dürfen eine Zeile beginnen, 图 statt 図, chinesische Glyphenformen im PDF). Seit postext 1.30 ist die Auszeichnung auch die Sprache, in der der Text geformt wird (gemessen, gezeichnet und eingebettet). Jede Auszeichnung außer 'ja' liefert daher andere Glyphenformen als die japanischen (unter einer chinesischen Auszeichnung die chinesischen) und andere Zeilenumbrüche. Schreiben Sie 'ja': Das wählt die Region Japan (JLReq-Zeilenumbruch und -Interpunktion, Sesam-Betonungszeichen, Furigana-Abstände, die Beschriftungen 図 und 表) und schaltet die Silbentrennung ab. Der Lint schlägt bei einem Text mit Kana unter einer zh- oder ko-Auszeichnung fehl. Japanischer Zeilenumbruch (Kinsoku) →
Häufiger Fehler
Setzen Sie Japanisch in einer japanischen Schrift
Noto Serif SC und TC haben Kana, zeichnen die Kanji aber in chinesischen Formen (直, 骨 und 角 unterscheiden sich) und die Kana in chinesischer Gestaltung. Setzen Sie den Text in Noto Serif JP oder Shippori Mincho B1 und die Überschriften in Noto Sans JP, geladen mit loadCjkFonts. Die japanischen Dateien von Fontsource enthalten keine Hentaigana oder andere historische Kana (U+1B000–1B16F): loadCjkFonts scheitert daran und das PDF druckt Kästchen. Schreiben Sie also die modernen Kana oder liefern Sie eine Schrift, die sie hat, in den Assets des Rezepts mit. Shippori Mincho fehlen außerdem die Makronvokale ō und ū: Setzen Sie Rōmaji in einer lateinischen Schrift. Kana, Kanji und Rōmaji →
Häufiger Fehler
Chinesische Schnitte werden in Teilstücken geladen, über den Block cjk
Fontsource liefert eine chinesische, japanische oder koreanische Familie als etwa hundert Dateien pro Schriftstärke aus, jede für einen Zeichenbereich. loadFonts holt nur die Datei latin, am Bildschirm stammen die Han-Zeichen also aus einer Systemschrift und werden falsch gemessen, und fontsourceProvider gibt dem PDF diese latin-Datei, die sie als leere Kästchen druckt. Führen Sie den Kit-Block cjk auf, rufen Sie loadCjkFonts(FONTS, markdown) nach loadFonts auf (einmal pro Stimme, mit dem Text, den sie setzt, wenn das Buch mehrere CJK-Schriften verwendet), und geben Sie renderToPdf fontProvider: cjkPdfProvider: Beide nehmen die Dateien, die die Zeichen des Textes enthalten. Chinesische, japanische und koreanische Schriften →
keep-allbehält die Umbrüche, die der Text schon hat: Postext findet die Phrasen nicht selbst, die Leerzeichen tippt der Autor. Eine Phrase, die länger ist als die Zeile, bricht trotzdem in sich um, an der letzten Stelle, die die Kinsoku-Regeln erlauben.- Chinesisch setzt nie Leerzeichen zwischen Wörter, und die chinesische Fibel Ein vertikales Lesebuch mit Zhuyin rechts hilft ihren jungen Lesern stattdessen mit Lesungen; ein japanisches Bilderbuch verwendet keine Kanji, braucht also keine Furigana, und die Leerzeichen zwischen den Phrasen tragen das Lesen.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- かみの ふね (The Paper Boat), a picture-book story in hiragana written for this recipe · Postext Cookbook · CC BY 4.0
- Bilder
- 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 · eigener Text
- Mii-chan folding the red paper boat at a low table by the window, after the rain · Generated With Diffusion Models · eigener Text
- Mii-chan waving goodbye to the boat on the stream · Generated With Diffusion Models · eigener Text
- A frog on a stone talking to the boat · Generated With Diffusion Models · eigener Text
- The boat under a wooden footbridge, little fish following it · Generated With Diffusion Models · eigener Text
- A white heron on the bank watching the boat on the wide river · Generated With Diffusion Models · eigener Text
- Dusk: a pink sky over the river mouth and the sea beyond · Generated With Diffusion Models · eigener Text
- Night: the full moon's reflection lighting the boat's way · Generated With Diffusion Models · eigener Text
- The boat at sea, greeted by seagulls · Generated With Diffusion Models · eigener Text
- Mii-chan at her window, looking towards the distant sea · Generated With Diffusion Models · eigener Text
- Schriften
- Klee One (SIL OFL 1.1) · Zen Maru Gothic (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


