Em poucas palavras
Um livrinho de fábulas diagramado uma vez para impressão e salvo como dois e-books: um que mantém cada página impressa e outro cujo texto o celular ou o leitor digital recompõe no seu tamanho.
O que você vai compor
Fables of Aesop, dezoito fábulas em três livros numa página de 120 × 190 mm, com folha de rosto, página de créditos no verso dela e uma abertura rebaixada para cada livro. A edição em espanhol compõe ¡Adiós, Cordera!, de Clarín, em três capítulos na mesma página. O livro é diagramado uma vez, capítulo por capítulo, e essa diagramação vira dois arquivos EPUB 3. O de layout fixo mantém cada página impressa: as aberturas, os cabeços e as quebras de linha, na Caslon incorporada. O arquivo fluido mantém o texto e a sua estrutura, e o aplicativo de leitura o recompõe num celular ou numa tela de tinta eletrônica. Os dois podem ser baixados da página, e os dois passam no EPUBCheck do W3C.
Esta receita responde a
- Como exporto meu livro em EPUB, com páginas fixas ou com texto refluível?
A 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' });
Ingredientes
- Funcionalidades
- Exportação para EPUBLivros compostos capítulo a capítuloAberturas desenhadasEstilos de títuloAtributos de títuloCabeços e fóliosCabeços por tipo de páginaMargens espelhadasCor do papelEstilos de parágrafoViúvas, órfãs e linhas curtasMetadados do documentoPaleta de cores semânticaFontes antes da diagramação
- Tipografia
- Libre Caslon Text, Libre Caslon Display, Jost (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Diagrame o livro em 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 diagrama cada capítulo como um documento próprio, levando os números de página e a paridade de um para o outro, então docs é o array que renderToPdf imprimiria. O gravador de EPUB lê o mesmo array: cada capítulo vira uma entrada de navegação, e o arquivo fluido grava um documento XHTML por capítulo. A folha de rosto e a página de créditos são o primeiro capítulo, e o frontmatter dele traz o título, o subtítulo e o autor do livro.
#2 · Entregue ao gravador os arquivos das fontes
// 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 };
})));
As fontes que o navegador carregou não são arquivos que o gravador consiga ler, então o código busca de novo os mesmos arquivos woff2 do Fontsource; o navegador já os tem em cache. Cada um vira um arquivo de fonte no EPUB e uma regra @font-face. Sem eles, o layout fixo poria o texto composto em Caslon numa fonte do próprio leitor, e as linhas deixariam de caber na medida.
#3 · Grave as duas versões
O código é a resposta curta lá em cima. layout: 'fixed' grava um documento XHTML por página impressa, no tamanho da página refilada, com texto de verdade e selecionável; layout: 'reflowable' reconstrói os parágrafos a partir das linhas compostas, tira os hífens que as quebras de linha acrescentaram e deriva uma folha de estilos da configuração. Uma data modified fixa faz cada execução gerar os mesmos bytes. Os metadados vêm do frontmatter do primeiro capítulo, então o título e o autor do arquivo são os impressos na folha de rosto.
#4 · Leia cada arquivo de volta
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 o arquivo como um aplicativo de leitura faz: o spine na ordem de leitura, o sumário, a lista de páginas e o layout. O arquivo fixo tem uma entrada de spine por página; o fluido tem quatro, uma por capítulo. Os dois levam uma lista de páginas com os fólios impressos, então o leitor consegue seguir uma citação até a página 9 em qualquer um deles. O link <a download> dá um nome de arquivo aos bytes; nada passa por um servidor.
A receita 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 = 'en'; // @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) }, // kept with its text by default ] }, 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), 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`---Amostra em Markdown · 18 linhas · content.en.md
title: "Fables of Aesop" subtitle: "Eighteen fables in three books" author: "Aesop" --- # Fables of Aesop {style="title-page"} :::pagebreak :::paragraphs{style="imprint-head"} *Fables of Aesop* · Eighteen fables in three books ::: :::paragraphs{style="imprint"} The fables follow George Fyler Townsend’s translation from the Greek, *Three Hundred Æsop’s Fables* (London: George Routledge and Sons, 1867), as transcribed by Project Gutenberg (eBook #21). The text is in the public domain; the opening words of each fable, set in capitals there, are set in lower case here. Set in Libre Caslon Text, Libre Caslon Display and Jost (SIL Open Font License). Laid out once with Postext and written out as two EPUB 3 files, one with fixed pages and one with reflowable text. :::`, String.raw`# The Lion’s Court {kicker="Book I"}Amostra em Markdown · 30 linhas · content.c1.en.md
## The Lion and the Mouse A Lion was awakened from sleep by a Mouse running over his face. Rising up angrily, he caught him and was about to kill him, when the Mouse piteously entreated, saying: “If you would only spare my life, I would be sure to repay your kindness.” The Lion laughed and let him go. It happened shortly after this that the Lion was caught by some hunters, who bound him by strong ropes to the ground. The Mouse, recognizing his roar, came and gnawed the rope with his teeth, and set him free, exclaiming: “You ridiculed the idea of my ever being able to help you, not expecting to receive from me any repayment of your favour; now you know that it is possible for even a Mouse to confer benefits on a Lion.” ## The Lion in Love A Lion demanded the daughter of a woodcutter in marriage. The Father, unwilling to grant, and yet afraid to refuse his request, hit upon this expedient to rid himself of his importunities. He expressed his willingness to accept the Lion as the suitor of his daughter on one condition: that he should allow him to extract his teeth, and cut off his claws, as his daughter was fearfully afraid of both. The Lion cheerfully assented to the proposal. But when the toothless, clawless Lion returned to repeat his request, the Woodman, no longer afraid, set upon him with his club, and drove him away into the forest. ## The Sick Lion A Lion, unable from old age and infirmities to provide himself with food by force, resolved to do so by artifice. He returned to his den, and lying down there, pretended to be sick, taking care that his sickness should be publicly known. The beasts expressed their sorrow, and came one by one to his den, where the Lion devoured them. After many of the beasts had thus disappeared, the Fox discovered the trick and presenting himself to the Lion, stood on the outside of the cave, at a respectful distance, and asked him how he was. “I am very middling,” replied the Lion, “but why do you stand without? Pray enter within to talk with me.” “No, thank you,” said the Fox. “I notice that there are many prints of feet entering your cave, but I see no trace of any returning.” He is wise who is warned by the misfortunes of others. ## The Lion, the Fox, and the Ass The Lion, the Fox and the Ass entered into an agreement to assist each other in the chase. Having secured a large booty, the Lion on their return from the forest asked the Ass to allot his due portion to each of the three partners in the treaty. The Ass carefully divided the spoil into three equal shares and modestly requested the two others to make the first choice. The Lion, bursting out into a great rage, devoured the Ass. Then he requested the Fox to do him the favour to make a division. The Fox accumulated all that they had killed into one large heap and left to himself the smallest possible morsel. The Lion said, “Who has taught you, my very excellent fellow, the art of division? You are perfect to a fraction.” He replied, “I learned it from the Ass, by witnessing his fate.” Happy is the man who learns from the misfortunes of others. ## The Kingdom of the Lion The Beasts of the field and forest had a Lion as their king. He was neither wrathful, cruel, nor tyrannical, but just and gentle as a king could be. During his reign he made a royal proclamation for a general assembly of all the birds and beasts, and drew up conditions for a universal league, in which the Wolf and the Lamb, the Panther and the Kid, the Tiger and the Stag, the Dog and the Hare, should live together in perfect peace and amity. The Hare said, “Oh, how I have longed to see this day, in which the weak shall take their place with impunity by the side of the strong.” And after the Hare said this, he ran for his life. ## The Old Lion A Lion, worn out with years and powerless from disease, lay on the ground at the point of death. A Boar rushed upon him, and avenged with a stroke of his tusks a long-remembered injury. Shortly afterwards the Bull with his horns gored him as if he were an enemy. When the Ass saw that the huge beast could be assailed with impunity, he let drive at his forehead with his heels. The expiring Lion said, “I have reluctantly brooked the insults of the brave, but to be compelled to endure such treatment from thee, a disgrace to Nature, is indeed to die a double death.”`, String.raw`# The Fox’s Wits {kicker="Book II"}Amostra em Markdown · 26 linhas · content.c2.en.md
## The Fox and the Grapes A Famished Fox saw some clusters of ripe black grapes hanging from a trellised vine. She resorted to all her tricks to get at them, but wearied herself in vain, for she could not reach them. At last she turned away, hiding her disappointment and saying: “The Grapes are sour, and not ripe as I thought.” ## The Fox and the Crow A Crow having stolen a bit of meat, perched in a tree and held it in her beak. A Fox, seeing this, longed to possess the meat himself, and by a wily stratagem succeeded. “How handsome is the Crow,” he exclaimed, “in the beauty of her shape and in the fairness of her complexion! Oh, if her voice were only equal to her beauty, she would deservedly be considered the Queen of Birds!” This he said deceitfully; but the Crow, anxious to refute the reflection cast upon her voice, set up a loud caw and dropped the flesh. The Fox quickly picked it up, and thus addressed the Crow: “My good Crow, your voice is right enough, but your wit is wanting.” ## The Fox and the Goat A Fox one day fell into a deep well and could find no means of escape. A Goat, overcome with thirst, came to the same well, and seeing the Fox, inquired if the water was good. Concealing his sad plight under a merry guise, the Fox indulged in a lavish praise of the water, saying it was excellent beyond measure, and encouraging him to descend. The Goat, mindful only of his thirst, thoughtlessly jumped down, but just as he drank, the Fox informed him of the difficulty they were both in and suggested a scheme for their common escape. “If,” said he, “you will place your forefeet upon the wall and bend your head, I will run up your back and escape, and will help you out afterwards.” The Goat readily assented and the Fox leaped upon his back. Steadying himself with the Goat’s horns, he safely reached the mouth of the well and made off as fast as he could. When the Goat upbraided him for breaking his promise, he turned around and cried out, “You foolish old fellow! If you had as many brains in your head as you have hairs in your beard, you would never have gone down before you had inspected the way up, nor have exposed yourself to dangers from which you had no means of escape.” Look before you leap. ## The Fox Who Had Lost His Tail A Fox caught in a trap escaped, but in so doing lost his tail. Thereafter, feeling his life a burden from the shame and ridicule to which he was exposed, he schemed to convince all the other Foxes that being tailless was much more attractive, thus making up for his own deprivation. He assembled a good many Foxes and publicly advised them to cut off their tails, saying that they would not only look much better without them, but that they would get rid of the weight of the brush, which was a very great inconvenience. One of them interrupting him said, “If you had not yourself lost your tail, my friend, you would not thus counsel us.” ## The Ass in the Lion’s Skin An Ass, having put on the Lion’s skin, roamed about in the forest and amused himself by frightening all the foolish animals he met in his wanderings. At last coming upon a Fox, he tried to frighten him also, but the Fox no sooner heard the sound of his voice than he exclaimed, “I might possibly have been frightened myself, if I had not heard your bray.” ## The Fox and the Woodcutter A Fox, running before the hounds, came across a Woodcutter felling an oak and begged him to show him a safe hiding-place. The Woodcutter advised him to take shelter in his own hut, so the Fox crept in and hid himself in a corner. The huntsman soon came up with his hounds and inquired of the Woodcutter if he had seen the Fox. He declared that he had not seen him, and yet pointed, all the time he was speaking, to the hut where the Fox lay hidden. The huntsman took no notice of the signs, but believing his word, hastened forward in the chase. As soon as they were well away, the Fox departed without taking any notice of the Woodcutter: whereon he called to him and reproached him, saying, “You ungrateful fellow, you owe your life to me, and yet you leave me without a word of thanks.” The Fox replied, “Indeed, I should have thanked you fervently if your deeds had been as good as your words, and if your hands had not been traitors to your speech.”`, String.raw`# Small Creatures {kicker="Book III"}Amostra em Markdown · 28 linhas · content.c3.en.md
## The Ants and the Grasshopper The Ants were spending a fine winter’s day drying grain collected in the summertime. A Grasshopper, perishing with famine, passed by and earnestly begged for a little food. The Ants inquired of him, “Why did you not treasure up food during the summer?” He replied, “I had not leisure enough. I passed the days in singing.” They then said in derision: “If you were foolish enough to sing all the summer, you must dance supperless to bed in the winter.” ## The Hare and the Tortoise A Hare one day ridiculed the short feet and slow pace of the Tortoise, who replied, laughing: “Though you be swift as the wind, I will beat you in a race.” The Hare, believing her assertion to be simply impossible, assented to the proposal; and they agreed that the Fox should choose the course and fix the goal. On the day appointed for the race the two started together. The Tortoise never for a moment stopped, but went on with a slow but steady pace straight to the end of the course. The Hare, lying down by the wayside, fell fast asleep. At last waking up, and moving as fast as he could, he saw the Tortoise had reached the goal, and was comfortably dozing after her fatigue. Slow but steady wins the race. ## The Town Mouse and the Country Mouse A Country Mouse invited a Town Mouse, an intimate friend, to pay him a visit and partake of his country fare. As they were on the bare plowlands, eating there wheat-stocks and roots pulled up from the hedgerow, the Town Mouse said to his friend, “You live here the life of the ants, while in my house is the horn of plenty. I am surrounded by every luxury, and if you will come with me, as I wish you would, you shall have an ample share of my dainties.” The Country Mouse was easily persuaded, and returned to town with his friend. On his arrival, the Town Mouse placed before him bread, barley, beans, dried figs, honey, raisins, and, last of all, brought a dainty piece of cheese from a basket. The Country Mouse, being much delighted at the sight of such good cheer, expressed his satisfaction in warm terms and lamented his own hard fate. Just as they were beginning to eat, someone opened the door, and they both ran off squeaking, as fast as they could, to a hole so narrow that two could only find room in it by squeezing. They had scarcely begun their repast again when someone else entered to take something out of a cupboard, whereupon the two Mice, more frightened than before, ran away and hid themselves. At last the Country Mouse, almost famished, said to his friend: “Although you have prepared for me so dainty a feast, I must leave you to enjoy it by yourself. It is surrounded by too many dangers to please me. I prefer my bare plowlands and roots from the hedgerow, where I can live in safety, and without fear.” ## The Ant and the Dove An Ant went to the bank of a river to quench its thirst, and being carried away by the rush of the stream, was on the point of drowning. A Dove sitting on a tree overhanging the water plucked a leaf and let it fall into the stream close to her. The Ant climbed onto it and floated in safety to the bank. Shortly afterwards a birdcatcher came and stood under the tree, and laid his lime-twigs for the Dove, which sat in the branches. The Ant, perceiving his design, stung him in the foot. In pain the birdcatcher threw down the twigs, and the noise made the Dove take wing. ## The Crow and the Pitcher A Crow perishing with thirst saw a pitcher, and hoping to find water, flew to it with delight. When he reached it, he discovered to his grief that it contained so little water that he could not possibly get at it. He tried everything he could think of to reach the water, but all his efforts were in vain. At last he collected as many stones as he could carry and dropped them one by one with his beak into the pitcher, until he brought the water within his reach and thus saved his life. Necessity is the mother of invention. ## The Mice in Council The Mice summoned a council to decide how they might best devise means of warning themselves of the approach of their great enemy the Cat. Among the many plans suggested, the one that found most favour was the proposal to tie a bell to the neck of the Cat, so that the Mice, being warned by the sound of the tinkling, might run away and hide themselves in their holes at his approach. But when the Mice further debated who among them should thus “bell the Cat,” there was no one found to do it.`].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 em todas as receitas · 236 linhas
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Dê uma imagem de capa ao layout fixo
Pinte a folha de rosto num canvas e passe-a como capa; showsFirstPage diz ao gravador para não acrescentá-la como uma página à parte.
+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 });#Imprima os mesmos capítulos em PDF
renderToPdf(docs, …), do postext-pdf, recebe o mesmo array, então o PDF, o EPUB e a tela saem de uma única diagramação. A novela catalã de bolso mostra o lado do PDF.
Erros comuns
Erro comum
renderToEpub incorpora só as fontes que você passa
As fontes carregadas pelo navegador não são arquivos que o postext-epub consiga ler: passe em options.fonts os bytes de cada fonte que as páginas usam (os mesmos arquivos woff2 do Fontsource que a página carregou). Uma fonte que ficar de fora é informada como missingFont, e o sistema de leitura compõe esse texto com uma fonte própria, que em um layout fixo já não cabe nas linhas com que a página foi composta. Incorpore só fontes cuja licença permita. Exportação para EPUB →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
Erro comum
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
O arquivo fluido mantém o texto de um título, mas não o seu design: o antetítulo de cada abertura (Book I) é um elemento de design, então não está no texto. Ponha as palavras que o leitor precisa ver no próprio título ou no texto.
Créditos
- Receita
- Ignacio Ferro
- Texto
- 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 · domínio público
- ¡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 · domínio público
- Fontes
- Libre Caslon Text (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Jost (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


