In breve
La carta giapponese da manoscritto ha un quadretto per ogni carattere. Questa pagina vi scrive le prime righe di un romanzo famoso, con titolo, nome dell’autore e punteggiatura dove li mettono le regole scolastiche giapponesi.
Che cosa comporrai
Tre fogli di carta giapponese da manoscritto (原稿用紙, genkō yōshi), quella da 400 quadretti, con i primi tre paragrafi di Botchan di Natsume Sōseki ricopiati come farebbe uno scrittore o uno scolaro: 20 righe di 20 quadretti, lette dall’alto in basso a partire da destra, in Klee One, un carattere disegnato a penna. Il titolo sta nella prima riga dal terzo quadretto e il nome dell’autore nella seconda, un quadretto sopra il piede; ogni paragrafo comincia dopo un quadretto vuoto. Ogni carattere, kana piccolo e segno ha un quadretto tutto suo, e un 、 o un 。 che finirebbe a inizio riga viene scritto sotto l’ultimo quadretto della riga precedente. Una cornice bruno-rossiccia, il numero del foglio e la scritta stampata della carta, 20×20, completano il foglio. La pagina cinese contata in caratteri è la Nº 082.
Questa ricetta risponde a
- Come imposto la gabbia in caratteri, con tanti caratteri per riga e tante righe per pagina?
- Perché i kana piccoli, ー e le parentesi di chiusura non possono aprire una riga in giapponese, e come scelgo quanto è severa la regola?
La risposta breve
// The grid makes the type area 20 characters of 20 pt down and 20 lines of 27 pt across, and
// show draws its squares. The marks are set full width with nothing squeezed, so each
// character, mark or bracket fills its own square, as on manuscript paper (原稿用紙).
const cjk = {
// fullLines: on manuscript paper every square of a full line is written in, so a line
// gives up no character to keep a lone last one company (the runt rule would leave the
// twentieth square empty in the middle of a paragraph).
grid: { enabled: true, charsPerLine: N, linesPerPage: N, show: true, fullLines: true },
compressAdjacent: false, // 。」 take two squares, not one and a half
trimLineStart: false, // a bracket at the head of a line keeps its square
// The manuscript rule: a small kana or ー may head a line (each has its own square), but
// never 、。 or a closing bracket: such a mark is written in the last square of the line
// before, hanging below it here, and no line is squeezed to take it in.
lineBreak: 'ja-loose',
hangingPunctuation: 'force',
};
Ingredienti
- Funzionalità
- Griglia dei caratteriDivisione in righe del giapponese (kinsoku)Spaziatura della punteggiatura giapponese (yakumono)Testo verticaleLibri rilegati a destraTitoli su più righe di testo (gyōdori)Kana, kanji e rōmajiFont cinesi, giapponesi e coreaniImportazione da Aozora BunkoStili di paragrafoTesto, filetti e riquadri nei progetti di paginaTestatine e folioColore della cartaTavolozza di colori semanticaEsportazione in PDF
- Usa anche
- Divisione in righe del cineseLarghezze della punteggiatura cineseAttributi dei titoliTestatine secondo il ruolo della paginaFont incorporati nel PDF
- Tipo
- Klee One, Noto Sans JP (SIL OFL 1.1)
- Risorse
- Nessuna: ogni immagine è disegnata nel codice
Preparazione
#1 · Disegna i quadretti e dai a ogni segno un quadretto intero
Il codice è la risposta breve qui sopra. La griglia di caratteri fa della gabbia del testo 20 caratteri da 20 pt (7,1 mm) in verticale e 20 righe da 27 pt (9,5 mm) in orizzontale, e show: true ne disegna i quadretti in un grigio chiaro proprio, sullo schermo, e nel PDF quando renderToPdf riceve characterGrid: true (in un libro la griglia è un aiuto per le bozze, quindi il PDF la omette per impostazione predefinita); la striscia di 2,5 mm tra due righe è dove uno scrittore aggiunge letture e correzioni. Un segno giapponese è mezzo glifo e mezzo em di bianco, e un libro assorbe i bianchi dove due segni si incontrano o una parentesi apre una riga. Sulla carta da manoscritto ogni segno conserva il suo quadretto, quindi compressAdjacent: false compone 。」 in due quadretti e trimLineStart: false lascia a una parentesi a inizio riga il suo quadretto intero (tipografia dell’Asia orientale).
#2 · Segui le regole di riga del manoscritto
La carta da manoscritto ha un suo kinsoku. Un kana piccolo o un ー può aprire una riga, ciascuno nel proprio quadretto; ja-loose lo consente. Un 、 o un 。 che finirebbe a inizio riga si scrive nell’ultimo quadretto della riga precedente, insieme al carattere che c’è, oppure subito sotto; hangingPunctuation: 'force' lo appende sotto il ventesimo quadretto, e nessuna riga viene mai compressa. Con 'allow', il valore predefinito per il giapponese, il motore prima fa entrare il segno nella riga comprimendo a mezzo quadretto un 、 al suo interno, cosa che un libro stampato fa e un manoscritto mai: il resto di quella riga starebbe mezzo quadretto più in su, a cavallo delle linee della griglia.
L’ultima riga di un paragrafo richiede un’impostazione in più. Postext evita che un paragrafo finisca con un solo carattere: la riga precedente gli cede il suo ultimo, e poiché qui il testo non è giustificato, quella riga si fermerebbe un quadretto prima. Sulla carta da manoscritto ogni quadretto di una riga piena viene scritto, quindi la griglia imposta fullLines: true: nel foglio 3 la riga che finisce con 来 conserva i suoi venti quadretti e il paragrafo si chiude con た。 da solo (griglia di caratteri).
#3 · Scrivi il titolo e il nome dove li mettono le regole
// The title on the first line, two squares down; the writer's name on the second, ending a
// square above the foot (地から一字上げ), a blank square between family and given names.
// lineSpan: 1 keeps the title on one line of the grid, in the text's own size and face.
const title = { level: 1, fontFamily: PEN, fontSize: pt(CELL), fontWeight: 400, lineSpan: 1,
indent: em(2), breakBefore: { enabled: true, parity: 'any' } }; // the next sheet, either side
const paragraphStyles = [
{ id: 'name', textAlign: 'end', endIndent: em(1), firstLineIndent: em(0) },
];
Il titolo è un titolo con il corpo e il carattere del testo, quindi riempie i quadretti come qualsiasi altra riga: lineSpan: 1 lo tiene su una riga della griglia e indent: em(2) lascia due quadretti vuoti sopra di esso (impostazioni per livello). Il nome è un paragrafo con textAlign: 'end' ed endIndent: em(1), che lo fa finire un quadretto sopra il piede, e il Markdown scrive il quadretto vuoto tra cognome e nome come uno spazio ideografico: 夏目 漱石. Il testo comincia dalla terza riga, ogni paragrafo dopo un quadretto vuoto dato da firstLineIndent: em(1).
#4 · Stampa il foglio attorno ai quadretti
// The header is drawn on the sheet itself, not in the turned frame of the text: x runs right
// from the left edge, y down from the top. The frame stands 1.5 mm off the squares and 8 mm
// below them, where a 、 or 。 that cannot head a line is written outside the last square.
const box = (id, inset, foot, thickness) => ({ kind: 'box', id, pages: 'all',
placement: { anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(MARGIN.side - inset), y: mm(MARGIN.top - inset) },
size: { width: mm(GRID.across + 2 * inset), height: mm(GRID.down + inset + foot) } },
style: { borderColor: col('ruling'), borderWidth: pt(thickness) } });
const FOOT = MARGIN.top + GRID.down + 8; // mm: the frame's foot
const below = (id, content, x, extra) => ({ kind: 'text', id, content, pages: 'all',
placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(FOOT + 3) } },
...extra });
const header = { elements: [
box('outer', 2.6, 9.1, 1.2), box('inner', 1.5, 8, 0.4),
// The sheet number, as a writer numbers the sheets; the paper's printed label.
below('sheet', '({pageNumber})', MARGIN.side, { fontFamily: PEN, fontSize: pt(9),
color: col('muted') }),
below('label', '20×20', MARGIN.side + GRID.across - 12, { fontFamily: 'Noto Sans JP',
fontSize: pt(7), letterSpacing: pt(0.5), color: col('ruling') }),
] };
const footer = { elements: [{ kind: 'text', id: 'colophon', content: '{attr.colophon}',
pages: 'opener', fontFamily: 'Noto Sans JP', fontSize: pt(6), lineHeight: 1.4,
color: col('muted'), overflow: 'wrap', align: 'center',
placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-5) },
size: { width: mm(120) } } }] };
Gli elementi dell’intestazione sono disegnati sul foglio stesso: su una pagina verticale x va verso destra e y verso il basso dall’angolo superiore sinistro, a differenza del progetto grafico di un titolo, che si impagina nel riquadro ruotato del testo. La doppia cornice sta 1,5 mm fuori dai quadretti su tre lati e 8 mm sotto di essi, dove cadono i segni appesi. GRID e MARGIN la collocano a partire dagli stessi numeri di cui sono fatti i margini della pagina; i margini passati alla pagina sono mezzo millimetro più piccoli, perché la griglia li tratta come minimi e un adattamento esatto potrebbe arrotondare a 19 righe.
#5 · Componi in un carattere a penna
// Klee One draws the strokes of a pen, with the Japanese forms of the kanji; its vertical
// forms put 、。 in the upper right of their square and turn ー and the brackets.
const colophon = markdown.match(/colophon="([^"]*)"/)[1];
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [PEN]: ['400'] }, `${markdown}()一二三四五六七八九十`, { vertical: true });
await loadCjkFonts({ 'Noto Sans JP': ['400'] }, `20×20${colophon}`);
Klee One è disegnato a penna e conserva le forme giapponesi dei kanji, così i fogli si leggono come una scrittura a mano senza le irregolarità di una scansione. Le sue forme verticali mettono 、。 in alto a destra del loro quadretto e ruotano ー e le parentesi, che è dove li mette chi scrive. Come ogni famiglia giapponese su Fontsource arriva in circa 120 file, e loadCjkFonts scarica quelli di cui il testo ha bisogno (font cinesi, giapponesi e coreani).
La ricetta completa
// ═══ Postext Cookbook · Nº 120 · A story on genkō yōshi, one character to a square ═══ // https://postext.dev/en/cookbook/genko-yoshi-manuscript // Code: MIT · Text: 夏目漱石『坊っちゃん』, Aozora Bunko 752 (public domain) · Pictures: none // Fonts: Klee One, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.28.0 import { buildDocument, withLoadedFonts, renderPageToCanvas, loadVerticalAlternates, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the colophon; the manuscript is Japanese in both const RECIPE = 'genko-yoshi-manuscript'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: blue-black ink on a sheet ruled in red-brown const palette = { ink: '#1f2a44', // the pen: a blue-black ruling: '#b0573f', // the sheet's printed frame, its label muted: '#7b6f66', // the sheet number paper: '#fdfaf3', }; 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: 'defaults', value: { hex: palette.ruling, model: 'hex' } }, ]; // #endregion const PEN = 'Klee One'; // a pen-written kaisho with Japanese forms const [CELL, PITCH, N] = [20, 27, 20]; // pt: a 7 mm square, a 2.5 mm strip beside each line const PT = 25.4 / 72; // mm in a point const SHEET = { w: 230, h: 200 }; // mm const GRID = { down: N * CELL * PT, across: N * PITCH * PT }; // 141.1 × 190.5 mm const MARGIN = { top: 32, side: (SHEET.w - GRID.across) / 2 }; // mm // The margins handed to the page are a hair smaller: the grid takes them as minimums and // grows them back, where an exact fit could round to 19 lines. const SLACK = 0.5; // mm // #region answer: 20 squares down 20 lines, the squares drawn, every character in one // The grid makes the type area 20 characters of 20 pt down and 20 lines of 27 pt across, and // show draws its squares. The marks are set full width with nothing squeezed, so each // character, mark or bracket fills its own square, as on manuscript paper (原稿用紙). const cjk = { // fullLines: on manuscript paper every square of a full line is written in, so a line // gives up no character to keep a lone last one company (the runt rule would leave the // twentieth square empty in the middle of a paragraph). grid: { enabled: true, charsPerLine: N, linesPerPage: N, show: true, fullLines: true }, compressAdjacent: false, // 。」 take two squares, not one and a half trimLineStart: false, // a bracket at the head of a line keeps its square // The manuscript rule: a small kana or ー may head a line (each has its own square), but // never 、。 or a closing bracket: such a mark is written in the last square of the line // before, hanging below it here, and no line is squeezed to take it in. lineBreak: 'ja-loose', hangingPunctuation: 'force', }; // #endregion // #region heading: the title two squares down, the name a square above the foot // The title on the first line, two squares down; the writer's name on the second, ending a // square above the foot (地から一字上げ), a blank square between family and given names. // lineSpan: 1 keeps the title on one line of the grid, in the text's own size and face. const title = { level: 1, fontFamily: PEN, fontSize: pt(CELL), fontWeight: 400, lineSpan: 1, indent: em(2), breakBefore: { enabled: true, parity: 'any' } }; // the next sheet, either side const paragraphStyles = [ { id: 'name', textAlign: 'end', endIndent: em(1), firstLineIndent: em(0) }, ]; // #endregion // #region sheet: the printed frame round the squares, the sheet number and the paper's label // The header is drawn on the sheet itself, not in the turned frame of the text: x runs right // from the left edge, y down from the top. The frame stands 1.5 mm off the squares and 8 mm // below them, where a 、 or 。 that cannot head a line is written outside the last square. const box = (id, inset, foot, thickness) => ({ kind: 'box', id, pages: 'all', placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(MARGIN.side - inset), y: mm(MARGIN.top - inset) }, size: { width: mm(GRID.across + 2 * inset), height: mm(GRID.down + inset + foot) } }, style: { borderColor: col('ruling'), borderWidth: pt(thickness) } }); const FOOT = MARGIN.top + GRID.down + 8; // mm: the frame's foot const below = (id, content, x, extra) => ({ kind: 'text', id, content, pages: 'all', placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(FOOT + 3) } }, ...extra }); const header = { elements: [ box('outer', 2.6, 9.1, 1.2), box('inner', 1.5, 8, 0.4), // The sheet number, as a writer numbers the sheets; the paper's printed label. below('sheet', '({pageNumber})', MARGIN.side, { fontFamily: PEN, fontSize: pt(9), color: col('muted') }), below('label', '20×20', MARGIN.side + GRID.across - 12, { fontFamily: 'Noto Sans JP', fontSize: pt(7), letterSpacing: pt(0.5), color: col('ruling') }), ] }; const footer = { elements: [{ kind: 'text', id: 'colophon', content: '{attr.colophon}', pages: 'opener', fontFamily: 'Noto Sans JP', fontSize: pt(6), lineHeight: 1.4, color: col('muted'), overflow: 'wrap', align: 'center', placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-5) }, size: { width: mm(120) } } }] }; // #endregion const config = () => ({ locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag) colorPalette, page: { sizePreset: 'custom', width: mm(SHEET.w), height: mm(SHEET.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top - SLACK), bottom: mm(SHEET.h - MARGIN.top - GRID.down - SLACK), left: mm(MARGIN.side - SLACK), right: mm(MARGIN.side - SLACK) }, }, layout: { layoutType: 'single', writingMode: 'vertical-rl' }, cjk, bodyText: { fontFamily: PEN, fontSize: pt(CELL), lineHeight: pt(PITCH), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: em(1), indentAfterHeading: true, // a blank square }, headings: { fontFamily: PEN, color: col('ink'), levels: [title] }, paragraphStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Esempio Markdown · 15 righe · content.en.md
title: "坊っちゃん" author: "夏目漱石" --- # 坊っちゃん {colophon="Natsume Sōseki, 坊っちゃん (Botchan, 1906), its first three paragraphs, copied out on 400-character manuscript paper. Text: Aozora Bunko 752, public domain. Set in Klee One and Noto Sans JP (SIL OFL)."} :::paragraphs{style="name"} 夏目 漱石 ::: 親譲りの無鉄砲で小供の時から損ばかりしている。小学校に居る時分学校の二階から飛び降りて一週間ほど腰を抜かした事がある。なぜそんな無闇をしたと聞く人があるかも知れぬ。別段深い理由でもない。新築の二階から首を出していたら、同級生の一人が冗談に、いくら威張っても、そこから飛び降りる事は出来まい。弱虫やーい。と囃したからである。小使に負ぶさって帰って来た時、おやじが大きな眼をして二階ぐらいから飛び降りて腰を抜かす奴があるかと云ったから、この次は抜かさずに飛んで見せますと答えた。 親類のものから西洋製のナイフを貰って奇麗な刃を日に翳して、友達に見せていたら、一人が光る事は光るが切れそうもないと云った。切れぬ事があるか、何でも切ってみせると受け合った。そんなら君の指を切ってみろと注文したから、何だ指ぐらいこの通りだと右の手の親指の甲をはすに切り込んだ。幸ナイフが小さいのと、親指の骨が堅かったので、今だに親指は手に付いている。しかし創痕は死ぬまで消えぬ。 庭を東へ二十歩に行き尽すと、南上がりにいささかばかりの菜園があって、真中に栗の木が一本立っている。これは命より大事な栗だ。実の熟する時分は起き抜けに背戸を出て落ちた奴を拾ってきて、学校で食う。菜園の西側が山城屋という質屋の庭続きで、この質屋に勘太郎という十三四の倅が居た。勘太郎は無論弱虫である。弱虫の癖に四つ目垣を乗りこえて、栗を盗みにくる。ある日の夕方折戸の蔭に隠れて、とうとう勘太郎を捕まえてやった。その時勘太郎は逃げ路を失って、一生懸命に飛びかかってきた。向うは二つばかり年上である。弱虫だが力は強い。鉢の開いた頭を、こっちの胸へ宛ててぐいぐい押した拍子に、勘太郎の頭がすべって、おれの袷の袖の中にはいった。邪魔になって手が使えぬから、無暗に手を振ったら、袖の中にある勘太郎の頭が、右左へぐらぐら靡いた。しまいに苦しがって袖の中から、おれの二の腕へ食い付いた。痛かったから勘太郎を垣根へ押しつけておいて、足搦をかけて向うへ倒してやった。山城屋の地面は菜園より六尺がた低い。勘太郎は四つ目垣を半分崩して、自分の領分へ真逆様に落ちて、ぐうと云った。勘太郎が落ちるときに、おれの袷の片袖がもげて、急に手が自由になった。その晩母が山城屋に詫びに行ったついでに袷の片袖も取り返して来た。`; // content.<lang>.md: the same Japanese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Klee One': ['400'], // the manuscript 'Noto Sans JP': ['400'], // the sheet's printed label and the colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pen: a pen-written face, loaded with the characters the sheets set // Klee One draws the strokes of a pen, with the Japanese forms of the kanji; its vertical // forms put 、。 in the upper right of their square and turn ー and the brackets. const colophon = markdown.match(/colophon="([^"]*)"/)[1]; await loadFonts(FONTS, markdown); // the Latin files: the colophon await loadCjkFonts({ [PEN]: ['400'] }, `${markdown}()一二三四五六七八九十`, { vertical: true }); await loadCjkFonts({ 'Noto Sans JP': ['400'] }, `20×20${colophon}`); // #endregion const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'A story on genkō yōshi, one character to a square', es: 'Un relato en genkō yōshi, un carácter por casilla' }) }); // The PDF draws the squares only when asked: a grid is a screen aid in a book, the page here. offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, characterGrid: true }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, cjk: uguale in ogni ricetta · 458 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 · 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
#Verifica la griglia di un libro con gli stessi quadretti
show: true funziona su qualsiasi griglia di caratteri: sul bunko della Nº 117 disegna i 38 × 15 quadretti sopra il testo, una verifica rapida che ogni riga rispetti la griglia e ogni titolo le sue tre righe.
- grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+ grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },#Lascia che i segni si comprimano, come in una pagina stampata
Con il valore predefinito giapponese i fogli sembrano un libro battuto su carta a quadretti: un 、 che finirebbe a inizio riga viene assorbito comprimendone un altro, e il resto di quella riga sta mezzo quadretto fuori dalla griglia.
- hangingPunctuation: 'force',
+ hangingPunctuation: 'allow',Errori frequenti
Errore frequente
Assegna a un testo giapponese il tag 'ja', mai zh-Hans o LANG
Le edizioni di una ricetta sono en ed es, ma un esempio giapponese è giapponese in entrambe: `locale: LANG` gli darebbe il tag inglese o spagnolo, e un tag cinese lo comporrebbe con le regole cinesi (punteggiatura Kaiming, kana piccoli liberi di aprire una riga, 图 per 図, forme cinesi dei glifi nel PDF). Da postext 1.30 il tag è anche la lingua usata per lo shaping del testo (misura, disegno e incorporamento), quindi qualunque tag diverso da 'ja' dà forme dei glifi diverse da quelle giapponesi (quelle cinesi con un tag cinese) e altri a capo. Scrivi 'ja': sceglie la regione Giappone (divisione in righe e punteggiatura JLReq, segni d’enfasi a sesamo, spaziatura dei furigana, etichette 図 e 表) e disattiva la sillabazione. Il lint fa fallire un testo con kana sotto un tag zh o ko. Divisione in righe del giapponese (kinsoku) →
Errore frequente
I caratteri cinesi si caricano a fette, tramite il blocco cjk
Fontsource serve una famiglia cinese, giapponese o coreana come un centinaio di file per peso, ognuno dei quali copre un intervallo di caratteri. loadFonts scarica solo il file latin, così a schermo i caratteri Han vengono da un font di sistema e sono misurati male, e fontsourceProvider passa al PDF quel file latin, che li stampa come riquadri vuoti. Elenca il blocco cjk del kit, chiama loadCjkFonts(FONTS, markdown) dopo loadFonts (una volta per voce tipografica, con il testo che compone, quando il libro usa più caratteri CJK) e passa a renderToPdf fontProvider: cjkPdfProvider: entrambi prendono i file che contengono i caratteri del testo. Font cinesi, giapponesi e coreani →
Errore frequente
Una pagina verticale ha due sistemi di riferimento: quello del testo ruotato e quello del foglio
Un documento vertical-rl impagina il testo come una pagina orizzontale ruotata di un quarto di giro in senso orario, e la grafica di un titolo (gli elementi di advancedDesign.slot) si colloca in quel riferimento ruotato: x scorre giù lungo la pagina, y la attraversa verso sinistra dal bordo destro, e la larghezza di un elemento si misura giù lungo la pagina. Gli elementi di intestazione e piè di pagina restano sul foglio: x scorre verso destra dal bordo sinistro e y verso il basso dalla cima. Una cornice disegnata nell’intestazione e un titolo collocato nella grafica del titolo prendono quindi i loro scostamenti su assi diversi. In un frontespizio verticale, ancora gli elementi a `page` e misura dai suoi bordi: ancorati a `container`, gli elementi del piè di pagina stanno qualche millimetro (circa 5 mm) fuori dal centro della gabbia. Testo verticale →
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- 夏目漱石『坊っちゃん』 (Botchan, 1906), the first three paragraphs, without the readings; Aozora Bunko card 752, from 「ちくま日本文学全集 夏目漱石」 (Chikuma Shobō, 1992), input 真先芳秋, proofreading 柳沢成雄, converted with aozora.py · Natsume Sōseki; Aozora Bunko volunteers · pubblico dominio
- Font
- Klee One (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


