Lo que vas a componer
Siete páginas con dos textos fundacionales de la República de China: la proclama con la que Sun Wen asumió la presidencia provisional el 1 de enero de 1912 y los dos primeros capítulos de la Constitución Provisional del 11 de marzo. Todo va en vertical, en una página de 184 × 260 mm encuadernada por la derecha. Los documentos conservan sus numerales chinos, 民國元年三月十一日. Las presentaciones y las notas, en letra Kai, dan en cifras arábigas las fechas del calendario occidental, las cantidades y los números de serie, y esas cifras son el tema de la receta: 10月10日 pone cada número en pie en su casilla, 1912 y Sun Yat-sen van girados, 民國115年 mete tres cifras en una casilla y ROC apila sus letras. Una cubierta bermellón con doble filete, una banda bermellón en la apertura de cada documento y cabeceras en el corte, con folios en numerales chinos, enmarcan el texto.
Esta receta responde a
- ¿Cómo pongo en pie fechas y números dentro de un texto vertical?
- ¿Cómo compongo un libro chino en vertical, encuadernado por la derecha?
- ¿Cómo defino la mancha en caracteres, tantos por línea y tantas líneas por página?
La respuesta corta
const layout = { layoutType: 'single', writingMode: 'vertical-rl' }; // binding 'auto': right
const cjk = {
// 10月10日: each number of up to two digits stands upright in one cell (tate-chu-yoko);
// 1912 and Sun Yat-sen stay on their side, as clreq sets long numbers and Latin words.
uprightDigits: 2,
// The type area in characters: 44 down each line, 19 lines to the page.
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 19 },
};
// By hand, in the Markdown: 民國:tcy[115]年 puts three digits in one cell, :upright[ROC]
// stands each letter of an acronym upright, :sideways[…] turns any run with the line.
Ingredientes
- Funciones
- Números en pie en texto verticalTexto verticalLibros encuadernados por la derechaRetícula de caracteresNumeración chinaFuentes chinas, japonesas y coreanasAnchura de la puntuación chinaCorte de líneas en chinoAperturas diseñadasEstilos de títuloAtributos de títuloEstilos de párrafoCabeceras y foliosTextos, filetes y cajas en los diseños de páginaColor del papelPaleta de color semánticaExportación a PDFFuentes incrustadas en el PDF
- También usa
- Bibliografías y glosariosCabeceras según el tipo de páginaPreliminares en romanosCabeceras por secciónCapítulos sin número
- Tipografía
- Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Deja los números cortos a la configuración
El código es la respuesta corta de arriba. Con cjk.uprightDigits: 2, cualquier grupo de una o dos cifras de un texto vertical se pone en pie en una casilla de un cuadratín, con las dos cifras una junto a otra: 10月10日, 第35號 y 7章56條 no llevan marca alguna. Un número más largo se compone entero de lado, como una palabra latina, que es como clreq compone los números de cuatro cifras, así que 1912 y 1644 bajan por la línea girados un cuarto de vuelta. Si quieres en pie un número de tres cifras, márcalo: 民國:tcy[115]年 estrecha el 115 hasta que quepa en el cuadratín. :upright[ROC] pone cada letra en pie en su propia casilla, una debajo de otra, y la línea nunca se corta dentro (números en texto vertical).

Los textos de 1912 quedan como se imprimieron. Las notas del editor siguen las normas de Taiwán sobre los números, el 公文書橫式書寫數字使用原則 del Yuan Ejecutivo, de 2004: las fechas, los números de serie y las cantidades van en cifras arábigas (1912年3月8日, 第35號, 7章56條), y los nombres propios, las expresiones fijas y las cifras redondas conservan los numerales chinos (東三省, 四萬萬, 四億), igual que la fecha lunar, 辛亥年八月十九日. Cuando una nota cita un título o un término de los documentos, lo deja como está: 第一章, 二十二行省. Esas normas se escribieron para documentos en horizontal; en vertical, sus cifras son las que la receta tiene que poner en pie.
#2 · Abre cada documento con una banda a la derecha
// The margins are minimums: the grid centres the 44 characters (186.3 mm) between them,
// so the type area starts HEAD mm down the sheet. The band's title starts there too.
const HEAD = MARGIN.top + (TRIM.h - MARGIN.top - MARGIN.bottom - LINE) / 2;
const BAND = 42; // mm from the right trim edge: where a reader of vertical text starts
const onBand = { color: col('paper'), overflow: 'wrap' };
// In the flow of a vertical page x runs down the sheet and y leftward from its right edge.
const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(x), y: mm(y) }, ...extra });
const opener = {
enabled: true,
minHeight: mm(BAND - 15), // past the side margin (21.6 mm), 6 mm of paper before the text
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('seal') },
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: HEI,
fontWeight: 700, fontSize: pt(34), lineHeight: 1.2, letterSpacing: pt(4), align: 'left',
placement: at(HEAD, 8) },
{ kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: HEI,
fontSize: pt(9), lineHeight: 1.6, align: 'left', placement: at(HEAD, 25) },
// The date ends where the lines of text end: align 'right' is the foot of a vertical line.
{ kind: 'text', id: 'date', content: '{attr.date}', ...onBand, fontFamily: HEI,
fontWeight: 500, fontSize: pt(13), lineHeight: 1.2, align: 'right',
placement: at(HEAD, 24, { size: { width: mm(LINE) } }) },
] },
};
En un flujo vertical, la cabeza de la página es el borde derecho de la hoja, donde empieza la lectura. Un diseño de título anclado a la cabeza de la sangre es, por tanto, una banda que baja por el borde derecho, y su size.height es su anchura sobre el papel. Los desplazamientos giran con el flujo: x se mide hacia abajo y y hacia la izquierda. La retícula centra la mancha entre los márgenes, y HEAD repite esa cuenta para que el título empiece a la altura del primer carácter de cada línea. align: 'right' lleva la fecha al pie de su línea, donde terminan las líneas de texto, y cjk.uprightDigits alcanza también al texto del diseño: el 11 de 3月11日 ocupa una sola casilla, como en el cuerpo. La banda es la de Apertura de capítulo sobre banda a sangre, girada un cuarto de vuelta.
Un diseño de título no distingue páginas pares e impares, así que la banda baja siempre por el borde derecho de la hoja. En la página 2, que queda a la derecha, ese borde es el corte; en la página 5, que en un libro encuadernado por la derecha queda a la izquierda, es el lomo, y el título empieza a 8 mm de él, dentro del margen interior de 21,6 mm. Un cuadernillo grapado se abre lo bastante para eso. En un libro cosido o encolado, abre cada documento en una página de la derecha con breakBefore.parity: 'even', que deja en blanco la página 5.

#3 · Lleva las cabeceras al corte
const foreEdge = (id, content, edge, y) => ({
kind: 'text', id, content, writingMode: 'vertical-rl', pages: 'body', align: 'left',
fontFamily: HEI, fontSize: pt(9.6), color: col('muted'), overflow: 'clip',
placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
});
const header = { elements: [
foreEdge('head', '{chapterTitle}', 'top', 4), // four characters below the type area
foreEdge('folio', '{pageNumber}', 'bottom', -5), // ends five characters above its foot
] };
anchor.to: 'outer' es el margen del lado opuesto al lomo, así que en un libro encuadernado por la derecha las cabeceras bajan por el borde izquierdo de la página impar y por el derecho de la par. El título queda cuatro caracteres por debajo de la cabeza de la mancha y el folio termina cinco por encima de su pie, ambos en vertical, al 80 % del cuerpo del texto (elementos de texto verticales). pageNumbering.format: 'trad-chinese-informal' imprime los folios 三, 四, 六 y 七, y pages: 'body' los quita de las dos aperturas.
#4 · Lleva la cubierta a sangre con un diseño de página
const frame = (id, inset, width) => ({ kind: 'box', id,
style: { borderColor: col('tint'), borderWidth: pt(width) },
placement: at(inset, inset, { size: { width: mm(TRIM.h - 2 * inset),
height: mm(TRIM.w - 2 * inset) } }) });
const coverLine = (id, content, y, align) => ({ kind: 'text', id, content, ...onBand,
fontFamily: HEI, fontWeight: 500, fontSize: pt(18), lineHeight: 1.2, align,
placement: at(HEAD, y, { size: { width: mm(LINE) } }) });
const cover = {
id: 'cover', numbered: false, toc: false, runningChapter: false,
span: 'page', // a page-wide design is not cut at the foot of the column: the field bleeds
header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'box', id: 'field', style: { backgroundColor: col('seal') },
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(TRIM.w) } } },
frame('rule-outer', 11, 1.6), frame('rule-inner', 13, 0.5), // 文武線: thick, then thin
{ kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: HEI,
fontWeight: 700, fontSize: pt(56), lineHeight: 1.1, letterSpacing: pt(5.6), align: 'left',
placement: at(HEAD, 26) },
{ kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: HEI,
fontSize: pt(11), lineHeight: 1.4, align: 'left', placement: at(HEAD, 51) },
coverLine('first', '臨時大總統宣言書', 104, 'left'),
coverLine('first-date', '1912年1月1日', 104, 'right'),
coverLine('second', '中華民國臨時約法', 118, 'left'),
coverLine('second-date', '1912年3月11日', 118, 'right'),
] } },
};
Un diseño de título en una columna se corta al pie de la columna, y en una página vertical ese pie es el margen izquierdo: un campo anclado a la sangre se quedaba a 21,6 mm del corte izquierdo. Con span: 'page' el título se compone como banda de página, que no se corta, y el campo llega a los dos bordes. Los dos marcos dibujan el filete doble, grueso y fino, de los libros impresos chinos: 1,6 pt y 0,5 pt, separados 2 mm.
#5 · Carga cada voz con el texto que compone
const voice = (styles) => (markdown.match(
new RegExp(`:::paragraphs\\{style="(?:${styles})"\\}[\\s\\S]*?:::`, 'g')) ?? []).join('');
const kaiText = voice('headnote|note');
// Hei: headings and their attributes, seals, colophon, the cover's lines, folios 一 to 十.
const heiText = (markdown.match(/^#.*$/gm) ?? []).join('') + voice('seal|colophon')
+ '臨時大總統宣言書1912年1月1日中華民國臨時約法1912年3月11日一二三四五六七八九十';
Fontsource sirve cada peso de estas fuentes en un centenar de archivos, cada uno con un tramo de caracteres, y loadCjkFonts carga los archivos que toca un texto. Con solo los títulos, los avisos de sello, el colofón, las líneas de la cubierta y los folios, cada peso de Noto Sans TC carga 10 archivos; con la muestra entera cargaría 35, y son tres pesos. LXGW WenKai TC recibe las presentaciones y las notas, y carga 16. Una fuente que recibe menos texto del que compone hace fallar la captura (C12), que nombra los caracteres que tuvo que tomar de otra fuente.
La receta completa
// ═══ Postext Cookbook · Nº 081 · Dates and acronyms upright in vertical text ═══════ // https://postext.dev/en/cookbook/chinese-dates-upright // Code: MIT · Text: 1912 documents, zh.wikisource (CC BY-SA 4.0); notes (CC BY 4.0) // Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0 // Two founding documents of the Republic of China, 1912, set vertically as a reader. import { buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'chinese-dates-upright'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: ink, the vermilion of a seal, and the paper of a gazette const palette = { ink: '#1e1a17', // text: a warm near-black seal: '#b1291f', // the one accent: the bands, the seal notes, the chapter heads tint: '#f6dccf', // the cover's double rule, a pale vermilion muted: '#6d645b', // fore-edge heads, the colophon paper: '#fffdf8', }; 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: 'seal (defaults)', value: { hex: palette.seal, model: 'hex' } }, ]; // #endregion const SONG = 'Noto Serif TC'; // 宋/明: the documents const HEI = 'Noto Sans TC'; // 黑: titles, heads, labels const KAI = 'LXGW WenKai TC'; // 楷: the editor's headnotes and notes const BODY = 12; // pt, 小四 const LEAD = 21; // pt: a line gap of 0.75 em const CHARS = 44; // characters to a line const LINE = (CHARS * BODY * 25.4) / 72; // mm: the length of a line, 186.3 const TRIM = { w: 184, h: 260 }; // mm, 16开 const MARGIN = { top: 30, bottom: 22, side: 20 }; // mm, minimums: 天头 deeper than 地脚 // #region answer: vertical text where numbers of one or two digits stand upright const layout = { layoutType: 'single', writingMode: 'vertical-rl' }; // binding 'auto': right const cjk = { // 10月10日: each number of up to two digits stands upright in one cell (tate-chu-yoko); // 1912 and Sun Yat-sen stay on their side, as clreq sets long numbers and Latin words. uprightDigits: 2, // The type area in characters: 44 down each line, 19 lines to the page. grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 19 }, }; // By hand, in the Markdown: 民國:tcy[115]年 puts three digits in one cell, :upright[ROC] // stands each letter of an acronym upright, :sideways[…] turns any run with the line. // #endregion // #region opener: a vermilion band down the right edge, where each document begins // The margins are minimums: the grid centres the 44 characters (186.3 mm) between them, // so the type area starts HEAD mm down the sheet. The band's title starts there too. const HEAD = MARGIN.top + (TRIM.h - MARGIN.top - MARGIN.bottom - LINE) / 2; const BAND = 42; // mm from the right trim edge: where a reader of vertical text starts const onBand = { color: col('paper'), overflow: 'wrap' }; // In the flow of a vertical page x runs down the sheet and y leftward from its right edge. const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) }, ...extra }); const opener = { enabled: true, minHeight: mm(BAND - 15), // past the side margin (21.6 mm), 6 mm of paper before the text slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('seal') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } }, { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: HEI, fontWeight: 700, fontSize: pt(34), lineHeight: 1.2, letterSpacing: pt(4), align: 'left', placement: at(HEAD, 8) }, { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: HEI, fontSize: pt(9), lineHeight: 1.6, align: 'left', placement: at(HEAD, 25) }, // The date ends where the lines of text end: align 'right' is the foot of a vertical line. { kind: 'text', id: 'date', content: '{attr.date}', ...onBand, fontFamily: HEI, fontWeight: 500, fontSize: pt(13), lineHeight: 1.2, align: 'right', placement: at(HEAD, 24, { size: { width: mm(LINE) } }) }, ] }, }; // #endregion // #region heads: fore-edge heads, the folio in Chinese numerals const foreEdge = (id, content, edge, y) => ({ kind: 'text', id, content, writingMode: 'vertical-rl', pages: 'body', align: 'left', fontFamily: HEI, fontSize: pt(9.6), color: col('muted'), overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } }, }); const header = { elements: [ foreEdge('head', '{chapterTitle}', 'top', 4), // four characters below the type area foreEdge('folio', '{pageNumber}', 'bottom', -5), // ends five characters above its foot ] }; // #endregion // #region cover: the title page, a vermilion field inside a double rule const frame = (id, inset, width) => ({ kind: 'box', id, style: { borderColor: col('tint'), borderWidth: pt(width) }, placement: at(inset, inset, { size: { width: mm(TRIM.h - 2 * inset), height: mm(TRIM.w - 2 * inset) } }) }); const coverLine = (id, content, y, align) => ({ kind: 'text', id, content, ...onBand, fontFamily: HEI, fontWeight: 500, fontSize: pt(18), lineHeight: 1.2, align, placement: at(HEAD, y, { size: { width: mm(LINE) } }) }); const cover = { id: 'cover', numbered: false, toc: false, runningChapter: false, span: 'page', // a page-wide design is not cut at the foot of the column: the field bleeds header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'box', id: 'field', style: { backgroundColor: col('seal') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(TRIM.w) } } }, frame('rule-outer', 11, 1.6), frame('rule-inner', 13, 0.5), // 文武線: thick, then thin { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: HEI, fontWeight: 700, fontSize: pt(56), lineHeight: 1.1, letterSpacing: pt(5.6), align: 'left', placement: at(HEAD, 26) }, { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: HEI, fontSize: pt(11), lineHeight: 1.4, align: 'left', placement: at(HEAD, 51) }, coverLine('first', '臨時大總統宣言書', 104, 'left'), coverLine('first-date', '1912年1月1日', 104, 'right'), coverLine('second', '中華民國臨時約法', 118, 'left'), coverLine('second-date', '1912年3月11日', 118, 'right'), ] } }, }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'zh-Hant', // Taiwan conventions: centred punctuation (gotcha: cjk-locale-tag) colorPalette, page: { sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.side), right: mm(MARGIN.side), mirror: true }, pageNumbering: { format: 'trad-chinese-informal' }, // 一, 二, 三… }, layout, cjk, 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: HEI, color: col('seal'), fontWeight: 700, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, fontSize: pt(BODY), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, ], }, headingStyles: [cover], paragraphStyles: [ // 低二格: the editor's Kai, two body cells lower (pt: the Kai's own em is off the grid). { id: 'headnote', fontFamily: KAI, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), indent: pt(2 * BODY), firstLineIndent: pt(2 * BODY), marginBottom: pt(LEAD) }, { id: 'note', fontFamily: KAI, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), firstLineIndent: pt(0), hangingIndent: em(1) }, { id: 'order', firstLineIndent: em(2), marginTop: pt(0) }, { id: 'dateline', textAlign: 'left', indent: em(12), firstLineIndent: pt(0) }, // The seal notice starts 22 cells down, a cell past the end of 大中華民國元年元旦. { id: 'seal', fontFamily: HEI, fontSize: pt(10.5), color: col('seal'), textAlign: 'left', indent: pt(22 * BODY), firstLineIndent: pt(0) }, { id: 'article', firstLineIndent: pt(0), hangingIndent: em(2) }, { id: 'item', indent: em(2), firstLineIndent: pt(0), hangingIndent: em(2) }, { id: 'colophon', fontFamily: HEI, fontSize: pt(7.5), lineHeight: pt(LEAD), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], header, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 139 líneas · content.es.md
title: "民國元年文告二種" author: "孫文 臨時參議院" --- # 民國元年文告二種 {style="cover" latin="Dos documentos fundacionales de 1912"} # 臨時大總統宣言書 {date="1912年1月1日" latin="Proclama del presidente provisional"} :::paragraphs{style="headnote"} 1912年1月1日晚上10時,孫文在南京就任中華民國臨時大總統,宣誓受印之後,以這篇宣言書布告國民。同月29日《臨時政府公報》創刊,第1號第3至6頁刊出全文,題作〈臨時大總統宣言書〉,字句略異。本篇據廣州市博物館藏原件排印;原件沒有標點,也不分段,標點與分段從維基文庫整理本。 ::: 中華民國締造之始,而文以不德,膺臨時大總統之任,夙夜戒懼,慮無以副國民之望。夫中國專制政治之毒,至二百餘年來而滋甚,一旦以國民之力踣而去之,起事不過數旬,光復已十餘行省,自有歷史以來成功未有如是之速也。國民以為於內無統一之機關,於外無對待之主體,建設之事更不容緩,於是以組織臨時政府之責相屬。自推功讓能之觀念以言,文所不敢任也;自服務盡責之觀念以言,則文所不敢辭也。是用黽勉從國民之後,能盡掃專制之流毒,確定共和,以達革命之宗旨,完國民之志願,端在今日。敢披瀝肝膽,為國民告: 國家之本,在於人民。合漢、滿、蒙、回、藏諸地為一國,即合漢、滿、蒙、回、藏諸族為一人。——是曰民族之統一。 武漢首義,十數行省先後獨立。所謂獨立,對於清廷為脫離,對於各省為聯合。蒙古、西藏,意亦同此。行動既一,決無歧趨,樞機成於中央,斯經緯周於四至。——是曰領土之統一。 血鐘一鳴,義旗四起,擁甲帶戈之士遍於十餘行省。雖編製或不一,號令或不齊,而目的所在則無不同。由共同之目的,以為共同之行動,整齊劃一,夫豈其難?——是曰軍政之統一。 國家幅員遼闊,各省自有其風氣所宜。前此清廷強以中央集權之法行之,遂其偽立憲之術;今者各省聯合,互謀自治,此後行政,期於中央政府與各省之關係調劑得宜。大綱既挈,條目自舉。——是曰內治之統一。 滿清時代,借立憲之名,行斂財之實,雜捐苛細,民不聊生。此後國家經費,取給於民,必期合於理財學理,而尤在改良社會經濟組織,使人民知有生之樂。——是曰財政之統一。 以上數者,為政務之方針,持此進行,庶無大過。 若夫革命主義,為吾儕所昌言,萬國所同喻,前此雖屢起屢躓,外人無不鑒其用心。八月以來,義旗飆發,諸友邦對之抱和平之望、持中立之態,而報紙及輿論尤每表其同情。鄰誼之篤,良足深謝。臨時政府成立以後,當盡文明國應盡之義務,以期享文明國應享之權利。滿清時代辱國之舉措與排外之心理,務一洗而去之。與我友邦益增睦誼,持和平主義,將使中國見重於國際社會,且將使世界漸趨於大同。循序以進,不為幸獲。對外方針,實在於是。 夫民國新建,外交內政百緒繁生,文自顧何人,而克勝此?然而臨時之政府,革命時代之政府也。十餘年來從事於革命者,皆以誠摯純潔之精神戰勝所遇之艱難。即使後此之艱難遠逾於前日,而吾人惟保此革命之精神,一往而莫之能阻,必使中華民國之基礎確定於大地,然後臨時政府之職務始盡,而吾人始可告無罪於國民也。今以與我國民初相見之日,披布腹心,惟我四萬萬之同胞共鑒之。 :::paragraphs{style="dateline"} 大中華民國元年元旦 ::: :::paragraphs{style="seal"} (中華民國臨時大總統印) ::: ## 注釋 :::paragraphs{style="note"} 【中華民國】1912年1月1日建立。英文國號作 Republic of China,縮寫:upright[ROC]。 【文以不德】文,孫文自稱其名;不德,自謙德行不足。孫文號逸仙,西文拼作 Sun Yat-sen(1866—1925)。 【二百餘年】指清朝的統治。清兵1644年入關,至1912年2月12日宣統帝退位,凡:tcy[268]年。 【八月以來】辛亥年八月十九日(1911年10月10日)武昌起義以來。 【四萬萬之同胞】四萬萬,四億。當時以此通稱全國人口。 【大中華民國元年元旦】1912年1月1日,夏曆辛亥年十一月十三日。民國紀年由此日起算,當年稱民國元年。公元2026年是民國:tcy[115]年。 ::: # 中華民國臨時約法 {date="1912年3月11日" latin="Constitución Provisional de la República de China"} :::paragraphs{style="headnote"} 1912年3月8日,南京臨時參議院議決《中華民國臨時約法》;3月11日,臨時大總統孫文公布施行,全文刊於《臨時政府公報》第35號。約法凡7章56條,本篇錄第一章〈總綱〉與第二章〈人民〉,共15條,前冠公布令。原文的「爲」「靑」「敎」「决」,本篇改用通行字形。 ::: :::paragraphs{style="order"} 玆准參議院咨送議決臨時約法前來,合行公布。 ::: :::paragraphs{style="seal"} 孫文印 ::: :::paragraphs{style="dateline"} 中華民國元年三月十一日 ::: ## 第一章 總綱 :::paragraphs{style="article"} 第一條 中華民國由中華人民組織之。 第二條 中華民國之主權屬於國民全體。 第三條 中華民國領土,為二十二行省、內外蒙古、西藏、青海。 第四條 中華民國以參議院、臨時大總統、國務院、法院行使其統治權。 ::: ## 第二章 人民 :::paragraphs{style="article"} 第五條 中華民國人民一律平等,無種族、階級、宗教之區別。 第六條 人民得享有左列各項之自由權: ::: :::paragraphs{style="item"} 一、人民之身體,非依法律不得逮捕、拘禁、審問、處罰。 二、人民之家宅,非依法律不得侵入或搜索。 三、人民有保有財產及營業之自由。 四、人民有言論、著作、刊行及集會結社之自由。 五、人民有書信秘密之自由。 六、人民有居住遷徙之自由。 七、人民有信教之自由。 ::: :::paragraphs{style="article"} 第七條 人民有請願於議會之權。 第八條 人民有陳訴於行政官署之權。 第九條 人民有訴訟於法院受其審判之權。 第十條 人民對於官吏違法損害權利之行為,有陳訴於平政院之權。 第十一條 人民有應任官考試之權。 第十二條 人民有選舉及被選舉之權。 第十三條 人民依法律有納稅之義務。 第十四條 人民依法律有服兵之義務。 第十五條 本章所載人民之權利,有認為增進公益、維持治安或非常緊急必要時,得依法律限制之。 ::: ## 注釋 :::paragraphs{style="note"} 【參議院】臨時參議院,1912年1月28日在南京開院,參議員由各省都督府選派,每省至多3人。 【二十二行省】清末本部十八省,加上東三省與新疆,合稱二十二行省。 ::: :::paragraphs{style="colophon"} Compuesto en Noto Serif TC, Noto Sans TC y LXGW WenKai TC (SIL OFL) · Textos: zh.wikisource, CC BY-SA 4.0 · Presentaciones y notas: CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint; the Chinese ones by files (gotcha: cjk-fonts-slices). const FONTS = { 'Noto Serif TC': ['400'], 'Noto Sans TC': ['400', '500', '700'], 'LXGW WenKai TC': ['400'] }; // #region voices: each Chinese face loads the files for the text it sets const voice = (styles) => (markdown.match( new RegExp(`:::paragraphs\\{style="(?:${styles})"\\}[\\s\\S]*?:::`, 'g')) ?? []).join(''); const kaiText = voice('headnote|note'); // Hei: headings and their attributes, seals, colophon, the cover's lines, folios 一 to 十. const heiText = (markdown.match(/^#.*$/gm) ?? []).join('') + voice('seal|colophon') + '臨時大總統宣言書1912年1月1日中華民國臨時約法1912年3月11日一二三四五六七八九十'; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: FONTS[SONG] }, markdown, { vertical: true }); await loadCjkFonts({ [HEI]: FONTS[HEI] }, heiText, { vertical: true }); await loadCjkFonts({ [KAI]: FONTS[KAI] }, kaiText, { vertical: true }); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showBook(doc, { title: t({ en: 'Two documents of 1912', es: 'Dos documentos de 1912' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, cjk: igual en todas las recetas · 422 líneas
// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; 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` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs 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; its * bold, italic and bold-italic variants are listed whether or not used. */ 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' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ 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 v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { 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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show 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 goes to fontsourceProvider (the "pdf" block). */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style); 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())); })); } /** 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 ───────────────────────────────────────────────────────────────────────
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗
Variantes
#Pon en pie también los números de tres cifras
Con 3, el 268 y el 115 de las notas ocupan su casilla sin marca, igual que cualquier número de artículo o de página hasta el 999; 1912 sigue de lado.
- uprightDigits: 2,
+ uprightDigits: 3,#Gira todos los números
Con 0 ningún número se pone en pie por sí solo: 10月10日 se gira como 1912, y solo el 268, el 115 y ROC, que llevan marca, siguen en pie.
- uprightDigits: 2,
+ uprightDigits: 0,Errores frecuentes
Error frecuente
Las fuentes chinas se cargan por fragmentos, con el bloque cjk
Fontsource sirve una familia china, japonesa o coreana en un centenar de archivos por peso, cada uno con un intervalo de caracteres. loadFonts solo descarga el archivo latin, así que en pantalla los caracteres chinos salen de una fuente del sistema y se miden mal, y fontsourceProvider entrega al PDF ese archivo latin, que los imprime como cajas vacías. Añade el bloque cjk del kit, llama a loadCjkFonts(FONTS, markdown) después de loadFonts (una vez por voz, con el texto que compone, si el libro usa varias fuentes chinas) y pasa a renderToPdf fontProvider: cjkPdfProvider: ambos toman los archivos que contienen los caracteres del texto. Fuentes chinas, japonesas y coreanas →
Error frecuente
Etiqueta el documento zh-Hans o zh-Hant, no con LANG
Las ediciones de una receta son en y es, pero una muestra china es china en las dos: `locale: LANG` la etiquetaría como inglés o español, separaría sus palabras latinas, llamaría Figure o Figura a sus figuras y daría al PDF un idioma equivocado. Escribe tú la etiqueta: 'zh-Hans' (convenciones de la China continental: corte GB, puntuación Kaiming) o 'zh-Hant' (Taiwán: puntuación de ancho completo centrada); 'zh-HK' para Hong Kong. Un 'zh' a secas se lee como chino simplificado continental. Corte de líneas en chino →
Error frecuente
Un libro encuadernado por la derecha muestra sus pliegos con showBook
En un libro encuadernado por la derecha (chino vertical, o page.binding 'right') la página 1 sigue siendo impar, pero queda a la izquierda del lomo, y los pliegos se leen [3 | 2]. showPages compone todos los libros como si se encuadernaran por la izquierda; showBook, del bloque cjk, lee doc.binding y refleja los pliegos. capture.hero sigue nombrando un pliego en orden de lectura, [par, impar]: [2, 3]. Libros encuadernados por la derecha →
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
Una configuración se cachea por identidad: crea un objeto nuevo
El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
- Noto Serif TC, tal como la sirve Fontsource, no tiene 爲 ni 敎: su hoja de estilos los declara, pero ningún archivo trae el glifo. La constitución, tal como se imprimió en 1912, usa esas formas para 為 y 教. El lienzo dibujó 敎 con una fuente del sistema y el PDF no tenía glifo para él, de lo que avisó la captura (C25). La edición compone 為 y 教, lleva con ellos 靑 y 决 a 青 y 決, y la presentación lo dice.
- LXGW WenKai TC, tal como la sirve Fontsource, dibuja 即, 為, 青 y 教 con sus formas antiguas, 卽, 爲, 靑 y 敎, que desentonan junto a los mismos caracteres del texto en Song. Las notas se redactaron sin esos cuatro; solo la presentación que enumera las formas antiguas de la constitución las quiere. Lee a tamaño real el texto en Kai de tus propias notas.
- Un número compuesto de lado se separa del chino que lo rodea como una palabra latina, un cuarto de cuadratín a cada lado (
cjk.latinSpacing), y por eso las presentaciones muestran1912 年; un número en una casilla no lleva espacio, como un carácter chino.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Sun Wen (Sun Yat-sen), proclama al asumir la presidencia provisional, 1 de enero de 1912, según el original del Museo de Guangzhou · Sun Wen; transcription and punctuation by Wikisource editors · CC BY-SA 4.0
- Constitución Provisional de la República de China, 11 de marzo de 1912 (Linshi zhengfu gongbao, n.º 35), capítulos 1 y 2, con su orden de promulgación; cuatro formas antiguas de carácter, en su forma actual · Provisional Senate, Nanjing; transcription by Wikisource editors · CC BY-SA 4.0
- Las presentaciones y las notas en chino, escritas para esta receta · Postext Cookbook · CC BY 4.0
- Fuentes
- Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
- Código
- MIT, como Postext


