跳到主要内容

章 3.19 · 篇 II · 工艺

以编程方式使用:.postext文件包

用代码打开、排版、渲染和创建.postext文件,双语文件包,旧版本的文件,以及底层API

更新于 2026-10-1620分钟所属章节: 3 配置enescadefritptzhjaar

简单来说

本页写给写代码的人。一个.postext文件把整本书装在一个文件里:文字、设置、字体和图片。本页说明怎样打开这样的文件、给它排版并画出页面。它说明怎样用你自己的文字做一个新文件,以及怎样把一本书的两种语言放在同一个文件里。本页还说明旧版本Postext做出的文件怎样读取。

#文件包(.postext文件)

.postext文件把整本书装进一个文件:它是一个zip压缩包,里面有preset.json清单、每章一个Markdown文件、资源的数据文件(位图、SVG、PDF印刷母版),以及配置中用到的字体文件。沙盒可以导出和导入它,智能体技能以它作为交付物。postext包同样能创建和打开它,所以一本书可以在这些工具和你自己的程序之间来回传递,不丢失任何内容。

my-book.postext
├── preset.json            清单:名称、语言、章节、配置、资源、字体
├── chapters/01-dusk.md
├── chapters/02-night.md
├── resources/lantern.svg
└── fonts/ebgaramond-400-normal.woff2

清单的各个字段在沙盒文档的附录预设文件包格式中逐一说明。文件里还可以带一个layouts.json,即沙盒记录的页数,这样书在沙盒中打开时就已经分好页;多语言的书也可以每个版本带一个(如layouts.zh-Hant.json,优先读取)。openBundle会忽略这些文件。

这套API既从postext本身导出,也从postext/bundle子路径导出,后者还提供底层辅助函数。如果你同时要渲染,就从postext导入。这样文件包适配器和渲染器共用同一个模块实例;在esm.sh这类CDN上,每个入口都是单独构建的,这一点就很重要。

#打开文件包

openBundle接收文件的字节(Uint8Array、ArrayBuffer,或来自<input type="file">的Blob / File),返回引擎及其各个后端所需的全部内容:

import { openBundle } from 'postext';
 
const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });
 
bundle.chapters;   // [{ title, file, markdown }, …],按书中顺序排列
bundle.config;     // PostextConfig,可直接交给buildDocument
bundle.resources;  // Resource[]
bundle.files;      // Map<path, Uint8Array>:文件包中的所有文件
字段内容
manifest经过校验的preset.json。
id、name、description取自清单。
locale、locales读取内容时使用的语言,以及双语文件包包含的全部语言。options.locale选择其中之一:先找完全相同的标签,再找基础语言,最后用文件包自己的语言。
chapters每章一个{ title, file, markdown }。清单中没有标题的章,取其第一个#标题的文字。
config依次叠加:默认调色板和文件包语言下的资源类型,然后是清单的config,最后是该语言的覆盖项。文件包的语言就是上面的locale,因此单一语言的文件包总是得到它自己语言的标签,不论options.locale要求什么。清单没有指明语言时,取其config设定的语言(先看locale,再看断词语言),都没有则用options.locale。customFonts列出文件包中的字体家族。沙盒打开文件包时用的也是这份配置。
resources各个资源,带有所选语言的题注。清单中缺少的尺寸从文件本身读取。
fonts每个字面一项:{ family, weight, style, format, file, bytes }。
files压缩包中的所有文件,以路径为键。
thumbnail、canvasScope封面图片的路径,以及文件包希望的查看方式:清单的view,再以所提供语言的localized[…].view覆盖。
start文件包只含一本更长的书的一部分时,这本书从哪里开始:清单的start,或所提供语言的localized[…].start。从头开始的书没有这一项。
warnings不致命的问题:不支持的字体文件、缺失的印刷母版等。

数据文件的fileId就是它在文件包内的路径。resource.svg.fileId、resource.bitmap.fileId以及每个customFonts变体的fileId都可以直接在bundle.files中查到。以下情况openBundle会抛出异常:字节不是zip;找不到有效的preset.json(在根目录或某个顶层文件夹下);清单中列出的某个文件缺失。

postext 1.4及更早版本写出的文件包

createBundle和沙盒写出的每份清单都带有configVersion: 14,表示其config是按哪一版配置规则写的。没有这个字段的清单出自postext 1.4或更早版本,当时有十八处设定与现在不同:

  • 标题分页(规则3):1.4及以前,headings对象若没有给H1设置分页,H1就不分页(见按级别覆盖)。
  • 公式字号(规则4):1.4及以前,公式排出的大小是fontSizeScale所规定的1.131倍(见公式字号)。
  • 行内资源下方的间距(规则5):1.4及以前,placement.position: 'here'的图或表之后,正文从下一条网格线接着排,下方没有浮动体间距(见版式中的layout.inlineResourceGap)。
  • 框内行内资源周围的间距(规则6):1.4及以前,这类资源紧贴着所在框的文字(见版式中的layout.inlineResourceGapInBoxes)。
  • 标题中的行内标记(规则6):1.4及以前,标题中*italic*、**bold**等标记的文字按标题本身的普通样式排出(见标题中的headings.inlineMarks)。
  • 首字下沉的大小(规则6):1.4及以前,设计文本的dropCap若没有fontSize,其高度等于它跨越的全部行框之和,顶端高出第一行(见文本元素中的dropCap)。
  • 冒号行下方的空间(规则6):1.4及以前,keepColonWithList认为以冒号结尾的那一行下方留出一行空间就足以放列表,于是一个被段首、段末孤行规则保持完整的两行首项会单独移到下一栏,冒号行留在原处(见bodyText.colonListRoom)。
  • 框切分后留下的行数(规则6):1.4及以前,在段落或列表项内部切分的框,只要框的每一侧总共有splitMinLines行,就可能在某一侧只留下该段的一行(见版式中的layout.boxChildSplitMinLines)。
  • 破折号处断行(规则7):1.4及以前,Knuth-Plass从不在两词之间不留空格的长破折号或短破折号(say—that’s)之后断行,带格式文本的逐行断行器也只在两个字母之间的破折号之后断行(见正文中的bodyText.breakAfterDashes)。
  • 齐左文本(规则7):1.4及以前,齐左的正文逐行排版,排满一行再排下一行,不管optimalLineBreaking如何设置(见正文中的bodyText.optimalRagged)。
  • 标题下方的切分(规则8):1.4及以前,位于栏底的标题下面的段落,能在该栏放下几行就留几行,不管移到下一栏的有多少行(见标题中的headings.keepWithNextSplit)。
  • :::paragraphs容器下方的间距(规则8):1.4及以前,样式的间距在网格对齐之前加在最后一段下方,下一个块的上方间距(如标题的marginTop)又叠加在其下,而正文的段间距没有计入(见正文中的bodyText.paragraphContainerSpacing)。
  • 复合词连字符处断行(规则8):1.4及以前,在不含行内格式的段落中,Knuth-Plass从不在两个字母之间的连字符(well-known)之后结束两端对齐的行,而在含格式的段落中却会(见正文中的bodyText.breakAfterHyphens)。
  • 无分隔符的诗(规则9):1.22及以前,各行不含||的:::verse诗按单独的半句排,每行居中(见诗歌下的bodyText.verse.layout)。
  • 首行缩进与悬挂缩进并用(规则9):1.22及以前,段落样式的hangingIndent取代其firstLineIndent,首行从indent开始(见段落样式)。
  • 行末的反斜杠(规则9):1.22及以前,段落、引文或列表项中行末的反斜杠,以及后跟空格的\\,都照样印出,各行用空格连接(见正文下的bodyText.hardLineBreaks)。
  • 代码围栏(规则9):1.22及以前,```或~~~围栏及其中的行按Markdown读取:各行合并成段落,以#开头的行变成标题,围栏照样印出(见代码清单下的codeStyle.blocks)。
  • 诗行转行(规则10):在1.23中,逐行排的诗里比版心宽的诗行一律以自然词距转行,哪怕只超出一点(见诗歌下的bodyText.verse.tighten)。

openBundle和readBundle读取这类清单的config以及每种语言的localized配置时,会经过migrateConfig:它把1.4排出的分页明确写出来,并把公式缩放乘以1.131(其以em为单位的公式外边距则除以1.131)。标记为3到7的清单出自1.5的预发布版本,只会补上其标记之后那些规则的固定值。标记为3时,固定公式字号、行内间距、规则6的五项、规则7的两项和规则8的三项;为4时,固定行内间距以及规则6、7、8的各项;为5时,固定规则6、7、8的各项;为6时,固定规则7和8的各项;为7时,只固定规则8的各项。标题下方的切分(pinLegacyHeadingSplit)写成headings.keepWithNextSplit: 'fill',加在各层叠加后生效的headings上,条件是:读取的某一章有标题,配置本身没有给出值,headings.keepWithNext保持开启,且没有关闭bodyText.avoidOrphans。复合词断行(pinLegacyHyphenBreaks)写成bodyText.breakAfterHyphens: false,加在生效的bodyText上,条件是:读取的某一章在两个字母之间用了连字符,且配置既没有设置该项,也没有关闭optimalLineBreaking。容器下方的间距(pinLegacyParagraphContainerSpacing)写成bodyText.paragraphContainerSpacing: 'add',加在生效的bodyText上,条件是:配置声明了段落样式(在paragraphStyles或HTML查看器的覆盖项中),读取的某一章在单独一行打开了:::paragraphs容器,且配置没有设置该项。破折号断行(pinLegacyDashBreaks)写成bodyText.breakAfterDashes: false,加在生效的bodyText上,条件是:读取的某一章在两词之间不留空格地使用了长破折号或短破折号(前面是字母、数字或闭合标点,后面是字母、数字或开括号、开引号;前面是引号时,只要引号前是字母、数字、闭合标点或不间断空格也算,如"no"—and,而said "—Hola不算;紧贴破折号或引号的行内标记,如**riddles.**—I中的**,在哪一侧都算),且配置没有设置该项。齐左断行(pinLegacyRaggedBreaking)写成bodyText.optimalRagged: false,加在生效的bodyText上,条件是:配置把某些正文设为齐左(正文、段落样式、框体、篇的正文或章节样式的正文(headingStyles[].bodyStyle)的textAlign不是'justify',或在HTML查看器的覆盖项中如此设置),没有设置该项,也没有关闭optimalLineBreaking。框内间距(pinLegacyBoxResourceGap)写成layout.inlineResourceGapInBoxes: false,加在生效的layout上,条件是:读取的章节中,某个:::callout内有单独一行嵌入的资源,且配置没有设置该项。框的切分(pinLegacyBoxChildCut)写成layout.boxChildSplitMinLines: 1,加在生效的layout上,条件是:读取的某一章在单独一行打开了:::callout,且配置没有设置该项。标题标记(pinLegacyHeadingMarks)写成headings.inlineMarks: false,加在各层叠加后生效的headings上,条件是:读取的章节中某个标题带有标记(标题中有*、_、^、~、:smallcaps[或链接),且配置本身没有给出值。首字下沉(pinLegacyDropCapSize)不论位于何处,都把1.4时的大小写成dropCap.fontSize:元素的行距是长度时用行距的单位,否则用其字号的单位。冒号行下方的空间(pinLegacyColonListRoom)写成bodyText.colonListRoom: 'line',加在生效的bodyText上,条件是:读取的章节中有列表紧跟在以冒号结尾的行之后(中间允许有空行),且配置既没有指定空间,也没有关闭keepColonWithList。行内间距(pinLegacyInlineGap)写成layout.inlineResourceGap: 'above',加在各层叠加后生效的layout上,条件是:读取的章节中有一行嵌入了资源(按解析器的读法,::resource{id="…"}单独占一行;正文或代码片段中提及不算),且配置本身没有给出间距。公式字号固定在各层叠加后生效的math上(某种语言自己的math会取代共享的那个),而且只在读取的章节中有$时才这样做:没有公式的文件包保持其config原样。若清单和语言都没有给出math,生效的就是readBundle的baseConfig(读取方自己的配置),它也会被固定,因为1.4就是按这个字号排文件包里的公式的:基础配置的fontSizeScale: 1.5读出来是1.5 × 1.1312。基础配置的标题分页保持原样。因此,旧文件包会保留这些规则排出的效果,bundle.config会显示排版时所用的分页、公式字号、各项间距、标题标记、首字下沉大小、冒号行下方空间、框的切分、破折号断行、复合词断行、齐左断行、标题下方的切分和容器下方的间距。1.5的版面修正没有对应的固定值,对它和对其他书一样生效,所以受影响的页面仍可能有变化(清单见公式字号)。为现行规则手写的preset.json应设置"configVersion": 14;给旧文件包的清单加上这个标记,也是用现行规则读取它的一行做法(此时没有版本号的文件包也会失去标题分页的固定值)。标为8的清单(由postext 1.5至1.22写出)只得到规则9的四项固定,更早的清单也会得到:诗的排法(pinLegacyVerseLayout)写成生效的bodyText上的bodyText.verse.layout: 'bayt',条件是读取的章节中有开始标记未指定排法、各行不带半句分隔符的:::verse诗,且配置尚未设置该项;成对的缩进(pinLegacyPairedIndents)去掉同时设置了非零hangingIndent的每个段落样式(在paragraphStyles或HTML查看器的覆盖设置中)的firstLineIndent;强制换行(pinLegacyHardBreaks)写成生效的bodyText上的bodyText.hardLineBreaks: false,条件是读取的章节中有段落、引文或列表项的某行以反斜杠结尾且该块在下一行继续,或有后跟空格和其他文字的\\(行内代码和公式、独立公式、标题和:::verse诗除外),且配置尚未设置该项;代码围栏(pinLegacyCodeBlocks)写成codeStyle.blocks: false,条件是读取的章节中打开了```或~~~围栏(三个或更多字符,最多缩进三个空格),且配置尚未设置该项。标为9的清单(由postext 1.23写出)只得到规则10的固定值,更早的清单也都会得到:诗行转行(pinLegacyVerseTightening)写成生效的bodyText上的bodyText.verse.tighten: false,条件是读取的章节中有逐行排的诗(:::verse开始标记写明layout=lines,或未写排法且各行没有半句分隔符,而配置并未把这类诗排成双半句),且配置尚未设置该项。标为10的清单(由postext 1.24写出)只得到规则11的固定值,更早的清单也都会得到:字符网格的齐底(pinLegacyGridBalancing)写成生效的headings上的headings.balancing.enabled: true,条件是合并后的配置在横排中设置了cjk.grid.enabled,且本身没有设置enabled,因为1.24默认会对这样的页面做齐底。规则11还使书名不在第一个字之后断开、带圈数字按汉字排、CJK设计文字按正文规则排版(#637):标为10或更早的清单,若读取的章节含有书名(《、〈或:book[),会在生效的cjk上得到cjk.titleMinChars: 1(pinLegacyTitleBreaks);若含有带圈数字(U+2460—U+24FF、U+2776—U+2793),得到cjk.circledNumbers: 'western'(pinLegacyCircledNumbers);若读取的章节或配置本身含有CJK文字,得到cjk.composeDesignText: false(pinLegacyDesignText)。以上各项都只在配置尚未设置该项时写入。规则11还会切开行内表,并在正文中排:::columns(#634):标为10或更早的清单,若读取的章节嵌入了资源,会在生效的tableStyle上得到tableStyle.splitInline: false(pinLegacyInlineTableSplit);若读取的章节在单独一行上开启:::columns围栏,会在生效的layout上得到layout.flowColumns: false(pinLegacyFlowColumns)。两项都只在配置尚未设置该项时写入。这些规则还把通栏标题所在栏的栏首作为浮动体的空位(#639):标为10或更早的清单,若合并后的配置设有span: 'page'的标题级别或标题样式,且读取的章节含有标题,会在生效的layout上得到layout.floatsUnderOpener: false(pinLegacyOpenerHeadFloats),同样只在配置尚未设置该项时写入。规则12让字符网格上的页面排满应有的行数(#717):标为11或更早的清单,若合并后的配置开启了cjk.grid.enabled,会在生效的bodyText上得到bodyText.avoidWidows: true,在japan地区还会得到bodyText.avoidOrphans: true(pinLegacyGridBreakRules),每一项都只在配置尚未设置时写入。这些规则还让双行夹注在断行处两侧各留两个字(#720):这样的清单,若读取的章节含有:warichu[夹注,或合并后的配置设有citations.notes: 'warichu',会在生效的cjk上得到cjk.warichu.minChars: 1(pinLegacyNoteBreaks),同样只在配置尚未设置该项时写入。在日本地区,西文单词的括号和符号旁边也加中西文间距(#721):标为11或更早的清单,若合并后的配置属于日本地区,且读取的章节含有假名或汉字,会在生效的cjk上得到cjk.latinSpacingEdge: 'letters'(pinLegacyLatinSpacingEdge),同样只在配置尚未设置该项时写入。随文放置的资源也像栏内浮动体那样采用placement.captionSide(#729):标为11或更早的清单,若合并后的配置设置了layout.layoutType: 'oneAndHalf',且读取的章节嵌入了资源,会得到layout.inlineCaptionSide: false(pinLegacyInlineCaptionSide),同样只在配置尚未设置该项时写入。

规则12还改变了短栏的齐底方式,标为11或更早的清单保持它原来排版时的做法。各手段的顺序(#707):若读取的章节含有标题,且合并后的配置对各栏做齐底、允许一个标题加两行以上、又尚未设置该项,清单会在生效的headings上得到headings.balancing.maxLinesBeforeLoose,其值取自己的maxLinesPerHeading,未设置时为4(pinLegacyLooseOrder)。跨栏图下方的加行(#708):若读取的章节嵌入或引用了资源,或让一个框浮动,且合并后的配置对各栏做齐底、没有关闭stretchAfterFloats、又尚未设置该项,清单会在生效的headings上得到headings.balancing.stretchAfterWideFloats: 'always'(pinLegacyWideFloatStretch)。索引各组内部的换栏(#712):若读取的章节有独占一行的:::index围栏,且配置尚未设置该项,清单会在生效的index上得到index.groups.splitMinEntries: 1(pinLegacyIndexGroupSplit)。截平的单位(#711):若读取的章节可能排出不在正文行距上的文字(:::围栏、代码围栏、引文、列表、独立公式或文献引用),且合并后的配置对各栏做齐底、保留trailing或beforeSpan、又尚未设置该项,清单会在生效的headings上得到headings.balancing.trailingStep: 'grid'(pinLegacyTrailingStep)。

规则12让以文字印出的引用在引用文字的空格处断开(#702)。若读取的章节含有引用(:ref{…}、\ref{…}、\eqref{…}或pandoc-crossref的@id),且配置没有设置该项,标为11或更早的清单会在生效的crossRefs上得到crossRefs.breakLabels: false(pinLegacyRefLabelBreaks):每个引用文字仍是原来排版时的那一个词。

规则12让没有字母的词(46.、1367))按周围文字的书写系统成形(#725)。标记为11或更早的清单,只要配置没有写明这项设置,不论章节内容如何,都会在生效的bodyText上得到bodyText.scriptContext: false(pinLegacyScriptContext):这类词仍然单独成形,与当初排版时一样。这条规则只对按书写系统绘制标点的字体起作用,而读取清单时并不知道这本书加载哪些字体。

规则13把文字成形所用的语言做成一项设置(#764),规则14把文档自己的语言定为它的默认值(#770)。标为13或更早的清单,只要配置没有写明这项设置,不论章节内容如何,都会在生效的bodyText上得到bodyText.shapingLanguage: 'host'(pinLegacyShapingLanguage);写明了值的清单保持它的值。它的文字按承载排版的页面的语言测量,在工作线程里不带语言,与当初排版时一样。标为13的清单由postext 1.29写出:1.29.1和1.29.2按宿主的语言排没有写明这个值的书,固定值也是这样;1.29.0的默认值是'document',它按文档的语言排这样的书,Canvas和PDF却没有跟着测量走,这样的书现在也按'host'读取。标为14而没有写明这个值的清单,按文档的语言排版。

规则13还由引擎绘制任务项(- [ ]、- [x])的方框:一个方形轮廓,任务完成时带一个勾,在列表项文字之前占有自己的宽度(#765)。标为12或更早的清单,只要有章节含有任务项而配置没有写明这项设置,就会在生效的unorderedLists上得到unorderedLists.taskBox: 'character'(pinLegacyTaskBox)。它的方框仍是按列表字体排出的字符☐或☑,列表项的文字从这个字符的宽度之后开始,与当初排版时一样。这个宽度来自含有该字符的字体,多数书里是系统的某个字体,而读取清单时并不知道它。

import { CONFIG_VERSION, migrateConfig } from 'postext/bundle';
 
migrateConfig({ headings: { fontFamily: 'Georgia' } }, undefined, { content: 'A book with no maths.' });
// => { headings: { fontFamily: 'Georgia', levels: [{ level: 1, breakBefore: { enabled: false } }] } }
migrateConfig({ math: { fontSizeScale: 1.2 } }, 3);
// => { math: { fontSizeScale: 1.35746…, marginTop: { value: 0.7072, unit: 'em' }, marginBottom: { value: 0.7072, unit: 'em' } },
//      layout: { inlineResourceGap: 'above', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 },
//      headings: { inlineMarks: false, keepWithNextSplit: 'fill' }, bodyText: { colonListRoom: 'line', breakAfterDashes: false, breakAfterHyphens: false, verse: { layout: 'bayt', tighten: false }, hardLineBreaks: false }, codeStyle: { blocks: false } }
migrateConfig({ layout: { layoutType: 'single' } }, 4, { content: 'Text.\n\n::resource{id="fig"}' });
// => { layout: { layoutType: 'single', inlineResourceGap: 'above' } }
migrateConfig({ layout: { layoutType: 'single' } }, 5, { content: ':::callout\nText.\n\n::resource{id="fig"}\n:::' });
// => { layout: { layoutType: 'single', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 } }
migrateConfig({ bodyText: { textAlign: 'left' } }, 6, { content: 'I say—that is all.' });
// => { bodyText: { textAlign: 'left', breakAfterDashes: false, optimalRagged: false } }
migrateConfig({ paragraphStyles: [{ id: 'verse' }] }, 7, { content: ':::paragraphs{style="verse"}\nA line.\n:::' });
// => { paragraphStyles: [{ id: 'verse' }], bodyText: { paragraphContainerSpacing: 'add' } }
migrateConfig({ bodyText: { fontFamily: 'Georgia' } }, 7, { content: 'A well-known tale.' });
// => { bodyText: { fontFamily: 'Georgia', breakAfterHyphens: false } }
migrateConfig(config, CONFIG_VERSION); // 现行规则:返回`config`本身

content是该配置要排版的Markdown(一个字符串或一组章节)。不提供它时,只要开启了公式就固定公式字号,只要配置声明了段落样式就固定容器下方的间距,而两项间距、标题标记、冒号行下方空间、框的切分、破折号断行、标题下方的切分和复合词断行总是固定,因为引擎无法判断书中是否有公式、:::paragraphs容器、行内图、带标记的标题、冒号引出的列表、框、不留空格的破折号、标题或复合词。齐左断行只看配置决定是否固定,与有无内容无关。存储的配置只迁移一次,然后以CONFIG_VERSION重新存储:公式的固定值会乘以缩放,迁移两次就会放大两次。没有它时,诗的排法和代码围栏也会被固定,因为书中可能有不带分隔符的:::verse诗或围栏;成对的缩进则只看配置本身。1.23的诗行转行也会被固定,因为书中可能有逐行排的诗。

#排版和渲染文件包

有四个辅助函数把打开的文件包接到引擎和各个后端上:

  • loadBundleFonts(bundle)把文件包的字面注册到document.fonts,并把它们的字节注册到引擎的字体注册表,供SVG图片使用(registerFontBytes)。排版前要等它完成,因为排版时是用浏览器已有的字体测量文字的。文件包中提到但没有携带的字体家族(Google Fonts)仍需你自己加载,和其他文档一样。
  • registerBundleImages(bundle)为Canvas后端(renderPage、renderToCanvas)解码图片,包括视频封面。bundleImageUrl(bundle)是renderToHtml的resourceImageUrl解析器,bundleVideoUrl(bundle)则是它的resourceVideoUrl解析器,用于文件包携带的视频文件。开启diagramStyle.singleInk时,两者都会对SVG图重新着色一次:它们改写标记本身并给图片打上标记,任何后端都不会再次着色(见Canvas与HTML中的单色墨)。两者还会在每个SVG中嵌入其文字指定的字面,先取文件包自己的字体,再取向引擎注册的字体(见SVG文字中的字体);registerBundleImages(bundle, { onWarning })和bundleImageUrl(bundle, { onWarning })会报告没有字面可用的字体族。
  • buildBundle(bundle)按顺序排版各章,每章返回一个VDTDocument。每一章都接续前一章:标题和资源的计数器、当前所在的篇、页的奇偶和页码。印出目录(:::toc)或索引(:::index)的章会拿到整本书的大纲。它接受与buildDocument相同的选项,另加用来覆盖文件包配置的config、共享测量缓存的cache,以及metadata(见下文)。
  • bundleResourceBytes(bundle)和bundleFontProvider(bundle, { decodeWoff2, fallback })分别对应postext-pdf的renderToPdf的resourceBytes和fontProvider选项。字体提供器从文件包中为所请求的样式挑选最接近的字重。对.woff2字面,它需要decompressWoff2;对文件包没有携带的字体家族,它以渲染器传来的参数(包括request)调用fallback,并原样转交其返回值。如果后备函数只获取某个家族的latin文件,文件包没有嵌入的中文字体就会印成空方框;如果它按分片应答,例如中文、日文和韩文字体中的sliceFontProvider,就能完整印出。
import { openBundle, loadBundleFonts, registerBundleImages, buildBundle, renderPage,
  bundleResourceBytes, bundleFontProvider } from 'postext';
import { renderToPdf, decompressWoff2 } from 'postext-pdf';
 
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle);
await registerBundleImages(bundle);
 
const docs = buildBundle(bundle);                        // 每章一个VDTDocument
const firstPage = renderPage(docs[0].pages[0], docs[0]); // 一个<canvas>
 
const pdf = await renderToPdf(docs, {                    // 整本书
  fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
  resourceBytes: bundleResourceBytes(bundle),
});

如果要自己排某一章,把bundle.chapters[i].markdown、bundle.resources和bundle.config传给buildDocument,和其他文档一样。

书的元数据。和沙盒一样,第一章的前置元数据就是整本书的元数据:buildBundle把其中的title、author等传给每一章,所以{title}和{author}书眉在每一页都有效,每章的doc.metadata也都带有这些值。后面各章开头的前置元数据块会被忽略:它按---行识别,不经解析直接清空,所以解析器无法接受的YAML也不会造成问题。options.metadata提供前置元数据没有设置的值(以前置元数据为准)。整本书的页数也会传到每一章:{bookTotalPages}印出它,而{totalPages}只计本章(见全书页数)。

const docs = buildBundle(bundle, { metadata: { author: 'A. Author' } });
docs[3].metadata.title;   // 第一章的`title:`

书从哪里开始。一个文件包可以只含一部更长出版物的一部分:某期刊物的第58到61页,某本教材的第4章。它的start说明前面有什么,用的是buildDocument的continuation的字段:第一页之前的页数(pageIndexOffset,它决定第1页落在哪一面,镜像的页边距和奇偶页的页眉也随之而定)、当前生效的页码编排(pageNumbering)、标题计数器(headings)、尚未结束的部分(part),以及资源、编号条目、脚注和行号的计数器。createBundle把它写成preset.json的start,openBundle通过bundle.start返回它,buildBundle用它排第一章,结果与buildDocument({ markdown, continuation: start }, config)相同,后面的各章从这里接着排;{bookTotalPages}也把书之前的页数算在内。没有start的清单照旧读取,不认识这个字段的读取程序会忽略它。bookPageCount不在其中:页数由读取方来数。在多语言的文件包里,localized[…].start可以给某个版本单独指定起点。

const { bytes } = await createBundle({
  name: 'Field notes, chapter 4',
  markdown,
  config,
  // 第58页(偶数页),第4章。
  start: { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3 } },
});
const bundle = await openBundle(bytes);
bundle.start;                 // { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3, h2: 0, … } }
const [doc] = buildBundle(bundle);
doc.pages[0].pageLabel;       // '58'

#在线示例:打开文件包

这个pen从仓库加载一本两章的示例书(lantern.postext,带有自己的字体、一幅SVG图和一张表)。它注册文件包的字体和图片,用buildBundle排版全书并绘制每一页。Make the PDF用postext-pdf渲染同样的文档,并嵌入文件包中的字体。选择一个你自己的.postext文件(比如从沙盒导出的),也能以同样的方式查看。

Postext · 打开.postext文件包
import {
  openBundle,
  loadBundleFonts,
  registerBundleImages,
  buildBundle,
  bundleResourceBytes,
  bundleFontProvider,
  renderPage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
 
// A two-chapter book with its own typeface, an SVG figure and a table.
const SAMPLE = 'https://cdn.jsdelivr.net/gh/drnachio/postext@main/docs/examples/open-bundle/lantern.postext';
 
const status = document.getElementById('status');
const pdfButton = document.getElementById('pdf');
let current = null;
 
async function show(data) {
  // Chapters, config (fonts wired to the bundle's own files), resources and
  // every file, keyed by its path inside the bundle.
  const bundle = await openBundle(data);
 
  // Layout measures text with the fonts the browser has: register the
  // bundle's faces, and load the Google Fonts it names but does not carry
  // (the default running heads use Open Sans; see the pen's CSS).
  await loadBundleFonts(bundle);
  await document.fonts.load('600 16px "Open Sans"');
  await registerBundleImages(bundle);
 
  // One VDTDocument per chapter, each continuing the one before it.
  const docs = buildBundle(bundle);
  const pages = docs.flatMap((doc) => doc.pages.map((page) => renderPage(page, doc)));
  document.getElementById('pages').replaceChildren(...pages);
  status.textContent = `${bundle.name} · ${bundle.chapters.length} chapter(s) · ${pages.length} page(s)`
    + (bundle.warnings.length ? ` · ${bundle.warnings.length} warning(s)` : '');
  current = { bundle, docs };
  pdfButton.disabled = false;
  document.getElementById('links').hidden = true;
}
 
// Fonts the bundle does not carry come from Fontsource.
async function fontsource(family, weight, style) {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`);
  if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${family}`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}
 
pdfButton.addEventListener('click', async () => {
  pdfButton.disabled = true;
  status.textContent = 'Rendering the PDF…';
  const { bundle, docs } = current;
  const bytes = await renderToPdf(docs, {
    fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
    resourceBytes: bundleResourceBytes(bundle),
  });
  const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
  document.getElementById('open').href = url;
  document.getElementById('download').href = url;
  document.getElementById('links').hidden = false;
  status.textContent = `${bundle.name} · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
  pdfButton.disabled = false;
});
 
document.getElementById('file').addEventListener('change', async (event) => {
  const file = event.target.files[0];
  if (!file) return;
  status.textContent = `Opening ${file.name}…`;
  await show(file).catch((err) => { status.textContent = `Could not open ${file.name}: ${err.message}`; });
});
 
const res = await fetch(SAMPLE);
await show(await res.arrayBuffer());
index.html
<p>
  <label>Open a .postext file: <input id="file" type="file" accept=".postext,application/zip"></label>
  <button id="pdf" disabled>Make the PDF</button>
  <span id="links" hidden>
    <a id="open" target="_blank" rel="noopener">open it</a> ·
    <a id="download" download="book.pdf">download it</a>
  </span>
</p>
<p id="status">Loading the sample book…</p>
<div id="pages"></div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#pages {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: flex-start;
}
#pages canvas {
  display: block;
  width: 240px;
  height: auto;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

从codepen.io加载一个交互式编辑器。示例从CDN导入postext的最新版本。

#创建文件包

createBundle根据一份文档写出.postext文件:包括它的章节、配置、资源以及资源引用的数据文件。

import { createBundle } from 'postext';
 
const { bytes, manifest, warnings } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters: [
    { markdown: '# Dusk\n\nIt is drawn in :ref{id="lantern"}.' },
    { title: 'Night', markdown: '# Night\n\n…' },
  ],
  config,
  resources: [{
    id: 'lantern', typeId: 'figure', kind: 'svg', caption: 'The lantern.',
    svg: { fileId: 'lantern.svg', width: 240, height: 150 },
    createdAt: 0, updatedAt: 0,
  }],
  files: { 'lantern.svg': svgMarkup, 'garamond-regular': fontBytes },
});
输入含义
name、id、description、locale清单的元数据。id默认取name的slug。
chapters或markdown书的内容,每章一个{ title?, markdown },或者单个文档。
configPostextConfig。与默认值相同的值不写入清单。
resources各个资源。图片通过bitmap.fileId / svg.fileId指明其数据文件(印刷母版用svg.pdfFileId)。
files按fileId给出的数据文件(对象或Map):资源引用的图片,以及config.customFonts各变体引用的字体文件。值可以是Uint8Array、ArrayBuffer、Blob或字符串(SVG标记)。
thumbnail{ data, mime }:封面图片(PNG、JPEG、WebP、GIF或SVG)。
canvasScope'book'要求查看器把整本书排成一张画布。
start文件包只含一本更长的书的一部分时,这本书从哪里开始:也就是单独构建一份文档时会传入的continuation。写入清单的start。
mtime写在压缩包每个文件上的修改日期(Date、时间戳或日期字符串)。省略时取调用时刻,因此相同输入的两次调用会得到不同的字节。传入固定日期,相同输入就得到相同字节,可以用来计算哈希或比较。zip记录的日期和时间不带时区,以两秒为步长,范围是1980年到2099年,并按本机的本地时间写入。要在所有机器上得到相同的字节,请用本地字段构造日期,例如new Date(1980, 0, 1):时间戳或以Z结尾的字符串表示一个绝对时刻,在不同时区对应不同的本地时间('1980-01-01T00:00:00Z'在UTC以西仍是1979年)。按本地时间落在这些年份之外的日期会抛出异常。
localized同一本书的其他语言,以语言标签为键:{ es: { chapters?, config?, resources? } }。此时上面的输入就是locale语言的内容,而locale变为必填。见双语文件包。

它返回压缩包的bytes、写成preset.json的manifest、以files给出的所有文件(路径 → 字节),以及一个warnings列表。文件按资源id(resources/lantern.svg)或字体文件名(fonts/…)命名,章节按顺序和标题命名(chapters/01-dusk.md)。字体声明在清单的fonts中,从不放在config.customFonts里。以下内容会被略去,并各给出一条警告:

  • 数据文件不在files中的资源或字面
  • .woff字面(PDF后端无法嵌入)
  • 标记为redistributable: false的字体家族

在浏览器中,把bytes交给下载链接:URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }))。在Node中,用fs.writeFile写出。createBundle和openBundle不需要DOM。发布包中的模块路径不带扩展名,所以在不用打包工具的纯Node环境下需要一个解析钩子。仓库中的docs/examples/open-bundle/build-sample.mjs用几行代码展示了一个。

#双语文件包

一个.postext文件可以包含一本书的多种语言版本,openBundle(bytes, { locale })可以按其中任何一种读取。createBundle根据localized写出这种文件:每种额外语言一项,每项只写与主要内容(locale输入)不同的部分:

const { bytes, manifest } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters: [{ markdown: '# Dusk\n\n…' }, { markdown: '# Night\n\n…' }],
  config,
  resources: [lanternFigure, hoursTable],
  files: { 'lantern.svg': svgEn, 'lantern-es.svg': svgEs },
  localized: {
    es: {
      chapters: [{ markdown: '# Anochecer\n\n…' }, { markdown: '# Noche\n\n…' }],
      config: { headings: { levels: [{ level: 1, numberingTemplate: 'Capítulo {1}' }] } },
      resources: [
        { id: 'lantern', caption: 'El farol.', svg: { fileId: 'lantern-es.svg', width: 240, height: 150 } },
        { id: 'hours', caption: 'Horas de luz.' },
      ],
    },
  },
});
 
const es = await openBundle(bytes, { locale: 'es' });   // 西班牙语的章节、配置和题注
  • chapters:该语言的书。章节文件按语言分文件夹存放(chapters/en/01-dusk.md、chapters/es/01-anochecer.md),清单的chapters变成语言 → 章节的映射。没有chapters的语言读取主要语言的章节;如果没有任何语言有自己的章节,它们就仍是单一列表。
  • config:该语言的配置。以该语言读取文件包时,每个顶层键整体取代共享的同名键,所以上例中的headings会取代整个headings对象。省略的键或与共享值相同的键视为共享,不会写出,因此传入该语言的完整配置和只传入少数改动的键效果一样。如果某个键设为默认值而共享的键不是(layout: {}),就照原样写出,从而把共享值重置。字体是共享的:某种语言customFonts中的字体家族会并入文件包的fonts。
  • resources:共享资源的文字,按id对应:caption、note、altText以及表格的table。含有文字的图片可以有自己的图稿:bitmap.fileId或svg.fileId(以及svg.pdfFileId)指向files中的另一个数据文件,写为resources/es/lantern.svg。其他字段,如类型或位置,都是共享的。不在resources中的id会被略去并给出警告;缺少某种语言的图片时,该语言沿用共享的图片,同样给出警告。

清单在locales中列出所有语言(['en', 'es']),把主要语言保存为locale,其余的保存在localized下。不指定语言时,openBundle读取主要语言。

读者得到哪种语言。openBundle(bytes, { locale })先提供完全匹配的语言,其次是其基础语言(es-MX读取es),最后是主要语言,bundle.locale说明实际提供的是哪一种。章节和文字总是来自同一种语言。即使localized带有主要语言的某个地区变体,主要语言也保留共享的文字:一个pt-PT文件包带有pt-BR项时,只有pt-BR读到巴西葡萄牙语的题注,pt-PT和pt读到的都是共享题注。

#在线示例:创建文件包

这个pen构建一本带有一幅SVG图的两章书,列出createBundle写出的文件以及清单。它提供压缩包下载,然后用openBundle重新打开并绘制第一页:几行代码走完整个往返过程。把下载的文件导入沙盒,就可以在那里继续编辑。

Postext · 创建.postext文件包
import { createBundle, openBundle, registerBundleImages, buildBundle, renderPage } from 'https://esm.sh/postext';
 
// A picture resource names its payload by fileId; the bytes (here, SVG
// markup) go in `files` under that same id.
const lanternSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 150">
  <rect width="240" height="150" fill="#f3efe6"/>
  <path d="M100 36 h40 l8 14 h-56 z" fill="#2f3e46"/>
  <rect x="98" y="50" width="44" height="58" rx="4" fill="#f6c453" stroke="#2f3e46" stroke-width="4"/>
  <circle cx="120" cy="79" r="11" fill="#fff4c2"/>
  <path d="M94 108 h52 l-6 12 h-40 z" fill="#2f3e46"/>
</svg>`;
 
const resources = [{
  id: 'lantern',
  typeId: 'figure',
  kind: 'svg',
  caption: 'The lantern by the door.',
  svg: { fileId: 'lantern.svg', width: 240, height: 150 },
  createdAt: 0,
  updatedAt: 0,
}];
 
const text = 'The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.';
 
// One entry per chapter; a chapter without a title takes its first # heading.
const chapters = [
  { markdown: `# Dusk\n\n${text} It is drawn in :ref{id="lantern"}.\n\n${text}\n\n${text}` },
  { markdown: `# Night\n\n${text}\n\n${text}` },
];
 
const config = {
  layout: { layoutType: 'double' },
  // Two short chapters that run on, with no blank verso between them (an
  // H1 otherwise opens on a fresh recto), as in the open-bundle sample.
  headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}', breakBefore: { enabled: false } }] },
};
 
// Everything a .postext file holds: manifest, chapters, resources, fonts.
const { bytes, manifest, files, warnings } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters,
  config,
  resources,
  files: { 'lantern.svg': lanternSvg },
});
if (warnings.length) console.warn(warnings);
 
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }));
document.getElementById('download').href = url;
document.getElementById('actions').hidden = false;
document.getElementById('files').replaceChildren(...Object.entries(files).map(([path, data]) => {
  const li = document.createElement('li');
  li.textContent = `${path} (${data.length} B)`;
  return li;
}));
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
 
// Round trip: open the file just written, the way any program would.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const bundle = await openBundle(bytes);
await registerBundleImages(bundle);
const [firstChapter] = buildBundle(bundle);
document.getElementById('page').replaceChildren(renderPage(firstChapter.pages[0], firstChapter));
document.getElementById('status').textContent =
  `${bundle.name}: ${bundle.chapters.length} chapters, ${(bytes.length / 1024).toFixed(1)} KB`;
index.html
<p id="status">Building the bundle…</p>
<p id="actions" hidden>
  <a id="download" download="lantern.postext">Download lantern.postext</a> ·
  <a href="https://postext.dev/en/sandbox" target="_blank" rel="noopener">open the Sandbox</a> and import it (Projects → New → Import .postext…)
</p>
<div id="output">
  <section>
    <h3>Files in the bundle</h3>
    <ul id="files"></ul>
    <h3>preset.json</h3>
    <pre id="manifest"></pre>
  </section>
  <section>
    <h3>Opened again: page 1</h3>
    <div id="page"></div>
  </section>
</div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#output {
  display: flex;
  flex-wrap: wrap;
  gap: 24px;
  align-items: flex-start;
}
#output section {
  flex: 1 1 280px;
  min-width: 0;
}
h3 {
  margin: 8px 0;
  font-size: 14px;
}
ul {
  margin: 0;
  padding-left: 20px;
  font-family: ui-monospace, monospace;
  font-size: 13px;
}
pre {
  max-height: 320px;
  overflow: auto;
  padding: 8px;
  background: #fff;
  font-size: 12px;
}
#page canvas {
  display: block;
  max-width: 100%;
  height: auto;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

从codepen.io加载一个交互式编辑器。示例从CDN导入postext的最新版本。

#使用文件包

沙盒、智能体技能和postext包读写的是同一种文件,所以.postext文件很适合在工具之间传递一本书:

  • 从文件包开始。用智能体技能移植一本现有出版物,或者在沙盒中设计一本书并导出(在书籍面板中该书那一行的⋯菜单里选下载(.postext))。在你的程序中用openBundle加载文件,渲染到Canvas、HTML或PDF。把这个文件当作书的源文件:在代码中编辑章节、配置或资源,再用createBundle写回;或者在文件变动时重新加载即可。
  • 在沙盒中调试和微调。当程序的输出有地方需要调整时(某幅图落错了页、某个标题样式、各栏齐底),用createBundle把程序排版的内容导出。在沙盒中导入该文件(书库 → 新建 → 打开.postext文件…),借助实时预览、检查面板和PDF视图修改文字、设计或图,然后再次导出。你的程序随后用openBundle加载修正后的文件。也可以把改动抄回代码:清单的config只包含与默认值不同的值,读起来就像一份简短的diff。

#底层API

postext/bundle还导出了openBundle和createBundle背后的构建模块,供以自己的方式存储或提供文件包的宿主使用(例如通过HTTP提供的解压目录、数据库中的记录):

  • openBundleZip(bytes) / zipBundle(files, { mtime }):压缩包层。打开时允许有一个顶层文件夹,并忽略__MACOSX条目和点文件。会拒绝逃出文件包范围的路径。mtime给文件标注日期的方式与createBundle的同名输入相同。
  • readBundle(manifest, readFile, options)根据清单和一个readFile(path)回调读出章节、配置、资源、图片和字体。options设置语言、文件id的命名方式(ids)、基础配置(baseConfig,位于清单配置之下;默认是文件包语言下bundleBaseConfig的调色板和资源类型,该语言由resolveBundleConfigLocale(manifest, locale)返回,传入自己的baseConfig的宿主应把它本地化为该语言;清单早于configVersion: 4时,其math与文件包的一起固定;早于5时,固定其layout的行内间距;早于6时,固定其layout的框内间距、bodyText的冒号行下方空间、headings的行内标记和首字下沉大小;早于7时,固定其bodyText的破折号断行和齐左断行;早于8时,固定其headings的标题下方切分,以及bodyText的复合词断行和:::paragraphs容器下方的间距,见postext 1.4及更早版本写出的文件包),以及固有尺寸的测量方式。readResolution从文件读取每张位图的分辨率,写入bitmap.fileResolution;文件包设了layout.bitmapResolution: 'file'时默认为true。
  • planBundle(meta, content) / resolveBundleFiles(plan, sources):写出的一侧,分为一个纯粹的计划(文件名和清单)和通过readBlob / readFont回调解析字节两步。
  • isBundleManifest(value)、语言选择函数(pickChapterSpecs、pickLocaleOverrides、pickBundleView、resolveBundleLocale、resolveBundleConfigLocale)、svgSize / bitmapSize / bitmapInfo(位图的像素及其文件标明的分辨率),以及格式类型(BundleManifest、BundleResourceSpec、BundleFontFamilySpec……)。
  • CONFIG_VERSION、migrateConfig(config, configVersion, { content })、pinLegacyHeadingBreaks(config)、pinLegacyMathSize(config)、pinLegacyInlineGap(config)、pinLegacyBoxResourceGap(config)、pinLegacyHeadingMarks(config)、pinLegacyDropCapSize(config)、pinLegacyColonListRoom(config)、pinLegacyBoxChildCut(config)、pinLegacyDashBreaks(config)、pinLegacyRaggedBreaking(config)、pinLegacyHeadingSplit(config)、pinLegacyParagraphContainerSpacing(config)、pinLegacyHyphenBreaks(config)和LEGACY_MATH_SIZE(0.5 ÷ 0.442):把存储的配置转换为现行规则的表述(见postext 1.4及更早版本写出的文件包)。readBundle会应用它们;以自己的方式存储配置的宿主也可以使用,每份存储的副本用一次。

沙盒就建立在这些函数之上,另外加上它自己的存储id和layouts.json页数记录。