简单来说
一本小小的寓言集,为印刷排一次版,存成两本电子书:一本保留每一页印刷页面,另一本的文字由手机或电子阅读器按自己的字号重新排。
成品一览
《伊索寓言》(Fables of Aesop),十八篇寓言分为三卷,页面120 × 190 mm,有书名页、背面的版权页,每卷有下沉的章首页;西班牙语版用同样的设计排克拉林(Clarín)的短篇小说*¡Adiós, Cordera!*。全书按章排一次版,这份排版生成两个EPUB 3文件。固定版式保留每一页印刷页面:章首页、书眉和换行,用嵌入的Caslon字体。流式版式保留文字及其结构,由阅读系统在手机或电子墨水屏上重新排。两个文件都能从页面下载,也都通过W3C的EPUBCheck校验。
这道食谱解答
- 怎样把书导出为EPUB,用固定页面还是流式文本?
简短回答
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' });
用料
- 类型
- Libre Caslon Text, Libre Caslon Display, Jost(SIL OFL 1.1)
- 素材
- 无:所有图片都用代码绘制
做法
#1 · 按章排书
// 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把每一章排成一个独立的文档,并把页码和奇偶页从一章带到下一章,所以docs就是renderToPdf要打印的那个数组。EPUB写入器读取同一个数组:每章是导航目录里的一项,流式文件为每章写一个XHTML文档。书名页和版权页是第一章,它的frontmatter提供书名、副题和作者。
#2 · 把字体文件交给写入器
// 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 };
})));
浏览器加载的字体不是写入器能读取的文件,所以代码再次获取同一批Fontsource woff2文件,浏览器已经缓存了它们。每个文件都成为EPUB里的一个字体文件和一条@font-face规则。没有它们,固定版式会用阅读器自己的字体放置用Caslon排的文字,行就不再符合原来的行宽。
#3 · 写出两种版式
代码就是上面的简短回答。layout: 'fixed'为每一页印刷页面写一个XHTML文档,尺寸是裁切后的页面,文字是真正的文字,可以选中;layout: 'reflowable'根据排好的行重建段落,去掉换行时加上的连字符,并从配置生成样式表。固定的modified日期让每次运行得到相同的字节。元数据来自第一章的frontmatter,所以文件的书名和作者就是书名页上印的那些。
#4 · 把每个文件读回来
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像阅读系统那样打开文件:阅读顺序(spine)、目录、页码列表和版式。固定版式文件每页一项;流式文件有四项,每章一项。两个文件都带有印刷页码列表,所以引文里的第9页在哪个文件里都能找到。<a download>链接给字节起一个文件名,不经过任何服务器。
完整食谱
// ═══ 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), 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`---Markdown样例 · 18行 · 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"}Markdown样例 · 30行 · 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"}Markdown样例 · 26行 · 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"}Markdown样例 · 28行 · 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`); // #endregion工具包 · core, fonts, viewer:每道食谱都相同 · 235行
// ─── 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗ (在新标签页中打开)
变化
#给固定版式加封面图片
把书名页画到canvas上,作为封面传入;showsFirstPage告诉写入器不要再把它加成一页。
+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 });#把同样的章节印成PDF
postext-pdf的renderToPdf(docs, …)接受同一个数组,所以PDF、EPUB和屏幕都来自同一份排版。加泰罗尼亚语口袋本小说展示了PDF这一边。
常见问题
易错点
renderToEpub只嵌入你交给它的字体
浏览器加载的字体不是postext-epub能读取的文件:把页面用到的每款字体的字节放进options.fonts(页面加载的同一批Fontsource woff2文件)。缺少的字体会报告为missingFont,阅读系统会用自己的字体排这段文字,在固定版式中就不再符合页面排版时的行。只嵌入许可证允许嵌入的字体。 导出EPUB →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
流式文件保留标题的文字,但不保留它的设计:每个章首页的眉题(Book I)是设计元素,不属于正文。读者必须看到的字要写在标题本身或正文里。
致谢
- 文本
- 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 · 公有领域
- ¡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 · 公有领域
- 字体
- Libre Caslon Text (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Jost (SIL OFL 1.1)


