En pocas palabras
Un cuento corto compuesto una vez para imprimir y guardado como dos libros electrónicos: uno que conserva cada página impresa y otro cuyo texto el móvil o el lector electrónico recompone a su tamaño.
Lo que vas a componer
¡Adiós, Cordera!, el cuento de Clarín, en tres capítulos sobre una página de 120 × 190 mm, con portada, página de créditos al dorso y una apertura hundida en cada capítulo; la edición inglesa compone dieciocho fábulas de Esopo con el mismo diseño. El libro se compone una vez, capítulo a capítulo, y esa composición da dos archivos EPUB 3. El de maquetación fija conserva cada página impresa: las aperturas, las cabeceras y los cortes de línea, en la Caslon incrustada. El fluido conserva el texto y su estructura, y el sistema de lectura lo vuelve a componer en un móvil o en una pantalla de tinta electrónica. Los dos se descargan desde la página y los dos pasan EPUBCheck, el validador del W3C.
Esta receta responde a
- ¿Cómo exporto mi libro a EPUB, con páginas fijas o con texto fluido?
La respuesta corta
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' });
Ingredientes
- Funciones
- Exportación a EPUBLibros construidos capítulo a capítuloAperturas diseñadasEstilos de títuloAtributos de títuloCabeceras y foliosCabeceras según el tipo de páginaMárgenes simétricosColor del papelEstilos de párrafoViudas, huérfanas y líneas cortasMetadatos del documentoPaleta de color semánticaFuentes antes de componer
- Tipografía
- Libre Caslon Text, Libre Caslon Display, Jost (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Compón el libro por capítulos
// 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 compone cada capítulo como un documento propio y pasa de uno a otro el número de página y la paridad, así que docs es la misma lista que imprimiría renderToPdf. El escritor de EPUB lee esa lista: cada capítulo es una entrada del índice de navegación, y el archivo fluido escribe un documento XHTML por capítulo. La portada y su página de créditos forman el primer capítulo, y su frontmatter da el título, el subtítulo y el autor del libro.
#2 · Dale al escritor los archivos de las fuentes
// 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 };
})));
Las fuentes que cargó el navegador no son archivos que el escritor pueda leer, así que el código vuelve a pedir los mismos woff2 de Fontsource, que el navegador ya tiene en caché. Cada uno entra en el EPUB como un archivo con su regla @font-face. Sin ellos, la maquetación fija colocaría el texto compuesto en Caslon con una fuente del lector, y las líneas ya no cabrían en su medida.
#3 · Escribe las dos versiones
El código es la respuesta corta de arriba. layout: 'fixed' escribe un documento XHTML por página impresa, al tamaño de la página refilada y con texto de verdad, que se puede seleccionar; layout: 'reflowable' rehace los párrafos a partir de las líneas compuestas, quita los guiones que añadieron los cortes de línea y saca una hoja de estilos de la configuración. Una fecha modified fija hace que cada ejecución dé los mismos bytes. Los metadatos salen del frontmatter del primer capítulo, de modo que el título y el autor del archivo son los que se imprimen en la portada.
#4 · Lee cada archivo de vuelta
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 abre el archivo como lo abre un sistema de lectura: el orden de lectura (spine), el índice, la lista de páginas y la maquetación. El archivo fijo tiene una entrada por página; el fluido, cuatro, una por capítulo. Los dos llevan la lista de los folios impresos, así que una cita de la página 9 se encuentra en cualquiera de ellos. El enlace <a download> da nombre a los bytes; nada pasa por un servidor.
La receta 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`---Muestra en Markdown · 18 líneas · 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!"}Muestra en Markdown · 34 líneas · 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!"}Muestra en Markdown · 46 líneas · 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!"}Muestra en Markdown · 50 líneas · 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 en todas las recetas · 235 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 ───────────────────────────────────────────────────────────────────────
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 ↗ (se abre en una nueva pestaña)
Variantes
#Pon una imagen de cubierta a la maquetación fija
Pinta la portada en un canvas y pásala como cubierta; showsFirstPage le dice al escritor que no la añada como 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 });#Imprime los mismos capítulos en PDF
renderToPdf(docs, …), de postext-pdf, recibe la misma lista, así que el PDF, el EPUB y la pantalla salen de una sola composición. La novela catalana de bolsillo muestra el lado del PDF.
Errores frecuentes
Error frecuente
renderToEpub solo incrusta las fuentes que le pasas
Las fuentes que ha cargado el navegador no son archivos que postext-epub pueda leer: pasa en options.fonts los bytes de cada fuente que usan las páginas (los mismos woff2 de Fontsource que cargó la página). Una fuente que falte se avisa como missingFont, y el sistema de lectura compone ese texto con una suya, que en una maquetación fija ya no cabe en las líneas con que se compuso la página. Incrusta solo fuentes cuya licencia lo permita. Exportación a EPUB →
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
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 →
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 →
El archivo fluido conserva el texto de un título, pero no su diseño: el antetítulo de cada apertura es un elemento de diseño y no forma parte del texto. Lo que el lector tenga que ver va en el propio título o en el texto.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Dieciocho fábulas de Three Hundred Æsop’s Fables, traducidas del griego por George Fyler Townsend (Londres, 1867), con las mayúsculas iniciales de cada fábula en minúscula · Aesop, tr. George Fyler Townsend · dominio público
- ¡Adiós, Cordera!, completo, de El Señor y lo demás, son cuentos (Madrid, 1893), con sus cinco partes agrupadas en tres capítulos, raya en los diálogos y comillas « » · Leopoldo Alas, Clarín · dominio público
- Fuentes
- Libre Caslon Text (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Jost (SIL OFL 1.1)
- Código
- MIT, como Postext
Editar este texto ↗ (se abre en una nueva pestaña)Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)


