En poques paraules
Un conte curt compost una vegada per imprimir i desat com dos llibres electrònics: un que conserva cada pàgina impresa i un altre el text del qual el mòbil o el lector electrònic recompon a la seva mida.
Què compondràs
¡Adiós, Cordera!, el conte de Clarín, en tres capítols sobre una pàgina de 120 × 190 mm, amb portada, pàgina de crèdits al dors i una obertura enfonsada a cada capítol; l'edició anglesa compon divuit faules d'Isop amb el mateix disseny. El llibre es compon una vegada, capítol a capítol, i aquesta composició dona dos fitxers EPUB 3. El de maquetació fixa conserva cada pàgina impresa: les obertures, les capçaleres i els talls de línia, en la Caslon incrustada. El fluid conserva el text i la seva estructura, i el sistema de lectura el torna a compondre en un mòbil o en una pantalla de tinta electrònica. Tots dos es baixen des de la pàgina i tots dos passen EPUBCheck, el validador del W3C.
Aquesta recepta respon a
- Com exporto el meu llibre a EPUB, amb pàgines fixes o amb text fluid?
La resposta curta
const { title, subtitle, author } = docs[0].metadata; // the first chapter's front matter
const book = {
metadata: { title, subtitle, creators: [author], language: LANG,
rights: t({ en: 'Public domain', es: 'Dominio público' }),
// A fixed date gives the same bytes on every run; the identifier (a urn:uuid) comes
// from the title, the author and the language, so a new version keeps its place.
modified: new Date('2026-10-04T00:00:00Z') },
// Every face the pages use, as bytes: a face left out is set in the reader's own
// (gotcha: epub-embeds-given-fonts).
fonts: await epubFonts(FONTS),
onWarning: (w) => console.warn(`[epub] ${w.kind}`, w.family ?? w.fileId ?? w.detail),
};
// The same documents renderToPdf would print: one per chapter, in book order.
const fixed = await renderToEpub(docs, { ...book, layout: 'fixed' });
const reflowable = await renderToEpub(docs, { ...book, layout: 'reflowable' });
Ingredients
- Funcions
- Exportació a EPUBLlibres construïts capítol a capítolObertures dissenyadesEstils de títolAtributs de títolCapçaleres i folisCapçaleres segons el tipus de pàginaMarges simètricsColor del paperEstils de paràgrafVídues, òrfenes i línies curtesMetadades del documentPaleta de color semànticaFonts abans de compondre
- També fa servir
- Salts de pàgina i de columnaCapçaleres per secció
- Tipografia
- Libre Caslon Text, Libre Caslon Display, Jost (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Compon el llibre per capítols
// Each line spans the measure and centres its text; the rule centres under the title.
const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id,
content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'center',
overflow: 'wrap', lineHeight: 1.15, ...extra,
placement: { ...placement, size: { width: 'fill', height: 'auto' } } });
const rule = (id, below, y) => ({ kind: 'rule', id, direction: 'horizontal', thickness: pt(0.75),
color: col('accent'),
placement: { ...at(below, 'below', (MEASURE - 10) / 2, y), size: { width: mm(10) } } });
const titlePage = { id: 'title-page', header: { elements: [] }, footer: { elements: [] },
marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: mm(120), slot: { elements: [
text('author', '{author}', LABEL, 9, at('container', 'top-left', 0, 30), caps(9)),
text('title', '{titleText}', DISPLAY, 34, at('#author', 'below', 0, 8)),
rule('rule', '#title', 6),
text('subtitle', '{subtitle}', TEXT, 11, at('#title', 'below', 0, 13), { italic: true }),
] } } };
const opener = { enabled: true, minHeight: mm(56), slot: { elements: [
text('kicker', '{attr.kicker}', LABEL, 8, at('container', 'top-left', 0, 14), caps(8)),
text('title', '{titleText}', DISPLAY, 28, at('#kicker', 'below', 0, 4)),
rule('rule', '#title', 5),
] } };
buildBundle compon cada capítol com un document propi i passa d'un a l'altre el número de pàgina i la paritat, així que docs és la mateixa llista que imprimiria renderToPdf. L'escriptor d'EPUB llegeix aquesta llista: cada capítol és una entrada de l'índex de navegació, i el fitxer fluid escriu un document XHTML per capítol. La portada i la seva pàgina de crèdits formen el primer capítol, i el seu frontmatter dona el títol, el subtítol i l'autor del llibre.
#2 · Dona a l'escriptor els fitxers de les fonts
// Only the latin files: they hold every letter of both samples. A text beyond Latin-1 would
// add each family's latin-ext file with its unicodeRange.
const epubFonts = (faces) => Promise.all(Object.entries(faces).flatMap(([family, specs]) =>
specs.map(async (spec) => {
const [weight, style] = [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'];
const id = fontsourceId(family);
const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/`
+ `${id}-latin-${weight}-${style}.woff2`);
if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
const bytes = new Uint8Array(await res.arrayBuffer());
return { family, weight, style, format: 'woff2', bytes };
})));
Les fonts que ha carregat el navegador no són fitxers que l'escriptor pugui llegir, així que el codi torna a demanar els mateixos woff2 de Fontsource, que el navegador ja té a la memòria cau. Cadascun entra a l'EPUB com un fitxer amb la seva regla @font-face. Sense ells, la maquetació fixa col·locaria el text compost en Caslon amb una font del lector, i les línies ja no cabrien a la seva mida.
#3 · Escriu les dues versions
El codi és la resposta curta de dalt. layout: 'fixed' escriu un document XHTML per pàgina impresa, a la mida de la pàgina refilada i amb text de debò, que es pot seleccionar; layout: 'reflowable' refà els paràgrafs a partir de les línies compostes, treu els guions que hi van afegir els talls de línia i treu un full d'estils de la configuració. Una data modified fixa fa que cada execució doni els mateixos bytes. Les metadades surten del frontmatter del primer capítol, de manera que el títol i l'autor del fitxer són els que s'imprimeixen a la portada.
#4 · Llegeix cada fitxer de tornada
const el = (tag, css, text) => Object.assign(document.createElement(tag),
{ textContent: text ?? '' }, { style: css });
const shelf = el('section', 'display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;'
+ 'padding: 24px 16px 0; color: #d9d5cc; font: 13px/1.5 system-ui, sans-serif');
shelf.id = 'epub';
for (const [layout, bytes] of [['fixed', fixed], ['reflowable', reflowable]]) {
const epub = readEpub(bytes); // what a reading system opens: spine, contents, page list
const file = `${RECIPE}-${layout}.epub`;
const card = el('article', 'width: min(420px, 92vw); padding: 16px 18px;'
+ 'border: 1px solid #2c3038; border-radius: 6px; background: #15181d');
const spine = el('ol', 'margin: 8px 0; padding-left: 22px; max-height: 9em; overflow: auto');
spine.append(...epub.spine.map((item) => el('li', '', item.path.slice(epub.root.length))));
const link = el('a', 'color: #d8a21a', `Download ${file}`);
link.href = URL.createObjectURL(new Blob([bytes], { type: 'application/epub+zip' }));
link.download = file;
card.append(el('h2', 'margin: 0; font-size: 15px; color: #f4f1ea', `EPUB 3 · ${epub.layout}`),
el('p', 'margin: 4px 0 0', `${epub.spine.length} documents in the spine · `
+ `${epub.toc.length} contents entries · ${epub.pageList.length} print pages · `
+ `${Math.round(bytes.length / 1024)} KB`), spine, link);
shelf.append(card);
}
document.getElementById('pages').before(shelf);
kitStatus(`${docs.length} documents laid out · two EPUB 3 files`);
readEpub obre el fitxer com l'obre un sistema de lectura: l'ordre de lectura (spine), l'índex, la llista de pàgines i la maquetació. El fitxer fix té una entrada per pàgina; el fluid, quatre, una per capítol. Tots dos porten la llista dels folis impresos, així que una cita de la pàgina 9 es troba en qualsevol dels dos. L'enllaç <a download> dona nom als bytes; res no passa per un servidor.
La recepta completa
// ═══ Postext Cookbook · Nº 115 · One book, two EPUB 3 files: fixed and reflowable ════ // https://postext.dev/en/cookbook/epub-fixed-and-reflowable // Code: MIT · Text: Aesop, tr. G. F. Townsend, 1867; L. Alas, Clarín, 1893 (PD) · Pictures: none // Fonts: Libre Caslon Text, Libre Caslon Display, Jost (SIL OFL 1.1) · Needs postext ≥ 1.15.0 // // A small book of three chapters, laid out once with buildBundle and written as two EPUB 3 // files with postext-epub: a fixed layout that keeps every printed page, and reflowable text // that a phone or an e-reader sets again. Each file is read back with readEpub to list what // a reading system will open, with a link to download it. import { buildBundle, renderPageToCanvas, clearMeasurementCache, } from 'https://esm.sh/postext'; import { renderToEpub, readEpub } from 'https://esm.sh/postext-epub'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'epub-fixed-and-reflowable'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: semantic colours, every one linked by id const palette = { ink: '#211e1b', // text: a warm near-black accent: '#2d5872', // Prussian blue: kickers, rules, folios rule: '#c8c0b4', // the title page's hairline muted: '#6b645d', // running heads and the imprint paper: '#fbf8f1', // an off-white page, kept in the fixed-layout EPUB }; 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: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // #endregion const [TEXT, DISPLAY, LABEL] = ['Libre Caslon Text', 'Libre Caslon Display', 'Jost']; const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER] = [120, 190, 20, 22, 16, 15]; // mm const MEASURE = TRIM_W - INNER - OUTER; // 89 mm: about 62 characters of Libre Caslon Text const LEAD = 13.5; // pt: the body leading const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const caps = (size, color = 'accent') => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight: 600, letterSpacing: pt(size * 0.2), textTransform: 'uppercase', color: col(color) }); // #region openers: a title page, then each chapter sunk under its kicker and a short rule // Each line spans the measure and centres its text; the rule centres under the title. const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id, content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', lineHeight: 1.15, ...extra, placement: { ...placement, size: { width: 'fill', height: 'auto' } } }); const rule = (id, below, y) => ({ kind: 'rule', id, direction: 'horizontal', thickness: pt(0.75), color: col('accent'), placement: { ...at(below, 'below', (MEASURE - 10) / 2, y), size: { width: mm(10) } } }); const titlePage = { id: 'title-page', header: { elements: [] }, footer: { elements: [] }, marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: mm(120), slot: { elements: [ text('author', '{author}', LABEL, 9, at('container', 'top-left', 0, 30), caps(9)), text('title', '{titleText}', DISPLAY, 34, at('#author', 'below', 0, 8)), rule('rule', '#title', 6), text('subtitle', '{subtitle}', TEXT, 11, at('#title', 'below', 0, 13), { italic: true }), ] } } }; const opener = { enabled: true, minHeight: mm(56), slot: { elements: [ text('kicker', '{attr.kicker}', LABEL, 8, at('container', 'top-left', 0, 14), caps(8)), text('title', '{titleText}', DISPLAY, 28, at('#kicker', 'below', 0, 4)), rule('rule', '#title', 5), ] } }; // #endregion // #region running-heads: the book on the verso, the chapter on the recto, folios outside const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', ...caps(7, 'muted'), fontWeight: 500, placement: at('page', edge, x, 11), ...extra }); const folio = { fontFamily: TEXT, fontSize: pt(8.5), fontWeight: 400, letterSpacing: pt(0), color: col('accent') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('verso-title', '{title}', 'even', 'top', (OUTER - INNER) / 2), head('recto-title', '{chapterTitle}', 'odd', 'top', (INNER - OUTER) / 2), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom', 0, { ...folio, pages: 'opener', placement: at('page', 'bottom', 0, -11) })] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: LANG, colorPalette, page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: TEXT, color: col('ink'), fontWeight: 400, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, fontFamily: DISPLAY, fontSize: pt(28), breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), italic: true, color: col('accent'), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2), keepWithNext: true }, ] }, headingStyles: [titlePage], paragraphStyles: [ // The imprint on the title's verso: the book's line in ink, then the notes in grey. { id: 'imprint-head', fontFamily: TEXT, fontSize: pt(8), lineHeight: pt(11.5), color: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), marginBottom: pt(8) }, { id: 'imprint', fontFamily: TEXT, fontSize: pt(8), lineHeight: pt(11.5), color: col('muted'), italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(5) }, { id: 'asterism', fontSize: pt(9), letterSpacing: pt(3), color: col('accent'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) }, ], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── // The title page and its imprint, then three chapters: one document each in the book. const chapters = [String.raw`---Mostra en Markdown · 18 línies · content.es.md
title: "¡Adiós, Cordera!" subtitle: "Cuento en tres capítulos" author: "Leopoldo Alas, Clarín" --- # ¡Adiós, Cordera! {style="title-page"} :::pagebreak :::paragraphs{style="imprint-head"} *¡Adiós, Cordera!* · Cuento en tres capítulos ::: :::paragraphs{style="imprint"} El texto sigue la primera edición del cuento en *El Señor y lo demás, son cuentos* (Madrid, 1893), transcrita por el Proyecto Gutenberg (libro electrónico n.º 62361), y está en el dominio público. Se respetan la ortografía de la época y las cursivas del autor; el diálogo va con raya y las comillas son latinas. Las cinco partes del original, separadas por asteriscos, se agrupan aquí en tres capítulos. Compuesto en Libre Caslon Text, Libre Caslon Display y Jost (SIL Open Font License). Maquetado una vez con Postext y escrito en dos archivos EPUB 3, uno con las páginas fijas y otro con el texto fluido. :::`, String.raw`# I {kicker="¡Adiós, Cordera!"}Mostra en Markdown · 34 línies · content.c1.es.md
¡Eran tres: siempre los tres! Rosa, Pinín y la *Cordera*. El *prao* Somonte era un recorte triangular de terciopelo verde tendido, como una colgadura, cuesta abajo por la loma. Uno de sus ángulos, el inferior, lo despuntaba el camino de hierro de Oviedo a Gijón. Un palo del telégrafo, plantado allí como pendón de conquista, con sus *jícaras* blancas y sus alambres paralelos, a derecha e izquierda, representaba para Rosa y Pinín el ancho mundo desconocido, misterioso, temible, eternamente ignorado. Pinín, después de pensarlo mucho, cuando a fuerza de ver días y días el poste tranquilo, inofensivo, campechano, con ganas, sin duda, de aclimatarse en la aldea y parecerse todo lo posible a un árbol seco, fué atreviéndose con él, llevó la confianza al extremo de abrazarse al leño y trepar hasta cerca de los alambres. Pero nunca llegaba a tocar la porcelana de arriba, que le recordaba las *jícaras* que había visto en la rectoral de Puao. Al verse tan cerca del misterio sagrado, le acometía un pánico de respeto, y se dejaba resbalar de prisa hasta tropezar con los pies en el césped. Rosa, menos audaz, pero más enamorada de lo desconocido, se contentaba con arrimar el oído al palo del telégrafo, y minutos, y hasta cuartos de hora, pasaba escuchando los formidables rumores metálicos que el viento arrancaba a las fibras del pino seco en contacto con el alambre. Aquellas vibraciones, a veces intensas como las del diapasón, que, aplicado al oído, parece que quema con su vertiginoso latir, eran para Rosa los *papeles* que pasaban, las *cartas* que se escribían por los *hilos*, el lenguaje incomprensible que lo ignorado hablaba con lo ignorado; ella no tenía curiosidad por entender lo que los de allá, tan lejos, decían a los del otro extremo del mundo. ¿Qué le importaba? Su interés estaba en el ruido por el ruido mismo, por su timbre y su misterio. La *Cordera*, mucho más formal que sus compañeros, verdad es que, relativamente, de edad también mucho más madura, se abstenía de toda comunicación con el mundo civilizado, y miraba de lejos el palo del telégrafo, como lo que era para ella, efectivamente, como cosa muerta, inútil, que no le servía siquiera para rascarse. Era una vaca que había vivido mucho. Sentada horas y horas, pues, experta en pastos, sabía aprovechar el tiempo, meditaba más que comía, gozaba del placer de vivir en paz, bajo el cielo gris y tranquilo de su tierra, como quien alimenta el alma, que también tienen los brutos; y si no fuera profanación, podría decirse que los pensamientos de la vaca matrona, llena de experiencia, debían de parecerse todo lo posible a las más sosegadas y doctrinales odas de Horacio. Asistía a los juegos de los pastorcicos encargados de *llindarla*, como una abuela. Si pudiera, se sonreiría al pensar que Rosa y Pinín tenían por misión en el prado cuidar de que ella, la *Cordera*, no se extralimitase, no se metiese por la vía del ferrocarril ni saltara a la heredad vecina. ¡Qué había de saltar! ¡Qué se había de meter! Pastar de cuando en cuando, no mucho, cada día menos, pero con atención, sin perder el tiempo en levantar la cabeza por curiosidad necia, escogiendo sin vacilar los mejores bocados, y, después, sentarse sobre el cuarto trasero con delicia, a rumiar la vida, a gozar el deleite del no padecer, del dejarse existir: esto era lo que ella tenía que hacer, y todo lo demás aventuras peligrosas. Ya no recordaba cuándo le había picado la mosca. «El *xatu* (el toro), los saltos locos por las praderas adelante... ¡todo eso estaba tan lejos!» Aquella paz sólo se había turbado en los días de prueba de la inauguración del ferrocarril. La primera vez que la *Cordera* vió pasar el tren, se volvió loca. Saltó la sebe de lo más alto del Somonte, corrió por prados ajenos, y el terror duró muchos días, renovándose, más o menos violento, cada vez que la máquina asomaba por la trinchera vecina. Poco a poco se fué acostumbrando al estrépito inofensivo. Cuando llegó a convencerse de que era un peligro que pasaba, una catástrofe que amenazaba sin dar, redujo sus precauciones a ponerse en pie y a mirar de frente, con la cabeza erguida, al formidable monstruo; más adelante no hacía más que mirarle, sin levantarse, con antipatía y desconfianza; acabó por no mirar al tren siquiera. En Pinín y Rosa la novedad del ferrocarril produjo impresiones más agradables y persistentes. Si al principio era una alegría loca, algo mezclada de miedo supersticioso, una excitación nerviosa, que les hacía prorrumpir en gritos, gestos, pantomimas descabelladas, después fué un recreo pacífico, suave, renovado varias veces al día. Tardó mucho en gastarse aquella emoción de contemplar la marcha vertiginosa, acompañada del viento, de la gran culebra de hierro, que llevaba dentro de sí tanto ruido y tantas castas de gentes desconocidas, extrañas. Pero telégrafo, ferrocarril, todo eso, era lo de menos: un accidente pasajero que se ahogaba en el mar de soledad que rodeaba el *prao* Somonte. Desde allí no se veía vivienda humana; allí no llegaban ruidos del mundo más que al pasar el tren. Mañanas sin fin, bajo los rayos del sol a veces, entre el zumbar de los insectos, la vaca y los niños esperaban la proximidad del mediodía para volver a casa. Y luego, tardes eternas, de dulce tristeza silenciosa, en el mismo prado, hasta venir la noche, con el lucero vespertino por testigo mudo en la altura. Rodaban las nubes allá arriba, caían las sombras de los árboles y de las peñas en la loma y en la cañada, se acostaban los pájaros, empezaban a brillar algunas estrellas en lo más obscuro del cielo azul, y Pinín y Rosa, los niños gemelos, los hijos de Antón de Chinta, teñida el alma de la dulce serenidad soñadora de la solemne y seria Naturaleza, callaban horas y horas, después de sus juegos, nunca muy estrepitosos, sentados cerca de la *Cordera*, que acompañaba el augusto silencio de tarde en tarde con un blando son de perezosa esquila. En este silencio, en esta calma inactiva, había amores. Se amaban los dos hermanos como dos mitades de un fruto verde, unidos por la misma vida, con escasa conciencia de lo que en ellos era distinto, de cuanto los separaba; amaban Pinín y Rosa a la *Cordera*, la vaca abuela, grande, amarillenta, cuyo testuz parecía una cuna. La *Cordera* recordaría a un poeta la *zavala* del Ramayana, la vaca santa; tenía en la amplitud de sus formas, en la solemne serenidad de sus pausados y nobles movimientos, aires y contornos de ídolo destronado, caído, contento con su suerte, más satisfecha con ser vaca verdadera que dios falso. La *Cordera*, hasta donde es posible adivinar estas cosas, puede decirse que también quería a los gemelos encargados de apacentarla. Era poco expresiva; pero la paciencia con que los toleraba cuando en sus juegos ella les servía de almohada, de escondite, de montura, y para otras cosas que ideaba la fantasía de los pastores, demostraba tácitamente el afecto del animal pacífico y pensativo. En tiempos difíciles, Pinín y Rosa habían hecho por la *Cordera* los imposibles de solicitud y cuidado. No siempre Antón de Chinta había tenido el prado Somonte. Este regalo era cosa relativamente nueva. Años atrás, la *Cordera* tenía que salir *a la gramática*, esto es, a apacentarse como podía, a la buena ventura de los caminos y callejas de las rapadas y escasas praderías del común, que tanto tenían de vía pública como de pastos. Pinín y Rosa, en tales días de penuria, la guiaban a los mejores altozanos, a los parajes más tranquilos y menos esquilmados, y la libraban de las mil injurias a que están expuestas las pobres reses que tienen que buscar su alimento en los azares de un camino. En los días de hambre, en el establo, cuando el heno escaseaba, y el narvaso para *estrar* el lecho caliente de la vaca faltaba también, a Rosa y a Pinín debía la *Cordera* mil industrias que la hacían más suave la miseria. ¡Y qué decir de los tiempos heroicos del parto y la cría, cuando se entablaba la lucha necesaria entre el alimento y regalo de la *nación*, y el interés de los Chintos, que consistía en robar a las ubres de la pobre madre toda la leche que no fuera absolutamente indispensable para que el ternero subsistiese! Rosa y Pinín, en tal conflicto, siempre estaban de parte de la *Cordera*, y en cuanto había ocasión, a escondidas, soltaban el recental, que, ciego, y como loco, a testaradas contra todo, corría a buscar el amparo de la madre, que le albergaba bajo su vientre, volviendo la cabeza agradecida y solícita, diciendo, a su manera: —Dejad a los niños y a los recentales que vengan a mí. Estos recuerdos, estos lazos, son de los que no se olvidan. Añádase a todo que la *Cordera* tenía la mejor pasta de vaca sufrida del mundo. Cuando se veía emparejada bajo el yugo con cualquier compañera, fiel a la gamella, sabía someter su voluntad a la ajena, y horas y horas se la veía con la cerviz inclinada, la cabeza torcida, en incómoda postura, velando en pie mientras la pareja dormía en tierra.`, `# II {kicker="¡Adiós, Cordera!"}Mostra en Markdown · 46 línies · content.c2.es.md
Antón de Chinta comprendió que había nacido para pobre cuando palpó la imposibilidad de cumplir aquel sueño dorado suyo de tener un *corral* propio con dos yuntas por lo menos. Llegó, gracias a mil ahorros, que eran mares de sudor y purgatorios de privaciones, llegó a la primera vaca, la *Cordera*, y no pasó de ahí; antes de poder comprar la segunda se vió obligado, para pagar atrasos al *amo*, el dueño de la *casería* que llevaba en renta, a llevar al mercado a aquel pedazo de sus entrañas, la *Cordera*, el amor de sus hijos. Chinta había muerto a los dos años de tener la *Cordera* en casa. El establo y la cama del matrimonio estaban pared por medio, llamando pared a un tejido de ramas de castaño y de cañas de maíz. La Chinta, musa de la economía en aquel hogar miserable, había muerto mirando a la vaca por un boquete del destrozado tabique de ramaje, señalándola como salvación de la familia. «Cuidadla, es vuestro sustento», parecían decir los ojos de la pobre moribunda, que murió extenuada de hambre y de trabajo. El amor de los gemelos se había concentrado en la *Cordera*; el regazo, que tiene su cariño especial, que el padre no puede reemplazar, estaba al calor de la vaca, en el establo, y allá, en el Somonte. Todo esto lo comprendía Antón a su manera, confusamente. De la venta necesaria no había que decir palabra a los *neños*. Un sábado de julio, al ser de día, de mal humor Antón, echó a andar hacia Gijón, llevando la *Cordera* por delante, sin más atavío que el collar de esquila. Pinín y Rosa dormían. Otros días había que despertarlos a azotes. El padre los dejó tranquilos. Al levantarse se encontraron sin la *Cordera*. «Sin duda, *mío pá* la había llevado al *xatu*.» No cabía otra conjetura. Pinín y Rosa opinaban que la vaca iba de mala gana; creían ellos que no deseaba más hijos, pues todos acababa por perderlos pronto, sin saber cómo ni cuándo. Al obscurecer, Antón y la *Cordera* entraban por la *corrada* mohinos, cansados y cubiertos de polvo. El padre no dió explicaciones, pero los hijos adivinaron el peligro. No había vendido, porque nadie había querido llegar al precio que a él se le había puesto en la cabeza. Era excesivo: un sofisma del cariño. Pedía mucho por la vaca para que nadie se atreviese a llevársela. Los que se habían acercado a intentar fortuna se habían alejado pronto echando pestes de aquel hombre que miraba con ojos de rencor y desafío al que osaba insistir en acercarse al precio fijo en que él se abroquelaba. Hasta el último momento del mercado estuvo Antón de Chinta en el Humedal, dando plazo a la fatalidad. «No se dirá, pensaba, que yo no quiero vender: son ellos que no me pagan la *Cordera* en lo que vale.» Y, por fin, suspirando, si no satisfecho, con cierto consuelo, volvió a emprender el camino por la carretera de Candás adelante, entre la confusión y el ruido de cerdos y novillos, bueyes y vacas, que los aldeanos de muchas parroquias del contorno conducían con mayor o menor trabajo, según eran de antiguo las relaciones entre dueños y bestias. En el Natahoyo, en el cruce de dos caminos, todavía estuvo expuesto el de Chinta a quedarse sin la *Cordera*; un vecino de Carrió que le había rondado todo el día ofreciéndole pocos duros menos de los que pedía, le dió el último ataque, algo borracho. El de Carrió subía, subía, luchando entre la codicia y el capricho de llevar la vaca. Antón, como una roca. Llegaron a tener las manos enlazadas, parados en medio de la carretera, interrumpiendo el paso... Por fin, la codicia pudo más; el pico de los cincuenta los separó como un abismo; se soltaron las manos, cada cual tiró por su lado; Antón, por una calleja que, entre madreselvas que aún no florecían y zarzamoras en flor, le condujo hasta su casa. :::paragraphs{style="asterism"} \`* * *\` ::: Desde aquel día en que adivinaron el peligro, Pinín y Rosa no sosegaron. A media semana se *personó* el mayordomo en el *corral* de Antón. Era otro aldeano de la misma parroquia, de malas pulgas, cruel con los *caseros* atrasados. Antón, que no admitía reprimendas, se puso lívido ante las amenazas de desahucio. El amo no esperaba más. Bueno, vendería la vaca a vil precio, por una merienda. Había que pagar o quedarse en la calle. El sábado inmediato acompañó al Humedal Pinín a su padre. El niño miraba con horror a los contratistas de carnes, que eran los tiranos del mercado. La *Cordera* fué comprada en su justo precio por un rematante de Castilla. Se la hizo una señal en la piel y volvió a su establo de Puao, ya vendida, ajena, tañendo tristemente la esquila. Detrás caminaban Antón de Chinta, taciturno, y Pinín, con ojos como puños. Rosa, al saber la venta, se abrazó al testuz de la *Cordera*, que inclinaba la cabeza a las caricias como al yugo. «¡Se iba la vieja!»—pensaba con el alma destrozada Antón el huraño. «Ella ser, era una bestia, ¡pero sus hijos no tenían otra madre ni otra abuela!» Aquellos días en el pasto, en la verdura del Somonte, el silencio era fúnebre. La *Cordera*, que ignoraba su suerte, descansaba y pacía como siempre, *sub specie æternitatis*, como descansaría y comería un minuto antes de que el brutal porrazo la derribase muerta. Pero Rosa y Pinín yacían desolados, tendidos sobre la hierba, inútil en adelante. Miraban con rencor los trenes que pasaban, los alambres del telégrafo. Era aquel mundo desconocido, tan lejos de ellos por un lado, y por otro el que les llevaba su *Cordera*. El viernes, al obscurecer, fué la despedida. Vino un encargado del rematante de Castilla por la res. Pagó; bebieron un trago Antón y el comisionado, y se sacó a la *quintana* la *Cordera*, Antón había apurado la botella; estaba exaltado; el peso del dinero en el bolsillo le animaba también. Quería aturdirse. Hablaba mucho, alababa las excelencias de la vaca. El otro sonreía, porque las alabanzas de Antón eran impertinentes. ¿Que daba la res tantos y tantos *xarros* de leche? ¿Que era noble en el yugo, fuerte con la carga? ¿Y qué, si dentro de pocos días había de estar reducida a chuletas y otros bocados suculentos? Antón no quería imaginar esto; se la figuraba viva, trabajando, sirviendo a otro labrador, olvidada de él y de sus hijos, pero viva, feliz... Pinín y Rosa, sentados sobre el montón de *cucho*, recuerdo para ellos sentimental de la *Cordera* y de los propios afanes, unidos por las manos, miraban al enemigo con ojos de espanto. En el supremo instante se arrojaron sobre su amiga; besos, abrazos: hubo de todo. No podían separarse de ella. Antón, agotada de pronto la excitación del vino, cayó como en un marasmo; cruzó los brazos, y entró en el *corral* obscuro. Los hijos siguieron un buen trecho por la calleja, de altos setos, el triste grupo del indiferente comisionado y la *Cordera*, que iba de mala gana con un desconocido y a tales horas. Por fin, hubo que separarse. Antón, malhumorado, clamaba desde casa: —¡Bah, bah, *neños*, acá vos digo; basta de *pamemes*!—Así gritaba de lejos el padre con voz de lágrimas. Caía la noche; por la calleja obscura que hacían casi negra los altos setos, formando casi bóveda, se perdió el bulto de la *Cordera*, que parecía negra de lejos. Después no quedó de ella más que el *tintán* pausado de la esquila, desvanecido con la distancia, entre los chirridos melancólicos de cigarras infinitas. —¡Adiós, *Cordera*!—gritaba Rosa deshecha en llanto—. ¡Adiós, *Cordera* de *mío* alma! —¡Adiós, *Cordera*!—repetía Pinín, no más sereno. —Adiós—contestó por último, a su modo, la esquila, perdiéndose su lamento triste, resignado, entre los demás sonidos de la noche de julio en la aldea...`, `# III {kicker="¡Adiós, Cordera!"}Mostra en Markdown · 50 línies · content.c3.es.md
Al día siguiente, muy temprano, a la hora de siempre, Pinín y Rosa fueron al *prao* Somonte. Aquella soledad no lo había sido nunca para ellos, triste; aquel día, el Somonte sin la *Cordera* parecía el desierto. De repente silbó la máquina, apareció el humo, luego el tren. En un furgón cerrado, en unas estrechas ventanas altas o respiraderos, vislumbraron los hermanos gemelos cabezas de vacas que, pasmadas, miraban por aquellos tragaluces. —¡Adiós, *Cordera*!—gritó Rosa, adivinando allí a su amiga, a la vaca abuela. —¡Adiós, *Cordera*!—vociferó Pinín con la misma fe, enseñando los puños al tren, que volaba camino de Castilla. Y, llorando, repetía el rapaz, más enterado que su hermana de las picardías del mundo: —La llevan al Matadero... Carne de vaca, para comer los señores, los curas... los indianos. —¡Adiós, *Cordera*! —¡Adiós, *Cordera*! Y Rosa y Pinín miraban con rencor la vía, el telégrafo, los símbolos de aquel mundo enemigo, que les arrebataba, que les devoraba a su compañera de tantas soledades, de tantas ternuras silenciosas, para sus apetitos, para convertirla en manjares de ricos glotones... —¡Adiós, *Cordera*!... —¡Adiós, *Cordera*!... :::paragraphs{style="asterism"} \`* * *\` ::: Pasaron muchos años. Pinín se hizo mozo y se lo llevó el rey. Ardía la guerra carlista. Antón de Chinta era casero de un cacique de los vencidos; no hubo influencia para declarar inútil a Pinín, que, por ser, era como un roble. Y una tarde triste de octubre, Rosa, en el *prao* Somonte sola, esperaba el paso del tren correo de Gijón, que le llevaba a sus únicos amores, su hermano. Silbó a lo lejos la máquina, apareció el tren en la trinchera, pasó como un relámpago. Rosa, casi metida por las ruedas, pudo ver un instante en un coche de tercera multitud de cabezas de pobres quintos que gritaban, gesticulaban, saludando a los árboles, al suelo, a los campos, a toda la patria familiar, a la pequeña, que dejaban para ir a morir en las luchas fratricidas de la patria grande, al servicio de un rey y de unas ideas que no conocían. Pinín, con medio cuerpo fuera de una ventanilla, tendió los brazos a su hermana; casi se tocaron. Y Rosa pudo oir entre el estrépito de las ruedas y la gritería de los reclutas la voz distinta de su hermano, que sollozaba exclamando, como inspirado por un recuerdo de dolor lejano: —¡Adiós, Rosa!... ¡Adiós, *Cordera*! —¡Adiós, Pinín! ¡Pinín de *mío* alma!... «Allá iba, como la otra, como la vaca abuela. Se lo llevaba el mundo. Carne de vaca para los glotones, para los indianos; carne de su alma, carne de cañón para las locuras del mundo, para las ambiciones ajenas.» Entre confusiones de dolor y de ideas, pensaba así la pobre hermana viendo al tren perderse a los lejos, silbando triste, con silbido que repercutían los castaños, las vegas y los peñascos... ¡Qué sola se quedaba! Ahora sí, ahora sí que era un desierto el *prao* Somonte. —¡Adiós, Pinín! ¡Adiós, *Cordera*! Con qué odio miraba Rosa la vía manchada de carbones apagados; con qué ira los alambres del telégrafo. ¡Oh! bien hacía la *Cordera* en no acercarse. Aquello era el mundo, lo desconocido, que se lo llevaba todo. Y sin pensarlo, Rosa apoyó la cabeza sobre el palo clavado como un pendón en la punta del Somonte. El viento cantaba en las entrañas del pino seco su canción metálica. Ahora ya lo comprendía Rosa. Era canción de lágrimas, de abandono, de soledad, de muerte. En las vibraciones rápidas, como quejidos, creía oir, muy lejana, la voz que sollozaba por la vía adelante: —¡Adiós, Rosa! ¡Adiós, *Cordera*!`].map((markdown) => ({ markdown })); const markdown = chapters.map((c) => c.markdown).join('\n\n'); // every word, for the fonts // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Libre Caslon Text': ['400', '400i'], // the text, the fable titles, the imprint 'Libre Caslon Display': ['400'], // the book's and the chapters' titles Jost: ['500', '600'], // kickers, author, running heads }; // #region embed: the same Fontsource files, as the EPUB's font files // Only the latin files: they hold every letter of both samples. A text beyond Latin-1 would // add each family's latin-ext file with its unicodeRange. const epubFonts = (faces) => Promise.all(Object.entries(faces).flatMap(([family, specs]) => specs.map(async (spec) => { const [weight, style] = [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']; const id = fontsourceId(family); const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/` + `${id}-latin-${weight}-${style}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); const bytes = new Uint8Array(await res.arrayBuffer()); return { family, weight, style, format: 'woff2', bytes }; }))); // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const docs = await buildWithFonts(() => buildBundle({ chapters, config: config() }), markdown); showPages(docs, { title: `${docs[0].metadata.title} · EPUB 3` }); // #region answer: one layout, two EPUB 3 files: every printed page, or text that reflows const { title, subtitle, author } = docs[0].metadata; // the first chapter's front matter const book = { metadata: { title, subtitle, creators: [author], language: LANG, rights: t({ en: 'Public domain', es: 'Dominio público' }), // A fixed date gives the same bytes on every run; the identifier (a urn:uuid) comes // from the title, the author and the language, so a new version keeps its place. modified: new Date('2026-10-04T00:00:00Z') }, // Every face the pages use, as bytes: a face left out is set in the reader's own // (gotcha: epub-embeds-given-fonts). fonts: await epubFonts(FONTS), onWarning: (w) => console.warn(`[epub] ${w.kind}`, w.family ?? w.fileId ?? w.detail), }; // The same documents renderToPdf would print: one per chapter, in book order. const fixed = await renderToEpub(docs, { ...book, layout: 'fixed' }); const reflowable = await renderToEpub(docs, { ...book, layout: 'reflowable' }); // #endregion // #region shelf: each file read back: its spine, its contents and a link to keep it const el = (tag, css, text) => Object.assign(document.createElement(tag), { textContent: text ?? '' }, { style: css }); const shelf = el('section', 'display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;' + 'padding: 24px 16px 0; color: #d9d5cc; font: 13px/1.5 system-ui, sans-serif'); shelf.id = 'epub'; for (const [layout, bytes] of [['fixed', fixed], ['reflowable', reflowable]]) { const epub = readEpub(bytes); // what a reading system opens: spine, contents, page list const file = `${RECIPE}-${layout}.epub`; const card = el('article', 'width: min(420px, 92vw); padding: 16px 18px;' + 'border: 1px solid #2c3038; border-radius: 6px; background: #15181d'); const spine = el('ol', 'margin: 8px 0; padding-left: 22px; max-height: 9em; overflow: auto'); spine.append(...epub.spine.map((item) => el('li', '', item.path.slice(epub.root.length)))); const link = el('a', 'color: #d8a21a', `Download ${file}`); link.href = URL.createObjectURL(new Blob([bytes], { type: 'application/epub+zip' })); link.download = file; card.append(el('h2', 'margin: 0; font-size: 15px; color: #f4f1ea', `EPUB 3 · ${epub.layout}`), el('p', 'margin: 4px 0 0', `${epub.spine.length} documents in the spine · ` + `${epub.toc.length} contents entries · ${epub.pageList.length} print pages · ` + `${Math.round(bytes.length / 1024)} KB`), spine, link); shelf.append(card); } document.getElementById('pages').before(shelf); kitStatus(`${docs.length} documents laid out · two EPUB 3 files`); // #endregionKit · core, fonts, viewer: igual a totes les receptes · 235 línies
// ─── 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Posa una imatge de coberta a la maquetació fixa
Pinta la portada en un canvas i passa-la com a coberta; showsFirstPage diu a l'escriptor que no l'afegeixi com una pàgina més.
+const canvas = document.createElement('canvas');
+renderPageToCanvas(docs[0].pages[0], docs[0], canvas, { scale: 2 });
+const png = await new Promise((done) => canvas.toBlob(done, 'image/png'));
+const cover = { bytes: new Uint8Array(await png.arrayBuffer()), mediaType: 'image/png',
+ showsFirstPage: true };
-const fixed = await renderToEpub(docs, { ...book, layout: 'fixed' });
+const fixed = await renderToEpub(docs, { ...book, layout: 'fixed', cover });#Imprimeix els mateixos capítols en PDF
renderToPdf(docs, …), de postext-pdf, rep la mateixa llista, així que el PDF, l'EPUB i la pantalla surten d'una sola composició. La novel·la catalana de butxaca mostra el costat del PDF.
Errors freqüents
Error freqüent
renderToEpub només incrusta les fonts que li passes
Les fonts que ha carregat el navegador no són fitxers que postext-epub pugui llegir: passa a options.fonts els bytes de cada font que fan servir les pàgines (els mateixos woff2 de Fontsource que va carregar la pàgina). Una font que falti s'avisa com a missingFont, i el sistema de lectura compon aquest text amb una de seva, que en una maquetació fixa ja no cap a les línies amb què es va compondre la pàgina. Incrusta només fonts la llicència de les quals ho permeti. Exportació a EPUB →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
Error freqüent
Una configuració es desa a la memòria cau per identitat: crea un objecte nou
El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →
El fitxer fluid conserva el text d'un títol, però no el seu disseny: l'avanttítol de cada obertura és un element de disseny i no forma part del text. El que el lector hagi de veure va al mateix títol o al text.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Eighteen fables from Three Hundred Æsop’s Fables, translated from the Greek by George Fyler Townsend (London, 1867), the opening capitals of each fable set in lower case · Aesop, tr. George Fyler Townsend · domini públic
- ¡Adiós, Cordera!, complete, from El Señor y lo demás, son cuentos (Madrid, 1893), its five parts grouped in three chapters, with dialogue dashes and « » quotation marks · Leopoldo Alas, Clarín · domini públic
- Fonts
- Libre Caslon Text (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Jost (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


