En bref
Un chapitre de livre chinois composé en colonnes de haut en bas. Chaque source est citée dans une petite note sur deux lignes à l’intérieur de la colonne, et les ouvrages cités sont listés à la fin.
Ce que vous allez composer
La fin d’un chapitre d’un livre inventé sur l’imprimerie chinoise, 版刻叢談 (« Propos sur l’impression xylographique »), composé en caractères traditionnels, de haut en bas, en colonnes lues à partir de la droite, sur une page 大32開 de 140 × 203 mm reliée à droite. Le chapitre traite de la mise en page des éditions Song et cite cinq ouvrages réels. Chaque citation est composée comme les livres chinois composent le commentaire depuis des siècles : en 夾注, une note sur deux lignes à demi-corps, dans la colonne même où se trouve la citation. Une note qui ne tient pas dans ce qui reste d’une colonne se poursuit en tête de la suivante, et l’une d’elles continue après la tourne. Les notes suivent la forme de note de GB/T 7714—2015, la norme continentale pour les références, et le chapitre se termine par la liste numérotée des ouvrages qu’il cite, les titres anglais couchés. Le même chapitre avec ses notes en bas de page se composerait comme Nº 071.
Cette recette répond à
- Comment citer selon la norme chinoise GB/T 7714, y compris en texte vertical ?
- Comment composer les citations en notes de bas de page, style Chicago, avec des formes abrégées pour les citations suivantes ?
La réponse courte
// The GB/T 7714 note style turns each citation into a note. notes: 'warichu' sets the note
// in the column where the citation stands, in two rows at half the text size, the right
// row read first; a note longer than what is left of the column goes on in the next one.
// The list at the chapter's end comes from :::bibliography{scope=chapter}, numbered in
// the order the works are cited. Latin words turn sideways in the notes and in the list.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
style: 'china-national-standard-gb-t-7714-2015-note',
notes: 'warichu', // 夾注; 'footnote' sets the same notes at the foot of the page
bibliography: { fontSize: em(0.9), entrySpacing: pt(0) },
};
const cjk = {
grid: { enabled: true, charsPerLine: 36, linesPerPage: 15 },
warichu: { fontSize: em(0.5), color: col('ink') }, // no brackets: the half size sets it off
};
Ingrédients
- Fonctionnalités
- Citations en noteCitations dans un style bibliographiqueWarichu : notes sur deux lignes dans la ligneBibliographie tirée des référencesTexte verticalLivres reliés à droiteGrille de caractèresNumération chinoiseLargeur de la ponctuation chinoisePolices chinoises, japonaises et coréennesOuvertures composéesTitres numérotésTitres courants et foliosTitres courants selon le rôle de la pageMarges en miroirPalette de couleurs sémantiqueExport PDF
- Utilise aussi
- Coupure des lignes en chinoisAttributs de titreCouleur du papierPolices intégrées au PDFPages liminaires en chiffres romains
- Typographie
- Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Un style de notes, composé dans la ligne
Le code est dans la réponse courte plus haut. Le style de notes GB/T 7714 (china-national-standard-gb-t-7714-2015-note) est un style à notes : chaque citation devient une note qui nomme l’ouvrage en entier (Citations). Par défaut, un style à notes envoie ses notes en bas de page. notes: 'warichu' garde au contraire chaque note dans la colonne, composée en warichu : deux lignes à cjk.warichu.fontSize, la moitié du corps du texte, centrées sur la colonne, la ligne de droite lue en premier (Signes, ruby et warichu). Le Markdown est le même que pour n’importe quel style, [@su1999] après les mots qu’il appuie, et la note prend la place de la citation, avant le 。 qui termine la phrase.

Les lignes se remplissent morceau par morceau. Au pied de la troisième colonne, il y a la place pour 宿白. 唐宋時期 et rien de plus : ce fragment est donc réparti sur deux lignes de trois et quatre cases ; le reste de la note remplit la tête de la colonne suivante, de nouveau sur deux lignes. Page 2, la note sur Twitchett commence au pied de la dernière colonne, et le lecteur tourne la page pour la terminer.
#2 · Les ouvrages cités, à la fin du chapitre
:::bibliography{title="本章引用書目" scope=chapter} compose la liste là où elle se trouve et ne retient que les ouvrages cités dans ce chapitre (La bibliographie). Les références viennent d’un bloc BibTeX. Chaque entrée porte un langid, chinese ou english, que le style lit comme la langue de l’ouvrage, et les noms écrits en caractères restent entiers. La liste est numérotée dans l’ordre de première citation, et les colonnes de suite d’une entrée commencent sous son étiquette.
#3 · Des caractères traditionnels aux règles continentales
locale: 'zh-Hant-CN' demande des caractères traditionnels composés selon les règles du continent, comme les éditeurs de Pékin et de Shanghai composent l’érudition classique : la virgule et le point se placent dans le coin supérieur droit de leur case, et un livre vertical est relié à droite. Les citations suivent l’étiquette : l’écriture traditionnelle choisit la locale CSL zh-TW, si bien que les mots ajoutés par le style sont eux aussi en caractères traditionnels. Noto Serif SC dessine les caractères traditionnels sous leurs formes continentales, ce qui convient à cette édition ; LXGW WenKai TC, un caractère taïwanais, ne compose que le titre du chapitre, qui n’a pas de ponctuation (Les nombres en texte vertical explique ce qu’il advient des chiffres).
Dans les notes et la liste, un titre latin et une année à quatre chiffres se couchent et descendent la colonne. Un nombre court à côté du texte chinois, comme le 1 d’une étiquette, reste debout dans sa case.
#4 · L’ouverture entre des filets
// In the flow frame x runs down the column and y across the page, right to left, so a
// 'horizontal' rule is a vertical line on the sheet: three of them rule the two columns.
const [TITLE, PITCH] = [15, 1.6 * LEAD]; // pt: the title's size and its column pitch
const rule = (n) => ({ kind: 'rule', id: `rule-${n}`, direction: 'horizontal',
thickness: pt(n === 1 ? 0.4 : 0.8), color: col('vermilion'),
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { y: pt(LEAD + n * PITCH) }, size: { width: 'fill' } } });
// A line of PITCH in column n (0, 1), its characters centred between two rules.
const title = (id, content, n, family, size, weight) => ({ kind: 'text', id, content,
fontFamily: family, fontWeight: weight, fontSize: pt(size), lineHeight: PITCH / size,
color: col('ink'), letterSpacing: pt(size * 0.25), align: 'left',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: pt(2 * BODY), y: pt(LEAD + n * PITCH) } } }); // two characters down
const opener = { enabled: true, minHeight: cols(5), slot: { elements: [
rule(0), rule(1), rule(2),
title('number', '{number}', 0, HEI, 12, 700), // 第三章 in the first column, in 黑
title('title', '{titleText}', 1, KAI, TITLE, 400), // the title in the second, in 楷
] } };
Sur une page verticale, une maquette de titre se dessine dans le cadre du flux : x descend la colonne et y traverse la page à partir de son bord droit, si bien qu’un filet 'horizontal' est une ligne verticale sur la feuille. Trois filets encadrent deux colonnes espacées de 30,4 pt. 第三章 se place dans la première en Hei gras, et le titre dans la seconde en Kai (Emplacements et maquette avancée). Le numéro de chapitre vient de startAt=3 sur le titre et du modèle 第{1:一}章, et les folios de pageNumbering : trad-chinese-informal à partir de 45. Les titres courants et les folios descendent le long de la marge extérieure comme dans Nº 075 (Éléments en texte vertical).
const palette = {
ink: '#22201c', vermilion: '#a8352a', muted: '#6d665d', paper: '#fbf8f1',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.vermilion })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
La recette complète
// ═══ Postext Cookbook · Nº 091 · Vertical Chinese with citations in inline notes ══════ // https://postext.dev/en/cookbook/vertical-book-with-jiazhu-citations // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocument, withLoadedFonts, renderPageToCanvas, registerCitationEngine, loadVerticalAlternates, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc'; const LANG = 'en'; // @lang: the language of the frame; the chapter is Chinese in both editions const RECIPE = 'vertical-book-with-jiazhu-citations'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: black ink, one vermilion for the opener's rules, the paper of the page const palette = { ink: '#22201c', vermilion: '#a8352a', muted: '#6d665d', paper: '#fbf8f1', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.vermilion }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const [SONG, HEI, KAI] = ['Noto Serif SC', 'Noto Sans SC', 'LXGW WenKai TC']; // 宋, 黑, 楷 const [BODY, LEAD] = [10.5, 19]; // pt: 五號 on a column pitch of 1.8 em const cols = (n) => pt(n * LEAD); // n columns across the page // #region answer: a note style set as 夾注, two half-size rows inside the column // The GB/T 7714 note style turns each citation into a note. notes: 'warichu' sets the note // in the column where the citation stands, in two rows at half the text size, the right // row read first; a note longer than what is left of the column goes on in the next one. // The list at the chapter's end comes from :::bibliography{scope=chapter}, numbered in // the order the works are cited. Latin words turn sideways in the notes and in the list. registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES })); const citations = { style: 'china-national-standard-gb-t-7714-2015-note', notes: 'warichu', // 夾注; 'footnote' sets the same notes at the foot of the page bibliography: { fontSize: em(0.9), entrySpacing: pt(0) }, }; const cjk = { grid: { enabled: true, charsPerLine: 36, linesPerPage: 15 }, warichu: { fontSize: em(0.5), color: col('ink') }, // no brackets: the half size sets it off }; // #endregion // #region opener: 第三章 and the title in two ruled columns, the text after a blank one // In the flow frame x runs down the column and y across the page, right to left, so a // 'horizontal' rule is a vertical line on the sheet: three of them rule the two columns. const [TITLE, PITCH] = [15, 1.6 * LEAD]; // pt: the title's size and its column pitch const rule = (n) => ({ kind: 'rule', id: `rule-${n}`, direction: 'horizontal', thickness: pt(n === 1 ? 0.4 : 0.8), color: col('vermilion'), placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(LEAD + n * PITCH) }, size: { width: 'fill' } } }); // A line of PITCH in column n (0, 1), its characters centred between two rules. const title = (id, content, n, family, size, weight) => ({ kind: 'text', id, content, fontFamily: family, fontWeight: weight, fontSize: pt(size), lineHeight: PITCH / size, color: col('ink'), letterSpacing: pt(size * 0.25), align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: pt(2 * BODY), y: pt(LEAD + n * PITCH) } } }); // two characters down const opener = { enabled: true, minHeight: cols(5), slot: { elements: [ rule(0), rule(1), rule(2), title('number', '{number}', 0, HEI, 12, 700), // 第三章 in the first column, in 黑 title('title', '{titleText}', 1, KAI, TITLE, 400), // the title in the second, in 楷 ] } }; // #endregion // The fore-edge: the book's title or the chapter's down the outer margin, the folio low. const foreEdge = (id, content, parity, edge, y, pages) => ({ kind: 'text', id, content, parity, pages, writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(8), color: col('muted'), overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } }, }); const header = { elements: [ foreEdge('book', '{title}', 'even', 'top', 4, 'body'), foreEdge('chapter', '{chapterNumber} {chapterTitle}', 'odd', 'top', 4, 'body'), foreEdge('folio', '{pageNumber}', 'all', 'bottom', -5, 'all'), // Across the foot of the opening page, in the edition's language: what is invented. { kind: 'text', id: 'imprint', content: '{subtitle}', pages: 'opener', fontFamily: SONG, fontSize: pt(6.5), color: col('muted'), align: 'center', overflow: 'wrap', placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-9) }, size: { width: mm(100) } } }, ] }; const config = () => ({ // Traditional characters to the mainland's rules, as Beijing and Shanghai publishers set // classical scholarship (gotcha: cjk-locale-tag). The citations follow it: zh-TW terms. locale: 'zh-Hant-CN', colorPalette, citations, cjk, page: { sizePreset: 'custom', width: mm(140), height: mm(203), dpi: 150, // 大32開 backgroundColor: col('paper'), margins: { top: mm(34), bottom: mm(26), left: mm(15), right: mm(19), mirror: true }, pageNumbering: { format: 'trad-chinese-informal', startAt: 45 } }, // 四十五 layout: { layoutType: 'single', writingMode: 'vertical-rl' }, // bound on the right bodyText: { fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true, }, headings: { fontFamily: SONG, fontWeight: 700, color: col('ink'), levels: [{ level: 1, numberingTemplate: '第{1:一}章', numberSeparator: ' ', breakBefore: { enabled: true, parity: 'odd' }, // a chapter opens on an odd page marginBottom: pt(0), advancedDesign: opener }] }, header, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 28 lignes · content.en.md
title: "版刻叢談" author: "沈硯秋" subtitle: "A specimen chapter written for the Postext Cookbook. The book and its author are invented; the five works cited are real." --- # 宋刻本的版式 {startAt=3} 雕版印書,始於唐而盛於宋。唐代的印本流傳至今的極少,所見多是佛經、曆書一類的零星之物,敦煌所出的咸通九年《金剛經》卷子,是其中有確切紀年的一件[@su1999]。到了宋代,經史子集都有了刻本,官府、書院、私家和書坊競相開雕,書的樣子也大體定了下來[@zhang1989]。後世講版本的人談到書葉的格局,總是從宋本說起。 一塊書版刻成一葉,四周用欄線圍住,叫作版框。欄線只有一道的稱「四周單邊」,外粗內細兩道的稱「四周雙邊」,只在左右兩邊用雙線的稱「左右雙邊」。框內用直線分行,每半葉幾行、每行幾字,叫作行款。書目裏常見的「半葉十行,行二十字」,說的就是這個。同一地方的書坊往往沿用一種行款,所以著錄一部書,行款總是要記的,鑑定版本時也常用它來推斷刻書的地點和年代。 版框正中留出窄窄的一行,不刻正文,是為版心。印好的紙沿這一行對折,版心便落在書口上,翻檢時一眼可見。版心裏刻書名、卷次和葉碼,有時還刻上刻工的姓名和這一版的字數,那是當年計算工錢用的。版心上下各有一個形如魚尾的標記,叫作魚尾;魚尾到欄線之間若刻一道黑線,叫作黑口,空著不刻的叫作白口。 大體說來,浙江一帶的官刻和家刻,字體方整,多作白口、左右雙邊;福建建陽的坊刻,字體瘦勁,版式緊湊,常在框外左上角加一個小方格,刻上篇名,稱為書耳,便於讀者翻檢[@huang2005]。坊刻的書要賣得出去,處處替讀者打算,這一點從版式上也看得出來。 書末或目錄之後,常有一塊刻記,寫明何人於何年在何處刊行,有時兼作書坊的廣告,叫作牌記。牌記的形制有長方的,也有鐘形、亞字形的。後人鑑定版本,牌記是最直接的依據,可也最容易被書賈挖改作偽,所以不能單憑牌記下斷語,還要和行款、字體、紙墨、避諱字一起看。 西方學者系統地研究中國的雕版書,是二十世紀的事。Carter的專著第一次把中國印刷術的發明及其西傳完整地介紹給英語讀者[@carter1925];Twitchett則從書業和讀者的角度,討論中古時期的印刷與出版[@twitchett1983]。兩書所談雖不限於版式,卻都把宋代看作雕版書定型的時代,和中國學者的看法相合。 :::bibliography{title="本章引用書目" scope=chapter} :::references{format=bibtex} @book{su1999, author = {宿白}, title = {唐宋時期的雕版印刷}, publisher = {文物出版社}, address = {北京}, year = 1999, langid = {chinese}} @book{zhang1989, author = {張秀民}, title = {中國印刷史}, publisher = {上海人民出版社}, address = {上海}, year = 1989, langid = {chinese}} @book{huang2005, author = {黃永年}, title = {古籍版本學}, publisher = {江蘇教育出版社}, address = {南京}, year = 2005, langid = {chinese}} @book{carter1925, author = {Carter, Thomas Francis}, title = {The invention of printing in China and its spread westward}, publisher = {Columbia University Press}, address = {New York}, year = 1925, langid = {english}} @book{twitchett1983, author = {Twitchett, Denis}, title = {Printing and publishing in medieval China}, publisher = {Frederic C. Beil}, address = {New York}, year = 1983, langid = {english}} :::`; // content.<lang>.md: the same Chinese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Noto Serif SC': ['400', '700'], // SONG: the text, notes, list, imprint; the list's title 'Noto Sans SC': ['400', '700'], // HEI: the fore-edge heads and folios; 第三章 'LXGW WenKai TC': ['400'], // KAI: the chapter's title }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Each voice loads the files of what it sets; the Song also what the style writes. const heading = markdown.match(/^# (.*?)(?: \{.*\})?$/m)[1]; // the title, no attributes const book = markdown.match(/^title: "(.*)"/m)[1]; // 版刻叢談, the head of the even pages const numerals = '第章一二三四五六七八九十'; await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, `${markdown}卷等頁`, { vertical: true }); await loadCjkFonts({ [HEI]: ['400'] }, `${heading}${book}${numerals}`, { vertical: true }); await loadCjkFonts({ [SONG]: ['700'] }, markdown.match(/bibliography\{title="([^"]*)"/)[1], { vertical: true }); await loadCjkFonts({ [HEI]: ['700'] }, numerals); await loadCjkFonts({ [KAI]: ['400'] }, heading); const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'Vertical Chinese with citations in inline notes', es: 'Chino vertical con citas en notas dentro del renglón' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, cjk : identique dans toutes les recettes · 458 lignes
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · cjk v2 ── Chinese, Japanese and Korean books · postext.dev/cookbook // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { let at; cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1))) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], at).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` the screen sets code point `cp` in, if any: the * last one loadCjkFonts adds whose range holds it. A numbered file that * repeats a letter of latin is added after latin, and wins it. */ function cjkSliceFor(slices, cp) { const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to the engine's later round * gets the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files the * screen set its pages' characters in (`request.codePoints`), each marked * with its unicode-range and its place in the order loadCjkFonts added * them: postext-pdf sets a character two files hold in the one added * last, as the browser does. Any other family gets the latin file * fontsourceProvider fetches (the "pdf" block) and, when the face sets * letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.map(async (slice, order) => { if (!picked.has(slice)) return null; const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: slice.range, order }); })).then((files) => files.filter(Boolean)); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them, each marked with its unicode-range and its place in that order: * the PDF sets a character in the file the browser took it from. A face * Fontsource ships without latin-ext, or whose file does not come, gets * latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `${await fontsourceAt(id)}files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); const ranges = kitRanges(); return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }), Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Des crochets autour de chaque note
Certaines éditions ferment un 夾注 entre crochets au corps du texte. cjk.warichu les pose pour toutes les notes, citations comprises.
- warichu: { fontSize: em(0.5), color: col('ink') }, // no brackets: the half size sets it off
+ warichu: { fontSize: em(0.5), color: col('ink'), open: '〔', close: '〕' },#Des numéros dans la colonne au lieu des notes
Le style numérique GB/T 7714 avec crochets d’angle imprime 〔1〕 debout dans la colonne, et la liste reste telle quelle.
- style: 'china-national-standard-gb-t-7714-2015-note',
- notes: 'warichu', // 夾注; 'footnote' sets the same notes at the foot of the page
+ style: 'china-national-standard-gb-t-7714-2015-numeric',
+ marker: 'corner', // 〔1〕, upright in vertical textErreurs fréquentes
Erreur fréquente
Balisez le document zh-Hans ou zh-Hant, pas avec LANG
Les éditions d’une recette sont en et es, mais un exemple chinois est en chinois dans les deux : `locale: LANG` le baliserait en anglais ou en espagnol, couperait ses mots latins, nommerait ses figures Figure ou Figura et donnerait au PDF la mauvaise langue. Depuis postext 1.30, la balise est aussi la langue dans laquelle le texte est mis en forme (mesuré, peint et incorporé) : sous une balise anglaise ou espagnole, une police chinoise compose ses chiffres et la ponctuation qu’elle partage avec le texte latin sous d’autres formes, et les lignes se coupent ailleurs. Écrivez la balise vous-même : 'zh-Hans' (conventions de Chine continentale : coupure des lignes GB, ponctuation Kaiming) ou 'zh-Hant' (Taïwan : ponctuation pleine chasse centrée) ; 'zh-HK' pour Hong Kong. Un 'zh' seul se lit comme du chinois simplifié de Chine continentale. Un exemple japonais prend 'ja' (piège ja-locale-tag). Coupure des lignes en chinois →
Erreur fréquente
Les polices chinoises se chargent par tranches, avec le bloc cjk
Fontsource sert une famille chinoise, japonaise ou coréenne sous la forme d’une centaine de fichiers par graisse, chacun couvrant une plage de caractères. loadFonts ne récupère que le fichier latin : à l’écran, les caractères han viennent d’une police système et sont mal mesurés, et fontsourceProvider remet ce fichier latin au PDF, qui les imprime en cases vides. Listez le bloc cjk du kit, appelez loadCjkFonts(FONTS, markdown) après loadFonts (une fois par voix, avec le texte qu’elle compose, quand le livre utilise plusieurs polices CJK) et donnez à renderToPdf fontProvider: cjkPdfProvider : les deux prennent les fichiers qui contiennent les caractères du texte. Polices chinoises, japonaises et coréennes →
Erreur fréquente
Composez la ponctuation de chaque région dans un caractère de cette région
Les chasses de ponctuation déplacent le blanc à côté de chaque signe du côté où la région du document le place, et non du côté où la police le dessine : en zh-Hans, une virgule Kaiming garde la moitié gauche de son cadre, où un caractère simplifié dessine le glyphe, et abandonne la moitié droite. Un caractère traditionnel centre ,。 dans le cadre : la moitié retirée contient une partie du glyphe, et la virgule serre le caractère suivant. LXGW WenKai TC, le seul caractère de texte Kai sur Fontsource, produit cet effet dans un texte simplifié. Composez le texte, les notes et les citations de Chine continentale en Noto Serif SC ou Noto Sans SC, gardez les polices TC pour zh-Hant, et n’employez un caractère pinceau simplifié (Ma Shan Zheng) que pour les lignes de titrage, où le texte de maquette n’applique aucune chasse de ponctuation. Ce caractère dessine aussi des formes héritées que ni la norme continentale ni celle de Taïwan n’impriment, 為 avec le haut 爫 de 爲 et 令 (dans 冷, 領) avec un pied 卩 : vérifiez les caractères qu’une page y compose. Largeur de la ponctuation chinoise →
Erreur fréquente
Un livre relié à droite montre ses doubles pages avec showBook
Dans un livre relié à droite (texte arabe, hébreu ou persan, chinois vertical, ou page.binding 'right'), la page 1 reste le recto, mais elle se trouve à gauche du dos, et les paires se lisent [3 | 2]. showPages présente tout livre relié à gauche ; showBook, du bloc book (le bloc cjk contient la même fonction), lit doc.binding et inverse les paires. capture.hero désigne toujours une double page dans l’ordre de lecture, [verso, recto] : [2, 3]. Livres reliés à droite →
- Un style à notes écrit en forme abrégée un ouvrage cité une seconde fois, et le style de notes GB/T 7714 l’abrège en 同1, « comme à la note 1 ». Un warichu n’a pas de numéro : le lecteur ne peut donc pas retrouver la note 1. Ce chapitre cite chaque ouvrage une seule fois ; un texte qui cite de nouveau un ouvrage doit écrire la seconde citation à la main, ou employer le style numérique de la seconde variante.
- Le style écrit une ponctuation occidentale dans les entrées chinoises,
宿白. 唐宋時期的雕版印刷[M]. 北京: 文物出版社, 1999., comme la norme les imprime. Dans une colonne verticale, ces signes gardent leur forme occidentale : ils se couchent et occupent moins d’une case, à la différence du 。 et du , du texte. - Une ligne de warichu fait la moitié du corps du texte : à 10,5 pt, chaque ligne fait 5,25 pt, le plus petit caractère de la page. Composez le texte en 五號 ou plus grand, ou augmentez
cjk.warichu.fontSize; les deux lignes partagent toujours la largeur d’une seule colonne.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Polices
- Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


