Em poucas palavras
O papel de manuscrito japonês tem um quadrado para cada caractere. Esta página copia nele as primeiras linhas de um romance famoso, com o título, o nome do autor e cada sinal de pontuação onde as regras escolares japonesas mandam.
O que você vai compor
Três folhas de papel de manuscrito japonês (原稿用紙, genkō yōshi), do modelo de 400 quadrados, com os três primeiros parágrafos de Botchan, de Natsume Sōseki, copiados como faria um escritor ou um aluno: 20 linhas de 20 quadrados, lidas de cima para baixo a partir da direita, em Klee One, uma fonte desenhada com caneta. O título fica na primeira linha a partir do terceiro quadrado e o nome do autor na segunda, um quadrado antes do pé; cada parágrafo começa depois de um quadrado vazio. Cada caractere, kana pequeno e sinal tem um quadrado próprio, e um 、 ou 。 que abriria uma linha é escrito embaixo do último quadrado da linha anterior. Uma moldura marrom-avermelhada, o número da folha e o rótulo impresso do papel, 20×20, completam a folha. A página chinesa contada em caracteres é a Nº 082.
Esta receita responde a
- Como defino a mancha em caracteres, tantos por linha e tantas linhas por página?
- Por que kana pequenos, ー e sinais de fechamento não podem começar uma linha em japonês, e como escolho o rigor da regra?
A resposta curta
// 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 = {
grid: { enabled: true, charsPerLine: N, linesPerPage: N, show: 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',
};
Ingredientes
- Funcionalidades
- Grade de caracteresQuebra de linha em japonês (kinsoku)Espaçamento da pontuação japonesa (yakumono)Texto verticalLivros encadernados pela direitaTítulos sobre linhas do texto (gyōdori)Kana, kanji e rōmajiFontes chinesas, japonesas e coreanasImportação do Aozora BunkoEstilos de parágrafoTextos, fios e caixas nos designs de páginaCabeços e fóliosCor do papelPaleta de cores semânticaExportação para PDF
- Também usa
- Quebra de linha em chinêsLargura da pontuação chinesaEquilíbrio de colunasAtributos de títuloCabeços por tipo de páginaFontes incorporadas ao PDF
- Tipografia
- Klee One, Noto Sans JP (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Desenhe os quadrados e dê a cada sinal um quadrado inteiro
O código está na resposta curta, mais acima. A grade de caracteres faz a mancha medir 20 caracteres de 20 pt (7,1 mm) para baixo e 20 linhas de 27 pt (9,5 mm) na largura, e show: true desenha os quadrados num cinza-claro próprio, na tela, e no PDF quando renderToPdf recebe characterGrid: true (num livro a grade é um recurso de revisão, então o PDF a deixa de fora por padrão); a faixa de 2,5 mm entre duas linhas é onde quem escreve acrescenta leituras e correções. Um sinal japonês é meio glifo e meio eme de branco, e um livro recolhe brancos onde dois sinais se encontram ou onde um colchete abre a linha. No papel de manuscrito cada sinal mantém o seu quadrado, então compressAdjacent: false põe 。」 em dois quadrados e trimLineStart: false deixa a um colchete no início da linha o quadrado inteiro (Tipografia do Leste Asiático).
#2 · Siga as regras de linha do manuscrito
O papel de manuscrito tem o seu próprio kinsoku. Um kana pequeno ou um ー pode começar uma linha, cada um no seu quadrado; ja-loose permite isso. Um 、 ou 。 que abriria uma linha é escrito no último quadrado da linha anterior, junto com o caractere que está ali, ou logo abaixo dele; hangingPunctuation: 'force' o pendura embaixo do vigésimo quadrado, e nenhuma linha é apertada. Com 'allow', o padrão para japonês, o motor primeiro recolhe o sinal na linha apertando a meio quadrado um 、 de dentro dela, o que um livro impresso faz e um manuscrito nunca faz: o resto dessa linha ficaria meio quadrado acima, atravessando as linhas da grade.
#3 · Escreva o título e o nome onde as regras mandam
// 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' } }; // gotcha: headings-drop-h1-break
const paragraphStyles = [
{ id: 'name', textAlign: 'end', endIndent: em(1), firstLineIndent: em(0) },
];
O título é um título com o tamanho e a fonte do texto, então ele preenche quadrados como qualquer outra linha: lineSpan: 1 o mantém numa linha da grade e indent: em(2) deixa dois quadrados vazios acima dele (Ajustes por nível). O nome é um parágrafo com textAlign: 'end' e endIndent: em(1), que o faz terminar um quadrado antes do pé, e o Markdown escreve o quadrado em branco entre sobrenome e nome como um espaço ideográfico: 夏目 漱石. O texto começa na terceira linha, e cada parágrafo depois de um quadrado vazio, o de firstLineIndent: em(1).
#4 · Imprima a folha em volta dos quadrados
// 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) } } }] };
Os elementos do cabeçalho são desenhados na própria folha: numa página vertical, x corre para a direita e y para baixo a partir do canto superior esquerdo, ao contrário do design de um título, que é diagramado no quadro girado do texto. A moldura dupla fica a 1,5 mm dos quadrados em três lados e a 8 mm abaixo deles, onde caem os sinais pendurados. GRID e MARGIN a posicionam a partir dos mesmos números de que são feitas as margens da página; as margens passadas à página são meio milímetro menores, porque a grade as trata como mínimos e um encaixe exato poderia arredondar para 19 linhas.
#5 · Componha numa fonte de caneta
// 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}`);
A Klee One é desenhada com caneta e mantém as formas japonesas dos kanji, então as folhas parecem escritas à mão sem a irregularidade de uma digitalização. As formas verticais dela põem 、。 no canto superior direito do quadrado e giram ー e os colchetes, que é onde quem escreve os põe. Como toda família japonesa do Fontsource, ela vem em cerca de 120 arquivos, e loadCjkFonts baixa os que o texto precisa (Fontes chinesas, japonesas e coreanas).
A receita 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.16.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, 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 = { grid: { enabled: true, charsPerLine: N, linesPerPage: N, show: 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' } }; // gotcha: headings-drop-h1-break 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 = () => ({ // a factory: the engine caches resolved configs per object 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], balancing: { enabled: false } }, // gotcha: cjk-grid-balancing paragraphStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Amostra em Markdown · 15 linhas · 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 buildWithFonts(() => buildDocument({ markdown }, config()), 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: igual em todas as receitas · 459 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const jobs = []; let added = 0; 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 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** 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, '-'); } /** 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 v1 ── 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. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * 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 more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); 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 v1 ── 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .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], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** 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 buildWithFonts 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 that * hold the characters its pages set (`request.codePoints`); 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.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** 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. Latin comes first: postext-pdf draws a character from the first * file that has it, as the browser takes a character both files hold from * latin. 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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)]); return ext ? [latin, ext] : 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 ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Confira a grade de um livro com os mesmos quadrados
show: true funciona em qualquer grade de caracteres: no bunko da Nº 117 ele desenha os 38 × 15 quadrados sobre o texto, um jeito rápido de verificar que cada linha respeita a grade e cada título as suas três linhas.
- grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+ grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },#Deixe os sinais se apertarem, como numa página impressa
Com o padrão japonês, as folhas parecem um livro datilografado em papel quadriculado: um 、 que abriria uma linha é recolhido apertando outro, e o resto dessa linha fica meio quadrado fora da grade.
- hangingPunctuation: 'force',
+ hangingPunctuation: 'allow',Erros comuns
Erro comum
Marque um texto japonês como 'ja', nunca com zh-Hans nem LANG
As edições de uma receita são en e es, mas uma amostra japonesa é japonesa nas duas: `locale: LANG` a marcaria como inglês ou espanhol, e uma marcação chinesa a comporia pelas regras chinesas (pontuação Kaiming, kana pequeno livre para começar linha, 图 no lugar de 図, formas chinesas dos glifos no PDF). Escreva 'ja': isso escolhe a região do Japão (quebra de linha e pontuação da JLReq, pontos de ênfase em gergelim, espaçamento dos furigana, rótulos 図 e 表) e desativa a hifenização. O lint reprova um texto com kana sob uma marcação zh ou ko. Quebra de linha em japonês (kinsoku) →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
Fontes chinesas são carregadas em fatias, pelo bloco cjk
O Fontsource serve uma família chinesa, japonesa ou coreana em cerca de cem arquivos por peso, cada um com um intervalo de caracteres. loadFonts baixa só o arquivo latin, então na tela os caracteres chineses vêm de uma fonte do sistema e são medidos errado, e o fontsourceProvider entrega ao PDF esse arquivo latin, que os imprime como caixas vazias. Inclua o bloco cjk do kit, chame loadCjkFonts(FONTS, markdown) depois de loadFonts (uma vez por voz, com o texto que ela compõe, quando o livro usa várias fontes CJK) e passe a renderToPdf fontProvider: cjkPdfProvider: os dois pegam os arquivos que contêm os caracteres do texto. Fontes chinesas, japonesas e coreanas →
Erro comum
Em uma grade de caracteres, desative o balanceamento de colunas
O balanceamento de colunas preenche uma página que termina curta (como quando um título mantido junto ao seu texto deixa linhas em branco no pé) acrescentando linhas da grade acima dos títulos e compondo um parágrafo com uma linha mais frouxa. Uma linha chinesa mais frouxa espaça os seus caracteres (0,13 eme em uma página GB/T 9704, muito acima de balancing.maxTracking), então os caracteres saem das colunas da grade e os títulos saem das suas linhas. Uma página contada em células deve terminar curta: defina headings.balancing: { enabled: false }. Grade de caracteres →
Erro comum
Uma página vertical tem dois sistemas de referência: o do texto girado e o da folha
Um documento vertical-rl compõe o texto como uma página horizontal girada um quarto de volta no sentido horário, e o design de um título (os elementos de advancedDesign.slot) é posicionado nesse sistema girado: x desce pela página, y a atravessa para a esquerda a partir da borda direita, e a largura de um elemento é medida para baixo. Os elementos do cabeçalho e do rodapé continuam na folha: x avança para a direita a partir da borda esquerda e y desce a partir do alto. Uma moldura desenhada no cabeçalho e um título posicionado no design do título tomam, portanto, os seus deslocamentos em eixos diferentes. Em uma folha de rosto vertical, ancore os elementos a `page` e meça a partir das bordas dela: ancorados a `container`, os elementos do rodapé ficam alguns milímetros (cerca de 5 mm) fora do centro da mancha. Texto vertical →
Créditos
- Receita
- Ignacio Ferro
- Texto
- 夏目漱石『坊っちゃん』 (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 · domínio público
- Fontes
- Klee One (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


