Em poucas palavras
Um informativo de bairro japonês impresso em linhas de cima para baixo. Mostra os diferentes jeitos de escrever números em japonês e como as datas continuam fáceis de ler no texto vertical.
O que você vai compor
Quatro páginas verticais de um informativo de bairro, 東若葉台だより, número 第百二十号, numa página B5 encadernada pela direita: o aviso da festa de verão, o calendário de agosto, as mensalidades com um recibo de doação e uma breve história da associação. A associação é inventada; os seus números estão escritos como um editor japonês os escreve. O número do informativo, o 第三十二回 da festa e os 二千四百人 que compareceram são contados com 十 百 千; os anos são escritos dígito a dígito, 二〇二六年 e 一九六〇年; o valor do recibo está em daiji (大字), 壱萬弐阡, que não pode ser adulterado com um traço; os horários e as datas estão em algarismos arábicos, e o 22 fica em pé numa só célula. As listas descem 一、 → (一) → イ → あ, e o calendário ocupa a contracapa.
Esta receita responde a
- Como numero capítulos 第一章 com numerais kanji, ou itens de lista あ い う e イ ロ ハ?
- Por que meu texto japonês sai com regras chinesas: rótulos 图, kana pequenos no início da linha, kanji em formas chinesas?
A resposta curta
// formatNumeral is the counter the engine numbers pages, lists and headings with.
const ISSUE = 120;
const kan = (n) => formatNumeral(n, 'japanese-informal'); // 120 → 百二十: counted
const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
const daiji = (n) => formatNumeral(n, 'japanese-formal'); // 12000 → 壱萬弐阡: on money
// {{daiji:12000}} in the text: the amount of a receipt in the hard-to-alter daiji.
const withDaiji = (md) => md.replace(/\{\{daiji:(\d+)\}\}/g, (_, n) => daiji(Number(n)));
const cjk = {
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
};
// Sections 一、二、三、, items (一)(二), then イ ロ ハ and あ い う beneath them.
const sectionLevel = { level: 2, numberingTemplate: '{2:一}、', numberSeparator: '',
fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), color: col('leaf'), lineSpan: 2 }; // 二行取り
const level = (n, numberFormat, prefix, separator) => ({ level: n, numberFormat, prefix,
separator }); // each level starts where the text of the one above starts
const orderedLists = {
fontFamily: GOTHIC, color: col('leaf'), fontWeight: 700, gap: em(0.5),
marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0),
levels: [level(1, 'japanese-informal', '(', ')'), level(2, 'katakana-iroha', '', ''),
level(3, 'hiragana', '', '、')],
};
Ingredientes
- Funcionalidades
- Numerais japoneses e contadores em kanaNúmeros em pé no texto verticalListas numeradasTexto verticalLivros encadernados pela direitaGrade de caracteresKana, kanji e rōmajiFontes chinesas, japonesas e coreanasEspaçamento da pontuação japonesa (yakumono)Títulos sobre linhas do texto (gyōdori)Títulos numeradosAberturas desenhadasEstilos de títuloEstilos de parágrafoBoxesEstilos de tabela com nomeTipos de recurso personalizadosCor do papelPaleta de cores semânticaExportação para PDF
- Também usa
- Bibliografias e glossáriosQuebra de linha em chinêsAtributos de títuloFiguras exatamente aquiCabeços por tipo de páginaFontes incorporadas ao PDFElementos pré-textuais em romanosCapítulos sem número
- Tipografia
- Noto Serif JP, Noto Sans JP, Zen Maru Gothic (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Escolha a grafia pelo lugar onde o número está
O código é a resposta curta logo acima. formatNumeral é o contador com que o motor numera páginas, listas e títulos, e o pen o chama para os números que ele mesmo compõe: japanese-informal conta (120 → 百二十, sem 一 antes de 十 百 千), cjk-decimal escreve dígito a dígito (2026 → 二〇二六) e japanese-formal dá o daiji de um valor (12000 → 壱萬弐阡). Um {{daiji:12000}} no texto vira o valor do recibo, então o número é digitado uma vez só, em algarismos. Os mesmos estilos dão nome aos contadores: as seções são {2:一}、, e orderedLists.levels define (一), depois katakana-iroha (イ ロ ハ) e depois hiragana com 、 (あ、い、う). cjk.uprightDigits: 2 põe 8月22日 e 23日 em pé sem marcação, e o !? de 「雨が降ったら中止!?」 divide uma célula, um padrão do japonês (números em texto vertical).

#2 · Ponha o cabeçalho numa faixa pela borda direita
const BAND = 46; // mm from the right trim edge
const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(x), y: mm(y) }, ...extra }); // x down the sheet, y leftward from its right
const onBand = { color: col('paper'), overflow: 'wrap', align: 'left' };
const masthead = {
id: 'masthead', numbered: false, toc: false, runningChapter: false,
breakBefore: { enabled: true, parity: 'any' },
advancedDesign: { enabled: true, minHeight: mm(BAND - 15), slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: ROUND,
fontWeight: 700, fontSize: pt(36), lineHeight: 1.1, letterSpacing: pt(3),
placement: at(HEAD, 9) },
{ kind: 'text', id: 'issue', content: `第${kan(ISSUE)}号`, ...onBand, fontFamily: GOTHIC,
fontWeight: 700, fontSize: pt(12), letterSpacing: pt(2), placement: at(HEAD, 26) },
{ kind: 'text', id: 'date', content: `${pos(2026)}年(令和${kan(8)}年)${kan(8)}月${kan(1)}日発行`,
...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right',
placement: at(HEAD, 26, { size: { width: mm(LINE) } }) },
{ kind: 'text', id: 'publisher', content: '{author}', ...onBand, fontFamily: GOTHIC,
fontSize: pt(10), align: 'right', placement: at(HEAD, 33, { size: { width: mm(LINE) } }) },
{ kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: GOTHIC,
fontSize: pt(7.5), placement: at(HEAD, 40) },
] } },
};
Numa página vertical, o alto do fluxo é a borda direita da folha, então um design de título ancorado ao alto da sangria é uma faixa ao longo dessa borda, e o seu size.height é a largura dela na folha. O número e a data são texto de design montado com kan() e pos(), e a data termina no pé da mancha com align: 'right'.
#3 · Dê ao calendário uma página só dele
const DAYS = { 2: '訓練', 9: '清掃', 13: 'お盆', 18: '練習会', 19: '練習会',
22: '夏祭り', 23: '夏祭り', 30: '役員会' };
const day = (d) => (d ? { content: `**${d}**\n${DAYS[d] ?? '\u00a0'}`, align: 'center',
...(DAYS[d] ? { background: col(d === 22 || d === 23 ? 'blush' : 'tint') } : {}) }
: { content: '' });
const weeks = []; // 1 August 2026 is a Saturday
for (let d = -5; d <= 31; d += 7) weeks.push([0, 1, 2, 3, 4, 5, 6].map((k) => day(d + k > 0
&& d + k <= 31 ? d + k : 0)));
const calendar = { id: 'calendar', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
// An upright table takes every line of the vertical page it stands on (gap:
// vertical-tables): the calendar is the back page, cited at the end of the text.
placement: { position: 'top', span: 'page' },
caption: '八月の行事。13日から16日まではごみの収集がありません。',
table: { model: { headerRowCount: 1, rows: [[...'日月火水木金土'].map((c) => ({ content: c,
isHeader: true, align: 'center' })), ...weeks] } } };
const resourceTypes = [{ id: 'table', name: '表', shortLabel: '表', captionPrefix: '表',
numberingTemplate: '{n}', counterFormat: '一', resetOn: 'never' }]; // 表一, 表二…
Uma tabela fica em pé numa página vertical e se lê na horizontal, como os livros japoneses imprimem as suas tabelas; ela ocupa todas as linhas da página onde está. O calendário é citado depois do último parágrafo, como flutuante na largura da página, e por isso vira a contracapa enquanto o texto anterior preenche as suas páginas. resourceTypes o numera com o token 一 (表一), e um documento ja compõe sozinho o rótulo 表一 : rótulo e número sem espaço e um espaço ideográfico depois.
#4 · Carregue cada voz com o texto que ela compõe
const heads = (markdown.match(/^#+ .*$/gm) ?? []).join('');
const gothicText = `${heads}${markdown}第号年月日発行令和${kan(ISSUE)}${pos(2026)}${
calendar.caption}日月火水木金土${Object.values(DAYS).join('')}領収証(記入例)表一`;
A Noto Serif JP compõe o texto; a Noto Sans JP, os títulos, as listas, o calendário e o rótulo do recibo; e a Zen Maru Gothic, só o título. Cada uma carrega os arquivos do Fontsource dos seus próprios caracteres, mais os números que o pen compõe.
A receita completa
// ═══ Postext Cookbook · Nº 129 · Kanji numerals, counters and dates in vertical text ═══ // https://postext.dev/en/cookbook/kanji-numerals-and-dates // Code: MIT · Text: written for the recipe (CC BY 4.0) // Fonts: Noto Serif JP, Noto Sans JP, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.16.1 // A neighbourhood newsletter set vertically: every way Japanese writes a number. import { buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates, formatNumeral, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the frame; the newsletter is Japanese in both const RECIPE = 'kanji-numerals-and-dates'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: ink, the green of young leaves, its tint, a festival red and its blush const palette = { ink: '#1f2420', // the text leaf: '#2f6b3f', // masthead band, section numbers (6.4:1 on the paper) tint: '#e4efdf', // the calendar's marked days, the receipt red: '#b4362b', // the receipt's amount blush: '#f4d4cc', // the festival days in the calendar muted: '#5f665f', // folios, the colophon paper: '#fdfdf9', }; 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: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } }, ]; // #endregion const MINCHO = 'Noto Serif JP'; // the text const GOTHIC = 'Noto Sans JP'; // section heads, labels, the calendar const ROUND = 'Zen Maru Gothic'; // the masthead's title const [BODY, LEAD] = [11, 20]; // pt: large print for every age on the street const [CHARS, LINES] = [44, 20]; const PT = 25.4 / 72; // mm in a point const TRIM = { w: 182, h: 257 }; // mm, B5 const LINE = CHARS * BODY * PT; // mm: 170.7, the length of a line const HEAD = 30 + (TRIM.h - 30 - 24 - LINE) / 2; // mm: the head of the type area // #region answer: one number, four spellings, chosen by where it stands // formatNumeral is the counter the engine numbers pages, lists and headings with. const ISSUE = 120; const kan = (n) => formatNumeral(n, 'japanese-informal'); // 120 → 百二十: counted const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit const daiji = (n) => formatNumeral(n, 'japanese-formal'); // 12000 → 壱萬弐阡: on money // {{daiji:12000}} in the text: the amount of a receipt in the hard-to-alter daiji. const withDaiji = (md) => md.replace(/\{\{daiji:(\d+)\}\}/g, (_, n) => daiji(Number(n))); const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default) }; // Sections 一、二、三、, items (一)(二), then イ ロ ハ and あ い う beneath them. const sectionLevel = { level: 2, numberingTemplate: '{2:一}、', numberSeparator: '', fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), color: col('leaf'), lineSpan: 2 }; // 二行取り const level = (n, numberFormat, prefix, separator) => ({ level: n, numberFormat, prefix, separator }); // each level starts where the text of the one above starts const orderedLists = { fontFamily: GOTHIC, color: col('leaf'), fontWeight: 700, gap: em(0.5), marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0), levels: [level(1, 'japanese-informal', '(', ')'), level(2, 'katakana-iroha', '', ''), level(3, 'hiragana', '', '、')], }; // #endregion // #region masthead: a green band down the right edge, the issue and the date in kanji const BAND = 46; // mm from the right trim edge const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) }, ...extra }); // x down the sheet, y leftward from its right const onBand = { color: col('paper'), overflow: 'wrap', align: 'left' }; const masthead = { id: 'masthead', numbered: false, toc: false, runningChapter: false, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: { enabled: true, minHeight: mm(BAND - 15), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } }, { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: ROUND, fontWeight: 700, fontSize: pt(36), lineHeight: 1.1, letterSpacing: pt(3), placement: at(HEAD, 9) }, { kind: 'text', id: 'issue', content: `第${kan(ISSUE)}号`, ...onBand, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), letterSpacing: pt(2), placement: at(HEAD, 26) }, { kind: 'text', id: 'date', content: `${pos(2026)}年(令和${kan(8)}年)${kan(8)}月${kan(1)}日発行`, ...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right', placement: at(HEAD, 26, { size: { width: mm(LINE) } }) }, { kind: 'text', id: 'publisher', content: '{author}', ...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right', placement: at(HEAD, 33, { size: { width: mm(LINE) } }) }, { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: GOTHIC, fontSize: pt(7.5), placement: at(HEAD, 40) }, ] } }, }; // #endregion // #region calendar: August 2026 as a table, the festival and the duties marked const DAYS = { 2: '訓練', 9: '清掃', 13: 'お盆', 18: '練習会', 19: '練習会', 22: '夏祭り', 23: '夏祭り', 30: '役員会' }; const day = (d) => (d ? { content: `**${d}**\n${DAYS[d] ?? '\u00a0'}`, align: 'center', ...(DAYS[d] ? { background: col(d === 22 || d === 23 ? 'blush' : 'tint') } : {}) } : { content: '' }); const weeks = []; // 1 August 2026 is a Saturday for (let d = -5; d <= 31; d += 7) weeks.push([0, 1, 2, 3, 4, 5, 6].map((k) => day(d + k > 0 && d + k <= 31 ? d + k : 0))); const calendar = { id: 'calendar', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, // An upright table takes every line of the vertical page it stands on (gap: // vertical-tables): the calendar is the back page, cited at the end of the text. placement: { position: 'top', span: 'page' }, caption: '八月の行事。13日から16日まではごみの収集がありません。', table: { model: { headerRowCount: 1, rows: [[...'日月火水木金土'].map((c) => ({ content: c, isHeader: true, align: 'center' })), ...weeks] } } }; const resourceTypes = [{ id: 'table', name: '表', shortLabel: '表', captionPrefix: '表', numberingTemplate: '{n}', counterFormat: '一', resetOn: 'never' }]; // 表一, 表二… // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object 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(30), bottom: mm(24), left: mm(16), right: mm(18), mirror: true }, pageNumbering: { format: 'japanese-informal' }, }, layout: { layoutType: 'single', writingMode: 'vertical-rl' }, cjk, bodyText: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true, }, headings: { fontFamily: GOTHIC, color: col('ink'), levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }, sectionLevel] }, headingStyles: [masthead], orderedLists, paragraphStyles: [ { id: 'item', firstLineIndent: em(0), indent: em(1), hangingIndent: em(3) }, { id: 'chronicle', firstLineIndent: em(0), hangingIndent: em(14) }, { id: 'colophon', fontFamily: GOTHIC, fontSize: pt(7), color: col('muted'), textAlign: 'left', firstLineIndent: em(0), marginTop: pt(LEAD) }, ], calloutStyles: [{ id: 'receipt', title: '領収証(記入例)', background: col('tint'), padding: { top: mm(4), right: mm(4), bottom: mm(4), left: mm(4) }, marginTop: pt(LEAD / 2), marginBottom: pt(0), // the next heading's 行取り gives it room titleStyle: { fontFamily: GOTHIC, fontSize: pt(12), fontWeight: 700, color: col('leaf') }, body: { fontFamily: MINCHO, color: col('ink'), boldColor: col('red'), textAlign: 'left', firstLineIndent: em(0) } }], tableStyle: { rules: 'horizontal', borderColor: col('leaf'), borderWidth: pt(0.5), headerBackground: col('leaf'), headerColor: col('paper'), headerFontFamily: GOTHIC, headerFontSize: pt(12), bodyFontFamily: GOTHIC, bodyFontSize: pt(11), bodyColor: col('ink'), cellPadding: mm(3.2) }, // 表一 八月の行事: label and number solid, then U+3000 (the ja default) captionStyle: { fontFamily: GOTHIC, fontSize: pt(10), color: col('ink'), labelColor: col('leaf') }, header: { elements: [{ kind: 'text', id: 'folio', content: '{pageNumber}', pages: 'body', fontFamily: GOTHIC, fontSize: pt(8), color: col('muted'), align: 'center', placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-12) } } }] }, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = withDaiji(String.raw`---Amostra em Markdown · 74 linhas · content.en.md
title: "東若葉台だより" author: "東若葉台二丁目町会 広報部" --- # 東若葉台だより {style="masthead" latin="Higashi-Wakabadai News, the newsletter of a neighbourhood association (fiction)"} 暑い日が続いています。今号は夏祭りの案内を中心に、八月の行事、町会費と協賛金のお願い、それに町会の歩みをお届けします。 ## 夏祭りのお知らせ 今年の夏祭りは第三十二回になります。平成七年に公民館の駐車場で始まった小さな盆踊りが、昨年は二日間で延べ二千四百人が訪れる行事になりました。今年も子ども会、婦人部、消防団の皆さんが準備を進めています。 :::paragraphs{style="item"} 日時 8月22日(土)・23日(日) 午後4時から9時まで 会場 若葉台第二公園(雨の場合は公民館ホール) ::: 1. 盆踊り 1. 練習会 8月18日(火)・19日(水)の午後7時から、公民館ホールで行います。浴衣でなくてもかまいません。 2. 本番 両日とも午後6時半から。太鼓は今年も若葉台中学校の和太鼓部が務めます。 2. 屋台 1. 子ども会 焼きそば、かき氷 2. 婦人部 おでん、冷やしきゅうり 3. 消防団 ラムネ、射的 3. 子ども神輿 1. 対象 小学生。保護者の方の付き添いをお願いします。 2. 出発 23日(日)午後4時、公民館前。若葉通りを回り、5時半に戻ります。 3. 持ち物 1. 帽子 2. 水筒 3. タオル 「雨が降ったら中止!?」という問い合わせを、毎年この時期にいただきます。小雨なら公園で行い、強い雨のときは公民館ホールに移します。中止にするのは警報が出たときだけで、その場合は当日の正午までに町会の掲示板でお知らせします。 ## 八月の行事 八月の行事は、裏表紙の表一にまとめました。お盆の13日から16日までは、ごみの収集がありません。15日の朝、集積所にごみを出さないようにお願いします。 ## 町会費と協賛金のお願い 今年度の町会費は、一世帯あたり年額三千六百円です。九月の班長会までに、各班の班長さんへお納めください。 夏祭りには、毎年たくさんの商店と個人の方から協賛金をいただいています。協賛金には領収証をお渡しします。金額は、書き換えを防ぐために大字(だいじ)で書きます。 :::callout{type="receipt"} 若葉台商店会 様 金 **{{daiji:12000}}** 円也 但し 第三十二回夏祭り協賛金として 二〇二六年八月一日 東若葉台二丁目町会 会計 中村 節子 ::: ## 町会の歩み :::paragraphs{style="chronicle"} 一九六〇年(昭和三十五年) 町会ができる。このときの世帯数は八十四。 一九七二年(昭和四十七年) 公民館が完成する。 一九九五年(平成七年) 第一回の夏祭りを公民館の駐車場で開く。 二〇一一年(平成二十三年) 若葉台第二公園に防災倉庫を置く。 二〇二六年(令和八年) 世帯数は三百十二になった。 ::: :::paragraphs{style="colophon"} Set in Noto Serif JP, Noto Sans JP and Zen Maru Gothic (SIL OFL) · The association, its people and its events are fiction written for this recipe, CC BY 4.0 ::: ::resource{id="calendar"}`); // content.<lang>.md, amounts filled in // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Noto Serif JP': ['400', '700'], 'Noto Sans JP': ['400', '700'], 'Zen Maru Gothic': ['700'] }; // #region voices: each face loads the files of the characters it sets const heads = (markdown.match(/^#+ .*$/gm) ?? []).join(''); const gothicText = `${heads}${markdown}第号年月日発行令和${kan(ISSUE)}${pos(2026)}${ calendar.caption}日月火水木金土${Object.values(DAYS).join('')}領収証(記入例)表一`; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadCjkFonts({ [MINCHO]: FONTS[MINCHO] }, markdown, { vertical: true }); await loadCjkFonts({ [GOTHIC]: FONTS[GOTHIC] }, gothicText, { vertical: true }); await loadCjkFonts({ [ROUND]: FONTS[ROUND] }, heads, { vertical: true }); const doc = await buildWithFonts( () => buildDocument({ markdown, resources: [calendar] }, config()), markdown); showBook(doc, { title: t({ en: 'A newsletter in kanji numerals', es: 'Un boletín con numerales kanji' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${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
#Escreva todos os anos por extenso
Há quem prefira 二千二十六年 a 二〇二六年 no texto corrido; mude o estilo de pos.
-const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
+const pos = (n) => formatNumeral(n, 'japanese-informal'); // 2026 → 二千二十六#Ponha três algarismos em pé
Com 3, um 100 ou um 365 no texto também ocupam uma só célula.
- uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
+ uprightDigits: 3,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
Componha o japonês com uma fonte japonesa
A Noto Serif SC e a TC têm kana, mas desenham os kanji com formas chinesas (直, 骨 e 角 mudam) e o kana com desenho chinês. Componha o texto em Noto Serif JP ou Shippori Mincho B1 e os títulos em Noto Sans JP, carregadas com loadCjkFonts. Os arquivos japoneses do Fontsource não têm hentaigana nem outros kana históricos (U+1B000–1B16F): loadCjkFonts falha com eles e o PDF imprime caixas, então escreva o kana moderno ou inclua nos recursos da receita uma fonte que os tenha. A Shippori Mincho também não tem as vogais com mácron ō e ū: componha o rōmaji com uma fonte latina. Kana, kanji e rōmaji →
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
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
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
- O calendário ocupa uma página porque uma tabela em pé toma todas as linhas de uma página vertical (lacuna: tabelas compostas em texto vertical). Uma tabela que deva dividir a página com texto precisa de uma página horizontal, ou de um desenho SVG posto por um design de título.
- Em chinês, o mesmo contador escreve 一百二十 e 一千零一; em japonês, 百二十 e 千一. Escreva
locale: 'ja', senão o token 一 escolhe o estilo chinês. O livro chinês Datas e siglas em pé no texto vertical compõe as suas datas com o mesmouprightDigits.
Créditos
- Receita
- Ignacio Ferro
- Texto
- The newsletter of a fictional neighbourhood association, written in Japanese for this recipe · Postext Cookbook · CC BY 4.0
- Fontes
- Noto Serif JP (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1) · Zen Maru Gothic (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)


