跳到主要内容

章 3 · 篇 II · 工艺

Postext配置

Postext所有版面配置选项的完整参考

更新于 2026-09-2930分钟eneszh

Postext的每一项版面决定都由同一个配置对象控制。

PostextConfig控制页面尺寸、分栏方式、正文排印、标题样式、文档语言(locale)等。所有属性都是可选的:Postext自带一套合理的默认值,参照的是传统书籍排版。你只需写出想改的部分。

import { buildDocument } from 'postext';
 
const document = buildDocument(content, {
  page: { sizePreset: '21x28', dpi: 300 },
  layout: { layoutType: 'double', gutterWidth: { value: 0.5, unit: 'cm' } },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt覆盖默认的8 pt
  headings: { fontFamily: 'Open Sans' },
});

引擎如何处理这份配置,概览见架构一页。

#内容一览

这份参考很长,主要分为以下几块:

  • 页面:尺寸、页边距、基线网格、裁切线、装订。
  • 版式:栏数、栏间距、栏线、竖排。
  • 页眉与页脚:每页的文字元素和线条元素,支持占位符、奇偶页和对齐方式。
  • 正文:排印、断词、段首孤行与段末孤行。
  • 文档语言:顶层的locale,决定断词的后备语言、内置资源类型、表格和拆分标注框的续接文字、写入无障碍PDF的语言标签;另见Postext排版所支持的语言与文字。
  • 东亚排版:cjk,涵盖中文、日文和韩文的地区惯例与断行、两端对齐时行内空白的分配、标点宽度与悬挂、汉字与拉丁字母之间的空白、字符网格、竖排中的直立数字,以及着重号、注音和割注。完整指南见中文排版。
  • 标题:共享默认值以及H1–H6各自的覆盖设置。
  • 无序列表与有序列表:项目符号、编号、嵌套。
  • 数学公式:LaTeX渲染、缩放、颜色、外边距。
  • 资源类型:图、表及自定义类型的分类编号。
  • 表格样式(含命名表格样式)与题注样式:表格资源及其题注的排印与装饰。
  • 图示样式:把嵌入的SVG图示改为单一油墨,用于专色印刷。
  • 段落样式:供:::paragraphs容器使用的命名样式,如参考文献、术语表、注释。
  • 标注框样式:供:::callout容器使用的带框注释、提示和学习目标。
  • 篇::::part容器生成的篇章分隔页,包括奇偶页、正文区域、篇首页版面设计、正文排印。
  • 标题样式:供{style="…"}标题使用的命名样式,如不编号的章、带独立书眉的前置部分、页面几何与调色板。
  • 目录::::toc排出的内容,包括条目排印、前导符、页码、作者行、篇条目。
  • 索引::::index排出的内容,包括条目排印、缩进、页码分隔符与页码范围、字母分组、排序语言。
  • 单位与颜色 + 调色板:Dimension、ColorValue、命名颜色。
  • 自定义字体:在Google Fonts之外声明用户上传的字体族。
  • HTML查看器:HTML后端的目标栏宽与断行。
  • PDF生成(配置):PDF后端的书签大纲、无障碍(带标签的)输出、强制色彩空间。
  • 调试:编辑器中的可视化叠加层与编写警告。
  • 以编程方式使用:buildDocument、它报告的警告、解析器、缓存。
  • 在Web Worker中运行排版:在主线程之外构建,并可取消。
  • 集成HTML查看器与生成PDF:端到端的完整做法。

#页面

page属性控制页面的物理尺寸和外观。

属性类型默认值说明
sizePresetPageSizePreset'17x24'预定义的页面尺寸。设为'custom'则使用明确给出的宽度和高度。
widthDimension17 cm页面宽度。省略时取自sizePreset;明确给出的值总是优先(完全自定义的尺寸请用sizePreset: 'custom')。
heightDimension24 cm页面高度。省略时取自sizePreset;明确给出的值总是优先。
marginsPageMargins四边均为2 cm页面边缘与内容区域之间的空白。上、下、左、右四边各自独立设置。设了mirror: true时,这些页边距就是对页页边距:left是内侧(订口一侧)页边距,right是外侧页边距;奇数页(第1页是奇数页)按原样使用,偶数页左右互换,于是内容区域,连同其中的栏、浮动体带、页眉页脚容器和章首横带,都会在跨页中左右移动。默认为false。详见下文。
backgroundColorColorValuetransparent页面背景色。
dpinumber300每英寸点数。影响物理单位(cm、mm、in)换算为像素的方式。
cutLinesCutLinesConfig关闭在页面四角显示裁切标记,供印后裁切使用。启用后,画布会扩大以包含出血区域和裁切标记。详见下文。
baselineGridBaselineGridConfig关闭在页面上画出基线网格,用来检查纵向节奏。无论画不画出来,版面都会对齐到网格。详见下文。
binding'auto' | 'left' | 'right''auto'书籍装订的一侧。layout.writingMode为'vertical-rl'时,'auto'即'right',否则为'left'。右侧装订的书从左页开始,页边距的镜像方向也相反。这是全书级设置:标题样式自己的layout不会改变它。见装订。

#镜像页边距

书是按跨页来读的,内侧页边距通常与外侧不同。margins.mirror把四边页边距变成对页页边距:

{
  "page": {
    "margins": {
      "top": { "value": 2, "unit": "cm" },
      "bottom": { "value": 2.5, "unit": "cm" },
      "left": { "value": 2.2, "unit": "cm" },
      "right": { "value": 1.4, "unit": "cm" },
      "mirror": true
    }
  }
}

按这份配置,每个奇数页左侧(订口)页边距为2.2 cm,右侧(切口)为1.4 cm;每个偶数页左侧(切口)为1.4 cm,右侧(订口)为2.2 cm。排好的每一页都在VDTPage上带有自己的contentArea,因此由它推导出来的一切,包括栏、整页宽的浮动体带、页眉页脚容器以及span: 'page'章首横带,都会自动随镜像后的几何关系变化。锚定到'page'/'bleed'的设计元素所用的页面框和出血框不受影响:它们描述的是实际纸张,而不是页边距。

#装订

“竖排的中文书籍在右侧装订,横排的书籍在左侧装订”(clreq §7.1.1.1)。page.binding: 'right'按右侧装订来排书:

  • 第1页仍是奇数页,也仍是右页,所以breakBefore.parity、:::pagebreak{parity}、带parity的设计元素以及所有页数的含义都不变。变的是右页所在的一侧:它成了跨页中左边的那一页。所以从新的右页开始的章,开在左边一页上(clreq §7.1.3.3)。
  • 设了margins.mirror时,left仍是内侧页边距,但左右互换的是奇数页:第1页的内侧页边距在右边,第2页在左边。位于'outer'/'inner'的oneAndHalf侧栏、贴着订口的旋转浮动体、篇章页的页边距,以及位于'outer'/'inner'的框角图标,都遵循同样的规则。
  • 文档本身会标明这一点(VDTDocument.binding: 'right'),宿主程序无须读取配置:沙盒把跨页显示为[3 | 2],第1页单独位于订口左侧;它的HTML查看器从右向左排列页面,从右端打开,左箭头翻到下一页。renderToHtml在multi模式下把一行页面从右向左排列。
  • 无论是否带标签,PDF都写入/ViewerPreferences << /Direction /R2L >>和/PageLayout /TwoPageRight(第1页单独,之后两两成对)。Acrobat和Foxit会遵循这两项设置,Chrome内置的查看器则都忽略。

页码和书眉不会自行移动:把页码印在外侧角的模板,需要为右侧装订分别设置奇数页和偶数页的元素(元素可以带parity)。

#页面尺寸预设

预设宽度高度常见用途
'11x17'11 cm17 cm口袋书
'12x19'12 cm19 cm标准平装书
'17x24'17 cm24 cm技术书、教材
'21x28'21 cm28 cm杂志、报告(接近A4)
页面尺寸预设按比例绘制的四种内置页面尺寸预设:口袋书11x17、平装书12x19、技术书17x24,以及接近A4的21x28 cm。21×28 · ~A417×24 · 教材12×19 · 平装书11×17 · 口袋书21 cm28 cm
各预设按比例绘制。

#基线网格

基线网格是正文的节奏:从内容区域顶端算起,每隔一个正文行距一条线。无论是否画出来,版面都会使用它:标题、列表末尾、框、图和行间公式之后,文字都会回到网格上(除非它们自己的snapToGrid关闭),所以相邻各栏的行保持对齐。enabled只负责在Canvas、PDF和沙盒视图中画出这些线,用来检查节奏;打开或关闭它不会移动任何东西。这些线只覆盖页面上实际的文字,从第一行文字到最后一行,所以浮动体带、为奇偶页补的空白页以及末尾未用的空间都不显示网格。

属性类型默认值说明
enabledbooleanfalse是否画出网格线(Canvas和PDF)。只影响绘制,版面不论开关都一样。
colorColorValue#cccccc网格线的颜色。
lineWidthDimension0.5 pt网格线的粗细。
page: {
  baselineGrid: { enabled: true, color: { hex: '#e0e0e0', model: 'hex' } }
}

#裁切线

启用后,画布会扩大以包含出血区域,引擎还会在每个角画出裁切标记,供印刷生产使用。

属性类型默认值说明
enabledbooleanfalse是否扩大画布以包含出血并画出裁切标记。
bleedDimension3 mm页面四周用于印刷出血的额外区域。
markLengthDimension5 mm每条裁切标记的长度。
markOffsetDimension3 mm成品边缘与每条裁切标记起点之间的距离。标记不会从出血区域内开始:bleed更宽时,标记从出血边缘开始。
markWidthDimension0.25 pt裁切标记的粗细。
colorColorValue#000000画布上以及RGB或灰度PDF中裁切标记的颜色。CMYK的PDF则改用套准色绘制(见下文)。

纸张每一边扩大bleed + markOffset + markLength,裁切后的页面位于正中。成品的每个角有两条标记,各长markLength,分别与在该角相交的一条边对齐。标记从成品边缘外markOffset处开始;如果bleed比它宽,就从出血边缘开始,这样标记不会压在延伸到出血区域的图文上。按默认值(3 mm出血、3 mm偏移),标记位于成品外3到8 mm之间,标记之外还有一圈3 mm宽的空白带环绕纸张。cropMarkSegments(page, doc.config.page, doc.trimOffset)以页面像素返回一页的八条标记,也就是Canvas和PDF后端所画的那些。第三个参数是成品在纸张中的位置,PDF的TrimBox就是据此写出的;省略时按同样方式从cutLines算出。

出血区域之外,除了标记什么都不印。页面绘制的一切,包括锚定到'page'或'bleed'的设计元素,在Canvas、PDF和HTML输出中都会被裁到出血框以内,与桌面排版软件导出时的裁切一致:有意超出出血放置的色带或图片,在出血边缘被截断,反正裁切时那部分也会被切掉。postext 1.4及以前,这样的元素会越过标记一直延伸到纸张边缘。

在PDF(postext-pdf)中,每页的MediaBox是整张纸。页面还带有TrimBox(裁切后的成品页面)和BleedBox(成品加出血),供拼版和印前检查工具读取。PDF以CMYK写出时(colorSpace: 'cmyk',或pdfGeneration.forceColorSpace配合colorSpace: 'cmyk'),标记用套准色即/All分色绘制,因此每块印版上都会印出。RGB和灰度PDF则用color绘制。

#页码编号

page.pageNumbering块控制页码标签的格式以及计数器的起始值。它只定义整个文档的默认设置。要在文档中途重新编号(例如前置部分用罗马数字,正文各章改用从1开始的阿拉伯数字),请使用:::numbering指令(见文档格式 → 指令)。

属性类型默认值说明
format'decimal' | 'lower-roman' | 'upper-roman' | 'lower-alpha' | 'upper-alpha',或某种东亚样式'decimal'页码标签所用的数字样式。东亚样式('trad-chinese-informal'把页码编为一、二、三)列在编号格式的写法中。
startAtnumber1不论格式如何,赋给第一页的数值。format: 'lower-roman', startAt: 1得到i, ii, iii, …;format: 'decimal', startAt: 17得到17, 18, 19, …。

计算出的标签以pageLabel存放在每个VDTPage上,页眉页脚占位符{pageNumber}解析出的就是它。PDF会写出/PageLabels数字树,使“预览”/Acrobat的页码指示和“转到页面”导航与印出的标签完全一致。PDF没有对应代码的样式(中文数字、带圈数字和全角数字)会逐页写出,所以阅读器同样显示一、二、三。

编号格式的写法

有三处设置可以选择编号格式,各自形成了自己的写法:页码标签(page.pageNumbering.format和:::numbering{format=…})写作lower-roman,有序列表(orderedLists.numberFormat)用arabic表示十进制,资源类型(counterFormat)写作roman-lower。这三处都接受下表中的所有写法,所以从一处复制过来的格式在另外两处同样可用。名称不区分大小写;单字符形式区分(i和I不同)。

格式输出可用写法
十进制1, 2, 3decimal、arabic、1
小写罗马数字i, ii, iiilower-roman、roman-lower、i
大写罗马数字I, II, IIIupper-roman、roman-upper、I
小写字母a, b, clower-alpha、alpha-lower、lower-latin、a
大写字母A, B, Cupper-alpha、alpha-upper、upper-latin、A
中文数字(简体)一, 十二, 一百零一simp-chinese-informal、简体文档中的一
中文数字(繁体)一, 十二, 一萬trad-chinese-informal、cjk-ideographic、繁体文档中的一
中文大写数字(简体)壹, 壹拾贰, 壹佰贰拾simp-chinese-formal、简体文档中的壹
中文大写数字(繁体)壹, 壹拾貳, 壹佰貳拾trad-chinese-formal、繁体文档中的壹
中文数码一二〇, 二〇二六cjk-decimal、〇
天干甲, 乙, 丙 … 癸cjk-heavenly-stem、甲
地支子, 丑, 寅 … 亥cjk-earthly-branch、子
带圈数字①, ②, ③ … ㊿circled-decimal、①
全角数字1, 2, 3fullwidth-decimal、1

东亚样式在三处设置中都沿用CSS Counter Styles中的名称。中文小写数字在10到19之间写作十,前面不加一(十二,但一百一十);数中连续的零只写一个零(一百零一、一千零五十);万位用万或萬,亿位用亿或億(一万零一十)。只有cjk-decimal使用〇,逐位书写,与年份的写法相同(二〇二六,GB/T 15835—2011)。天干到10为止,地支到12,带圈数字到50;超出后以阿拉伯数字输出。一和壹跟随文档locale的字形:zh-Hant、zh-TW或zh-HK为繁体,其余为简体。有些较早的出版物把101写作一百一,不带零;Postext不输出这种形式。

其他写法是为不经类型检查的配置准备的,即JSON预设和纯JavaScript。TypeScript类型仍然只列出每处设置自己的写法,也就是沙盒写入、resolveAllConfig返回的那种(东亚名称也包括在内),所以有类型的PostextConfig要守住这种写法,用别的写法需要类型断言。

// JavaScript或JSON预设(在TypeScript中,用每处设置自己的写法)
orderedLists: { numberFormat: 'decimal' },          // 等同于'arabic'
page: { pageNumbering: { format: 'roman-lower' } }, // 等同于'lower-roman'
resourceTypes: [{ id: 'plate', counterFormat: 'upper-roman', … }], // 等同于'roman-upper'

resolveAllConfig会把列表格式和页码格式转换成各自设置的写法:在orderedLists中'decimal'解析为'arabic',在page.pageNumbering中'roman-lower'解析为'lower-roman';stripConfigDefaults会去掉默认值的任一写法。沙盒的面板无论配置用的是哪种写法,都按三处设置各自的写法显示。其他任何值,如roman、01或拼写错误,都按十进制编号而不会印出undefined,并作为配置警告报告。标题编号模板在冒号后接受同样的名称({1:roman-upper}即{1:I}),此外还有它们自己的补零形式{1:01}。

#版式

layout属性控制内容区域中的分栏方式。

栏、栏间距与页边距一个分为三栏的页面:每一栏是容纳文字的内容区域,栏间距是各栏之间的竖向空隙,页边距是页面边界与第一栏之间的空白边。页面页边距栏栏间距
栏容纳文字,栏间距把它们隔开,页边距框住内容。
页边距体系一个页面,内容区域四周有各自独立的上、右、下、左页边距。页面内容区域上1cm下2cm左2.5cm右1.5cm
页面的每一边都可以有自己的页边距。
属性类型默认值说明
layoutType'single' | 'double' | 'oneAndHalf''double'分栏方式。各类型的详情见下文。
gutterWidthDimension0.75 cm栏与栏之间的水平距离。只适用于多栏版式。
sideColumnPercentnumber33侧栏宽度占内容区域的百分比。只要两栏都还有宽度,任何值都按原样使用;否则会被限制到可用范围,构建时予以报告(见版式类型)。只适用于'oneAndHalf'版式。
sideColumnRole'text' | 'floats''text'侧栏承载什么:正文(主栏排满后流入侧栏),或者只放用span: 'side'放置的资源和标注框,即只放浮动体的边栏。仅限'oneAndHalf'。设为'text'时,从一栏接排到另一栏的段落会按接排那一栏的宽度重新断行;postext 1.4及以前,它保留起始那一栏的行,为主栏排的行会超出侧栏而被裁掉。
sideColumnSide'right' | 'left' | 'outer' | 'inner''right'侧栏位于内容区域的哪一边。页边距镜像时,'outer'/'inner'随页面奇偶变化(右页的外侧是右边,左页的外侧是左边)。仅限'oneAndHalf'。
columnRuleColumnRuleConfig关闭可选的栏线,画在各栏之间。详见下文。
fitFiguresToPagebooleanfalse图(位图或SVG)的图像、题注和注释加起来比内容区域还高时,把它缩小到放得下;行内图比所在栏的剩余空间稍高时,也把它排小一些(最小到原宽度的一半,题注仍保持该栏的行长),让它留在文字旁边。缩小后的图像按placement.align放在自己的位置里。HTML查看器会打开这一项,因为它的页面只有屏幕那么高;印刷页面的尺寸是按其中的图来定的。
hugClosingFloatsbooleantrue在章(以及文档)的最后一页,排在最后一段文字下方的整页宽图和表会上移,按原顺序叠放,紧贴在文字下方一个浮动体间距处:那里已没有后续内容。设为false则保留它们原本的位置,于是position: 'bottom'浮动体在最后一页也像在其他各页一样止于页脚,例如每页轮廓都在同一高度结束的数据手册。带侧栏的页面从不移动它们。
inlineResourceGap'around' | 'above''around'行内资源(placement.position: 'here',用::resource嵌入)在何处保留浮动体间距,即一行。'around'在资源上下都保留,其后的文字在该间距之下回到基线网格;紧跟其后的标题、列表、框或另一个行内资源,与它共用下方的间距和自身上方的空白,取两者中较大的一个。'above'只在上方保留:资源之后的文字从下一条网格线接着排,不管那条线有多近,从零到一行都有可能,与postext 1.4及以前相同。早期版本存储的配置,如果其中的章嵌入了资源,读入时按'above'处理,所以页面不会移动(见postext 1.4或更早版本写出的文件包)。为1.4在代码中编写的配置,可以自己设为'above',或通过postext/bundle中的pinLegacyInlineGap保留旧的间距。
inlineResourceGapInBoxesbooleantrue框(:::callout)内的行内资源是否保留inlineResourceGap设定的间距,即框内文字的一行:资源上方保留,设为'around'时下方也保留,并取它与下一块自身空白中较大的一个。位于框(或拆分后框的某一段)顶部或底部时,由内边距把资源隔开,不再加间距。false把资源紧接在前面的文字之下,把后面的文字紧接在资源之下,与postext 1.4及以前相同。早期版本存储的配置,如果其中的章在框内嵌入了资源,读入时按false处理(见postext 1.4或更早版本写出的文件包);在代码中,postext/bundle的pinLegacyBoxResourceGap起同样作用。
boxChildSplitMinLinesnumber2框拆分时,在段落或列表项内部切开,切口两侧至少各留该段落或列表项的几行(标注框样式下的splitMinLines仍按切口两侧框内所有行来计数)。取整数,至少为1。按默认值,切口不会在栏底或下一栏顶端只留下段落或列表项的孤零零一行;如果框样式的splitMinLines更小,则以它为限。1允许切口在一侧只留段落或列表项的一行,与postext 1.4及以前相同。早期版本存储的配置,如果其中的章含有:::callout,读入时按1处理(见postext 1.4或更早版本写出的文件包);在代码中,postext/bundle的pinLegacyBoxChildCut起同样作用。
writingMode'horizontal-tb' | 'vertical-rl''horizontal-tb'行的走向。'vertical-rl'把中文和日文排成竖排:字符从上到下,每一行位于前一行左边。标题样式的layout如果不自行设置就继承它,所以竖排的书后面可以接一个横排的附录。见竖排。

#竖排

设了writingMode: 'vertical-rl'时,页面的排法相当于把一个横排页面顺时针转四分之一圈。文字流排在一个框架里,框架的宽度等于纸张的高度;框架中的行就是竖排文字的各列,从右往左读,引擎对行所做的一切(断行、两端对齐、浮动体、脚注、不拆分规则)都在这个框架里进行。因此在纸面上:

  • 版式中的一栏就是一栏(tier):layoutType: 'double'得到上下叠放的两栏,从右上方开始填;gutterWidth是两栏之间的距离,栏线则是两栏之间的一条横线。章末各栏不做齐底(clreq §7.1.3.4):竖排文档中各栏齐底默认关闭,除非设置了headings.balancing.enabled。
  • 文字流所说的“顶部”是纸张的右边缘,阅读从这里开始:顶部浮动体位于页面右侧,底部浮动体位于左侧,通栏章首横带是沿右边缘向下的一条带,脚注落在每一栏的左端。锚定到页面顶部的设计元素(标题版面设计、固定框)锚定在右边缘。sideColumnSide为'left'时是上栏,'right'是下栏;'outer'和'inner'分别按'right'和'left'理解。
  • 文字流的页边距是纸张页边距旋转后的结果:右页边距是文字流的顶部,上页边距是它的左侧。page.margins在纸面上保持原来的名称。
  • 书眉、页码、裁切标记和页面背景留在纸面上,横排,与clreq对竖排书籍的描述一致。
  • **图和表保持直立。**图在题注允许的范围内占满所在栏的高度,宽度最多与页面相同;它所占的宽度就是它在文字流中占用的空间。题注横排在图下方,表格的单元格也横排:两者都按横排文字度量。表格直立横贯页面,比栏高时按栏切分行。placement.align把图放在所在栏的顶端('left')、中间或底端。图在竖排文字中首次被引用的位置不会应用placement.rotate:构建时报告rotateIgnoredVertical内容警告。书中横排的部分(layout设为'horizontal-tb'的标题样式)会按要求旋转其中的图。
  • 版面设计中的图片(章首页的插图、篇章页的插图)同样直立。它在文字流中的框按宽高互换来确定尺寸,所以size.width是图片沿栏向下延伸的长度,它在纸面上的宽度则按比例得出。
  • 字符:汉字、假名和全角字符直立,各占一个全角;拉丁单词和数字侧转,保持其横排宽度;标点使用字体的竖排字形。点号从不旋转:中国大陆字体把、。,.放在字格右上角,!?:;放在字格右半部;台湾或香港字体则居中(cjk.region)。括号使用竖排字形,中国大陆文本中的“ ”‘ ’显示为『』「」。破折号、省略号和波浪线在字体有竖排字形时使用它(Noto CJK把—的竖排字形与fwid一起放在vert下:字格中央一条竖线),否则旋转,使墨迹位于栏的中轴上。中文文本中的破折号(——)在栏中是一条连续的线:它的竖排字形会在每个字格两端留空,所以每个破折号随行旋转,并像横排时一样拉伸(见标点宽度)。不超过两位的数字直立占一个字格,除非它位于拉丁语句中,这时跟随该句的单词(见竖排中的数字)。间隔号(·)在中国大陆文本中占半个字格,在台湾和香港文本中占一整个字格。Unicode规定直立的符号(× © ± § ℃ ①之类)单独占一个字格,在数字中间也是如此:3×4是侧转的3和4,中间夹一个直立的×。拉丁单词中两个字母之间的撇号或间隔号(don’t、l·l)留在单词里,一起侧转。竖排文字流中的拉丁段落遵循同样的规则。行内公式、行内标签和色块随行侧转。
  • 每个字符都按绘制的方式度量:直立字符沿行向下前进一个字格,侧转的文字前进其横排宽度。在纸面上保持横排的文字(书眉、页码、题注、表格单元格)按横排度量。
  • 标点宽度、标点悬挂和汉字与拉丁字母之间的空白沿竖行的作用与沿横行相同。字形前的空白在它上方,后的空白在它下方:开明式的“、”占半个字格,」「挤压为一个半字格,行首被挤压的开括号上移半个字格,悬挂的“。”位于所在行底端之下,侧转单词上下各有四分之一个全角的汉字与拉丁字母间空白。:;?!在竖排中无论哪个地区都占一整个字格。
  • 字符网格沿行计字符数,横跨页面计行数:charsPerLine决定一栏有多长,linesPerPage决定一页有多少行,layoutType: 'double'得到两栏,每栏都是整数个字,栏间距是整数个全角。

供读取版面的宿主程序参考:竖排页面带有VDTPage.flow。它的contentArea、栏、块、行、浮动体、脚注区、章首横带和块级设计叠加层都使用文字流坐标;width、height、header和footer使用纸面坐标。flowToPage、pageToFlow、flowRectToPage和pageRectToFlow在两者之间换算,verticalOrientation(char, region)给出一个字符的朝向。flow.centralBaselines按字体族给出版面让直立字符居中所用的轴线:“中”字墨迹的中心。“中”字的长竖贯穿全角框的高度(在Noto Serif和Noto Sans中位于基线上方0.38 em,SC与TC相同),中文、日文和韩文字体都有这个字。每一行都以这条轴线居中:竖行的基线位于行框顶端之下半个行距再加该字体族的中心基线处(横行的基线在行距的0.8处),因此一列字符位于其行距的正中,而在两列之间按整行距画的线正好落在两者中间。竖排的设计文字在其自身的行中按同样方式排。Canvas自行绘制竖排页面;要用字体自带的竖排字形绘制标点,浏览器宿主程序需要为每个字体族加载一次打开了这些字形的孪生字体:loadVerticalAlternates(family, faces),其中faces是该字体族的来源(URL或字节数据)和描述符。孪生字体只在浏览器会对Canvas文字应用该特性时保留(Chrome 140及以后):它用孪生字体和一份不带该特性、按给定字重与样式加载的同一组字体各画一次「(《,墨迹不同时就保留孪生字体。之后对一个已在使用孪生字体的字体族再为其他字体调用(常规体之后的粗体),会把它们加入孪生字体。沙盒对竖排文档的每个字体族都这样做。没有孪生字体时,括号绕其全角框旋转,中国大陆的点号在字格内移到字体竖排字形所在的位置:、。,.移到右上角(与Noto Serif SC的竖排字形相差不超过0.07 em),!?:;右移半个全角并稍稍上移(相差不超过0.02 em)。PDF和HTML排出的是同样的页面:见PDF中的竖排文字,以及HTML输出与Canvas和PDF的区别下的表格。以Noto Serif TC和SC逐字格与HarfBuzz(使用vert的竖排排版)比对,「賈雨村」云云,宜乎?故曰!;:、。“引”‘單’……中的每个字符,在Canvas和PDF中偏差都在0.02 em以内,在HTML中在0.05 em以内(Chrome把旋转后的字形居中于字体上伸部与下伸部的中点,在Noto中比全角框中心高0.05 em)。flow.dashAdvances按字体族给出页面为填满字格而拉伸的每种横线(— – ― ⸺ ⸻ -)的横排前进宽度(以em计),供没有自身字体度量的渲染器使用:HTML按它拉伸横线,Canvas和PDF则按各自的度量拉伸。书眉和页码也可以竖排:见竖排文字元素。

#栏线

在栏间距中画一条细竖线,从视觉上分隔各栏。

属性类型默认值说明
enabledbooleanfalse是否画出栏线。
colorColorValue#cccccc栏线的颜色。
lineWidthDimension0.5 pt栏线的粗细。

在通栏标题(span: 'page')之下,栏线从各栏文字开始的地方开始,也就是标题横带下方,无论横带是默认的章首样式还是自己的版面设计所绘。postext 1.4及以前,栏线从文字块顶端开始,穿过横带。

标题样式可以在其layout中设置自己的栏线(见标题样式),该样式所在部分的页面就画这条栏线。样式没有设置的字段取文档的值,所以只改变栏数的部分保留文档的栏线。postext 1.4及以前,样式的栏线从不绘制:每一页都画文档的栏线。

#版式类型

  • 'single':一栏,占满整个内容宽度。最适合窄页面或以长段落为主的文字。

  • 'double':两个等宽栏。经典的编辑版式,使行长保持在40–50个字符的最佳范围内,读起来舒适。

  • 'oneAndHalf':非对称版式,一个主栏加一个较窄的侧栏。侧栏(由sideColumnPercent控制)适合放边注、小图或辅助内容。取值在25–40%之间效果较好;用于行号或边缘标记的窄栏,约取10–15%。侧栏占内容宽度的sideColumnPercent%,主栏是扣除栏间距后剩下的部分,所以取50%时侧栏比主栏宽一个栏间距。只要两栏都至少保有内容宽度的1%,任何值都按原样排版;会让任一栏更窄的值(0或以下,或者宽到主栏被栏间距吞没)会被限制到能保住两栏的最近值,非数字的值则取默认值33。这时文档的configWarnings会带有{ kind: 'sideColumnPercentClamped', path: 'layout.sideColumnPercent', value, used }。标题样式自己的layout的路径会指明该样式,例如headingStyles[2].layout.sideColumnPercent,并按该样式的页边距来度量。沙盒在其检查面板中列出这些警告;collectConfigWarnings(config)不做排版就返回同样的列表。不是'oneAndHalf'的版式从不读取这个值,也从不报告它。设了sideColumnRole: 'floats'时,正文从不进入侧栏:侧栏成为一条通道,专门容纳用span: 'side'放置的图、表和标注框。侧栏中的图或表,在首次引用它的那一页从通道顶端开始叠放,所以即使正文在更靠下的位置才引用,教材的边栏图也位于该页顶部;通道剩余空间放不下的,等到下一页的通道。侧栏中的框叠放在它所打断的文字旁边;如果通道剩余空间放不下,它就上移到仍能放下的最低位置(底边与通道底边齐平),或者等到下一页。框的围栏之后的文字接到下一页时(栏已排满,或断页规则把那段文字移到后面),框留在围栏处,位于前面文字的旁边;设了sideAtColumnEnd: 'after'的样式则把它放在下一页的通道里,与围栏后文字的第一行平齐,写在所属行之前的行号和边栏标题需要这样。标题版面设计中位于通道内的元素(锚定在外侧页边距的章序号)也会被叠放内容避开(见预留高度)。span: 'page'浮动体和框仍然横跨两栏,带placement.captionSide的栏内浮动体则把题注放在通道中,与图平齐。与镜像页边距和sideColumnSide: 'outer'配合,通道就位于每一页的外侧边缘,即教材的边栏。

#页眉与页脚

header和footer属性控制每一页的页眉和页脚槽位。页眉和页脚渲染在现有的页边距之内:它们不另外占用空间,也不会缩小内容区。

容器框。 锚定到'container'的元素放在正文与裁切边之间的页边带里,宽度等于内容区的宽度。页眉容器从裁切上边一直延伸到正文顶部;页脚容器从正文底部一直延伸到裁切下边。因此,页眉的top-*锚点和页脚的bottom-*锚点从裁切边量起,而页眉的bottom-*锚点和页脚的top-*锚点从正文边缘量起。容器从不包含出血区或裁切标记带,所以无论page.cutLines开还是关,页眉或页脚在裁切后的页面上都落在同一位置。要超出内容区的宽度或伸进出血区,就锚定到'page'(裁切框)或'bleed'。

槽位使用统一的设计槽位模型:每个元素都有一个placement,其中包含一个anchor(锚定到容器,或通过#id锚定到另一个元素)、一个可选的offset和一个可选的size。旧版的平铺字段align、marginFromBody、marginFromEdge和width: 'full'在输入时仍然接受,并会自动迁移成新的结构;对应的新结构写法见下文。

每个槽位保存一组文本、线条和方框元素。数组顺序就是绘制顺序(第一个元素最先绘制,最后一个元素画在最上层)。无论锚点怎么写,这一点都成立:元素可以锚定到排在它后面的元素(anchor.to: '#ttl'),所以背景方框可以排在最前面,同时仍按它衬在其后的文本来定位。

内置默认值。 当header或footer为undefined时,postext会使用一套合理的内置默认设置,而不是留一个空槽位:

  • 默认页眉: 奇数页右对齐显示{title},偶数页左对齐显示{chapterTitle},另加一条通宽线条;全部使用调色板的主色,Open Sans 8pt/600,marginFromBody为16pt(文本)/13pt(线条)。
  • 默认页脚: 每页居中显示{pageNumber},使用调色板的主色,Open Sans 8pt/600,marginFromBody为16pt。

要关闭内置默认值,设置header: { elements: [] }(或footer: { elements: [] })。显式的空elements数组会保留为“没有元素”;只有undefined才会触发默认值。

属性类型默认值说明
elementsHeaderFooterElement[]为undefined时使用内置默认值;[]表示禁用文本元素和线条元素的有序列表。

#文本元素

文本元素渲染一个模板字符串,并替换其中的占位符。占位符使用{name}语法;{{和}}输出字面的花括号。

下表中的默认值是你自己添加的文本元素的默认值。上文内置默认值中介绍的内置页眉和页脚是现成的元素,带有各自的取值(调色板主色的Open Sans 8pt/600),并非元素的默认值。

属性类型默认值说明
kind'text'—类型判别字段。
idstring—稳定的id,在槽位内唯一。其他元素通过anchor.to: '#id'锚定到它。沙盒在创建元素时会自动分配一个。
contentstring''模板字符串。支持下面列出的占位符,另外还支持:写在当前章H1行上的属性(# Title )。缺失的属性解析为空字符串,不发出警告。换行符,或者写在模板或属性值中的两个字符\n,无论overflow取什么值,都会另起一行。占位符从文档复制来的文本(标题、frontmatter字段)按原样输出:在那里只有真正的换行(例如标题中的换行)才会另起一行。
align'left' | 'center' | 'right' | 'justify''center'各行在元素方框内的水平对齐方式。'justify'会拉宽每一段中除最后一行以外所有折行的词间距,让行填满方框;首字下沉旁边的行填满它旁边的空间。开启hyphenate时,放不进两端对齐行剩余空间的词还会在音节处断开来填满这一行。段落的最后一行、没有可拉伸空格的行,以及不折行的文本都齐左排。两端对齐的文本按段落逐段排版,所以段落之间的多个空行算作一个。Canvas和PDF把每个词放在版面给定的位置;HTML则用word-spacing加宽空格。
parity'all' | 'odd' | 'even''all'元素出现在哪些页上(按页码奇偶:第1页为奇数页)。
pages'all' | 'body' | 'opener' | 'part' | 'blank''all'元素出现在哪些页面角色上,与parity组合使用。排版完成后,每一页都会被归为'blank'(为奇偶补的页或分隔用的空白页,或没有内容的页)、'part'(篇的分隔页)、'opener'(第一个块是一个标题,且该级标题通栏或在其前强制分页,即一章的第一页)或'body'(其余所有页)。pages: 'body'在章首页上隐藏书眉;pages: 'opener'只在章首页显示页码。
fontFamilystring'EB Garamond'字体族。
fontSizeDimension8 pt字号。
fontWeightnumber400字重(100–900)。
italicbooleanfalse是否以斜体渲染。
colorColorValue#000000文本颜色。
overflow'wrap' | 'ellipsis-start' | 'ellipsis-middle' | 'ellipsis-end' | 'clip''ellipsis-end'文本超出元素可用宽度时引擎如何处理。'wrap'把一行折成多行;几种省略号模式让每行保持为一行,并在开头、中间或末尾用…截断;'clip'在元素的外框处硬性裁掉,不插入任何字符。内容中的换行在所有模式下都有效:省略号模式和裁切模式各自截断或裁切每一行。省略overflow的元素取'ellipsis-end',所以凡是可能排成多行的内容(地址、长标题)都要设为'wrap'。带dropCap的文本无论这里怎么设都会折行;设为'clip'时,它的行仍会在固定高度方框的边缘被裁掉。'ellipsis-end'和'ellipsis-start'在词的边界处截断,得到The history of…,而不是The history of th…;省略号旁边不会紧挨空格或连接性标点(逗号、冒号、破折号、斜杠、左括号)。只有当在词边界截断(去掉这些标点后)保留的内容不到可容纳内容的一半时,才会在词中间截断:一个很长的词,或一个URL(http://exampl…,而不是http…)。不换行空格或不换行连字符(U+2011,如MS‑DOS)不算词边界;'ellipsis-middle'可以在任意位置截断,但会去掉两侧的空格。postext 1.4及更早版本中,所有模式都在最后一个放得下的字符处截断,空格也算在内。
verticalAlign'top' | 'middle' | 'bottom''middle'文本在高于其各行的方框中的位置:固定的placement.size.height,或被锚定的相邻元素撑高的方框。当各行比方框高时(固定高度方框里的大号数字、较紧的lineHeight),它们会向对齐方式留空的一侧溢出,与CSS flex对齐的做法相同:'bottom'让最后一个行框的底边与方框底边对齐,从顶部溢出;'middle'在两端均匀溢出;'top'从底部溢出。postext 1.4及更早版本中,这样的行无论对齐方式如何都从顶部往下排。dropCap会随所在的行一起移动(postext 1.4及更早版本中,'middle'或'bottom'把行往下移时,首字仍留在方框顶部)。
lineHeightnumber | Dimension1.2元素各行的行距。数字表示fontSize的倍数。也接受Dimension,与配置中其他所有行距的写法一致:em / rem同样表示倍数,绝对长度(pt、mm、px…)表示基线之间的距离,无论字号多大都设为15.5 pt行距。其他取值(零、负数、格式错误的尺寸)一律使用默认值。postext 1.4及更早版本中,在这里写Dimension会让设计的高度无法测量:章首页于是完全不预留空间,连minHeight也不预留,正文会排到标题下面去。
letterSpacingDimension0字距:每个字符(包括空格)之后额外前进的距离,与CSS的letter-spacing完全相同。测得的宽度随之增大,所以自动宽度的方框仍然紧贴文本。一行最后一个字符之后的字距不计入对齐,也不计入自动宽度方框,因此加了字距的居中标题按字母居中,右对齐的标题恰好止于边缘,两端对齐行的最后一个字母也抵到边缘(postext 1.4及更早版本中,它们会向左偏半个或一个字距单位,锚定在加字距元素右侧的元素也会多隔开一个字距单位)。负值会收紧字母,36 pt的展示标题常用{ value: -0.3, unit: 'pt' },宽度也同样缩小;Canvas、HTML和PDF的绘制结果一致(postext 1.4及更早版本中,负值会被当作0,且不发出警告)。
textTransform'none' | 'uppercase''none'对解析后的文本(包括占位符的值)应用的大小写转换,例如目录中以大写字母排的篇名。
boxElementBoxStyle—可选的背景和边框,画在文本后面:backgroundColor、borderColor、borderWidth、borderRadius,以及按边设置、让方框超出文本范围的padding(字段说明见方框元素)。
dropCap{ lines, fontFamily, fontWeight, fontSize, color, gap }—首字下沉:第一个字母放大,排在前lines行(默认2行)旁边,使用自己的字体、字重和颜色,与文本相隔gap。这个字母立在它所跨最后一行的基线上,fontSize默认取让它的顶部与第一行大写字母顶部齐平的字号:文本字号加上lines − 1个行距,大写字母高度按字号的0.72计算(大写字母明显高于或低于这个比例的字体,应单独设置fontSize)。带首字下沉的文本无论overflow怎么设都会折行;设为'clip'时,它的行仍会在固定高度方框的边缘被裁掉。与调色板关联的color像设计的其他部分一样随篇和节的调色板变化。在标题设计中,这个字母不在文本下方预留空间:它的行框中位于基线以下的部分不会把正文往下推。基线以下还有笔画的字母(许多字体中的Q或J)因此可能伸进设计下方的空间:给标题设一个marginBottom来容纳它。postext 1.4及更早版本中,默认字号让这个字母与它所跨的所有行框一样高,所以它的顶部高出第一行;overflow不是'wrap'时,这个字母会被丢掉且不发出警告;节或篇的调色板不改变它的颜色;与旁边文本一样深的字母还可能把正文推低一条网格线。早先保存的配置保留1.4的字号,并以fontSize的形式写明(见postext 1.4及更早版本写出的文件包)。
paragraphIndentDimension0除第一段外每一段的首行缩进。内容中的换行符,或者来自属性值的文本中的两个字符\n,用来分隔段落;连续的换行符算作一个。
hyphenatebooleanfalse为true且文本折行(overflow: 'wrap',或带dropCap,后者总是折行)时,经过常规断行后仍然溢出的长词会在音节边界处拆开(使用文档当前的断词语言),并在断开处加软连字符。在两端对齐的文本(align: 'justify')中,放不进一行剩余空间的词还会在最后一个放得下的音节断点处断开,以填满这一行。
inlineMarksbooleanfalse把解析后的文本(包括占位符的值)当作行内Markdown读取:bold、italic、^superscript^、~subscript~。关闭时,这些标记按原样输出。见行内标记与描边。
stroke{ width, color?, hollow? }—围绕字母绘制的描边:width(一个Dimension,以字形边缘为中心)、color(默认取文本颜色;首字下沉取它自己的颜色)和hollow(true表示只画描边)。见行内标记与描边。
writingMode'horizontal-tb' | 'vertical-rl''horizontal-tb''vertical-rl'把文本从上到下排,行从右到左,字符直立:沿切口竖排的书眉,横排章节旁边的竖排标题。见竖排文本元素。
reservebooleantrue仅用于标题设计:该元素是否计入标题在文本流中预留的高度。对可以压在文本下面的装饰(页脚处的印章、边框、侧边色带)设为false。见预留高度。页眉、页脚和篇的设计忽略此项。
marginFromBodyDimension6 pt元素朝向正文的边与正文边缘之间的绝对距离。与其他元素无关。迁移为placement.offset.y。
marginFromEdgeDimension0 pt距所对齐的内容边缘的水平缩进。只在align为'left'或'right'时生效。迁移为placement.offset.x。
placementElementPlacement由align + marginFromBody + marginFromEdge推导高级定位(见下文)。设置后优先于旧版平铺字段。

可用的占位符:

  • {pageNumber}:当前页从1开始的页码。
  • {totalPages}:文档的总页数。在逐章排版的书中(沙盒、buildBundle),每一章都是一个文档,所以这是该章自己的页数。
  • {bookTotalPages}:整本书的总页数:所有章,包括空白页。对单独排版的文档,它等于{totalPages}。见下文全书页数。
  • {title}、{subtitle}、{author}、{publishDate}:从content.metadata读取的值。未知或为空的元数据渲染为空字符串(并在沙盒中发出警告)。
  • {chapterTitle}:当前页或之前最近一个H1的文本。标题样式设了runningChapter: false的H1(插图页、地图)会被跳过。
  • {chapterTitleAtTop}、{chapterNumberAtTop}:页面顶部所属章的标题和编号;在新章从页面中部其他文本之后开始的页上,它们与{chapterTitle}和{chapterNumber}不同。见下文页面顶部所属的章。
  • {partTitle}、{partNumber}:当前篇的标题和编号(当前页或之前最近的:::part页;紧挨在篇页之前为奇偶补的空白页已属于该篇)。第一篇之前为空。
  • {firstMark.<key>}、{lastMark.<key>}:页面的第一个和最后一个检索词:某一级标题(h1–h6),或某个段落样式的词条。见下文检索词:首标记与尾标记。

全书页数

{bookTotalPages}输出整本书的页数,也就是读者在“第12页,共348页”里看到的那个数。它和{totalPages}一样统计实际页数,包括空白页,但覆盖所有章:

  • 单独排版的文档(不带continuation的buildDocument)就是整本书:{bookTotalPages}等于{totalPages}。
  • **buildBundle**先排整本书,把所有章的页数加起来,再用这个总数重新排一遍,所以每一章输出的数字都相同。这个数从不改变分页位置,所以多排一轮就能定下来;不输出{bookTotalPages}的配置不会多花任何代价。
  • 沙盒在所有章的页数都已知之后,把总数交给每一章。在此之前,一章输出的是到它自己结尾为止的页数。PDF标签页的整书导出统计它排出的页数:如果某一章因为当时页数尚未确定而输出了别的数字,它会用各章最终合计的页数把书再排一遍。
  • 自行逐章排版的宿主程序把总数作为continuation.bookPageCount传入(第一章也要传)。不传时,{bookTotalPages}统计到文档结尾为止的页数(continuation.pageIndexOffset加上自身的页数),这只对最后一章是正确的。
footer: {
  elements: [{
    kind: 'text', id: 'folio', content: '{pageNumber} / {bookTotalPages}',
    fontSize: { value: 8, unit: 'pt' },
    placement: { anchor: { to: 'container', edge: 'top' }, size: { width: 'auto', height: 'auto' } },
  }],
}

configUsesPlaceholder(config, 'bookTotalPages')告诉宿主程序是否值得计算这个总数。

检索词:首标记与尾标记

词典在书眉里印出每页的第一个和最后一个词目(“Aback – Anchor”);参考书印出第一个和最后一个小节。{firstMark.<key>}和{lastMark.<key>}用来输出它们。键名指定用什么来标记页面:

  • h1到h6:该级的标题。标记是标题的文本,不含编号。
  • 段落样式id(entry)::::paragraphs{style="entry"}容器中的段落。标记是段落开头的粗体部分,即词目,去掉末尾的标点:**Aback.** Said of…的标记是Aback。不以粗体文本开头的段落不产生标记。

{firstMark.<key>}是在本页开始的第一个标记,{lastMark.<key>}是最后一个。没有任何标记在其上开始的页(一个很长的词条接排过来),两者都输出当时生效的标记,即它之前的最后一个。第一个标记之前的页不输出任何内容;在逐章排版的书中,这指的是该章的第一个标记,因为标记不会从一章带到下一章。跨页拆开的标题或段落只标记它开始的那一页。键名写在点号之后,由字母、数字、_和-组成,以字母或_开头;未知的键名不输出任何内容。

:::paragraphs{style="entry"}
**Aback.** Said of the sails when pressed back against the mast.
 
**Abaft.** Towards the stern, or behind a given point.
:::
header: {
  elements: [{
    kind: 'text', id: 'guide', content: '{firstMark.entry} – {lastMark.entry}',
    fontSize: { value: 8, unit: 'pt' },
    placement: { anchor: { to: 'container', edge: 'bottom' }, size: { width: 'auto', height: 'auto' } },
  }],
}

检索词属于书眉:它们在页眉和页脚槽位中解析(包括标题样式的header和footer),在标题、篇和目录的设计中不输出任何内容;在标题和篇的设计中,检查面板会把它们标为未知占位符。要在左页显示第一个词目、右页显示最后一个,就用两个元素,分别设parity: 'even'和parity: 'odd'。标题样式设了runningChapter: false的H1不产生h1标记。

页面顶部所属的章

{chapterTitle}和{chapterNumber}指向本页或之前最后开始的一章。在各章接排、章与章之间不分页的书中,如果一页结束了一章,又在靠近页脚处开始下一章,这一页就会在仍属于旧章的文本上方印出新章的标题。{chapterTitleAtTop}和{chapterNumberAtTop}改为指向页面顶部所属的章,章节接排的小说和许多参考书都是这样做的:

  • 第一个块是某章H1的页,指向该章;
  • 其他页指向从上一页接排过来的章,即使有新章在页面下方开始;
  • 为奇偶补的空白页归属于它后面的页,always-*模式的分隔页归属于它前面的页(见空白页的归属);如果奇偶补页后面那一页以一章的结尾开头,而不是以它的H1开头,这张空白页也归属于那一章;
  • 带runningChapter: false的H1会被跳过。
header: {
  elements: [{
    kind: 'text', id: 'chapter', content: '{chapterTitleAtTop}', parity: 'odd', pages: 'body',
    fontSize: { value: 8, unit: 'pt' },
    placement: { anchor: { to: 'container', edge: 'bottom-right' }, size: { width: 'auto', height: 'auto' } },
  }],
}

和检索词一样,这两个占位符都属于书眉:它们在页眉和页脚槽位中解析,在标题、篇和目录的设计中不输出任何内容。{attr.<key>}始终读取最后开始的一章。

由锚点边隐含的对齐方式

当文本元素的placement.anchor.to通过#id引用另一个元素时,锚点边会为折行后的各行隐含一个默认的文本对齐方式:

  • right-of和align-left隐含文本align: 'left':折行从锚点向右排。
  • left-of和align-right隐含align: 'right':折行贴向离锚定目标最近的一侧。

沙盒的标题编辑器在你更改锚点边或目标时会自动应用这些隐含的对齐方式。它们让折成多行的文本在视觉上始终锚定在相关的元素上(例如折行后“Postext”的“P”与“Introduction”的“I”上下对齐)。

行内标记与描边

文本元素默认用一种字体排出全部文本:**、^及其他Markdown标记按原样输出。设inlineMarks: true后,解析后的文本会被当作行内Markdown读取,支持与正文相同的标记(见文档格式 → 行内格式):

  • **bold**设为字重700(元素自身的fontWeight更重时取后者);*italic*翻转元素的倾斜状态,所以斜体元素中的强调会变成正体;***both***两者兼有。下划线形式(__bold__、_italic_)也可以用。
  • ^superscript^和~subscript~以58%的字号排出,上标升高字号的三分之一,下标降低字号的0.15;紧挨在一起的下标和上标(T~0~^2^)会上下叠排,与正文相同。
  • 反斜杠让标记字符按字面输出(\*、\_、\^、\~)。链接保留其文本;代码的反引号会被去掉。

标记在占位符填入之后读取,所以值本身可以带标记:标题属性中的作者行可以把单位编号排成上标。折行、两端对齐、省略号模式、dropCap和paragraphIndent都适用于带标记的文本;每一段文字都保持元素的颜色。

{ "kind": "text", "id": "authors", "content": "{attr.authors}", "inlineMarks": true, "overflow": "wrap",
  "fontSize": { "value": 11, "unit": "pt" },
  "placement": { "anchor": { "to": "#title", "edge": "below" }, "offset": { "y": { "value": 6, "unit": "pt" } } } }
# Snow cover and river flow {authors="Ana Ruiz^1^, Luis Gil^2^ and Marta Sanz^1,3^"}

stroke围绕字母绘制描边:width是线宽,以字形边缘为中心(一半落在字母内侧,一半在外侧;测得的文本宽度不变),color默认取文本颜色,hollow: true让字母不填充,只显示描边,适合空心的展示数字,或需要从照片上突显出来的标题。描边画在填充之上,在Canvas、HTML(-webkit-text-stroke)和PDF(文本渲染模式2,空心时为1)中的做法相同。

{ "kind": "text", "id": "year", "content": "1863", "fontFamily": "Bitter", "fontSize": { "value": 120, "unit": "pt" }, "fontWeight": 700,
  "color": { "hex": "#1d3557", "model": "hex" },
  "stroke": { "width": { "value": 1.5, "unit": "pt" }, "hollow": true },
  "placement": { "anchor": { "to": "page", "edge": "bottom-right" }, "offset": { "x": { "value": -15, "unit": "mm" }, "y": { "value": -20, "unit": "mm" } } } }

在PDF中,粗体和斜体部分会嵌入元素字体族中对应的字面,所以字体提供程序必须提供这些字面。

文本默认值与锚定陷阱

你自己写的文本元素从以下取值开始,其中有些会出乎意料:

  • overflow为'ellipsis-end'。 对所在空间来说太宽的文本会被截成一行并加上…。地址、署名或任何可能较长的标题,都要设overflow: 'wrap'。内容中的换行(换行符,或模板、属性值中的\n)在所有模式下都会另起一行;省略号模式各自截断每一行。
  • align为'center',verticalAlign为'middle'。 自动宽度的元素收缩到最长的一行,所以各行彼此居中;要排成齐左的一块,设align: 'left'(锚定到另一个元素的自动宽度元素会自行向锚点一侧对齐各行,见上文)。
  • 字体为EB Garamond 8 pt、黑色、lineHeight 1.2,与正文用什么字体无关。与配置中的其他行距不同,设计文本的lineHeight通常写成一个简单的倍数(1.2);Dimension也可以(见上文)。

没有placement.size.width(或为'auto')的元素按文本确定自身大小,但只能在锚点与它延伸方向上的容器边缘之间的空间内伸展;对top或bottom锚点,这个空间是到较近边缘距离的两倍。offset也计算在内。背离那条边的偏移不占空间:锚定在top-left、x为负值(悬挂到左页边距中)的书眉不损失空间,因为它向右延伸。朝向那条边的偏移会让空间缩小同样的量(对top或bottom锚点缩小两倍),而把锚点推过边缘的偏移则让空间归零:x小于容器宽度负值的top-right锚点,或横向移动超过容器宽度一半的top锚点。空间归零时,省略号模式什么也不输出,'wrap'则每行只排一个字符。有三种解决办法:

  • 给元素一个固定的size.width:固定宽度从不受限制;
  • 把它锚定到'page'或'bleed',让页面(或出血区)框定它的空间;
  • 把它锚定到容器的另一侧边缘。

页眉的容器从裁切上边一直延伸到正文,页脚的容器从正文一直延伸到裁切下边:在页眉中,top-*锚点从裁切边量起,bottom-*锚点从正文量起,页脚中则相反(见上文容器框)。页眉或页脚从不移动正文,并且画在正文之上,所以被推进正文区域的元素会盖住文本。相比之下,章首页的色带画在正文之下;它在文本流中占多少空间见预留高度。

#竖排文本元素

设了writingMode: 'vertical-rl'的文本元素在文本为横排的槽位中竖排:任何书的书眉和页码(它们始终在书页上)以及横排页面的所有设计都是如此。它的排法是:在自身的框里按横排文本排好(这个框顺时针转了四分之一圈),再转回到页面上:

  • 它的方框留在定位所给的位置。方框的高度就是一行的长度:由size.height设定(或'auto',即文本的长度;'fill',一直延伸到容器边缘),size.width决定横向能排几行,size.maxWidth限制一行的最大长度。
  • align沿方框方向放置各行('left'在顶部),verticalAlign在横向放置('top'在右侧,即第一行所在的位置),方框的内边距留在它所写明的那一侧。
  • 字符的测量和绘制与竖排页面相同:汉字直立,每字占一个全角,标点用竖排字形,拉丁词侧转,短数字排在一格内(cjk.uprightDigits)。Canvas、PDF和HTML把它放在同一个矩形中。
  • 设inlineMarks: true时,方向标记像在正文中一样把一段文字单独处理:第:tcy[3.0]回把3.0排在一格内,:upright[GDP]把字母逐个直立上下排列,:sideways[…]把一段文字侧转;这些片段内部从不断行。横排元素忽略这些标记。
  • 竖排元素不排首字下沉。
  • 在竖排页面的文本流中(章首页、篇页、方框标题),文本本来就是竖排的,writingMode在那里不起作用。

竖排中文书把书眉和页码放在以下三种位置之一(clreq §7.2;日文见JLREQ §2.6):

惯例位置设置方法
横排页眉和页脚版心上方和下方,与横排书相同;最常见。保持页眉和页脚原样。
切口(中缝式,台湾称邊峰)沿外侧页边距竖排:章名或书名从版心顶端往下约四个字处开始,页码止于版心底端往上约五个字处,用中文数字,字号约为正文的80 %。两个锚定到'outer'的竖排元素,见下文。
外侧下角页码在页面底部的外侧角上(台湾的中式书规范)。在右翻书中,页脚里放一个'bottom-left'、设parity: 'odd'的横排元素,和一个'bottom-right'、设parity: 'even'的横排元素(左翻书则相反)。

下面是沙盒添加的切口书眉(页眉 › 切口书眉(竖排)),这里按10 pt正文设置(沙盒把它们设为正文字号的80 %):

{
  "page": { "pageNumbering": { "format": "trad-chinese-informal" } },
  "header": { "elements": [
    { "kind": "text", "id": "head", "content": "{chapterTitle}", "writingMode": "vertical-rl",
      "fontSize": { "value": 8, "unit": "pt" }, "overflow": "clip", "align": "left",
      "placement": { "anchor": { "to": "outer", "edge": "top" }, "offset": { "y": { "value": 4, "unit": "em" } } } },
    { "kind": "text", "id": "folio", "content": "{pageNumber}", "writingMode": "vertical-rl",
      "fontSize": { "value": 8, "unit": "pt" }, "overflow": "clip", "align": "left",
      "placement": { "anchor": { "to": "outer", "edge": "bottom" }, "offset": { "y": { "value": -5, "unit": "em" } } } }
  ] }
}

anchor.to: 'outer'(见元素定位)是每一页的外侧页边距,所以在右翻书(page.binding)中,这两个元素沿右页的左边缘和左页的右边缘竖排。{pageNumber}按页码格式输出:trad-chinese-informal在第103页给出一百零三,cjk-decimal给出一〇三。各槽位的规则照样适用:pages: 'body'让书眉不出现在章首页上。书眉和页码之间的短装饰(鱼尾︻、一条线)是锚定到同一框的普通元素。

在VDT中,竖排块带有vertical(VDTDesignTextBlock.vertical:区域、直立数字和每个字体族的中轴);它的各行位于块自身旋转后的框中,xOffset从方框顶部向下量,baselineY从方框右边缘向左量。带标记的行中的一段文字带有tcy或orientation,与正文的片段相同。

#线条元素

线条元素渲染一条线:横贯槽位,或纵贯槽位。

属性类型默认值说明
kind'rule'—类型判别字段。
idstring—稳定的id,在槽位内唯一,供anchor.to: '#id'引用。
direction'horizontal' | 'vertical''horizontal'横线沿placement.size.width延伸('fill' = 到容器边缘),高度为thickness。竖线沿placement.size.height延伸('fill'或未设置 = 到容器边缘),宽度为thickness,例如书眉与页码之间的分隔线。
colorColorValue#000000线条颜色。
thicknessDimension0.5 pt线条粗细。省略时按默认值绘制(postext 1.4及更早版本中什么也不画)。
widthDimension | 'full''full''full'横跨内容区;Dimension把线条限制为固定长度,位置由align决定。
align'left' | 'center' | 'right''center'width不为'full'时的对齐方式。
marginFromBodyDimension6 pt线条朝向正文的边与正文边缘之间的绝对距离。与其他元素无关。
marginFromEdgeDimension0 pt距所对齐的内容边缘的水平缩进。只在width为固定Dimension且align为'left'或'right'时生效。
parity'all' | 'odd' | 'even''all'线条出现在哪些页上。
pages'all' | 'body' | 'opener' | 'part' | 'blank''all'线条出现在哪些页面角色上(见文本元素的pages字段)。
reservebooleantrue仅用于标题设计:该线条是否计入标题在文本流中预留的高度(见预留高度)。
placementElementPlacement由align + marginFromBody + marginFromEdge推导高级定位(见元素定位)。size.width / size.height设定线条长度;width: 'fill'即旧版的'full'。

#方框元素

方框元素在槽位内绘制一个圆角矩形,可用作章首页、侧栏或页脚中文本的背景。方框元素只通过placement字段定位,没有旧版的平铺简写。填充、描边和圆角半径放在嵌套的style对象(ElementBoxStyle)中,如“元素定位”下的JSON示例所示。

属性类型默认值说明
kind'box'—类型判别字段。
idstring—稳定的id,在槽位内唯一。同级元素通过anchor.to: '#id'锚定到它。沙盒在创建元素时会自动分配一个。
style.backgroundColorColorValuetransparent填充颜色。设为transparent得到只有轮廓的方框。
style.borderColorColorValuetransparent描边颜色。
style.borderWidthDimension0 pt描边宽度。描边画在方框外接矩形的内侧,所以外部尺寸保持不变:描边的外缘沿方框边缘走,圆角方框保持其外圆角半径。与方框一样宽的描边会把方框填满。Canvas、HTML和PDF的画法一致(postext 1.4及更早版本中,Canvas和PDF把描边以边缘为中心绘制,有一半落在方框外)。
style.borderRadiusDimension0 pt圆角半径。渲染时限制为较短边的一半。
placementElementPlacement—必填。见下文“元素定位”。
parity'all' | 'odd' | 'even''all'方框出现在哪些页上。
pages'all' | 'body' | 'opener' | 'part' | 'blank''all'方框出现在哪些页面角色上(见文本元素的pages字段)。
reservebooleantrue仅用于标题设计:该方框是否计入标题在文本流中预留的高度(见预留高度)。

#图像元素

image元素绘制文档中的一个位图或SVG资源,例如扉页上的出版社标志,或书眉中的一个标记。它的大小由placement.size决定:width / height中有一边保留为'auto'(默认值)时,另一边按图像的宽高比确定;两边都设定时,图像在方框内等比缩放并居中。缺失的资源或非图像资源不绘制任何内容。

{
  kind: 'image', id: 'logo', resourceId: 'logo-publisher',
  placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: { value: 64, unit: 'mm' }, y: { value: 233, unit: 'mm' } }, size: { width: { value: 83, unit: 'mm' }, height: 'auto' } },
}

resourceId接受文本元素content所接受的占位符,所以同一个设计可以为每个标题绘制不同的图片。在标题样式中设resourceId: '{attr.vignette}'后,# Chapter I {style="opener" vignette="log"}绘制资源log,# Chapter II {style="opener" vignette="wig"}绘制wig:各章共用同一个样式,不必每张图片克隆一个。页眉或页脚读取本页所属章的属性,与书眉中的{attr.<key>}相同;其他占位符同样可用('map-{chapterNumber}')。结果为空的id,例如没有该属性的标题,不绘制任何内容。自postext 1.8起提供。

属性类型默认值说明
idstring—稳定的标识符;其他元素可以通过#id锚定到它。
resourceIdstring—文档中某个位图或SVG Resource的id。可以包含占位符,其中包括{attr.<key>},按每个标题、篇或页面填入。
decorativebooleanfalse图片只起装饰作用(花饰、色带):即使它的资源带有替代文本,也不向输出提供替代文本(见下文)。
placementElementPlacement—锚点、偏移和尺寸(见元素定位)。设为'fill'的一边延伸到容器边缘。
parity、pages同上'all'图像出现在哪些页上。
reservebooleantrue仅用于标题设计:该图像是否计入标题在文本流中预留的高度(见预留高度)。

PDF后端像嵌入图一样嵌入该资源(带印刷母版的SVG会使用母版);HTML查看器通过resourceImageUrl解析它。

设计所绘制的图片,在其资源对它有描述时属于内容:资源的altText,没有时取其题注的纯文本(行内标签按其标签文字读,:ref有text时按text读),会写入VDT(VDTDesignImageBlock.altText),在HTML中成为其<img>的alt,在带标签的PDF中成为带/Alt的Figure,紧接在所属设计的文本之后读出(章的插图页紧接在该章标题之后)。资源两者都没有的图片,以及标为decorative的图片,属于装饰:HTML中为alt=""加role="presentation",PDF中为artifact。书眉或页脚中的图片每页重复出现,所以无论资源怎么描述都属于版面附属物:VDT中没有altText,HTML中为alt=""加role="presentation",PDF中为页面的artifact。

#元素定位

ElementPlacement是统一的定位模型,任何设计槽位(页眉、页脚或标题级的高级设计槽位)中的每种元素类型(文本、线条、方框)都使用它。一个定位由三部分状态描述:

interface ElementPlacement {
  /** 该元素锚定到什么,以及锚定到目标的哪条边。 */
  anchor: {
    to: 'container' | 'page' | 'bleed' | 'outer' | `#${string}`; // container = 槽位;page = 裁切框;bleed = 裁切框 + 出血;outer = 外侧页边距(页眉、页脚);#id = 另一个元素
    edge: AnchorEdge;
  };
  /** 距锚点的距离。 */
  offset?: { x?: Dimension; y?: Dimension };
  /** 可选的固定宽度 / 高度。宽度还接受 'fill'(横跨槽位)。
   *  `maxWidth` 限制 'auto' 宽度(文本)的上限:元素仍收缩贴合其内容,
   *  所以锚定到它的元素保持相连,但长文本会在此处折行或加省略号,
   *  这样书眉可以为挂在它旁边的标签预留空间,而不会把标签挤掉。 */
  size?: { width?: Dimension | 'fill' | 'auto'; height?: Dimension | 'fill' | 'auto'; maxWidth?: Dimension };
}

AnchorEdge接受:

  • 容器边(anchor.to为'container'、'page'或'bleed'时):top、top-left、top-right、bottom、bottom-left、bottom-right、left、right。
  • 相对元素的边(anchor.to === '#someId'时):right-of、left-of、below、above、align-top、align-bottom、align-left、align-right。

每种相对元素的边都把本元素的一个角放在所锚定元素的一个角上,再由offset从那里移动:

  • right-of:本元素左上角放在目标右上角(在目标旁边,顶部齐平);left-of:本元素右上角放在目标左上角;
  • below:本元素左上角放在目标左下角(在目标下方,左边齐平);above:本元素左下角放在目标左上角;
  • align-top和align-left:本元素左上角放在目标左上角。这两个名字给出相同的定位:顶边和左边都对齐;
  • align-bottom:本元素左下角放在目标左下角;
  • align-right:本元素右上角放在目标右上角。

相对元素的边与'container'、'page'或'bleed'一起使用,或容器边与'#id'一起使用时,都按左上角处理。

anchor.to: 'page'把元素锚定到裁切框(裁切后的实际页面),'bleed'则锚定到四边各扩出cutLines.bleed的裁切框(关闭裁切线时与裁切框相同)。这两个框也成为size: 'fill'和自动宽度限制的参照,所以色带可以不受页边距影响,从一边延伸到另一边:

{ "kind": "box", "id": "band", "placement": { "anchor": { "to": "bleed", "edge": "top-left" }, "size": { "width": "fill", "height": { "value": 6, "unit": "cm" } } }, "style": { "backgroundColor": { "hex": "#1d3557", "model": "hex" } } }

开启裁切线时,元素画出出血框之外的部分都会被裁掉(见裁切线)。

anchor.to: 'outer'(页眉和页脚槽位)把元素锚定到页面的外侧页边距:横向从版心边缘到远离书脊一侧的裁切边,纵向从版心顶端到底端。在左翻书中,它位于右页的右侧和左页的左侧,右翻书则相反(page.binding),所以一个元素就能同时服务跨页的两页:例如沿切口竖排的书眉(见竖排文本元素)。在其他槽位中,它按'container'处理。文本元素的offset可以用em表示,即它自身fontSize的em:版心顶端往下四个字就是{ "y": { "value": 4, "unit": "em" } }。

在标题的高级设计槽位中,锚定到页面和出血区的元素不会增加为标题预留的高度,除非它们延伸到标题顶边以下(横跨页面顶部的色带衬在章首页后面;延伸到标题以下的色带会把正文往下推)。用advancedDesign.minHeight可以无论如何都预留一个固定的章首页高度,对不应推动文本的元素则设reserve: false。完整规则见预留高度。

每个元素都有一个稳定的id(由沙盒自动分配;你也可以手动设置)。锚定到其他元素的元素构成一个小型依赖图,引擎在测量前解析它,所以一个元素可以接在另一个元素后面定位,无须手动指定坐标。

旧版的align + marginFromBody + marginFromEdge结构在输入时解析,并在解析配置时改写为定位,所以已有的配置无须改动即可继续使用。

#正文

bodyText属性控制所有段落文字的排版。

字号层级从H1到小号正文的字号层级,显示标题、正文和题注的相对大小。H1一级标题32pxH2二级标题24pxH3三级标题20px正文正文文字16px小号题注 / 注释13px
统一的字号层级让层次一眼可辨。
间距层级按级递增的间距值,用于外边距、内边距和间隙。xs4 px×1sm8 px×2md16 px×4lg24 px×6xl40 px×102xl64 px×164 px
逐级的间距在版面中形成可预期的节奏。
属性类型默认值说明
fontFamilystring'EB Garamond'正文字体。可以是任何Google字体、系统字体,或在customFonts中声明的自定义字体。只能写一个字体族,不能写CSS字体栈(见下文)。
fontSizeDimension8 pt正文的基准字号。
lineHeightDimension1.5 em行与行之间的垂直间距。相对单位(em、rem)随字号缩放。
paragraphSpacingbooleanfalse开启后,在相邻段落之间插入一个空行(高度等于lineHeight),即出版物常用的段间分隔方式。
colorColorValue#000000文字颜色。
boldColorColorValue主色(#295AA3)粗体(strong)片段的颜色。按默认调色板中的main-color条目解析,所以修改调色板颜色会重新着色整篇文档里的所有粗体文字。
italicColorColorValue主色(#295AA3)斜体(emphasis)片段的颜色。默认值与boldColor一样链接到调色板。
referenceColorColorValue主色(#295AA3)行内:ref标签(资源引用)的颜色。默认值与boldColor一样链接到调色板。从postext 1.5起它跟随调色板;1.4及更早的版本中,无论主色是什么,它都固定为#295AA3。
referenceBoldbooleantrue用粗体字体渲染行内:ref标签。
referenceItalicbooleanfalse用斜体渲染行内:ref标签。
textAlign'left' | 'justify''justify'文字对齐方式。两端对齐会把间距分配到每一行,使左右边缘整齐。两端对齐段落的末行按自然宽度齐左排出;例外是Knuth-Plass接受了一个依靠胶料收缩的超长末行,这时词间距会压缩,使这一行正好填满行长(TeX的胶料设定语义,Canvas、HTML和PDF三种后端的处理完全一致)。
fontWeightnumber400常规文字的字重(100–900)。
boldFontWeightnumber700粗体文字的字重(100–900)。
hyphenationHyphenationConfig开启,'en-us'自动断词设置。见下文。
firstLineIndentDimension1.5em每段首行的缩进(开启悬挂缩进时,则是除首行外所有行的缩进)。
hangingIndentbooleanfalse开启后,缩进作用于除首行外的所有行(悬挂缩进,又称法式缩进)。
indentAfterHeadingbooleantrue设为false时,紧跟在标题后面的第一段不做首行缩进,这是科技出版物和许多图书样式常用的排版惯例。紧跟在:::space行后面的段落同样如此。夹在两者之间、排在正文之外的框(位于侧栏span: 'side'、浮动到页首或页脚,或固定位置)以及浮动的图都会被跳过:在它所在的栏里,这个段落仍然算作紧跟标题,照样顶格排。排在正文中的框(placement: 'here')则算数,它后面的段落会缩进。开启hangingIndent时此项不起作用。
maxWordSpacingnumber2两端对齐文字中词间距的上限,以正常空格宽度的倍数表示。只要段落允许,Knuth-Plass就让每一行都保持在这个上限之内,先尝试给一个词断词,或把多余的空白分摊到相邻各行;任何断点组合都无法控制在上限内的行会超出上限,超过正常空格3倍的行则改为齐左排出。超过这个比例的行视为“松散”行:见断行器填不满的行;如果想让这些行改用少量字距补足,见maxJustifyTracking。
minWordSpacingnumber0.6两端对齐文字中词间距的下限,以正常空格宽度的倍数表示。
maxJustifyTrackingnumber0当一行两端对齐的文字只靠词间距会拉伸到超过maxWordSpacing,或压缩到低于minWordSpacing时,这一行最多可以使用的字距,单位为千分之一em,可正可负(InDesign的单位:10 = 每个字符0.01 em)。超出上下限的那部分调整量分配到字母之间,于是松散行的词间距回到maxWordSpacing,过紧的行则以minWordSpacing排进行长。Knuth-Plass在选择断点时会把它计入,但只用于单靠词间距会超出上下限的那些行:其他行、段落末行(除非它超长)、只有一个词的行以及含有行内标签的行都不加字距。该值作为letterSpacing记录在行上,Canvas、HTML和PDF都会按它绘制。需要开启optimalLineBreaking。设为0即关闭。见字距作为最后手段。
optimalLineBreakingbooleantrue使用Knuth-Plass最优断行,而不是贪心的逐行填充。整个段落的词间距会更均匀。中文、日文或韩文段落(见东亚排版),以及含有比栏还宽的词的段落,仍然逐行排;只引用了少量CJK词语的拉丁文段落则照常使用最优断行。配合optimalRagged,不齐行文字也使用它。见断词与两端对齐。
optimalRaggedbooleantrue齐左、齐右或居中的连续正文也用Knuth-Plass断行:包括正文、引用块和列表项,以及不齐行的段落样式、框的正文和篇、节样式的正文。词间距保持原宽。断行器衡量每一行比行长短多少(短3 em的行,代价与一行词间距达到maxWordSpacing的两端对齐行相同),因此它会让边缘更均匀,而不是先填满一行再排下一行;孤字规则(avoidRunts、tightenRunts)和hyphenateAcrossColumns对不齐行文字的作用也与两端对齐文字相同。开启hyphenation.ragged时,仍由断词区决定哪些音节可以出现在行尾(见不齐行文字)。不齐行的标题、题注、注释、表格单元格和目录仍然逐行排。需要开启optimalLineBreaking。设为false时,不齐行文字逐行排,与postext 1.4及更早版本相同;早先保存、把部分连续正文设为不齐行的配置读取时采用这个值(见postext 1.4及更早版本写出的文件包)。
breakAfterDashesbooleantrue允许在两个词之间紧排(前后无空格)的长破折号或短破折号之后断行:say—that’s、riddles.—I、Hamburg–Berlin,破折号后面的词换了样式时也一样(see—and)。Knuth-Plass把它当作一个词间空格处理,行在破折号处结束,不添加任何字符。以下情况从不在破折号后断行:破折号引出插入语或一句对话时(—dijo、said "—Hola、sagte »—Ich:破折号前有空格,或有空格加引号;如果前面的引号是收在一个词上的,比如"no"—and、德文„nein“—und或法文« non »—et,则可以断行);破折号后面是标点(él—,);破折号后面是引号或括号(thinking—" and、says—“no”、says—(no):破折号后面的引号往往是在结束被破折号打断的话语);在一串破折号内部;在用短破折号连接的数字范围内(1914–1918)。设为false时保留1.4的断行方式:Knuth-Plass从不在破折号后断行,而带格式或断词的不齐行文字所用的逐行断行器只在两个字母之间断行;早先保存、正文中含有这类破折号的配置读取时采用这个值(见postext 1.4及更早版本写出的文件包)。它作用于连续正文、标题、列表、引用块和框。题注、注释、表格单元格和目录保留1.4的断行方式,而逐行排的普通不齐行段落无论如何都遵循pretext自己的规则。
breakAfterHyphensbooleantrue在Knuth-Plass断行的每个段落里,允许在复合词的连字符(夹在两个字母之间的连字符)之后断行(well- · known、vencer- · se)。行在连字符处结束,不添加任何字符;这个断点的代价与音节断点相同。与数字或符号相邻的连字符之后从不断行(COVID-19、-5 °C)。没有行内格式的两端对齐段落,只有连字符两侧各有两个字母时才在此断行,所以不会有一行结束在e-mail的e-上。设为false时保留1.4的断行方式:没有行内格式的两端对齐段落从不在此断行,而同一个段落只要任意位置有一个斜体词,以及不齐行段落和逐行排的段落,都会在此断行;早先保存、正文中含有复合词的配置读取时采用这个值(见postext 1.4及更早版本写出的文件包)。它作用于连续正文、标题、列表、引用块和框。见复合词。
repeatHyphenbooleanfalse在复合词的连字符处断行后,下一行也以连字符开头:vencer- · -se,这是葡萄牙语拼写的要求;léxico- · -semántico,这是西班牙皇家学院自2010年起的规则。重复的连字符随所在的行一起测量和绘制,行把它记录为repeatedHyphen;行的plainStart和sourceStart指向它之后的位置,因此链接、书眉和沙盒读到的是原样的词。PDF在一个不包含它的/ActualText下绘制它,所以从PDF复制或提取的文字里这个词只出现一次。网址从不添加重复连字符。它作用于连续正文、标题、列表、引用块和框;含有复合词的无格式段落,这时改由处理带格式文字的断行器来断行。
blockquoteBlockquoteConfig见下文Markdown引用块(> …)的排法:颜色、斜体和缩进。见引用块。

引用块

引用块(以>开头的行)沿用正文的字体族、字号、行距、字重、对齐方式和断词设置。其余由bodyText.blockquote设置;不设置时,引用块的样子与postext 1.4及更早版本相同:灰色、斜体,使用正文的首行缩进,没有侧缩进。

属性类型默认值说明
colorColorValue#666666文字颜色。链接到调色板条目(paletteId)的颜色跟随该条目,与其他地方一样;正文的粗体、斜体和引用颜色在引用块内不起作用。
italicbooleantrue用斜体排文字。其中的…片段会变回正体;设为false时,它与普通段落中一样是斜体。
indentDimension0每一行距栏或框左边缘的缩进。行长相应缩短,所以两端对齐的行仍然止于右边缘。em以正文字号为准。
firstLineIndentDimension正文的值每个被引用段落首行的缩进,从indent算起(正文开启hangingIndent时,则是除首行外每一行的缩进)。不设置时取bodyText.firstLineIndent。
bodyText: {
  firstLineIndent: { value: 1.5, unit: 'em' },
  // 正体诗行,使用正文颜色,缩进2 em,无首行缩进。
  blockquote: { color: { hex: '#241f26', model: 'hex' }, italic: false, indent: { value: 2, unit: 'em' }, firstLineIndent: { value: 0, unit: 'em' } },
}

在沙盒中,这些设置位于“正文”部分的引用块分组。

每个fontFamily只写一个字体族

fontFamily,无论是这里还是其他任何字体族字段(headings.fontFamily、tableStyle.bodyFontFamily、separatorFontFamily、行内标签样式或设计元素的fontFamily……),都只指定一个字体族。Canvas、HTML输出和PDF必须使用同一款字体,而PDF对每个字体族只嵌入一款字体,没有回退链,所以CSS字体栈没有可以回退的对象。写成字体栈时,按其中第一个字体族排版,并报告一条配置警告:

bodyText: { fontFamily: "'EB Garamond', Georgia, serif" } // 用EB Garamond排版

排版前要先加载这个字体族(见自定义字体,以及生成PDF中的字体提供器);如果缺少它,浏览器会用默认字体测量,不管字体栈其余部分写了什么。引号内的逗号是名称的一部分('"Foo, Bar"'是一个字体族)。

#断词

文字对齐设为'justify'时,断词在音节边界处拆分长词,避免词间距过大。引擎使用TeX/Liang断词模式在音节边界找到自然的断点。详细说明见断词与两端对齐。不齐行文字只有在你要求时才断词:见下文不齐行文字。

属性类型默认值说明
enabledbooleantrue是否允许断词。
localeLocaleTag顶层locale,否则为'en-us'划分音节边界所依据的语言规则:下面列出的受支持语言之一,或任意BCP 47标签('es-ES'、'pt-BR')。
raggedbooleanfalse在zone范围内,也对不齐行文字(左对齐、右对齐或居中)断词。见不齐行文字。
zoneDimension3em不齐行文字的断词区:一个放不下的词,只有在整词移到下一行会留下比这更宽的空白时才拆分。em相对于该文字自身的字号。两端对齐文字忽略此项。
compoundsbooleantrue允许词典拆分复合词(两个字母之间带连字符的词,如af-ter-dinner)中的各个词。设为false时,这样的词除了自带的连字符外保持完整,行仍然可以在那个连字符处结束(after- · dinner),与TeX的做法相同。词中手动输入的软连字符照样断开,比整行还宽的复合词也照样拆分。它作用于连续正文、标题、列表、引用块和框;题注、注释、表格单元格和目录继续拆分复合词。见复合词。

受支持的语言:'en-us'(英语)、'es'(西班牙语)、'fr'(法语)、'de'(德语)、'it'(意大利语)、'pt'(葡萄牙语)、'ca'(加泰罗尼亚语)、'nl'(荷兰语)。

选择断词模式时,地区、文字和变体子标签都被忽略,大小写和_分隔符也一样:'es-ES'、'es-MX'和'es_419'都用'es'断词,'pt-BR'用'pt',所有英语标签('en'、'en-GB')都用'en-us'。'en-us'是唯一内置的英语模式,所以英式英语文字得到的是美式断点。没有内置模式的语言('sv'、'pl'、'fi'……)用'en-us'模式断词,结果是错误的断点,而不是不断词;引擎对每个标签用console.warn报告一次,沙盒则在“检查”面板中列出。这样的文档请设置enabled: false,警告也随之消失。中文、日文和韩文(zh、ja、ko,不论地区或文字)不需要断词模式,也不打印警告:用这些语言写的文档不断词。如果要拆分其中引用的拉丁文词语,设置enabled: true,并在locale中写明它们的语言(英语写'en-us')。设置enabled: true但没有locale,或locale是中文、日文或韩文时,断词保持关闭,并在控制台提示一次。matchHyphenationLocale(tag)返回一个标签对应的内置语言(没有时返回undefined),HYPHENATION_LOCALES列出所有内置语言。解析后的配置(doc.config.bodyText.hyphenation)在locale中写明实际使用的模式,如果你给的标签与之不同,则保留在tag中。PDF后端根据顶层locale声明文档语言,只有在locale未设置时才采用这个标签(见文档语言)。

import { matchHyphenationLocale } from 'postext';
 
matchHyphenationLocale('es-MX'); // 'es'
matchHyphenationLocale('en-GB'); // 'en-us'
matchHyphenationLocale('sv');    // undefined:用'en-us'断词,并在控制台警告

少于5个字符的词从不断词。引擎要求断点前至少有2个字符,断点后至少有3个字符。

不齐行文字

默认只对两端对齐的文字断词:设置textAlign: 'left'时,以及在左对齐、居中或右对齐的段落样式中,无论边缘多么参差,每个词都保持完整,只有文中手动输入的软连字符(U+00AD)处例外。设置hyphenation.ragged: true即可对不齐行文字也断词。

不齐行的行从不拉伸,所以如果把每个放不下的词都拆开,边缘会挂满连字符。断词区对此加以限制,做法与桌面出版软件的单行排版器相同。一个词在行尾放不下时,引擎看的是把它整个移到下一行会留下多大的空白。如果空白比zone宽,就在能放下的最后一个音节处拆分;否则整词移到下一行。以em表示的断词区相对于该文字自身的字号。默认值3em只拆分那些会留下明显短行的词。断词区越宽,连字符越少,边缘越参差;设为0则拆分每个放不下的词。逐行排时,连续以音节结尾的行不超过两行。硬连字符(enseñanza-aprendizaje)、URL的连接处、文中手动输入的软连字符,以及比整行还宽的词,都照常断开:断词区和两行限制只管词典给出的音节。

这项设置作用于整篇文档。正文和引用块不齐行时适用;每个不齐行的段落样式和标注框正文,只要它自己的hyphenation是开启的(默认取正文的hyphenation.enabled),也都适用,所以设置hyphenation: false可以让某个样式的词保持完整。标题、题注、注释、表格单元格和目录不断词;在这些地方,和其他地方一样,只有比整个行长还宽的词才会拆分。设计文字(书眉、章首页、篇页)遵循各个文字元素自己的hyphenate标志,内置的整页章首页和篇页都设置了它,同样使用文档的词典。两端对齐文字忽略ragged和zone。

const config: PostextConfig = {
  locale: 'es',
  bodyText: {
    textAlign: 'left',
    // 比默认的3 em多断一点词。
    hyphenation: { ragged: true, zone: { value: 2, unit: 'em' } },
  },
};

开启optimalRagged(默认)时,不齐行的连续正文用Knuth-Plass断行,断词区的含义不变:只有当一个词放不进本行余下的空间,而且整词移下去会留下超过断词区的空白时,才拆分它。连续两行以音节结尾并不被禁止,但代价与两端对齐文字中连续两个连字符相同,所以很少会连续出现第三行。设置optimalRagged: false或optimalLineBreaking: false时,先填满一行再排下一行,与postext 1.4及更早版本相同。

不齐行断词用的是带行内格式(粗体、斜体、链接、数学公式)的段落一直使用的那个断行器,所以开启它可能会让连字符以外的少数断点也发生变化。在不齐行的行上,除了空格和词典给出的音节,这个断行器还会在两个词之间的硬连字符或破折号之后断行(largas—separadas;开启breakAfterDashes时,任何在词间紧排的破折号都算,riddles.—I和riddles—*and*也包括在内),在URL的连接处和表意文字之间断行,并把分成几个片段排的一个词保持在一起(**Nota**:、(*véase*)。不开启不齐行断词时,没有格式的不齐行段落在optimalRagged开启(默认)时由Knuth-Plass断行:在词间空格处、在两个字母之间的硬连字符之后(meta- · analyses),这一点与另一个断行器相同;开启breakAfterDashes时,还在紧排的破折号之后。逐行排时,它交给pretext的断行器处理,区别有三点:它还可能在结束插入语的破折号之前或开启插入语的破折号之后断行(él · — y),断词时可能在斜杠之后断行(km/ · h),而对于比整行还宽的词,它会在任意字符处截断且不加连字符,另一个断行器则先尝试在音节处拆分。

在沙盒中,这个开关是不齐行文字断词,位于“正文”部分的段落对齐方式下面,断词区在它下方。正文两端对齐时,同一个开关与两端对齐设置放在一起,供不齐行的段落样式和框使用。

#文档语言

顶层locale是整篇文档的语言。它的取值与hyphenation.locale相同,并在后者未设置时作为回退值,所以一本西班牙语书只需写locale: 'es'就能按西班牙语断词。它还决定表格和拆分标注框内置续接文字的语言((cont.) / Continued与Continúa,见高于页面的表格和拆分框上的标记),决定用文字拼写的标题编号的语言(Chapter One与Capítulo uno,见用文字拼写的编号),也是写入无障碍PDF的语言。未设置时,引擎假定为'en-us';沙盒则回退到界面语言,并在设计 › 书写系统下设置此字段。

它也决定内置的资源类型。未设置resourceTypes时,buildDocument用defaultResourceTypes(locale)编号和生成题注,所以只写locale: 'de'就会得到Abbildung 1.1和Tabelle 1.1。未设置locale时,由断词语言代替它,资源类型和表格文字都是如此。显式给出的resourceTypes列表始终优先。早先的版本中,除非你自己传入defaultResourceTypes(locale),否则无论locale是什么都使用英文类型;设置了locale又想保留英文标签的文档,可以传入resourceTypes: defaultResourceTypes('en')。沙盒中的书和文件包自带类型列表,所以不受影响。

这里与hyphenation.locale一样接受任意BCP 47标签:'de-AT'得到德文文字。内置文字提供断词所支持的八种语言,以及简体和繁体中文;其他语言一律使用英文文字。

中文接受zh、zh-Hans、zh-Hant、zh-CN、zh-SG、zh-TW、zh-HK、zh-MO以及长形式(zh-Hant-TW),不区分大小写,分隔符用-或_均可。文字按书写系统选择,由Intl.Locale(tag).maximize()判断:zh、zh-CN和zh-SG是简体,zh-TW、zh-HK和zh-MO是繁体。依语言而定的排版默认值则按地区选择,这是clreq §1.2的建议:CN、SG和MY算作中国大陆,TW算作台湾,HK和MO算作香港;没有地区的标签按书写系统判断(zh-Hant为台湾,zh和zh-Hans为中国大陆)。localeScript(tag)、cjkRegionOf(tag)、stringsKeyOf(tag)和sameContentLocale(a, b)给出这些判断结果,DOCUMENT_LANGUAGES列出带内置文字的语言,每种语言都用它自己的语言命名,与沙盒“文档语言”下拉框中的显示一致。

语言图:名称、复数、简称表:名称、复数、简称表格续接:continuedSuffix、continuesMarker
英语(en)Figure, Figures, Fig.Table, Tables, Tab.(cont.), Continued
西班牙语(es)Figura, Figuras, Fig.Tabla, Tablas, Tabla(cont.), Continúa
法语(fr)Figure, Figures, Fig.Tableau, Tableaux, Tabl.(suite), À suivre
德语(de)Abbildung, Abbildungen, Abb.Tabelle, Tabellen, Tab.(Forts.), Wird fortgesetzt
意大利语(it)Figura, Figure, Fig.Tabella, Tabelle, Tab.(segue), Continua
葡萄牙语(pt)Figura, Figuras, Fig.Tabela, Tabelas, Tab.(cont.), Continua
加泰罗尼亚语(ca)Figura, Figures, Fig.Taula, Taules, Taula(cont.), Continua
荷兰语(nl)Figuur, Figuren, Fig.Tabel, Tabellen, Tab.(vervolg), Wordt vervolgd
简体中文(zh-Hans、zh、zh-CN)图, 图, 图表, 表, 表(续), 接下页
繁体中文(zh-Hant、zh-TW、zh-HK)圖, 圖, 圖表, 表, 表(續), 接下頁

题注前缀是类型的名称(Figure 1.1.)。中文类型在章内编号,用连字符连接,即{h1}-{n}(图 1-1);配合题注设置labelNumberGap: ''和labelSeparator: ' ',题注显示为“图1-1 标题”(见题注样式)。索引同样跟随语言:简体中,交叉引用前用“见”和“另见”,符号和数字条目上方的标题为“符号”和“数字”;繁体中则为“見”“另見”“符號”和“數字”。

中文、日文或韩文文档会在HTML输出中(.pt-doc根元素上的lang,zh-Hant-TW完整保留)以及它绘制的Canvas上(ctx.lang,Chrome 136及以上版本)声明语言,使浏览器绘制该地区的字形:Unicode统一了汉字编码,同一个码位在台湾字体和日文字体中的样子并不相同。其他文档照旧不带lang。PDF则对每个文档(无论是否带标签)根据locale声明/Lang,包括其书写系统和地区。

const config: PostextConfig = {
  locale: 'es',
  bodyText: { textAlign: 'justify', hyphenation: { enabled: true } }, // 按西班牙语断词
};

#语言与文字

Postext能排从左到右书写的拼音文字,以及横排和竖排的中文。中文排版介绍中文的排法和相关设置;对应的配置键位于东亚排版、竖排和装订。各种文字的支持情况如下:

  • 中文:段落中的CJK字符多于词间空格时,由CJK排版器处理:行在字符之间断开,遵守cjk.lineBreak的避头尾规则(行首不出现。、」或ー,行尾不出现「或(),——和……不拆开,数字连同其符号、拉丁文单词都保持完整;两端对齐的行在字符之间均匀分配空白以填满行长。标点宽度、标点悬挂、汉字与拉丁文之间的间距、字符网格、着重号、专名号和书名号、注音以及割注,都按locale的地区处理,横排(沿行)和竖排(layout.writingMode: 'vertical-rl')都一样。只引用少量CJK词语的拉丁文段落保持最优断行,并可以在这些词语旁边断行;拉丁文中引用的CJK括号、间隔号或全角符号(〈h〉、%)不影响任何处理。
  • 日文和韩文:使用同一个排版器,不断词,但采用中国大陆中文的默认值:它们各自的规范(JLREQ、KLREQ)尚未实现。韩文除了在空格处断行,也在音节之间断行。
  • 从右到左的文字(阿拉伯文、希伯来文、波斯文、乌尔都文……)目前还不支持。没有双向算法:各行按从左到右的方向断行并对齐到左边距,每行显示的效果取决于它的绘制方式。在Canvas和PDF输出中,没有行内格式的左对齐行作为一整段文字绘制,由浏览器或PDF的字体引擎自行转为从右到左,因此全部由同一种从右到左文字组成的行可以正确阅读,但混有不同方向、数字或标点的行则不行。HTML输出,两端对齐、居中和右对齐的行,以及带行内格式的行,都是从左到右逐词绘制的:每个词本身读起来正确,但词的顺序与阅读顺序相反。page.binding: 'right'可以为这类书排出右装订的跨页,但处理不了其中的行。

断词模式内置八种语言(en-us、es、fr、de、it、pt、ca、nl);内置的资源类型和续接文字提供这八种语言以及中文。中文、日文和韩文不断词。其他语言用美式英语模式断词并在控制台警告,使用英文文字。这类文档请设置hyphenation.enabled: false,并用该语言传入resourceTypes和tableStyle的续接文字。

#段首孤行、段末孤行、孤字与不拆分规则

这些缺陷值背后的机制见断词与两端对齐。本节是驱动它们的bodyText配置键的参考。

除了断词和间距上下限,正文配置还提供一组软规则,用来防止在结构上别扭的地方拆开段落。它们全部作为缺陷值(demerits)输入Knuth-Plass断行算法:让版面倾向于干净的断点,但从不强制执行硬性规则。把某个*Penalty值设为0,就相当于关闭对应的规则。

属性类型默认值说明
avoidOrphansbooleantrue避免段落在下一栏顶部只剩不足orphanMinLines行就结束。
orphanMinLinesnumber2段落被拆开时,下一栏顶部至少要有的行数。仅在avoidOrphans为true时起作用。
orphanPenaltynumber1000违反段末孤行(orphan)约束时加上的缺陷值。值越高,算法越强烈地避免段末孤行;0关闭该惩罚。
avoidOrphansInListsbooleantrue为true时,列表项也受段末孤行保护(不只是段落)。仅在avoidOrphans为true时有效。
avoidWidowsbooleantrue避免段落在当前栏底部只有不足widowMinLines行就开始。
widowMinLinesnumber2段落被拆开时,当前栏底部至少要有的行数。仅在avoidWidows为true时起作用。
widowPenaltynumber1000违反段首孤行(widow)约束时加上的缺陷值。0关闭该惩罚。
avoidWidowsInListsbooleantrue为true时,列表项也受段首孤行保护。仅在avoidWidows为true时有效。
avoidRuntsbooleantrue避免段落以很短的末行结束,即孤字,例如单独一个短词。配合optimalRagged,不齐行文字也适用。中文、日文或韩文段落不会以只有一个字符(单独或连同其后的收尾标点)的行结束(孤字):只要上一行在字距上限之内仍能两端对齐,就把最后一个字符让给这一行。
runtMinCharactersnumber20段落末行的阈值,以词间空格而不是字母计数:末行宽度小于runtMinCharacters × normalSpaceWidth像素时即为孤字。在大多数正文字体中,一个词间空格是四分之一到三分之一em,约为半个小写字母宽,所以默认值20捕捉的是短于4到7 em的末行,大约8到12个字母。如果想捕捉短于约N个字母的末行,把它设为2 × N左右。
runtPenaltynumber1000注入Knuth–Plass平方缺陷值公式中的等效劣度(与行的劣度同一尺度,劣度在10000处饱和)。0关闭该惩罚。
gradedRuntPenaltybooleanfalse按末行短了多少来缩放孤字惩罚:宽度为w、低于阈值t的末行,代价是runtPenalty × (1 − w / t),而不是整个惩罚值。这样两个词的结尾比一个词的代价低,当上面某一行能匀出一个词时,断行器会把它拉下来('…sallies of' / 'our minds.',而不是'…sallies of our' / 'minds.')。默认关闭:每个孤字代价相同,断行器保留上面较紧的行。
avoidRuntsInListsbooleantrue为true时,列表项也计入孤字惩罚。仅在avoidRunts为true时有效。
tightenRuntsbooleantrue惩罚无法避免孤字时,改为把段落排短一行:词间距收紧(不低于minWordSpacing),如果单靠这一点还挤不下,再加上少量负字距。以下情况拒绝排短,孤字保留:会把一行两端对齐的文字拉伸到超过maxWordSpacing,或段落中已有更松的两端对齐行时超过那一行;或者会让齐左排出的行(超过正常空格3倍)比原来更多。不齐行文字的词间距保持原宽,所以那里只有字距参与调整。需要开启optimalLineBreaking和avoidRunts(不齐行文字还需要optimalRagged)。
maxRuntTrackingnumber10修正孤字时最多可以使用的字距,单位为千分之一em(InDesign的单位:10 = 每个字符0.01 em),以收紧的方式施加。0表示只靠词间距来修正。
slackWeightnumber10施加在“未用栏空间”平方代价上的权重。值越高,版面越倾向于把栏填满;0完全取消空白压力。
keepColonWithListbooleantrue段落以直接引出列表的冒号结尾时,让带冒号的末行与列表保持在一起:如果放下这个段落后,第一个列表项没有空间在同一栏或同一页开始,就把末行(段落只有一行时则是整个段落)连同列表移到下一栏。多少空间才算够由colonListRoom决定。当这条规则要把整个段落推走,而栏中紧挨在它前面的是一串标题时,这些标题也会一起移走,以便headings.keepWithNext继续成立。
colonListRoom'item' | 'line''item'keepColonWithList要求在冒号行下面留出的空间。'item':列表的段首孤行和段末孤行规则会在栏底留下的第一个列表项的部分,允许在那里拆开时为一行,规则要求保持完整时为整个列表项(比如一个两行的列表项)。'line':一行,与postext 1.4及更早版本相同;这时被那些规则保持完整的第一个列表项会单独移到下一栏,把冒号行留在栏底。在configVersion 6之前保存、且书中有以冒号引出的列表的配置,读取时采用'line'(见postext 1.4及更早版本写出的文件包)。其他任何值都按'item'读取。
hyphenateAcrossColumnsbooleantrue允许一栏或一页以断词结束(InDesign的Hyphenate Across Column)。设为false时,跨越分栏处的段落会重新断行,使它在本栏的最后一行以完整的词结束;差额由上面各行的词间距在maxWordSpacing和minWordSpacing范围内吸收。这是一种偏好:如果在这些范围内找不到能避免断词的断点,连字符就保留。它会尝试段落的每一个分栏点:第一个分栏点,以及之后落在整栏结束处的分栏点,在一次重新断行中处理;之后落在其他位置的分栏点(遵守段首孤行规则、在截齐的分栏带处)则从那一栏开始另做一次重新断行,前面各栏已排好的行保持原有断点,只重排其余部分。框的正文不受影响。开启optimalRagged时,不齐行段落也同样重新断行:它的词间距保持原宽,所以只有行尾移动。每次重新断行都要再测量一遍段落,因此栏尾断词很多的书排版会稍慢一些。需要开启optimalLineBreaking,不齐行文字还需要optimalRagged。
paragraphContainerSpacing'collapse' | 'add''collapse'以段落收尾的:::paragraphs容器下方、即该段落与其后块之间的间距(见:::paragraphs容器)。'collapse':取样式的spaceBetween和marginBottom以及容器周围文字的段间距(开启paragraphSpacing时为一行;在框内则取框的段间距)中的较大者,再与下一个块自己在上方保留的间距合并,与连续正文中两个段落之间的处理相同:参考文献下面的标题与它相距自己的marginTop,而不是这个外边距再加上条目的间距。'add':与postext 1.4及更早版本相同,在网格对齐前只在最后一行下面放样式自身的间距,再加上下一个块自己的上方间距,不计段间距,所以容器之后的段落可能比其他任何段落都更贴近它。在configVersion 8之前保存、声明了段落样式、且书中含有这种容器的配置,读取时采用'add'(见postext 1.4及更早版本写出的文件包)。无论哪种方式,负的marginBottom都会把下一个块往上拉。

**关于孤字。**孤字是指段落的末行太短,不像一行正常的文字,通常是一两个短词孤零零地落在段落末尾。由于判断依据是这一行的像素长度与正常空格宽度之比,runtMinCharacters会自动适应当前字号。视觉宽度超过runtMinCharacters × spaceWidth的短词没有问题;比这窄(或真正单独一个)的词会招来孤字惩罚。阈值以词间空格计数,而词间空格约为字母宽度的一半:默认值20相当于大约8到12个字母的末行。每个孤字都承担完整的惩罚值,所以在两个都低于阈值的结尾之间,断行器会保留上面较紧的行;gradedRuntPenalty则按短缺程度给每个结尾定价,较长的结尾胜出。给对数学感兴趣的读者:在默认的runtPenalty为1000时,避免孤字的优先级高于任何需要大约r≈2.15以内词间距拉伸的其他断点组合。

**软规则,不是硬规则。**这些规则都不能阻止某个断点,引擎总会给出一个版面。它们是缺陷值:算法在一次全局优化中权衡劣度、断词代价、适配等级的平滑度和这些结构性惩罚,选出总代价最低的断点组合。如果需要更硬的保证,就提高惩罚值;如果某篇文档放宽惩罚后读起来更好,就降低它。

#标题

headings属性控制所有标题级别(H1–H6)的排版。你可以先设定适用于所有级别的通用默认值,再按级别覆盖其中的个别属性。

#通用默认值

属性类型默认值说明
fontFamilystring'Open Sans'所有标题的字体。
lineHeightDimension1.2 em标题的行距,比正文更紧。
colorColorValue主色(#295AA3)标题文字颜色。它绑定到默认调色板的main-color条目,换掉调色板里的这个颜色,所有标题都随之改色。
textAlign'left' | 'justify' | 'center' | 'right''left'所有标题级别的对齐方式(没有按级别设置的值):齐左、两端对齐、居中或齐右。两端对齐的标题像段落一样把最后一行排成齐左,所以单行标题看起来与'left'相同。Canvas、HTML和PDF以同样方式放置各行,编号前缀也包括在内。没有高级设计的span: 'page'标题,其默认章首区段也遵循这一设置(两端对齐时排成齐左);高级设计则用各文本元素自己的align对齐。
fontWeightnumber700标题的字重(100–900)。
marginTopDimension1.5 em标题上方的间距。
marginBottomDimension0.5 em标题下方的间距。
keepWithNextbooleantrue为true时,标题绝不会成为一栏或一页的最后一个元素。如果标题之后的空间放不下后续块的至少bodyText.widowMinLines行(bodyText.avoidWidows为false时为一行),标题就被推到后面,与正文保持在一起。它与bodyText.keepColonWithList相互配合:如果那条规则必须把以冒号结尾的段落整段推走,栏末的标题会随它一起移动,不会被孤零零地留下。
keepWithNextSplit'rules' | 'fill''rules'当标题落在栏底、而把其下段落整段推走会把标题留在原处时,该段落如何拆分。'rules':放得下多少行就放多少行,前提是标题下至少留bodyText.widowMinLines行,且至少有bodyText.orphanMinLines行转入下一栏;没有同时满足两者的拆法时,标题随段落一起移走,空出的位置由各栏齐底来填补。'fill':放得下多少行就放多少行,不管转入下一栏的有多少,所以一个四行段落在有三行空间时拆成3 + 1。postext 1.4及以前,所有标题都这样拆分,configVersion 8之前保存的配置也保持这种方式(见postext 1.4及更早版本写出的文件包)。关闭avoidWidows或avoidOrphans时,规则中相应的一侧就不再适用。
snapToGridbooleantrue标题之下的文字流是否重新对齐基线网格。为true时,标题的marginBottom向上取整为整数个网格行;为false时保留精确的间距,标题下的文字可能偏离网格,直到下一个对齐点(列表结束处、:::paragraphs的末尾、行间公式)。许多书在标题下留一行半,就是这种排法。级别(levels[].snapToGrid)或标题样式可以设置自己的值,这里的值是它们继承的默认值。
inlineMarksbooleantrue标题是否像段落一样识别行内标记:italic、bold、^superscript^、~subscript~、:smallcaps[…]和链接。斜体片段会翻转标题的倾斜,所以在斜体标题中它显示为正体;粗体片段采用bodyText.boldFontWeight,若标题自身的字重更重则采用标题字重。目录也会列出粗体和斜体片段;书眉和PDF书签只输出文字。为false时去掉标记,文字按标题自身的样式输出,与postext 1.4及以前一样。没有设计的span: 'page'标题,其默认章首区段也会排出粗体、斜体、上标和下标片段;标题设计(带设计的章首区段或栏内advancedDesign)无论如何都把作为纯文本输出。更早保存、且标题中带有标记的配置按false读取(见postext 1.4及更早版本写出的文件包)。
balancingColumnBalancingConfig启用纵向的各栏齐底:在标题上方加空,使各栏底端与页面底部平齐。见下文。

诗歌或剧本各幕用的居中标题,不需要设计槽位:

headings: {
  textAlign: 'center',
  levels: [{ level: 2, textTransform: 'uppercase' }],
}

传入headings对象时,你没有重新声明的级别默认值都会保留,包括H1的分页(见按级别覆盖)。

#各栏齐底

出版方要求每一栏都从页面顶部开始、在页面底部平齐结束。分页规则(段首孤行和段末孤行保护、标题与正文不分离、不可拆分的图)自然会留下短栏,即栏底空着一个或多个基线网格行。启用齐底后,引擎会像排版工人那样处理,按编辑上的优先顺序使用以下手段:

  1. 收尾的框:结束一个短栏的标注框整体下移,下移量正好等于其底部下方的空余,使它的下边缘落在页面最后一个网格位置上,与相邻栏的最后一行平齐。即使空余不足一行,只要没有内容移入另一栏,它也会占满这段空余。默认情况下这一手段先于其他所有手段执行,并占用整段空余,所以一个注释上方段落的框可能最后落在该段落下方好几行处;closingBox: 'last'让标题、列表结尾和其他间距手段先占用整行,框只占它们剩下的部分;closingBox: 'off'则从不移动它。
  2. 标题:在短栏内各标题的上外边距中增加整数个网格行。需要多行且栏内有多个标题时,这些行分配给各个标题,最重要的标题总是分得最多(h2比h3多)。位于栏顶的标题从不加空,因此各栏始终从页面顶部开始。唯一的例外是:在文字流继续到下一页的页面上,紧接在栏首图或表下方的标题,空间会加在这个标题上方、图的下方。
  3. 列表结尾:标题无法吸收全部空余时,在列表或编号列表结束处增加一个网格行(列表后的空白读起来很自然),每个列表结尾有上限。
  4. 放松段落:作为最后手段,把栏内某一段重新断行,使其多出一行(即TeX的\looseness=+1),并选最长的段落,让增加的词间距分散得看不出来。只有每一行都保持在**bodyText.maxWordSpacing以下**时,放松后的方案才会被采用,文字灰度绝不超出你已配置的限度。需要启用bodyText.optimalLineBreaking。

只有当页面自然延续到下一页时,才会对该页最后一栏做齐底;一章的末页本来就可以短一截。这样的页面,以及由trailing截平的收尾区段,还会让各栏的栏首保持平齐:栏首为图或表时,不会在其下方加行(浮动体后手段);在这样的图下方开栏的标题或框,无论stretchAfterFloats如何设置,都留在栏首。这样就不会为了让栏底对齐而让某一栏比旁边一栏起得更低。

属性类型默认值说明
enabledbooleantrue是否让各栏底部平齐。
maxLinesPerHeadingnumber4单个标题上方最多可增加的网格行数。
stretchAfterListsbooleantrue标题无法吸收全部空余时,允许在列表结束处增加网格行。
maxLinesAfterListnumber1单个列表结尾之后最多增加的网格行数。
stretchAfterFloatsbooleantrue在列表结尾手段之后,允许在短栏栏首的图或表(顶部浮动体)下方增加网格行,让其下的文字下移,而不是让该栏短一截结束。在不延续到下一页的页面(一章的末页)上,以及由trailing截平的收尾区段中,从不这样做:那里各栏栏首保持平齐,最后一栏可以短一行结束。图下方的第一个块可能是前一页开始的段落的余下部分:它同样会下移,下移量是分页规则在栏底留出的那一行(段首孤行(widow)规则留空的一行,或者一个段落间距,其后已放不下文字)。设为false可让文字紧贴在图下方。
maxLinesAfterFloatnumber1单个顶部浮动体下方最多增加的网格行数。
looseParagraphsbooleantrue最后手段:在bodyText.maxWordSpacing范围内,把短栏中的段落重新断行,放松一行(每段多一行)。
maxLooseParagraphsnumber2同一短栏中最多可以有几个段落多排一行,从最长的段落开始。
trackParagraphsbooleantrue单靠词间距无法多出一行时,放松的段落还可以采用恰好能多出这一行的最小正字距。
maxTrackingnumber10该字距的上限,单位是每个字符千分之一em(10 = 0.01 em)。
trailingbooleantrue截平一章和整个文档的收尾区段:当文字流在页面排满之前结束(遇到章首页、:::part、结束一章的placement: 'fixed'框或文档结尾),且各栏长短不齐时,把它们截成一样高。区段上限为ceil(Σ used / N / grid)行,在上述手段处理完前面各页之后才计算。这样,一份较短的参考文献在每一栏都结束于同一高度,而不是排满第一栏、让最后一栏空一半。截平时遵守未截平区段所遵守的规则:如果一个不能跨越截线拆分的块(段首孤行和段末孤行最小行数要求保持完整的段落尾部、不可拆分的框)会越过截线(而渲染器会按栏框裁切,于是丢掉最后几行),或者某个标题会落在栏末而其正文却在下一栏开头(启用headings.keepWithNext时,即默认情况),截线就下移一行,最多三次,否则放弃截平。如果某个块无法在栏首图下方截线留出的行中开始(段落在那里需要满足段末孤行(orphan)最小行数),它会转入该区段的下一栏,与未截平的栏一样,图则单独占一栏。栏底相差不超过一个网格行的各栏保持原样:最后一栏短一行结束是常见的收尾,截平只会挪动一行。截平作用于测量时所在的区段,页中由通栏框要求的截平也一样。postext 1.4及以前,如果末页的第一段文字曾先被分配给一个放不下它的区段(被浮动体整页占据的页面,或通栏框在前一页底部留下的窄区段),截平就耗在那个区段上,末页的所有行都留在第一栏。宽度不同的栏(两栏都有文字的一栏半版式)按面积截平,每栏的高度按其宽度加权,因为窄栏的一行容纳的文字更少。仅在enabled为true时生效。
beforeSpanbooleantrue截平通栏块留在身后的区段:当span: 'page'标注框即使经过截平仍放不进当前各栏下方,必须移到下一页或拆分(calloutStyles[].keepTogether: false)时,被它打断的各栏会被截成一样高(与收尾区段采用相同的截平上限),而不是第一栏排满页面、最后一栏短一截。该页作为显式分页处理,以免上述手段把最后一栏重新拉伸到页面底部;框或其放得下的部分随后位于截平后的各栏下方。仅在enabled为true时生效。
closingBox'first' | 'last' | 'off''first'结束短栏的框何时占用其底部下方的空余(手段1,记录为trailingCallout)。'first':先于其他所有手段,与postext 1.4及以前相同。框占用整段空余,即使有好几行,其上方的标题一行也分不到。'last':在标题、列表结尾、行间公式和浮动体手段之后执行,它们先占用整行;框随上方的文字一起下移,然后只占用它们剩下的部分,通常不足一行,这样框底仍对齐最后一个网格位置,框也仍靠近它所注释的文字。'off':从不执行;框保留其底部下方的空余,但其他手段在框上方加空时,仍可以把它整行下移,框下剩余的不足一行的空间保持不变。在末页上,框是唯一能让其底部与相邻栏对齐的手段,'off'让它留在文字流放置的位置。其他任何值都按'first'处理。

哪个手段起了作用

排版会把它使用的每个手段记录在所作用的块上,即buildDocument返回的VDT中的block.balancing。校样、测试或报告可以据此说明某一栏为何平齐,以及哪些栏没有任何手段能补齐。齐底没有处理的块不带balancing,用enabled: false构建的文档则完全没有。

interface VDTBalancing {
  levers: BalanceLever[]; // 通常只有一个:列表后的段落可能既占用列表结尾那一行,又多排一行
  spaceAbove: number;     // 间距手段在块上方增加的px(只有looseParagraph起作用时为0)
  extraLines?: number;    // looseParagraph:段落多出的行数
  tracking?: number;      // looseParagraph:多出这些行所用的字距,单位为千分之一em(0 = 仅靠词间距)
}
type BalanceLever = 'trailingCallout' | 'heading' | 'listEnd' | 'afterDisplay' | 'afterFloat' | 'looseParagraph';
手段记录位置作用
trailingCallout标注框的外框块结束该栏的框下移,下移量正好等于其底部下方的空余(spaceAbove;不足一行也可以)。
heading标题在标题上方增加整数个网格行,最多maxLinesPerHeading行。
listEnd列表之后的第一个块在列表结束处增加一个网格行,最多maxLinesAfterList行。
afterDisplay公式或框之后的块在行间公式或标注框下方增加一个网格行。
afterFloat该栏的第一个块在栏首的浮动体区段与其下文字之间增加一个网格行,最多maxLinesAfterFloat行。
looseParagraph段落段落重新断行后多出extraLines行,所用的是能做到这一点的最小字距(tracking;block.letterSpacing是以px表示的同一数值)。

截平记录在被截的栏上:每个被截平的栏(通栏框留下的区段、收尾区段)上column.bandCapped都为true,column.trailingCap还额外标出一章或整个文档的收尾区段(trailing)。

import { buildDocument } from 'postext';
 
const doc = buildDocument(content, config);
for (const page of doc.pages) {
  page.columns.forEach((column, i) => {
    const levers: string[] = column.blocks.flatMap((block) => block.balancing?.levers ?? []);
    if (column.trailingCap) levers.push('closing band cut level');
    else if (column.bandCapped) levers.push('band cut level');
    if (levers.length > 0) console.log(`page ${page.index + 1}, column ${i + 1}: ${levers.join(', ')}`);
  });
}
// page 3, column 1: heading, heading
// page 3, column 2: listEnd, looseParagraph

#按级别覆盖

每个标题级别都可以通过levels数组覆盖通用默认值。默认情况下,各级别只有fontSize不同(另外还有下文说明的H1的breakBefore),其他属性都继承通用标题设置。

级别默认字号默认breakBefore
H118 pt{ enabled: true, parity: 'always-odd' }
H215 pt{ enabled: false, parity: 'any' }
H312 pt{ enabled: false, parity: 'any' }
H410 pt{ enabled: false, parity: 'any' }
H59 pt{ enabled: false, parity: 'any' }
H68 pt{ enabled: false, parity: 'any' }

H1的默认值模拟图书的分章版面:每个一级标题都在新的右页(奇数页)开始,与前一章之间必有一个空白分隔页。如果你的文档层次比图书简单,可在levels[0].breakBefore上覆盖它。

级别的breakBefore按字段逐个合并到这个默认值之上,没有提到它的headings对象会保留默认值。H1上的{ parity: 'odd' }保留分页,只改变奇偶;{ enabled: false }让各章接排:

headings: {
  fontFamily: 'Merriweather',                               // H1仍然分页到新的右页(always-odd)
  levels: [{ level: 1, breakBefore: { parity: 'odd' } }],    // ……或者:右页开始,不强制空白页
}
 
headings: { levels: [{ level: 1, breakBefore: { enabled: false } }] } // 各章接排

postext 1.5起的变化。 postext 1.4及以前,任何headings对象都会关闭H1的分页,除非重新声明levels[0].breakBefore;不完整的breakBefore会用不分页的默认值补齐缺少的字段。因此,一份为1.4用代码写成、带有headings对象但没有H1分页的配置,现在会让每一章都从新的右页开始,必要时加入空白左页。要让各章继续接排,在levels的H1条目中加一个字段:

headings: { fontFamily: 'Merriweather', levels: [{ level: 1, breakBefore: { enabled: false } }] } // 与1.4的排法相同

如果配置是存储下来的,引擎能识别出来并替你处理:沙盒处理它当时保存的书和配置(见沙盒 → 持久化),openBundle / readBundle处理postext 1.4或更早版本写出的.postext文件包(见postext 1.4及更早版本写出的文件包)。你自己存储的配置可以交给postext/bundle中的migrateConfig(config)处理,它会写出1.4当时的分页方式(pinLegacyHeadingBreaks):在没有分页的H1上写enabled: false,在H1和标题样式中未指定奇偶的enabled: true旁写parity: 'any'。它还会按1.4的设定固定以下各项:数学公式的字号、行内图周围的间距(框内也一样)、标题的行内标记、首字下沉的尺寸、引出列表的冒号行下方的空间、框截断后段落或列表项留下的行数、破折号处的断行、齐左文字的逐行断行、标题下段落的拆分、复合词连字符处的断行,以及:::paragraphs容器下方的间距。把书的Markdown作为第三个参数{ content }传入时,它会省去用不到的固定项:文本中没有$时省去数学固定项;没有一行嵌入资源时省去间距固定项(框内没有时省去框内间距固定项);没有标题带标记时省去标题标记固定项;没有列表跟在以冒号结尾的行之后时省去冒号行固定项;文本中没有:::callout时省去框截断固定项;词与词之间没有紧排的破折号时省去破折号固定项;文本中没有标题时省去标题拆分固定项;文本中没有:::paragraphs容器时省去容器固定项;两个字母之间没有连字符时省去复合词固定项。齐左断行固定项只加给将某些正文设为齐左的配置,容器固定项只加给声明了段落样式的配置(见postext 1.4及更早版本写出的文件包)。如果只处理分页,调用pinLegacyHeadingBreaks(config)即可。

按级别覆盖支持与通用默认值相同的属性,即fontSize、lineHeight、fontFamily、color、fontWeight、marginTop、marginBottom、snapToGrid,另外还有以下仅限级别的字段:

属性类型默认值说明
italicbooleanfalse以斜体渲染标题,在fontWeight之上叠加。
textTransform'none' | 'uppercase''none'把标题文字转为大写(编号前缀保持原样,其中的行内标签和:ref的标签也一样)。转换不改变长度,使编辑器的源码映射保持一一对应:大写形式会变长的字符(ß → SS)保持不变。转换后的标题也用于高级设计和章首页的{titleText}占位符。PDF书签保留原写法的标题,即Author contributions而不是AUTHOR CONTRIBUTIONS,就像CSS的text-transform不改变文字本身一样(postext 1.4及以前书签用的是大写)。
letterSpacingDimension0标题每个字形之后的字距,包括空格和编号前缀,等同于CSS的letter-spacing。正值拉开字母(用textTransform: 'uppercase'排的大写字母通常需要一点:{ value: 0.12, unit: 'em' }),负值收紧大字号标题。em值相对于该级别的fontSize。标题各行按加了字距的文字测量,所以在加字距后的文字结束处换行,Canvas、HTML和PDF的绘制结果相同。居中或齐右的行按字母定位:像设计文本一样不计最后一个字形后的字距,因此居中标题与span: 'page'标题的默认章首区段对齐。由advancedDesign绘制的级别会忽略它,与此处其他排版字段一样:每个设计文本元素有自己的letterSpacing。标题样式也可以设置它,作用于使用该样式的标题。postext 1.4及以前标题没有字距设置,这个键会被悄悄丢弃。
numberingTemplatestring''该级别自动编号的模板。标记 … 输出对应标题级别的当前计数,可以加后缀指定格式:大写罗马数字,小写罗马数字, / 字母,补零,用文字拼写(twenty-one),序数词(twenty-first),中文数字,逐位书写,大写数字,带圈数字,或者编号格式写法中的任一名称。其他文字按字面输出('Chapter . '、'.'、'第回'输出第一百二十回;反斜杠转义字面的花括号)。计数仍为空的标记会连同相邻的分隔符一起省略。为空(默认)表示没有自动编号。生成的编号在文字流中加在标题前,用于高级设计槽位的占位符(那里不再在前面加前缀),并显示在目录中。文字写法见以文字书写的编号;若某一级别中部分标题要用自己的模板,见标题样式。
numberSeparatorstring' '编号与标题之间的内容,用于栏内、span: 'page'级别的默认章首区段、输出标题行的书眉,以及PDF书签。一级标题的分隔符还用于在默认的篇页和目录默认的篇条目中连接篇的编号和标题。中文章回标题用全角空格或不用分隔符(' ':第一回 甄士隱夢幻識通靈)。目录保留自己的编号列(toc.levels[].numberGap)。标题样式可以设置自己的分隔符。像中文小说的对句回目那样用把标题分成两半时,单行形式(栏内、目录、书眉)在两侧都是中文或日文字符时用全角空格连接两半,否则用空格。
breakBeforeHeadingBreakBeforeConfigH1:{ enabled: true, parity: 'always-odd' }
H2–H6:{ enabled: false, parity: 'any' }
在该级别每个标题前强制分页。parity: 'odd' / 'even'进一步限定标题在跨页的哪一侧开始,必要时插入一个空白填充页(仍计入页码)。'always-odd' / 'always-even'还保证在前面的内容与新标题之间至少有一个强制的空白分隔页(分隔页属于前一章;此外为满足奇偶而加的填充页属于新的一章)。当标题是文档的第一个块、且第一页仍为空时,不执行奇偶约束,标题照原样落在第1页。未设置的字段保留该级别的默认值:H1的{ parity: 'odd' }仍然分页。
hiddenbooleanfalse结构性标题:它不输出任何内容,在栏内或标注框中不占空间(没有文字、没有外边距、没有章首区段),但其他作用与标题完全相同。它的breakBefore仍会开新页,它开启其样式的章节,它参与计数(除非其样式设为numbered: false),并会被:::toc列出、被书眉引用、在PDF中生成书签。适用于献词、题词页或版权页这类需要出现在目录和读者书签中、但页面上不显示标题的内容。应在标题样式上设置,而不是作用于整个级别;单个标题可以用 / 覆盖。
headings: {
  fontFamily: 'Merriweather',
  levels: [
    // 标准图书预设:各章从右页(奇数页)开始。
    { level: 1, fontSize: { value: 24, unit: 'pt' }, breakBefore: { enabled: true, parity: 'odd' } },
    { level: 2, fontSize: { value: 18, unit: 'pt' }, italic: true },
  ]
}

snapToGrid也可以按级别设置。未设置时,级别沿用headings.snapToGrid;设置后则覆盖它。这样同一份文档可以让H2与其正文之间隔一行半、在下一个对齐点之前偏离网格,同时让H3下方的空间向上取整为整数个网格行。标题样式也可以设置它,作用于使用该样式的标题。

headings: {
  marginBottom: { value: 1.5, unit: 'em' },
  levels: [
    { level: 2, snapToGrid: false }, // 每个H2下方正好1.5 em
    { level: 3 },                    // 继承headings.snapToGrid: true
  ],
}

#前置分页

breakBefore与编号控制相互独立:开启它会强制分页,但数字计数只在你明确插入:::numbering指令时才重置。为满足奇偶而加的空白页在页序中算作真实的页,并按正常的奇偶页规则显示页眉和页脚。

紧挨在这种标题前的:::pagebreak不会取代标题自己的分页:标题仍会在该指令所开的页之后执行奇偶约束,可能因此多出一个空白页。标题需要紧接在手动分页之后开始时,见标题样式。

奇偶取值

取值行为
'any'(默认)没有奇偶约束,标题直接从下一页开始。
'odd'确保标题从奇数页(右页)开始。只有当自然的下一页为偶数页时,才插入一个空白页。
'even'同上,但针对偶数页(左页)。
'always-odd'保证前面的内容与新标题之间至少有一个强制的空白分隔页,然后确保从奇数页开始。适用于每一章都必须从新的跨页开始的情况。
'always-even'同上,但针对偶数页。

空白页的归属

breakBefore插入的空白页所带的章标题书眉,取决于它们为什么被插入:

  • 为满足奇偶约束而插入的页('odd'、'even',或'always-*'中的奇偶部分)属于后面的一章。它们书眉中的{chapterTitle}占位符解析为新一章的标题,因为这个空白页只是为了把新的一章推到正确的奇偶页上。
  • 'always-odd' / 'always-even'插入的强制前导分隔页属于前面的一章。它是章末有意留出的停顿,所以{chapterTitle}书眉仍显示上一章的标题。

带样式章节的书眉和调色板(见标题样式)在空白页上也遵循这两条规则。

文档开头的例外

当文档的第一个块是启用了breakBefore的标题,或源文件以:::pagebreak开头时,只要第一页仍为空,就不执行奇偶约束。标题照原样落在第1页,与所配置的奇偶无关,因此以# Chapter 1开头、配置为parity: 'odd'的文档不会多出一个多余的前导空白页。一旦放入了任何内容,奇偶约束就照常执行。

#通栏与高级设计

每个标题级别还接受两个字段,用于控制标题作为横跨整页的章首页时如何渲染。

属性类型默认值说明
span'column' | 'page''column'为'page'时,标题被视为章首,其高级设计(如果启用)作为正文上方的章首区段附加到页面上。请与breakBefore.enabled: true搭配使用,确保章首总是开新页。没有自己的设计时,标题由默认章首绘制,横跨整个内容区,使用该级别的字体和行距,并带有粗体、斜体和上下标片段(headings.inlineMarks),区段也按这个宽度测量。区段容纳章首绘制的每一行:章首占用的行数多于标题本身按栏宽测得的行数时(两端对齐的标题本可压缩空格排成一行,或有强制换行),区段也把这些行包括在内。postext 1.4及以前,区段按栏宽折行的标题来测量,所以在内容区一行就放得下的标题会占两行高的区段,那一行居中其中。其他各栏与标题所在栏一样从区段下方开始。在这些栏开头的正文,与紧接在章首下方的正文起点相同,不论章首所在栏接下来是什么内容:即章首标题或设计下方的marginBottom,该级别对齐网格时取到下一个网格行。在这些栏开头的标题、行间公式或目录行,与章首下方第一个标题或行间公式平齐,外边距也按那里的计算;章首所在栏接下来是其他内容时,它们与那段文字的起点平齐。章首下方的隐藏标题不计入,各栏齐底在章首下方标题上方加的空间也不会在其他栏中重复。这些栏中对齐网格的内容落在页面的网格上。postext 1.4及以前,它们的第一个块紧贴区段底部:区段在两行之间结束时偏离网格;章首后面跟着标题时,则紧挨区段、没有外边距(区段结束在网格上时比现在高一行),那里的标题还丢掉了第一栏的标题所保留的上外边距。
advancedDesignHeadingAdvancedDesignConfig该级别的自由组合设计槽位。enabled时,由槽位中的元素组成章首。在文本元素中用输出标题文字;用、等插入格式化后的标题编号。
advancedDesign.minHeightDimension—在栏内文字流中为标题保留的最小高度。标题占用max(design content bottom, minHeight),其下再加标题的marginBottom(取自其标题样式、其级别或headings.marginBottom;默认为标题字号的0.5 em),标题对齐网格时,总和向上取整到基线网格。因此,即使章首的元素很矮,或锚定在标题上方的页面或出血框上,章首也能把正文往下推(或占满整页)。要让区段正好高minHeight,把那个marginBottom设为0,并让minHeight为整数个网格行。只要enabled为true就生效,槽位为空也一样。设计内容底部如何计算,见保留高度。

与页面一样高的章首。 当保留高度超出栏底时(如minHeight等于页高的封面,或锚定在页面或出血框、一直延伸到裁切边的图片或框),章首占据本页余下部分:其后的文字从下一页开始,双栏和一栏半版式的每一栏都一样。所以封面之后不需要:::pagebreak(加了也无妨,不会多出空白页)。设计高于所在栏的栏内标题(span: 'column')占据该栏,文字从下一栏栏首开始。标题的块随后一直延伸到它所占栏的栏底,设计按这个区段排布:锚定在顶部的元素留在锚定处,跟随区段的元素(锚定在区段中部或底部,或高度为'fill')限制在标题占据的空间内,就像保留高度放得下时它们限制在保留高度内一样(postext 1.4及以前,块保持其文字的高度,所以这些元素是按只有一个标题高的区段排布的,其后的文字会接在设计下面继续排)。不应占据整页的版面装饰(贯穿整页的切口色条、页脚处的装饰)应放到页眉或页脚设计中:锚定到'page'或'bleed',并以pages: 'opener'显示,它就只绘制在章首页上,不占用正文空间。

示例:一个简约的章首,在标题上方显示“Chapter N”:

{
  "headings": {
    "levels": [
      {
        "level": 1,
        "span": "page",
        "breakBefore": { "enabled": true, "parity": "always-odd" },
        "advancedDesign": {
          "enabled": true,
          "slot": {
            "elements": [
              {
                "kind": "text",
                "id": "chapterLabel",
                "placement": {
                  "anchor": { "to": "container", "edge": "top" },
                  "offset": { "y": { "value": 48, "unit": "pt" } },
                  "size": { "width": "fill" }
                },
                "content": "Chapter {numberRoman}",
                "fontSize": { "value": 10, "unit": "pt" },
                "align": "center",
                "overflow": "ellipsis-end"
              },
              {
                "kind": "text",
                "id": "chapterTitle",
                "placement": {
                  "anchor": { "to": "#chapterLabel", "edge": "below" },
                  "offset": { "y": { "value": 12, "unit": "pt" } },
                  "size": { "width": "fill" }
                },
                "content": "{titleText}",
                "fontSize": { "value": 24, "unit": "pt" },
                "fontWeight": 700,
                "align": "center",
                "overflow": "wrap",
                "hyphenate": true
              }
            ]
          }
        }
      }
    ]
  }
}

级别设计槽位中可用的标题占位符:

  • {titleText}:标题的纯文本(不含编号前缀)。标题中的强制换行(\\)在这里是换行,章首区段和栏内设计都一样(postext 1.4及以前,栏内设计在那里输出一个空格,而高度却是按换行测量的)。隐藏标题在栏内折成的各行会按原写法重新连成标题:在自身连字符后断开的词保留连字符,后面不加空格;被栏宽断开的词重新连成整词,去掉断行时加上的连字符;由不换行空格连在一起、比栏还宽而在该空格处断开的词组,恢复其不换行空格(Capítulo XVIII,而不是CapítuloXVIII);其他每个断行处都还原为一个空格。postext 1.4及以前,每个换行都变成空格,所以在连字符处折行的标题会输出Word- Book,这类标题中的强制换行也会被丢掉。
  • {number}:按该级别numberingTemplate格式化的编号。
  • {numberDecimal}、{numberRoman}、{numberRomanLower}、{numberAlpha}、{numberAlphaLower}:以其他数字格式表示的标题计数(该级别的当前计数,不管模板输出什么):第三章给出3、III、iii、C、c,有没有numberingTemplate都一样。不编号的标题(样式设为numbered: false)让它们为空。
  • {numberWords}、{numberWordsLower}、{numberOrdinalWords}、{numberOrdinalWordsLower}:用文字拼写的同一计数,首字母大写或全小写:Three / three、Third / third(见以文字书写的编号)。
  • {numberHan}:用中文数字书写的同一计数,字形按文档locale的书写系统:用第{numberHan}回设置的章首,在第十二章输出第十二回,而目录列出的是12。
  • {chapterNumber}、{chapterTitle}、{pageNumber}、{totalPages}、{bookTotalPages}、{title}、{subtitle}、{author}、{publishDate}:通用的元数据占位符。
  • {attr.<key>}:写在标题行上的属性(# Title {author="I. Zango Martín"}),缺省时取当前章H1的同名属性。缺少的属性解析为空字符串,不给出警告。

{chapterNumber}输出的是书眉为该章输出的内容:H1的级别(或样式)有numberingTemplate时输出H1的编号,否则输出章的序号(1、2……接着本章之前已排的各章继续计数);不编号的章或模板为''的样式则什么也不输出。标题设计读取其标题所属的章:一级标题读取自己,更低级别的标题读取它之前的最后一个一级标题。两章在同一页相遇时也是如此,而该页的书眉输出的是后一章。章首保留的高度也按同一个值测量。postext 1.4及以前,高度按标题的编号前缀测量(没有模板时为空),所以高度取决于{chapterNumber}的设计可能绘制得比所占空间更高;而且设计输出的是该页的章,所以共用一页的两章中,前一章显示的是后一章的编号。

计数占位符在逐章排版时跟随全书(即continuationAfter()传递下去的计数),标题的startAt属性会让它们重新开始(见文档格式 → 标题属性)。一个章首显示Chapter III,而文字流和目录中的标题编号为3.:

{
  level: 1,
  span: 'page',
  numberingTemplate: '{1}.',
  advancedDesign: {
    enabled: true,
    slot: { elements: [
      { kind: 'text', id: 'label', content: 'Chapter {numberRoman}', fontSize: { value: 10, unit: 'pt' }, overflow: 'ellipsis-end',
        placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill', height: 'auto' } } },
      { kind: 'text', id: 'title', content: '{titleText}', fontSize: { value: 24, unit: 'pt' }, overflow: 'wrap',
        placement: { anchor: { to: '#label', edge: 'below' }, size: { width: 'fill', height: 'auto' } } },
    ] },
  },
}

保留高度

带高级设计的标题像其他块一样在栏内占用空间:其后的正文从这段空间下方开始。这段空间取以下三个高度中最大的一个,都从标题顶部往下量(对于开新页的章首,从内容区顶部往下量):

  1. 标题本身的文字,按该级别的字体排版(它被设计遮住,但仍保留自己的行);
  2. 设计内容底部,即计入的元素(见下文)中最低的下边缘;
  3. advancedDesign.minHeight。

其后加上标题的marginBottom(取自其标题样式、其级别或headings.marginBottom;默认0.5 em),标题对齐网格时,结果向上对齐到基线网格。对于章首(span: 'page'),页面每一栏都空出同样的区段。对于放得进所在栏的栏内标题,设计在标题的框内排布,框正好这么高。

哪些元素计入。 设计中的每个元素都计入,文字、线条、框和图片都一样(postext 1.4及以前,image元素从不计入,所以除非用minHeight挡住,文字可能从区段图片上面开始),以下情况除外:

  • 设有reserve: false的元素:可以位于文字下方的装饰;
  • 跟随区段本身的元素:锚定在容器中间一排(left、center、right)或底部一排(bottom-left、bottom、bottom-right)、高度为相对容器的'fill'的元素(没有设高度的框或竖线默认填满),以及锚定在这些元素上的任何元素。容器就是保留的区段,所以这些元素落在区段底部或纵贯区段:区段下方的一条线、标题后面的一块色底。它们跟随高度,从不决定高度。其中保持自身高度的文字仍需要空间:锚定在区段底部的标题会使区段至少与标题一样高,所以标题绝不会从标题块顶部之上开始。设minHeight: 36mm且标题锚定在bottom-left时,标题的框为36 mm加上其marginBottom,再向上对齐网格,标题位于该框底部,紧挨在后续文字上方;没有minHeight时,行数多于标题本身文字的标题会使区段与标题一样深。postext 1.4及以前,这样的标题会向上画到标题块上方的文字上。跟随区段的框、线条和图片不设这样的下限,所以锚定在底部的色底可以伸到标题上方。

锚定在页面和出血框上的元素,按它们伸到标题顶部以下多远来计入。横贯页面顶部、在标题上方就结束的色带不计入;越过标题的满版出血图片会把文字推到其下边缘之下。页面偏下的任何东西也是如此:距页脚25 mm的印章、整页高的侧边色带或边框,会把页面一直保留到其下边缘,文字通常就从下一页开始。把这类装饰标记为reserve: false(它仍会绘制,其他元素仍可锚定在它上面),再用文本元素或minHeight给标题留出所需的空间:

{
  "kind": "image", "id": "seal", "resourceId": "seal", "reserve": false,
  "placement": {
    "anchor": { "to": "page", "edge": "bottom-right" },
    "offset": { "x": { "value": -25, "unit": "mm" }, "y": { "value": -25, "unit": "mm" } },
    "size": { "width": { "value": 30, "unit": "mm" } }
  }
}

锚定在不保留空间的元素上的元素,除非也做了标记,仍会计入(放在印章上的说明文字需要自己的reserve: false)。

绘制位置。 章首的设计(span: 'page')在正文之前绘制,所以不保留空间的装饰位于文字下层。栏内标题的设计与标题块一起绘制,压在栏内它上方的块之上、它之后的块之下,只要位于所在栏栏底以上,放在哪里都可以:两侧页边距、上页边距、出血区和相邻各栏都行。在Canvas和PDF中,栏底会把它裁掉,因为文字流在那里结束(见下文高于所在栏)。postext 1.4及以前,Canvas和PDF还会在栏顶裁切它,所以锚定在页面顶部或出血框顶部的色带能伸进两侧页边距,却在上页边距处截止。锚定在页面底部的装饰应放在章首中,或放在以pages: 'opener'显示的页脚设计中。

高于所在栏。 当所需空间超出栏底时(与页面一样高的minHeight、一直延伸到裁切边的图片或边框),标题占据本页余下部分(章首,在每一栏)或本栏余下部分(栏内标题),其后的文字从下一页或下一栏开始。标题的块随后延伸到栏底,绝不会缩回到其文字的高度,所以设计所依据的区段就是它占据的空间(包括minHeight,直到栏底)。比页面本身还高的设计(小屏页面上很长的导语)仍会在页面底部被裁掉(栏内设计则在其所在栏的栏底):沙盒会在检查面板中列出它,标为Heading design cut off。排版本身不对此发出警告,因为占满整页的封面是常见情况,并不丢失任何内容;但任何宿主都可以对排好的版面做同样的检查:collectHeadingDesignCuts(doc)为每个设计文字超出页面裁切边底部(where: 'page',章首)或所在栏栏底(where: 'column')的标题返回一个{ kind: 'headingDesignCut', pageIndex, level, where, overflowPx, sourceStart, sourceEnd },formatWarning可以描述每一项。另见通栏与高级设计下的与页面一样高的章首。

侧栏。 在侧栏放置浮动体的一栏半版式中(sideColumnRole: 'floats'),栏内标题的设计中位于侧栏里的元素(如教科书章首中锚定在页面外侧边栏里的章号数字)会让侧栏的浮动体堆叠避开它。该页在标题之后放置的每个span: 'side'图、表或框,都与每个这样的元素保持一个浮动体间距:放得进元素上方时就留在堆叠所放的位置,否则放到元素下方;侧栏余下部分放不下时,则等到下一页。所以,位于侧栏顶端的章号数字会让整个堆叠都在它下方;而挂在页面偏下的标题旁边、页边距中的节号,则把侧栏顶端留给该页引用的图(边栏图仍位于其所在页的顶部)。标题放置时侧栏中已有的内容不会被移动:本页较早堆叠、向下伸到标题元素处的图留在原处,位于该元素下面,所以元素在页中间位于侧栏的设计,最好在标题之后才引用它的图。设有reserve: false的元素不占用侧栏,就像它们不占用文字空间一样。章首(span: 'page')不需要这种处理:它的区段在每一栏都保留,侧栏也包括在内。postext 1.4及以前,在这种章首上引用的侧栏图会放在侧栏顶端,压在章号数字上。

封面。 占满整页的封面标题(minHeight与页面一样高,或设计中有整页图片)因此会自行把其后的文字送到下一页,单栏和多栏版式都一样。紧接其后的:::pagebreak可有可无:在仍为空的页面上分页不起作用,所以绝不会多出空白页。只有当封面设计没有延伸到页面底部、而文字仍应从新页开始时,才需要它。

# Annual report 2026 {style="cover"}
 
:::pagebreak
 
# Letter from the chair

#以文字书写的编号

有两个编号模板后缀用文字书写计数,语言为文档语言(顶层的locale,否则用断词语言,见文档语言):words表示基数,ordinal表示序数。后缀的大小写决定文字的大小写,就像字母编号的A / a一样:

标记英文(21)西班牙文(21)中文(21)
twenty-oneveintiuno二十一
Twenty-oneVeintiuno二十一
TWENTY-ONEVEINTIUNO二十一
twenty-firstvigesimoprimero第二十一
Twenty-firstVigesimoprimero第二十一
TWENTY-FIRSTVIGESIMOPRIMERO第二十一

英文、西班牙文和中文会用文字书写;其他语言采用英文词,与内置的表格续表字符串相同。中文按locale的书写系统采用simp-chinese-informal或trad-chinese-informal的小写数字(一万 / 一萬),序数前加“第”;汉字没有大小写,所以同一后缀的三种写法输出相同。英文遵循美式用法(one hundred five,不加and)。西班牙文用阳性形式,与capítulo或libro的编号方式一致(capítulo primero、tercero、veintiuno),13到29的序数按RAE推荐的写法连写为一个词(decimotercero、vigesimoprimero)。基数最多拼写到999 999,西班牙文序数最多到999;更大的数字以阿拉伯数字输出。

在标题设计中,{numberWords} / {numberWordsLower}和{numberOrdinalWords} / {numberOrdinalWordsLower}以同样方式拼写标题的计数,所以章首可以写Chapter One,而目录列出1。文本元素的textTransform: 'uppercase'可给出大写:

// 西班牙文小说:标题上方为“CAPÍTULO PRIMERO”,目录中为“1.”。
{ level: 1, numberingTemplate: '{1}.', span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'n', content: 'Capítulo {numberOrdinalWordsLower}', textTransform: 'uppercase', /* … */ },
    { kind: 'text', id: 't', content: '{titleText}', /* … */ },
  ] } } }

#无序列表

unorderedLists属性控制项目符号列表(-、*、+)和GFM任务列表(- [ ]、- [x])的渲染方式。最多支持五层嵌套。

#无序列表默认值

属性类型默认值说明
fontFamilystring沿用bodyText.fontFamily列表项文字所用的字体。
colorColorValue主色(#295AA3)列表项文字和项目符号的颜色。绑定到默认调色板的main-color条目。
fontWeightnumber700列表项文字的字重(100–900)。项目符号沿用这一字重,除非在某一层单独覆盖。
italicbooleanfalse用斜体排列表项文字。
bulletCharstring'•'用作项目符号的字形。
bulletFontSizeDimension1 em项目符号字形的大小。相对单位随正文字号缩放。
gapDimension0.5 em项目符号与列表项文字之间的水平间距。
indentDimension0 em第1层的基础缩进。更深的层级从上一层文字的起点开始级联,除非单独覆盖(见下文)。
bulletVerticalOffsetDimension0 em微调项目符号的垂直位置。负值把符号上移,正值下移。
marginTop / marginBottomDimension1.5 em整个列表前后的间距。
itemSpacingDimension0 em在行距之外、插在列表项之间的额外垂直间距。一个列表嵌套在另一个列表的某一项中时,嵌套列表两侧都用外层列表的间距:嵌套列表第一项之前和最后一项之后(postext 1.4及以前,嵌套列表之后的那一项用的是嵌套列表的间距)。
snapTopToGridbooleanfalse把列表上方的间距向上取整,使第一个项目符号落在基线网格上,和标题下的正文一样;此时marginTop是最小值。无论是否开启,列表结束时版面流都会重新回到网格上,所以itemSpacing为0时,每一项都与旁边一栏的文字对齐。默认关闭,与postext 1.4及以前一致:marginTop不是整数行时,列表项会偏离网格,直到列表结束。标注框内部不在网格上,框内的列表不受影响。
hangingIndentbooleantrue开启时,折行与第一个文字字符对齐,而不是排到项目符号下面。
levelsUnorderedListLevelConfig[]—第1–5层的逐层覆盖。见下文。

#任务列表扩展

GFM任务项(- [ ] …、- [x] …)按无序列表项渲染,用复选框字形代替项目符号。以下字段只作用于任务项:

属性类型默认值说明
taskCheckboxCharstring'☐'未完成任务所用的字形。
taskCheckedCharstring'☑'已完成任务所用的字形。
taskCompletedStrikethroughbooleantrue在已完成任务的文字上画删除线。
taskCompletedColorColorValue沿用列表项颜色可选,用于已完成任务文字的颜色。省略时使用普通列表项的颜色。

#无序列表的逐层覆盖

levels中的每一项针对一个层级(1–5),可以覆盖以下任意字段:

属性类型说明
bulletCharstring该层的项目符号字形。
fontFamilystring该层列表项的字体。
fontSizeDimension该层项目符号字形的大小。
colorColorValue列表项颜色。
fontWeightnumber列表项字重。
italicboolean斜体开关。
indentDimension该层项目符号的显式缩进。见下文的级联规则。
verticalOffsetDimension该层项目符号的垂直微调。

**缩进级联。**第1层总是从通用的indent值开始(默认0 em,项目符号贴住栏的边缘)。对第2–5层,如果不设indent,引擎把项目符号放在上一层文字的起点(上一层缩进 + 项目符号宽度 + gap)。在某一层显式设置indent,就会打断级联,把这一层固定在你想要的任何位置。

unorderedLists: {
  bulletChar: '—',
  gap: { value: 0.4, unit: 'em' },
  hangingIndent: true,
  levels: [
    { level: 2, bulletChar: '·' },
    { level: 3, bulletChar: '◦', color: { hex: '#666666', model: 'hex' } },
  ],
}

#有序列表

orderedLists属性控制编号列表(1.、2)等)。最多支持五层嵌套,每一层可以使用不同的编号格式。

#有序列表默认值

属性类型默认值说明
fontFamilystring沿用bodyText.fontFamily列表项文字和编号所用的字体。
colorColorValue主色(#295AA3)列表项文字和编号的颜色。绑定到默认调色板的main-color条目。
fontWeightnumber700列表项文字和编号的字重(100–900)。
italicbooleanfalse用斜体排列表项文字。
numberFormatOrderedListNumberFormat'arabic'编号样式:'arabic'、'lower-alpha'、'upper-alpha'、'lower-roman'、'upper-roman'。其他设置里的写法也能用('decimal'、'roman-lower'、'i'……;见编号格式的写法);无法识别的值按阿拉伯数字编号,并报告出来。
prefixstring''排在编号前面的文字,样式与分隔符相同:这里设'('、分隔符设')',中文列表就排成(一)、(二)。分隔符单独绘制时,前缀也单独绘制,紧挨在编号之前。
separatorstring'.'放在编号和文字之间的字符,通常是'.'或')'。
separatorFontFamilystring沿用fontFamily分隔符的字体。分隔符的任一样式与编号不同时,分隔符单独绘制在(右对齐的)编号之后,例如黑色Optima Bold的1后面跟一个蓝色DIN Pro Bold的•。
separatorFontWeightnumber沿用fontWeight分隔符的字重(100–900)。
separatorItalicboolean沿用italic用斜体排分隔符。
separatorColorColorValue沿用color分隔符的颜色。支持调色板引用。
separatorGapDimension0 em编号与分隔符之间的间距。列表项文字仍在分隔符之后隔gap开始。
numberFontSizeDimension1 em编号的大小。
gapDimension0.5 em编号与列表项文字之间的水平间距。
indentDimension0 em第1层的基础缩进;更深的层级从上一层文字的起点开始级联,除非单独覆盖。
numberVerticalOffsetDimension0 em微调编号的垂直位置。
marginTop / marginBottomDimension1.5 em整个列表前后的间距。
itemSpacingDimension0 em列表项之间的额外垂直间距。一个列表嵌套在另一个列表的某一项中时,嵌套列表两侧都用外层列表的间距:嵌套列表第一项之前和最后一项之后(postext 1.4及以前,嵌套列表之后的那一项用的是嵌套列表的间距)。
snapTopToGridbooleanfalse把列表上方的间距向上取整,使第一个编号落在基线网格上,和标题下的正文一样;此时marginTop是最小值。无论是否开启,列表结束时版面流都会重新回到网格上,所以itemSpacing为0时,每一项都与旁边一栏的文字对齐。默认关闭,与postext 1.4及以前一致:marginTop不是整数行时,列表项会偏离网格,直到列表结束。标注框内部不在网格上,框内的列表不受影响。
numberWidth'run' | 'level''run'列表项编号栏的宽度,它决定文字从哪里开始;编号在这一栏里右对齐。'run':取该项所在连续段中最宽的编号。连续段指同一层级的若干项,它们之间只隔着更深层级的项。两项之间出现图、段落或框,就开始一个新的连续段,所以表后面的ii)的文字可能比表前面的i)稍微靠右,九项的列表的文字也比十二项的列表靠左。'level':取整个文档(在书中是整章)里该层级最宽的编号,这样每个列表,以及被打断的列表的每一部分,文字都从同一位置开始,与更深层级的缩进已有的做法一致。
hangingIndentbooleantrue折行与第一个文字字符对齐,而不是排到编号下面。
levelsOrderedListLevelConfig[]—第1–5层的逐层覆盖。

#有序列表的逐层覆盖

levels中的每一项可以覆盖numberFormat、prefix、separator、fontFamily、fontSize、color、fontWeight、italic、indent、verticalOffset以及分隔符样式(separatorFontFamily、separatorFontWeight、separatorItalic、separatorColor、separatorGap),缩进级联规则与无序列表相同。某一层的分隔符样式沿用该层自己的编号样式,除非给出了整个列表的分隔符设置。

**右对齐。**排版流程会测量一个连续段中格式化后最宽的编号,并据此缩进该段的所有列表项,使编号的右边缘对齐。十项的列表渲染为1.–10.时,一位数编号的右侧会补齐,使分隔符保持在同一列。

orderedLists: {
  numberFormat: 'arabic',
  separator: '.',
  levels: [
    { level: 2, numberFormat: 'lower-alpha' },
    { level: 3, numberFormat: 'lower-roman', separator: ')' },
  ],
}

得到的是经典的混合嵌套:

1. First item
   a. Sub-item
      i) Deep note
   b. Sub-item
2. Second item

中文公文的层次序数(GB/T 15834—2011,附录B.3)共五层,依次为“一、”“(一)”“1.”“(1)”“①”:

orderedLists: {
  levels: [
    { level: 1, numberFormat: 'simp-chinese-informal', separator: '、' },
    { level: 2, numberFormat: 'simp-chinese-informal', prefix: '(', separator: ')' },
    { level: 3, numberFormat: 'arabic', separator: '.' },
    { level: 4, numberFormat: 'arabic', prefix: '(', separator: ')' },
    { level: 5, numberFormat: 'circled-decimal', separator: '' },
  ],
}

#数学公式

math属性控制如何解析和渲染写在$...$(行内)和$$...$$(独立)定界符之间的LaTeX公式。底层引擎是MathJax(mathjax-full包),以SVG模式输出,在Canvas上栅格化,在PDF中以可缩放的字形嵌入。

interface MathConfig {
  enabled?: boolean;        // 渲染LaTeX。为false时,公式片段按原样输出TeX源码。
  fontSizeScale?: number;   // × 周围文字的字号(独立公式以正文字号为准)。
  color?: ColorValue;       // 公式颜色;省略时沿用正文颜色。
  marginTop?: Dimension;    // 独立公式块上方的间距。
  marginBottom?: Dimension; // 下方的最小间距;对齐基线网格时可能变大。
  indentAfterDisplay?: boolean; // 独立公式之后的段落首行缩进。
  keepWithLeadIn?: boolean; // 让独立公式与引出它的那一行在一起。
}
属性类型默认值说明
enabledbooleantrue为false时,$...$和$$...$$片段仍会解析(所以定界符未闭合的警告照样触发),但按TeX源码原样渲染。内容里本来就有美元符号,或者想完全关闭公式渲染时,这个选项很有用。
fontSizeScalenumber1.0渲染前作用于周围文字字号的倍数:公式TeX字体的1 em等于该字号 × fontSizeScale。对独立公式和正文中的行内公式,该字号是bodyText.fontSize;对标题、段落样式、题注或标注框正文中的行内公式,是所在块的字号。1.0与周围文字一致;数学字体看上去比正文字体略大或略小时,常用0.9–1.1之间的值。**postext 1.5起有变化:**1.4及以前,公式比这个大小约大13%(见下文)。
colorColorValue沿用正文颜色渲染出的公式的颜色。省略则沿用bodyText.color。想让公式的颜色与正文不同时显式设置,例如与标题的强调色一致。
marginTopDimension0.8em独立公式块上方的间距。对行内公式无效。
marginBottomDimension0.8em独立公式块下方的间距。这是最小值:对齐网格时可能把它加大,使下一条基线落在网格线上(无论page.baselineGrid是否画出网格)。
indentAfterDisplaybooleantrue独立公式之后的段落像其他段落一样首行缩进。设为false时,紧跟在独立公式后面的每个段落都顶格排,作为被公式打断的那句话的延续(“其中L是……”)。写在段落内部的公式(上下都没有空行)后面总是顶格排:它的闭合$$下面的文字延续这个段落,从不缩进(见数学公式)。
keepWithLeadInbooleanfalse让独立公式与引出它的那一行留在同一栏,相当于TeX的predisplay penalty。公式在前一段最后一行下面排不下时,这一行和公式一起移到下一栏或下一页;如果留下的行少于bodyText.widowMinLines(bodyText.avoidWidows关闭时为少于一行),就把在该栏开始的段落整段移走(按headings.keepWithNext,连同该段上方收尾这一栏的标题)。带过去的那一行单独位于下一栏的栏顶,不论段末孤行(orphan)规则如何要求。设为false时只移走公式,引出它的那一行可能收尾上一栏,或者位于下一栏栏首的图的上方。
math: {
  enabled: true,
  fontSizeScale: 1.0,
  color: { hex: '#295AA3', model: 'hex' },
  marginTop: { value: 1, unit: 'em' },
  marginBottom: { value: 1, unit: 'em' },
}

**公式大小,postext 1.5起有变化。**MathJax以ex给出公式的框,而它的TeX字体的1 ex等于0.442 em。postext 1.4及以前,引擎把它当作半个em,所以每个公式都比bodyText.fontSize × fontSizeScale大约13%。现在公式按文档说明的大小排出,含公式的行和页面会重新排版。为1.4用代码写的配置,可以把它原样传给postext/bundle中的pinLegacyMathSize一次,保留1.4的公式大小:

import { pinLegacyMathSize } from 'postext/bundle';
 
config = pinLegacyMathSize(config); // 公式及独立公式周围的间距,按1.4的排法

1.5的其他规则变化也可能让页面移动:标题的断行、行内图周围的间距(正文中和框内)、标题的行内标记、首字下沉的大小、冒号行之下为它引出的列表保留的空间、框的切口在段落或列表项中留下的行数、破折号之后的断行、齐左文字的断行、标题下段落的拆分、复合词连字符之后的断行,以及:::paragraphs容器下方的间距。migrateConfig与配置所排的markdown一起运行一次,就会固定这段文字需要的那些规则,公式大小也包括在内(见postext 1.4及以前写出的文件包):

import { migrateConfig } from 'postext/bundle';
 
config = migrateConfig(config, undefined, { content: markdown }); // 标题断行、公式、行内间距、标题标记、首字下沉、冒号行、框的切口、破折号断行、复合词断行、齐左断行、标题下的拆分和容器间距,按1.4的排法

这样能挡住这些规则变化会造成的移动,但不能保住1.4的每一页。1.5还修正了排版错误,而修正没有固定开关:旧配置和新配置一样会得到修正,所以受影响的页面仍可能移动。其中包括:没有自己版面设计的通栏标题按整页宽度测量,并用所在层级的行距排;标题版面设计中,首字下沉的基线下面不再预留空间;首字下沉采用该部分的调色板颜色,即使设计文字的overflow不是'wrap'也会排出,此时文字折行;框内的段落按测量时的字距绘制;拆分的框在每个片段上都保留图标栏;框内某一行如果要把空格拉宽到3倍以上,就改为齐左,与正文一样;松散段落的调节手段不会让两端对齐的行宽于maxWordSpacing所允许的宽度;浮动的框保留比浮动体间距更大的marginBottom(在顶部区域)或marginTop(在底部区域);带字距的居中或右对齐设计文字(书眉、章首页标题)按字母定位,不计最后一个字母之后的字距;在通栏章首页之下,第二栏开头的文字从紧接章首页下方的文字所在位置开始,章首页后面跟着标题时也是如此;在会把连续圆点的字偶间距拉开的字体中,目录的引导点也停在页码之前;书眉按标题的原文读取,标题某一行在连字符或破折号之后结束、或结束在因宽度而切开的单词内部时,不留空格(MEDIOAMBIENTALES,而不是MEDIOAMBIENTALE S),标题版面设计的{titleText}也同样读取(thousand-colour,而不是thousand- colour);锚定在标题版面设计色带底部或中部的文字,会让色带保持足够的高度来容纳它,不再压到标题上方的文字上;比所在行更宽、在自带的连字符旁被切开的单词,从这个连字符之后切开,不再加第二个连字符;嵌套在另一列表中的列表之后的那一项,用它自己所在列表的itemSpacing,而不是嵌套列表的;因比所在行更宽而被切开的单词,其余部分保留自己的断点,所以网址继续在连接处断开,复合词继续在连字符处断开,而不是按词典的音节断开。

pinLegacyMathSize把缩放比例乘以1.1312(0.5 ÷ 0.442),并把以em计的独立公式外边距除以同一系数。对含独立公式的书,只改缩放比例不够:它们的外边距是以公式自身大小计的长度,会同样增大13%,把下面的文字往下推。按默认外边距写出来,这三个值是:

math: {
  fontSizeScale: 1.131,
  marginTop: { value: 0.7072, unit: 'em' },
  marginBottom: { value: 0.7072, unit: 'em' },
} // 公式与1.4排出的一样大,周围留出1.4留的间距

对于已保存的配置,引擎能识别出来并替你完成这一步:openBundle / readBundle处理1.5之前写出的.postext文件包(见postext 1.4及以前写出的文件包),沙盒处理当时保存的书、工作副本和postext-config.json文件(见沙盒 → 持久化)。它们都经过migrateConfig读取,由它固定公式大小(pinLegacyMathSize):fontSizeScale变为保存的缩放比例(未设置时为1)× 1.1312,以em或rem计的独立公式外边距(以公式自身大小计的长度)除以同一系数,所以独立公式周围的间距保持1.4的样子(默认的0.8 em变为0.7072 em)。以页面单位(pt、mm……)计的外边距保持不变,enabled: false的配置或书中没有$的配置也保持不变。这样旧书就按1.4的方式排版,它的math部分显示实际使用的大小。要按现在的大小排这样的书,就把这些值重置:在沙盒中是公式 → 缩放比例、独立公式上边距和独立公式下边距,在代码中是:

math: { ...config.math, fontSizeScale: 1, marginTop: undefined, marginBottom: undefined } // 现在的大小和外边距

**带编号的公式。**带\tag{…}的独立公式占满它的行长(所在的栏,或所在标注框的内宽):公式居中,编号右对齐排在公式所在的行上,align中每个带标签的行都是如此。\tag*{…}按原样打印标签,不加括号。只有显式标签才打印编号;equation之类的环境本身不编号。比行长更宽的带编号公式向右溢出,与其他独立公式一样。(postext 1.4及以前,带\tag的公式根本不会绘制。)

解析函数和精简函数与其他部分一致:

import {
  DEFAULT_MATH_CONFIG,
  resolveMathConfig,
  stripMathDefaults,
} from 'postext';
 
const resolved = resolveMathConfig(config.math);
const minimal  = stripMathDefaults(config.math);

#启动公式引擎

MathJax按需加载,不随引擎的其他部分一起加载。在主线程上排版时,要在构建含公式的文档之前启动它:

import { buildDocument, initMathEngine, renderPage } from 'postext';
 
await initMathEngine(); // 只加载一次MathJax;之后的调用立即完成
const doc = buildDocument({ markdown: 'Euler: $e^{i\\pi}+1=0$.' }, config);
document.body.append(renderPage(doc.pages[0], doc));
  • **引擎运行之前,公式是占位符。**每个公式排成一个估计大小的灰色框。如果在从未调用initMathEngine()的情况下排版含公式的文档,控制台会显示一条警告说明这一点。
  • 排版工作线程会替你启动它。postext/worker中的构建在markdown包含$时会自行调用initMathEngine()。
  • 先排版,就绪后再排一次。isMathReady()告诉你引擎是否在运行。onMathReady(fn)在引擎就绪后调用fn一次(已经就绪则立即调用),并返回一个取消这次调用的函数。编辑器可以先显示占位符,等MathJax加载完再重新排版;initMathEngine()加载期间不会打印警告。
  • **失败。**MathJax无法加载时,initMathEngine()返回的Promise会被拒绝,之后再次调用会重试。
  • **任何打包工具、Node或CDN。**MathJax作为一个预先打包的模块随包发布(压缩前约1.8 MB,只由initMathEngine获取)。直接写import { initMathEngine } from 'https://esm.sh/postext'就能用,不需要?bundle。mathjax-full包只在构建postext时需要,所以安装postext不会安装它。MathJax及其打包的mhchem解析器采用Apache-2.0许可证:它们的声明和许可证文本随模块一起发布,位于dist/math/THIRD_PARTY_LICENSES.txt。
  • **一个页面一个引擎。**同一JavaScript realm中所有导入postext的代码共享引擎及其已渲染公式的缓存(见同一页面共享的全局状态)。

文档一侧的语法($...$、$$...$$、转义美元符号本身)见文档格式。

#脚注

footnotes属性设置以[^id]引用的注释放在哪里、如何编号、外观如何。标记写法见文档格式。

interface FootnotesConfig {
  placement?: 'column' | 'chapterEnd'; // 引用所在栏的栏底,或章末。
  numbering?: 'chapter' | 'document';  // 每章重新开始,或连续编号。
  chapterEndAlign?: 'foot' | 'text';   // chapterEnd:注释排在栏底,或紧接正文之下。
  fontSize?: Dimension;       // 注释字号;em为正文字号。
  lineHeight?: Dimension;     // 注释行距;em为注释字号。
  color?: ColorValue;         // 注释颜色;未设置时为正文颜色。
  textAlign?: TextAlign;      // 未设置时为正文的对齐方式。
  hangingIndent?: Dimension;  // 注释转行的缩进。
  spaceBetween?: Dimension;   // 两条注释之间的间距。
  spaceAbove?: Dimension;     // 正文与分隔线之间的间距;em为正文字号。
  spaceBelowRule?: Dimension; // 分隔线与第一条注释之间的间距。
  separator?: {
    enabled?: boolean;        // 画出分隔线。
    width?: number;           // 分隔线长度,占栏宽的比例。
    lineWidth?: Dimension;    // 分隔线粗细。
    color?: ColorValue;       // 未设置时为注释颜色。
  };
}
属性类型默认值说明
placement'column' | 'chapterEnd''column''column'把每条注释排在引用它的那一行所在栏的栏底,上面有一条短分隔线;单栏版面中就是页面底部。'chapterEnd'把一章的所有注释按引用顺序排在该章最后一个块之后。
numbering'chapter' | 'document''chapter''chapter'在每个一级标题下和每个文档开头从1重新编号。'document'在整个文档中连续编号;逐章排版的书中,也从一章延续到下一章(continuationAfter把最后一个编号记为continuation.footnoteNumber带过去)。
chapterEndAlign'foot' | 'text''foot'与placement: 'chapterEnd'配合使用:'foot'把收尾一栏的注释排在栏底,剩余的空行留在正文和注释之间,与栏底注释的位置一样。'text'把它们紧接正文排。
fontSizeDimension0.8em注释文字的字号。em和rem指正文字号。注释使用正文的字体和字重。
lineHeightDimension1.25em注释文字的行距;em指注释字号。注释不在基线网格上:它们从栏底向上堆叠,上面的正文保持在网格上。
colorColorValue正文颜色注释文字的颜色。
textAlignTextAlign正文的对齐方式注释文字的对齐方式。
hangingIndentDimension0注释第二行及以后各行的缩进,使它们对齐到编号之后。
spaceBetweenDimension0两条注释之间的间距。
spaceAboveDimension0.5em最后一行正文与分隔线之间的间距;em指正文字号。使用'chapterEnd'和chapterEndAlign: 'text'时,spaceAbove + spaceBelowRule就是正文与第一条注释之间的间距。
spaceBelowRuleDimension0.4em分隔线与第一条注释之间的间距。
separator.enabledbooleantrue在每栏的注释上方画分隔线。设为false时,上方的间距保留。
separator.widthnumber0.3分隔线长度,占栏宽的比例(0–1),从栏的左边缘起算。
separator.lineWidthDimension0.5pt分隔线的粗细。
separator.colorColorValue注释颜色分隔线的颜色。
footnotes: {
  fontSize: { value: 7.5, unit: 'pt' },
  lineHeight: { value: 9.5, unit: 'pt' },
  hangingIndent: { value: 0.8, unit: 'em' },
  separator: { width: 0.25, lineWidth: { value: 0.4, unit: 'pt' } },
}

栏底注释的排版方式:

  • **注释与引用同栏。**放置一行之前,版面会加上这一行首次引用的注释的高度(如果是该栏的第一条注释,还要加上分隔线)。一行的注释在它下面排不下时,这一行连同段落的其余部分移到下一栏,并遵守段首孤行和段末孤行规则。栏的文字区域缩小注释所占的高度,所以各栏齐底和章末收尾区域只计算正文。
  • 多条注释在同一栏中按引用顺序堆叠在一条分隔线下。后面再次引用的注释保持原编号,不再重复排出。
  • **底部浮动体。**注释排好之后才占据栏底的图,放在注释上方;图之后排的注释放在图的上方。
  • **框。**在标注框(行内、浮动或固定)中引用的注释,放在框后正文继续的那一栏的栏底,通常就是同一栏。收尾整个文档的框,把它的注释留在正文结束那一栏的栏底。
  • **限制。**注释从不拆分:比一栏还高的注释会溢出。题注、表格单元格和标题中的标记不会被识别(按原样打印)。
  • **输出。**Canvas、HTML和PDF都会绘制注释、标记(上标数字)和分隔线。在PDF中,每个标记链接到对应的注释;带标签的PDF把每条注释设为Note元素,带有唯一的/ID,列在结构树的/IDTree中(PDF/UA-1)。注释是设置了footnoteNote的VDTBlock,位于page.floats中;分隔线位于page.footnoteAreas中。
  • 警告。undefinedFootnote(标记没有定义:编号印在一条空注释上)和unusedFootnote(没有标记引用的定义:不会排出)。

解析函数和精简函数与其他部分一致:

import { DEFAULT_FOOTNOTES_CONFIG, resolveFootnotesConfig, stripFootnotesDefaults } from 'postext';

#东亚排版

cjk属性决定中文、日文和韩文的排版方式:遵循哪个地区的规范,行在哪里可以断开,标点排多宽、是否悬挂,汉字与拉丁字母之间的间距,版心的字格,以及文中的着重号、专名线、注音和夹注怎样印出。每个字段都是可选的;'auto'跟随文档语言(locale)所属的地区,因此locale: 'zh-Hant'的书无须任何其他设置,就会按台湾的做法断行和排标点。中文排版按地区逐一说明这些设置背后的规则,并给出两套完整的配置。

interface CjkConfig {
  region?: 'auto' | 'mainland' | 'taiwan' | 'hongkong';   // 地区规范。
  lineBreak?: 'auto' | 'none' | 'basic' | 'gb' | 'strict'; // 哪些标点不能出现在行首或行尾。
  punctuationWidth?: 'auto' | 'fullwidth' | 'kaiming' | 'lineEndHalf' | 'halfwidth';
  compressAdjacent?: 'auto' | boolean;      // 相邻的两个标点共占1.5 em。
  trimLineStart?: 'auto' | boolean;         // 位于行首或行尾的括号去掉外侧半个字的空白。
  hangingPunctuation?: 'none' | 'allow' | 'force';
  latinSpacing?: Dimension;                 // 汉字与拉丁字母之间;默认0.25 em。
  uprightDigits?: 0 | 2 | 3 | 4;            // 竖排:数字直立排进一个字格;默认2。
  grid?: { enabled?: boolean; charsPerLine?: number; linesPerPage?: number; show?: boolean };
  emphasis?: 'auto' | 'italic' | 'dots';    // *…*对汉字的效果。
  bookTitleMark?: 'auto' | 'brackets' | 'wavy' | 'none'; // :book[…]印出什么。
  annotationColor?: ColorValue;             // 着重号和专名线、书名线;默认为文字颜色。
  ruby?: { fontFamily?: string; fontSize?: Dimension; color?: ColorValue; position?: 'auto' | 'over' | 'under' | 'right' };
  warichu?: { fontSize?: Dimension; color?: ColorValue; open?: string; close?: string };
}
属性类型默认值说明
region'auto' | 'mainland' | 'taiwan' | 'hongkong''auto'文字遵循的规范,地区划分依照W3C《中文排版需求》(clreq)。'auto'从locale读取地区,locale未设置时从bodyText.hyphenation.locale读取:zh、zh-Hans、zh-CN和zh-SG对应'mainland';zh-Hant和zh-TW对应'taiwan';zh-HK和zh-MO对应'hongkong';其他语言一律对应'mainland'。地区决定lineBreak、punctuationWidth、compressAdjacent和trimLineStart的默认值。
lineBreak'auto' | 'none' | 'basic' | 'gb' | 'strict''auto'哪些标点不能出现在行首或行尾(clreq §6.1.1;见下表)。'auto':大陆为'gb',台湾和香港为'basic'。
punctuationWidth'auto' | 'fullwidth' | 'kaiming' | 'lineEndHalf' | 'halfwidth''auto'全角标点排多宽(见标点宽度)。'auto':大陆为'kaiming',台湾和香港为'fullwidth'。
compressAdjacent'auto' | boolean'auto'相邻的两个标点(。」、》(、:“)去掉二者之间半个字的空白,这一对共占1.5 em,而不是2 em。'auto':大陆和香港开启,台湾关闭。
trimLineStart'auto' | boolean'auto'位于行首的开引号或开括号去掉前面半个字的空白,位于行尾的闭引号或闭括号去掉后面半个字的空白。'auto':大陆和香港开启,台湾关闭。
hangingPunctuation'none' | 'allow' | 'force''none'点号能否悬挂到行尾之外(见标点悬挂)。
latinSpacingDimension{ value: 0.25, unit: 'em' }汉字与相邻拉丁字母或数字之间的间距,以中日韩文字字号的em计,也可以用任何长度;0表示关闭(见汉字与拉丁字母间距)。
uprightDigits0 | 2 | 3 | 42竖排:位数不超过此值的数字直立排进一个字格(纵中横),但位于拉丁文句子中的数字除外,这时它随句中的单词一起横倒;0表示关闭(见竖排中的数字)。
grid{ enabled?, charsPerLine?, linesPerPage?, show? }关闭以每行字数和每页行数规定版心(见字格)。
emphasis'auto' | 'italic' | 'dots''auto'Markdown强调(…)对汉字的效果:'dots'在每个字下面(竖排时在右侧)加着重号,与:dots[…]相同,同一强调中的拉丁字母仍为斜体;'italic'把汉字排成斜体,但中文字体没有真正的斜体,只能用倾斜来伪造。'auto':文档语言为中文时为'dots',否则为'italic'(见着重号、注音与夹注)。
bookTitleMark'auto' | 'brackets' | 'wavy' | 'none''auto':book[…]印出什么:书名两侧加《》(书名中的书名用〈〉),书名下加浪线式书名号,或者只印书名。'auto':大陆用书名号括起,台湾和香港用浪线。
annotationColorColorValue文字颜色着重号、专名线和书名线的颜色;也是注音和夹注颜色的默认值。
ruby{ fontFamily?, fontSize?, color?, position? }正文的一半大小,'auto':ruby[…]和{紅樓|hóng|lóu}的注音:字体(默认与正文相同)、字号(默认为正文的{ value: 0.5, unit: 'em' };注音符号为其60 %)、颜色,以及注音本身未指定时的位置('auto':注音符号在每个字的右侧;拼音在横排时位于文字上方,竖排时位于右侧)。
warichu{ fontSize?, color?, open?, close? }正文的一半大小,无括号:warichu[…]的双行夹注:夹注的字号(默认半个em,两行正好填满一行的em)、颜色,以及按正文字号排在第一行之前、最后一行之后的括号(夹注自己的open / close优先)。
级别不能出现在行首不能出现在行尾
none无:任意两个字符之间都可以断行,台湾和香港的报纸就是这样。无。
basic点号、,;:。!?.‼⁇⁈⁉;闭引号” ’ 」 』和闭括号)〕]}】〗》〉;连接号– ~ ~以及两个词之间的单个—;间隔号· ‧ ・;叠字符々〻ゝゞヽヾ和ー;数字单位% ‰ ° ℃ %以及单位组合字符㎡ ㎏ ㏄。开引号“ ‘ 「 『和开括号(〔[{【〖《〈;货币符号¥ $ € £。
gbbasic,加上斜线/ /(GB/T 15834—2011 §5.1.9)。basic,加上斜线。
strictgb,加上两字长的破折号—— ⸺和省略号…… ⋯⋯。gb。

各级别共同的规则:

  • ——和……是一个两字宽的整体,从不拆开;两个这样的整体连在一起时,二者之间可以断开。
  • 数字与其符号和单位不分开(¥5,999、50%、50%、120㎡),中间隔着空格时也一样(−3 ℃、50 %);拉丁单词也保持完整:夹在中日韩文字之间的西文作为一段不可在内部断行的文字排出,除非这段文字本身比行长还宽(这时在音节处加连字符断开,或在能排下的最后一个字符处断开;网址在其连接处断开)。单位组合字符㎡ ㎏ ㎞ ㏄(U+3371–337A、U+3380–33DF、U+33FF)属于其数字所在的那段文字,不会让拉丁文段落变成中日韩段落。
  • 用全角数字和字母写的数字或单词(123456、50%、¥599、3.14、12:30、ABC)同样不拆开;两端对齐的行仍像拉开汉字那样拉开这些字符。
  • 词间空格只在规则允许的地方才是断行点:下一个字符不能出现在行首(参见图表 ( 第三章 )绝不会让)排在行首),或者空格前的最后一个字符不能出现在行尾时,不在这里断行。
  • 脚注标记、:ref以及上标、下标与前面的字符连在一起。
  • 全角空格U+3000是一个一字宽的字符:可以在它之后断行,不能在它之前断行;它从不拉宽,在行首也不会被去掉。中文段落的首行缩进两字,请设置bodyText.firstLineIndent: { value: 2, unit: 'em' },不要输入两个U+3000(解析器会去掉段落开头的全角空格)。

一个字符排不进本行、又可以出现在下一行行首时,它移到下一行,两端对齐的行把剩下的字符拉开。它不能出现在下一行行首时(逗号、闭括号、开括号后面的字符),本行先尝试挤压把它收进来(挤进,clreq §6.2.2.3):如果能让出的空白(见标点宽度)足以抵消超出的部分,这个字符(连同必须跟着它的标点,例如句号后的闭引号)就留在本行,本行排满行长。否则本行让出字符(推出):断行点退回规则允许的上一个位置,两端对齐的行把剩下的字符拉开。如果行比一个数字加上其后的句号还窄,行尾放什么都不合规则,最终还是在句号之前断行。

这些规则适用于哪些段落:中日韩字符(汉字、假名、注音符号、谚文)多于词间空格的段落,即使其中没有两个中日韩字符相连(第1条、第2条、价¥5,999。好)。紧挨着中日韩字符或标点的空格不算词间空格:2026 年 9 月 28 日与2026年9月28日排法相同。这些段落逐行排版,无格式和带格式的路径都一样,因此含有一个粗体词的段落与不带粗体的同一段文字断行完全相同。引用了中文书名或人名的拉丁文段落空格多于中日韩字符:它保留最优断行,在其中的中日韩字符旁按同样的规则断行。题注、表格单元格、注释和标注框也按文档设定的级别断行。词典断词不作用于中日韩段落中的拉丁单词。

两端对齐的中日韩行(段落最后一行除外)按以下顺序拉满行长(clreq §6.2.2.4):先拉宽西文单词之间的空格,每处最多半个em;再拉宽汉字与拉丁字母之间的间距,每处最多半个em;然后把所有字符间隙和这些空格平均拉开。拉丁单词、数字和两字长的标点内部不加间距,连接号和斜线旁边也不加。间距按行中的片段设置(VDTLineSegment.tracking,每个字符之后的px值,计入片段的width),Canvas、HTML和PDF输出将其绘制为字距;后面带有间隙的拉丁单词,其最后一个字母单独成为一个片段。一个片段包含一段西文,或者样式、链接和间距相同、前进宽度一致的字符,因此沙盒放置光标时把片段宽度平均分配给这些字符,链接也只覆盖自己的字符。当一行字符之间需要超过半个em的间距(设置了bodyText.maxJustifyTracking时则为超过该值)时,这一行按该上限排出,达不到行长:该行标记为cjkLoose和ragged,构建时报告一条cjkLooseLine内容警告,附带该行文字。常见原因是一个无法移到上一行的长拉丁单词或网址。不含中日韩字符的行(长网址的开头部分)按左对齐不齐行排出,不报警告,与只有一个单词的拉丁文行一样。参见断词与两端对齐。

#标点宽度

一个全角中文标点由半个em的字形和半个em的空白组成,设置调整的是空白,从不调整字形(clreq §6.3.2)。开括号和开引号的空白在字形之前,闭括号和闭引号的空白在字形之后;大陆的点号、,。.;:?!位于字身框的一角,空白也在字形之后;台湾和香港居中的标点(、,。.;:)以及间隔号在两侧各留四分之一em。横排时,台湾和香港的?!仍占一个em;竖排时,各地的:;?!都占一个em。大陆的间隔号在任何样式下都是半个em,在两种书写方向上都居中(GB/T 15834,clreq §5.1):全角字形两侧的空白都去掉,竖排时它的字格本来就是半个em。

样式行内行尾
fullwidth(全角式)每个标点一个em。一个em;开启trimLineStart时闭括号为半个em。
kaiming(开明式)。.?!一个em;,、;:、括号、引号和间隔号半个em。大陆多数图书采用此式。每个标点半个em。
lineEndHalf(行末半角)每个标点一个em。每个标点半个em(按字面理解GB/T 15834—2011 §5.1.10)。
halfwidth(半角式)每个标点半个em,与词典相同。半个em。

开启compressAdjacent时,相邻的两个标点去掉二者之间的空白(clreq早期草案中的八条规则):闭括号在另一个闭括号或大陆的点号之后(。」,但不在台湾和香港居中的标点之后),点号在闭括号之后(」,),开括号在上述任何标点或另一个开括号之后(,「、》(、「『),以及间隔号与其前的闭括号或其后的开括号之间去掉四分之一em。挤压后这一对最少也占1.5 em:开明式下。”本来就占1.5 em,保持不变,》(占一个em。无论是否开启compressAdjacent,开明式都把句号的空白移到紧随其后的闭引号之后:两个字形挨在一起,半个em排在引号后面(。”␣母,而不是。␣”母),位于行尾时与句号的空白一样处理。开启trimLineStart时,行首的开括号去掉前面的空白,墨迹与文字边缘对齐(段落第一行的开括号落在缩进内半个em处);行尾的闭括号去掉后面的空白。居中的标点两侧各去掉四分之一em,从不在一侧去掉半个em。在两个标点之间断行时,二者都不再挤压:各自按行首或行尾的标点处理(全角,后面的「排到下一行行首时,这个,在本行行尾占满一个em)。

一行收进一个不能出现在下一行行首的字符(挤进)时,要看它还能让出的空白是否足以抵消超出的部分,按clreq的顺序:词间空格缩到四分之一em,然后是间隔号、括号、句内点号,汉字与拉丁字母的间距缩到八分之一em,最后是句末点号,每一步平均分摊。kaiming只允许句末点号缩到半个em,而且只在这种情况下:如果一行只是还差一个字才排满,就把这一行拉开,因此。?!在行内保持一个em。lineEndHalf允许每个标点缩到半个em;fullwidth的标点什么都不让出(全角式的书保持字格),halfwidth的标点已无可让。

拉丁文与中文共用的标点(“ ” ‘ ’ … — ·)在中文里占中文标点的字身框,与字体本身的前进宽度无关:LXGW WenKai把“ ”画成0.35 em宽,Noto Serif SC的破折号为0.89 em,·为三分之一em。当两侧最近的字符(跳过其他这类标点)有一个是中文,或者旁边没有西文时,它们就按中文标点处理。开引号的字形位于一em字身框的末端,闭引号的位于开头;间隔号、省略号和单个破折号居中;成对的省略号(……)按字体把两个连排的方式排出,并在两个em中居中。破折号(——)是一条不间断的线:每个破折号从字体自身的侧边距起拉伸到占满它的em,两笔在衔接处重叠,并升到字符的中线(VDTLineSegment.inkScale,仅作用于绘制的缩放)。之后样式像处理其他标点一样调整字身框。两侧都是西文时(他说:He said “yes” and left.),它们保留字体的前进宽度;本来就是一个em宽的字形不受影响。

渲染器不让浏览器自己的标点间距进入文字。Chrome在一次测量或绘制中遇到两个相邻标点时,会把前一个排成半宽(Noto Serif SC中的本)》录因此是3.5 em),所以相邻的两个标点分开测量和绘制;中日韩文字的HTML行带有text-spacing-trim: space-all和text-autospace: no-autospace,并关闭chws、halt和vchw特性。

在VDT中,让出了空白的标点单独成为一个片段,其width是它保留的前进宽度,并设置inkOffset(px):渲染器在x + inkOffset处绘制它的字形,因此在字形之前让出空白的标点(行首的开括号)绘制在字身框之前那么远的地方(偏移为负)。排在中文字身框中的共用标点记录字形在框中的位置,减去它之前让出的空白,这个值可能为正。PDF用字符间距显示这样的字形,使其前进宽度在字身框结束处结束,阅读器因此不会看到它压到下一个字符上,并把所在行放进一个/ActualText区段(见汉字与拉丁字母间距)。标点的空白在哪一侧由地区决定,与字体无关:请用对应地区的字体排书(大陆用Noto Serif SC,台湾用TC,香港用HK);在大陆标签下使用繁体字体,居中标点会压缩错误的一侧。

#标点悬挂

hangingPunctuation: 'allow'允许、,。.中的一个(大陆的标点位于字身框起始处,因此还包括;:?!)在本来要排到下一行行首、而挤压又收不进本行时,悬挂到行尾之外;横排的台湾和香港文字从不悬挂,因为居中的标点看起来会像被切掉(竖排时可以)。竖排时,悬挂的标点位于行的下端之外。'force'让这类标点只要位于行尾就悬挂(段落最后一行除外),排不下时立即悬挂。标点与另一个标点相接时从不悬挂(。」、,「)。悬挂的片段标记为hangs;行的bbox.width、两端对齐和对齐方式都不计入它,Canvas和PDF把栏的裁剪区加宽最宽的悬挂标点的宽度(hangingPunctuationOverhang),因此它不会被切掉。多数中文图书不使用标点悬挂;clreq只建议在使用字格时采用。

#汉字与拉丁字母间距

latinSpacing(默认四分之一em)在汉字(或假名)与相邻的拉丁字母或欧洲数字之间加入间距:用iPhone拍照和1999年排成用 iPhone 拍照和1999 年。行首和行尾不加,拉丁字符与中文标点之间不加(用iPhone,在逗号前不加任何间距),中文括号内侧不加((iPhone)),紧挨着非字母非数字的符号时也不加(为¥5,999)。作者在这种边界处输入的空格(用 iPhone 拍照,许多网络文本都这样写)会被汉字与拉丁字母间距取代,而不是叠加,因此两种写法排出来一样;不换行空格和全角空格保持原样。在两端对齐的行上,它先增大到最多半个em,然后才拉开字符;在要收进一个不能出现在下一行行首的字符的行上,它缩小到最少八分之一em。以em以外的单位给出的长度按页面的dpi换算。0表示关闭,这时在该处输入的空格仍是词间空格。

这个间距是一个kind: 'space'的片段,标记为autospace,其text为空(或者是作者输入的空格);它的width是最终值,渲染器自己的空格两端对齐不会改动它。它从不进入纯文本,因此搜索、复制粘贴和源文本范围读到的都是原文。PDF把每一个分段排出的行(拉开的字符、汉字与拉丁字母间距、让出空白或悬挂的标点)放进一个/Span,其/ActualText是该行的文字,因此文本提取读到的是用iPhone拍照,而不是用 iPhone 拍 照,含半宽标点的行也读成一行。

#竖排中的数字

竖排时,拉丁单词和较长的数字横倒排,较短的数字直立,各位数字并排放进一个一em的字格:即纵中横(縱中橫,clreq §2.1.3,CSS text-combine-upright)。uprightDigits设定这样的数字最多可以有几位:2(默认)、3、4,或0表示不使用。2026年9月28日在2之下:2026横倒,9直立,28直立并排在一个字格中。

  • 整个数字要么全部直立,要么全部不直立:在2之下,三位数保持横倒,从不拆开。
  • 与拉丁字母相接的数字(A4、mp3、3D)留在它的单词里,横倒排;带小数点或数字分组的数字(3.14、10,000)也一样。
  • 拉丁文句子中的数字跟随句子:两侧都是拉丁单词时,它与单词一起横倒(printed in 49 and 32 copies、chapters 49, 32 and (7) of)。向两侧查看时,跳过空格、其他数字、注释标记、横倒文字中的标点(, . : ; ( ) ' " - /)、短破折号和长破折号以及弯引号(pages 3–5 of、the “49” copies),直到遇到第一个字母或汉字。其标点通向中文的数字直立(上午12:30:45开会、比分为3:2:1、见图(3)所示、他住在"12"号楼);紧挨着汉字或中文标点的数字,无论有无空格,也直立(第 3 回、用iPhone 15拍攝、第3 copies);旁边是直立符号的数字(a 30×40 print)以及位于段首或段尾、只有一侧有单词的数字(49 copies were printed、on page 7.:要让它横倒,请写:sideways[…])同样直立。段落作为一个整体来读,跨越换行、强调和链接。
  • 紧挨着Unicode规定直立排的符号时,每个数字各占一个字格:30×40是30、×、40,全部直立。
  • 字符多到一个em放不下的字格会在横向压缩到一个em;字距和两端对齐从不进入字格内部,只作用于它之后。
  • 断行和两端对齐时,这个字格算作一个汉字,周围不加汉字与拉丁字母间距。
  • 测量器和每个渲染器找到的字格都相同:Canvas把数字转回直立并压缩后绘制,PDF用横排字体同样处理,HTML则用text-combine-upright: all包裹它们。

手动设置时,有三个行内标记在竖排中覆盖这一设置,在横排中不起作用(见文档格式 › 竖排中的文字方向):

第:tcy[120]回、:upright[GDP]與:sideways[12]

:tcy[…]把其中的文字排进一个直立的字格,:upright[…]让每个字符各自直立占一个字格(拉丁字母在其中居中),:sideways[…]把整段文字横倒,包括汉字。在VDT中,:tcy文字是一个tcy: true的片段,一个em宽;:upright或:sideways文字带有orientation。由uprightDigits排进一个字格的数字不带标记:用verticalRuns(graphemes, region, uprightDigits)可以找到它们。在段落中,与拉丁单词一起横倒的短数字带有orientation: 'sideways',如同写了:sideways[…]:它所在的片段不一定包含与它一起横倒的那些单词。

#字格

中文版心以字数来规定(clreq §7.1.1):正文字号 × 每行字数 × 每页行数,再加上行间距,双栏时还有栏间距。grid就按这种方式设定:

cjk: { grid: { enabled: true, charsPerLine: 28, linesPerPage: 28, show: true } }

enabled时,配置在被读取之前就会改写:每栏宽为charsPerLine个bodyText.fontSize的em,版心高为linesPerPage行bodyText.lineHeight;layoutType: 'double'时,栏间距为layout.gutterWidth取整到整数个em,至少一个。page.margins中的页边距作为最小值:版心放在它们留出区域的正中,每个页边距在其方向上增加剩余空间的一半(镜像页边距的书保留内外页边距的差别,二者增加相同的量)。未设置时,charsPerLine和linesPerPage取能排下的最大值。超出能排下的数值会减小到能排下的值,并报告一条cjkGridClamped配置警告。字号和行距保持原值。在oneAndHalf版式中,charsPerLine指主栏;侧栏取最接近sideColumnPercent在所配置页边距内给出宽度的整数个em(至少一个),栏间距按双栏的方式取整,并改写sideColumnPercent,使两栏都按整数个em切分。竖排时(layout.writingMode: 'vertical-rl'),一行的字符沿页面向下排,各行横向排列,因此charsPerLine量的是页面高度,double版式得到上下叠放的两栏,叠加显示的字格立在字符居中的轴线上。

中文出版实践中的两种版式,五号字(10.5 pt),行间距6 pt(lineHeight: 16.5pt):

  • 大32开,140 × 203 mm,28 × 28:行长294 pt(103.7 mm),版心高462 pt(163 mm);内外页边距16/20 mm、上下页边距18/20 mm的page.margins能容纳它。
  • 16开,184 × 260 mm,双栏,每栏23字,小五(9 pt,行距13.5 pt),栏间距两个字:2 × 207 pt + 18 pt。

show在版心上绘制字格(稿纸):在页面的每一栏(oneAndHalf的侧栏位于页面奇偶所决定的一侧)的每一行上,每个字位画一个浅灰色方格(即字符围绕基线的字身框),在Canvas和HTML中都显示。PDF只在renderToPdf收到characterGrid: true时才绘制它:它只是屏幕上的辅助工具。cjkGridGeometry(config)返回一份配置设定的字格(实际使用的数值、页边距、版心),applyCjkGrid(config)返回改写后的配置。

#着重号、注音与夹注

中文标记、注音与夹注的标记把文字留在段落中:搜索、目录、索引锚点和复制的文字读到的都是原文字符,只有字符周围绘制的内容取决于这些设置。

  • 着重号(:dots[…],以及emphasis: 'dots'下汉字上的*…*)每个字加一个点,居中于该字(不计两端对齐的间距),从不加在标点或空格上:横排时在字下,竖排时在字右(clreq §5.3.1)。style选择实心点、空心圆或芝麻点,fill="open"绘制轮廓,pos="over|under"选择一侧。
  • 专名线和书名线(专名号:name[…],bookTitleMark: 'wavy'下的书名号:book[…])画在字身框下方(竖排时在左侧),跨过线内的空格。两段相接处,各自的端点让出八分之一em,因此:name[賈寶玉]:name[林黛玉]读起来是两个名字。着重号和线标在同一侧标注同一段文字时,线离文字更近。在'brackets'下,《》是参与断行的文字,像其他字符一样绘制和复制;它们不占纯文本或源映射中的字符(这些片段标记为inserted)。
  • 注音排在行间空白中,贴着基字的字身框,居中于基字;含拉丁字母(拼音)且位于基字上方的注音,升高其字体下伸部(g、j、p、q、y)的深度再加正文的0.04 em,使其不碰到基字,同一行的所有注音保持同一条基线;比基字宽的注音会加宽基字的框,但减去它可以伸到相邻无注音字符上的四分之一注音em;两个注音之间至少相隔四分之一注音em(两个注音符号注音之间为四分之一符号em,因此两个字各配三个符号时仍留在各自的字格中)。单字注音(每个字一个读音)可以在字之间断行;词组注音从不断开。在行首或行尾,基字和注音对齐到边缘(clreq §5.5.4)。横排时,注音符号在每个字右侧排成一列,字的框随之加宽;竖排时,同样的一列沿字的右侧向下排。声调符号位于这一列右侧,其墨迹的一半高出最后一个符号的顶端(clreq §5.5.3.3),按墨迹定位,因为字体把这些符号排在字身框中偏高的位置;竖排时它直立,与第一个符号上方的轻声点一样。
  • 夹注(双行夹注)按夹注字号折成两行,在本行中居中,两行之间没有间隙。上一行持续收字,直到至少容纳这一部分的一半,因此下一行永远不会更长;如果下一行会以不能出现在行首的标点开头,上一行再多收一个字。一行剩余空间排不下的夹注先填满本行,再接到下一行、下一栏或下一页;它的括号只排在第一行之前和最后一行之后。竖排时,上一行是右边的一行,先读。

行距从不改变:着重号、线标和注音都位于行间空白中。如果一个段落的行间空白(行距减去字号)在一侧有标注时不足半个em,或在两侧都有标注时不足八分之五em(clreq §5.6.1),会报告一条cjkMarksExceedLeading内容警告;注音高于行间空白(拼音注音连同其升高量一起计算)的段落报告rubyExceedsLeading。请给这样的段落一个行距更大的段落样式。

在VDT中,带标注的片段带有cjkMarks,排版把着重号和线标作为VDTLine.marks放在各行上(点、圆圈、芝麻点、直线和波浪线,位于该行的排版方向坐标系中),Canvas、HTML和PDF按原样绘制(PDF:Artifact /Layout;HTML:aria-hidden框,带着重号的文字放在<em>中)。注音基字的片段带有ruby(注音及其各段),注音把基字拉开时在inkOffset处绘制基字;夹注在一行上的部分是一个片段,其text先是上一行、再是下一行,其warichu保存渲染器实际绘制的两行。带标签的PDF把注音放进其基字Ruby的RT中,把夹注放进Warichu中,该行的/ActualText读出基字文字,夹注只读一次。着重号、注音和夹注在正文、标题、列表和标注框中绘制;题注、表格单元格和设计元素中的文字保留原文,但不绘制这些标注。

解析函数和精简函数与其他部分一致;setCjkLineBreak和setCjkComposition为在buildDocument之外进行的测量设定级别和排版方式(标点宽度、悬挂、汉字与拉丁字母间距;cjkCompositionOf(resolved.cjk, dpi)),buildDocument则根据配置设定它们。在构建之外,标点保留完整的前进宽度,也不加汉字与拉丁字母间距:

import { DEFAULT_CJK_CONFIG, resolveCjkConfig, stripCjkDefaults, cjkRegionOf, setCjkLineBreak, setCjkComposition, cjkCompositionOf } from 'postext';
 
resolveCjkConfig(undefined, 'zh-HK');
// { region: 'hongkong', lineBreak: 'basic', punctuationWidth: 'fullwidth', compressAdjacent: true,
//   trimLineStart: true, hangingPunctuation: 'none', latinSpacing: { value: 0.25, unit: 'em' }, uprightDigits: 2,
//   grid: { enabled: false, charsPerLine: 0, linesPerPage: 0, show: false },
//   emphasis: 'dots', bookTitleMark: 'wavy',
//   ruby: { fontSize: { value: 0.5, unit: 'em' }, position: 'auto' },
//   warichu: { fontSize: { value: 0.5, unit: 'em' }, open: '', close: '' } }

在沙盒中,这些设置位于版面设计 › 书写系统 › 东亚排版。

#资源类型

资源类型是可由用户定义的类别,如图、表、示意图、代码清单……它决定该类资源如何编号、如何加题注、如何被引用。类型列表存放在config.resourceTypes中;沙盒在设计 → 图与表 → 编号与位置中编辑它。

未设置config.resourceTypes时,Postext提供两个内置默认类型:图和表,二者都按{h1}.{n}编号(每遇到一级标题重新计数),计数器为十进制。它们的名称采用文档语言:先取config.locale,没有则取bodyText.hyphenation.locale,再没有则用英语(见文档语言)。

内置默认类型会随语言区域变化。导出的defaultResourceTypes(locale = 'en')把类型名称、简写标签和题注前缀本地化为文档的语言区域:英语得到Figure/Fig.和Table/Tab.;西班牙语得到Figura/Fig.和Tabla/Tabla;法语、德语、意大利语、葡萄牙语、加泰罗尼亚语和荷兰语也各有对应名称(列在文档语言的表中)。es-ES这类带地区的标签按语言解析,没有译名的语言区域回退到英语。编号行为(numberingTemplate: '{h1}.{n}'、resetOn: 'h1'、十进制计数器)与语言无关。每次调用都返回新对象,因此可以随意修改结果:

import { defaultResourceTypes } from 'postext';
 
const types = defaultResourceTypes('es');
// => [{ id: 'figure', name: 'Figura', shortLabel: 'Fig.', captionPrefix: 'Figura',
//       numberingTemplate: '{h1}.{n}', resetOn: 'h1', counterFormat: 'decimal', … },
//     { id: 'table',  name: 'Tabla',  shortLabel: 'Tabla', captionPrefix: 'Tabla', … }]
type ResourceCounterFormat =
  | 'decimal'
  | 'roman-lower'
  | 'roman-upper'
  | 'alpha-lower'
  | 'alpha-upper';
 
type ResourceCounterReset = 'never' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
 
interface ResourcePlacement {
  position?: 'auto' | 'top' | 'bottom' | 'here'; // 浮动体可占用哪种空位;'here' = 在::resource指令处行内嵌入
  span?: 'column' | 'page' | 'side';             // 一栏、整个内容宽度,或只放浮动体的侧栏
  rotate?: 'ccw' | 'cw';                         // 旋转四分之一圈:横排的表单独占一页
  width?: number;                                // 栏宽或页宽的比例(0 < width < 1);默认占满整个宽度
  align?: 'left' | 'center' | 'right';           // 比所在栏窄的浮动体放在哪里;默认'left'
  captionSide?: boolean;                         // 题注放在图旁,位于oneAndHalf版式的侧栏中(仅限栏内浮动体)
}
 
interface ResourceType {
  id: string;                          // 稳定的id,由Resource.typeId引用
  name: string;                        // 单数显示名称,例如"Figure"
  namePlural?: string;                 // 可选的复数形式,例如"Figures"
  shortLabel: string;                  // 行内引用用的简写标签,例如"Fig."
  numberingTemplate: string;           // "{h1}.{n}"或"{n}"
  resetOn: ResourceCounterReset;       // {n}计数器何时重新计数
  counterFormat: ResourceCounterFormat;// {n}的格式
  captionPrefix: string;               // 加在题注前面,例如"Figure"
  defaultPlacement?: ResourcePlacement;// 该类型资源的后备位置
}

ResourcePlacement与资源自身的placement形状相同。position选择浮动体可占用的空位种类:auto(默认)取第一次引用之后的第一个空位,top / bottom把范围限定在该种版带,here把资源行内嵌入。span设定浮动体的宽度范围:一栏、整个内容宽度,或一栏半版式中只放浮动体的侧栏。rotate把资源旋转四分之一圈,并使它成为单独占一页的通栏浮动体。width把浮动体收窄为所在栏宽的一个比例(通栏浮动体则为页宽的比例),比如宽栏里的一张小表。align说明这种较窄的浮动体放在哪里(默认靠左,也可居中或靠右),也说明比空位窄的图片在空位中的位置:比栏窄的位图,或被layout.fitFiguresToPage缩小的图像。题注和注释保持空位的行长。(postext 1.4及以前,这类图片总是左对齐排放。)captionSide把题注放在图旁,位于一栏半版式中只放浮动体的侧栏(layout.sideColumnRole: 'floats'),与图的顶边平齐(底部浮动体则与底边平齐);它只作用于栏内浮动体,没有这种侧栏的页面仍把题注放在图下。资源和它的类型都没有设定位置时,内置默认值为auto / column。

属性类型说明
idstring稳定标识符,由每个资源的typeId引用。在创建类型时设定一次;删除仍被资源引用的类型会产生悬空类型警告。
namestring单数显示名称。用于style="full"行内引用(例如Figure 1.7)。
namePluralstring(可选)复数显示名称,用于界面标签和资源列表。
shortLabelstring默认行内引用样式使用的简写(例如Fig. 1.7)。
numberingTemplatestring计算编号所用的模板。见下文模板标记。常见形式有(按章编号,例如2.3)和(单一连续计数)。
resetOnResourceCounterReset'never'给出贯穿全文档的连续计数;'h1'..'h6'在每遇到该级(或任一上级)标题时把计数器清零。应与模板中出现的标题级别一致,例如配resetOn: 'h1'。
counterFormatResourceCounterFormat计数器的显示方式:十进制(1, 2, 3)、小写/大写罗马数字(i, ii / I, II)或小写/大写字母(a, b / A, B)。页码和列表使用的写法也可以('lower-roman'、'arabic'……;见编号格式的写法);未知值按十进制计数并报告出来。标题标记(……)总是显示为十进制。
captionPrefixstring加在图表题注前面的文字。计算出的编号跟在前缀之后,题注显示为 . ,例如Figure 1.7. The original plan. numberingTemplate为空的类型没有编号,题注为. 。前缀末尾的空格会被去掉;前缀若已以.、:、!、?或…(或其全角形式)结尾,就不再加句点:Pl. Lines at 0°。
defaultPlacementResourcePlacement(可选)该类型中未设定自身placement的资源所用的位置:position、span、rotate、width、align和captionSide,各字段分别解析。资源和类型都没有设定某个字段时,采用内置默认值:auto / column、不旋转、占满整个宽度、左对齐、题注在图下。解析顺序见下文编号与引用,各取值的作用(包括旋转的资源)见文档格式 › 资源。
captionStyleCaptionStyleConfig(可选)对该类型资源局部覆盖题注样式。只有你设定的键会替换全局captionStyle,其余一律继承(覆盖了color时,标签和注释的颜色也随之改变,除非它们另有明确设定)。典型用法:表的题注放在上方的色条上,图的题注仍在下方。调色板引用与其他颜色一样解析。

#模板标记

numberingTemplate与标题编号使用同一个渲染器(见标题)。它识别两类标记:

  • {n}:该类型的计数器,按counterFormat格式化。每个资源使它递增一次,并按resetOn清零。
  • {h1} … {h6}:第一次引用处生效的各级标题编号,总是显示为十进制。{h1}是当前一级标题的编号,{h2}是二级标题的编号,依此类推。

其他文字按字面输出。反斜杠用来转义字面的{、}或\。某个标题标记在当前范围内没有值时(例如在第一个一级标题之前的{h1}),它会连同相邻的分隔符一起省去,所以{h1}.{n}会自然退化为单独的计数器。

空模板('')不输出编号,但该类型仍会为资源计数:题注为Do. Lines at 0°,:ref只输出标签(Do)。postext 1.4及以前,这样的题注显示为Do .Lines at 0°,引用末尾还多一个不间断空格。

模板h1 = 2、计数器=3时说明
3单一连续计数。配resetOn: 'never'使用。
2.3按章编号。配resetOn: 'h1'使用。
2.0.3按节编号。配resetOn: 'h2'使用。

#编号与引用

资源类型生成的编号就是:ref输出的内容,也是题注前缀后面跟的编号。:ref{id}是主要写法:按阅读顺序的第一次引用会纳入该资源,资源浮动到这次引用之后的第一个空位:引用所在栏的底部、下一个空栏的顶部或底部,或下一页的某个版带(依其解析后的位置而定:position: 'auto' | 'top' | 'bottom' | 'here'和span: 'column' | 'page' | 'side',外加rotate、width、align和captionSide,先按资源自身解析,再取类型的defaultPlacement,最后取内置的auto / column;'top' / 'bottom'把搜索限定在该种空位)。::resource{id}块级嵌入是可选的,只在placement.position: 'here'时需要,即在文字流中某个确切位置行内嵌入、不浮动。文档一侧的完整语法(两种写法,以及:ref的style和text选项)见文档格式 › 资源,其中也说明了第一次引用的顺序如何决定计数。

这与标题编号相对应:标题级别带有numberingTemplate,资源类型也带有一个;不同的是资源计数器({n})按第一次引用递增,而不是按标题递增,resetOn再把它与标题层级联系起来。

#哪些资源会编号

正文引用了的资源才编号,无论是用:ref还是::resource嵌入,编号顺序就是这些第一次引用的顺序,与位置无关:浮动的、行内(here)的、在侧栏的或旋转的都一样。只由版面设计绘制的资源(章首页、书眉或篇首页的image元素),或者没有任何引用的资源,不编号,也不推进其类型的计数器。因此在一篇摄影随笔中,如果满版出血的图版都是章首页图像,只有一张较小的图版是被引用的浮动体,那么这张浮动体就是图版I,不管前面的章首页已经展示了多少图版;章首页的图版应在其版面设计中编号(用{attr.plate}这样的属性),计数器留给正文引用的图版。

逐章排版的书(沙盒、buildBundle,或用continuationAfter()传递计数器的buildDocument)中,以全书的第一次引用为准:资源保留它在首次引用它的那一章得到的编号,也只由那一章放置它。后面章节的:ref输出这个编号,但不放置任何东西;在那里对浮动资源的::resource嵌入也只是又一次引用(行内的here嵌入仍排在写它的位置)。当图与引用在同一份输出中时,引用会链接到图:整本书的PDF会把它链接到前面章节的那一页。单独渲染的一章,无论HTML还是PDF,都把它排成链接颜色的普通文字,因为它的图不在该文档里。宿主若把各章的HTML拼在同一页上,可以把各章锚定的资源作为refTargets传给renderToHtml,这样的引用就又会链接到前面章节的图:

import { anchoredResourceIds, buildBundle, renderToHtml } from 'postext';
 
const docs = buildBundle(bundle);
const refTargets = new Set(docs.flatMap((d) => [...anchoredResourceIds(d)]));
const html = docs.map((d) => renderToHtml(d, { refTargets })).join('');

**postext 1.5的变化:**1.4及以前,每个引用了某张图的章节都会再浮动一次这张图,而且每个:ref在HTML中都是链接,不管它的图是否在该页面上。

{h1}是一级标题的连续计数:每个H1都会使它递增,除非该标题的样式设了numbered: false;空的numberingTemplate只是隐藏标题的编号,并不停止计数。因此一篇只有标题是H1的文章,用内置的{h1}.{n}类型时,图的编号是1.1、1.2……要输出图1、2……有两种办法:

  • 使用按{n}编号、resetOn: 'never'的类型(若用resetOn: 'h1',每个H1都会重新计数);
  • 给标题以及其他不应计数的H1加上numbered: false的标题样式:这样的标题不推进{h1},而是保持原值(在第一个计数的H1之前为空,此时{h1}.{n}退化为单独的计数器),也从不触发resetOn: 'h1',所以计数会越过它继续。在# Introduction及其图1.1之后,未编号的# Appendix下的第一张图是1.2,而不是2.1。
// 单篇文章文档中的图1、2、3……
resourceTypes: defaultResourceTypes('en').map((t) => ({ ...t, numberingTemplate: '{n}', resetOn: 'never' })),

#表格样式

tableStyle属性控制表格资源的字体排印和装饰,作用于每张表,除非该表选用了某个命名表格样式。正文单元格和表头单元格分别设定样式。字体、字号和颜色未设置时继承解析后的正文,因此没有tableStyle的文档会用正文的字体排印来渲染表格。

const config: PostextConfig = {
  tableStyle: {
    headerBold: true,
    headerBackground: { hex: '#f0f0f0', model: 'hex' },
    borders: true,
    borderWidth: { value: 0.75, unit: 'pt' },
  },
};
属性类型默认值说明
bodyFontFamilystring正文字体正文单元格的字体。
bodyFontSizeDimension正文字号正文单元格的字号。
bodyColorColorValue正文颜色正文单元格的文字颜色。
headerFontFamilystring正文字体表头单元格的字体。
headerFontSizeDimension正文字号表头单元格的字号。
headerColorColorValue正文颜色表头单元格的文字颜色。
headerBoldbooleantrue表头单元格用粗体。
headerItalicbooleanfalse表头单元格用斜体。
headerLetterSpacingDimension0pt表头单元格中每个字符(包括空格)之后的字距,相当于CSS的letter-spacing。正值把字母拉开(全大写的表头通常取0.05em到0.1em),负值收紧。em以表头字号为准。表头各行按这个字距测量,因此换行、居中和对齐都考虑了字距,Canvas、HTML和PDF的绘制结果一致。它作用于所有表头单元格:表头行,以及任何标记了isHeader的单元格。
headerTextTransform'none' | 'uppercase''none'表头单元格用大写字母排。文字长度保持不变,因此沙盒仍能把每个字母对应到源文本:大写形式更长的字母(ß)保持原样。资源引用保留其标签。
headerBackgroundEnabledbooleantrue在表头行后面绘制填充色。
headerBackgroundColorValue#f0f0f0表头行的填充色。
bodyBackgroundEnabledbooleanfalse在正文行后面绘制填充色。
bodyBackgroundColorValue#ffffff正文行的填充色(仅在启用时绘制)。
bodyAlternateBackgroundEnabledbooleanfalse斑马纹:每隔一行正文用bodyAlternateBackground填充。见斑马纹。
bodyAlternateBackgroundColorValue#f2f2f2交替正文行的填充色(仅在启用时绘制)。
bordersbooleantrue绘制单元格边框。
borderColorColorValue正文颜色边框线的颜色。
borderWidthDimension0.75pt边框线的粗细(96 DPI下约为1px;随页面DPI缩放)。
cellPaddingDimension0.375em每个单元格的内边距。
rules'grid' | 'horizontal' | 'outer' | 'none''grid'borders开启时绘制哪些线:完整的单元格网格、只有横线(每行的上下边,无竖线)、只有外框,或都不画。
borderRadiusDimension0表格外框的圆角半径。外框画成圆角(用grid或outer线型时),单元格填充色和表头背景按它裁切(即使rules: 'none'或关闭边框也是如此),横线裁到其外轮廓为止;内部的线保持直线。跨页拆分的表,第一部分圆上方两角,最后一部分圆下方两角。半径不超过表格宽度和高度的一半。
overflow'split' | 'clip' | 'hide''split'比页面还高的表如何处理:在后续页面上接续、只保留放得下的行,或者不排。见下文。
continuedSuffixstring'(cont.)'加在拆分表格每个续表部分的题注之后,用斜体,前面隔一个空格;以汉字或全角字符开头的后缀('(续)')则与题注紧排。
continuesMarkerEnabledbooleantrue在每个转下页的部分下方排一个标记。
continuesMarkerstring'Continued' / 'Continúa'该标记的文字,以注释字体(见题注样式)右对齐排在该部分下方。默认值随文档语言区域而定(八种语言见文档语言)。

边框粗细保留小数:0.5pt的线在PDF和屏幕上都画成细线,而不会向上取整到整像素(最小为0.25px)。

#斑马纹

长数据表每隔一行着色,横向阅读时更容易跟行。bodyAlternateBackgroundEnabled打开条纹,bodyAlternateBackground设定条纹颜色:

const config: PostextConfig = {
  tableStyle: {
    bodyBackgroundEnabled: true,
    bodyBackground: { hex: '#ffffff', model: 'hex' },
    bodyAlternateBackgroundEnabled: true,
    bodyAlternateBackground: { hex: '#eef3fa', model: 'hex' },
  },
};

行从表头行之后的第一行开始计数(表头行由TableModel.headerRowCount给出,或者是由表头单元格组成的开头几行):这一行保持bodyBackground(bodyBackgroundEnabled关闭时则无填充),下一行取交替填充色,依此类推。计数依据表格模型而不是页面,所以跨页拆分的表在每一页上都保持各行原有的条纹,跨行合并的单元格取其第一行的条纹。表头单元格保持表头填充色,单元格自身的background优先于两者,与调色板关联的颜色随调色板变化。命名表格样式可以像设定其他字段一样设定这两个字段,因此一个样式可以带条纹,而文档中的其他表不带;在沙盒中它们是正文单元格下的斑马纹行开关及其颜色。

在VDT中,交替行的单元格带有alternate: true,表格版面带有bodyAlternateBackground。tableCellFill(table, cell)返回单元格实际绘制的填充色(它自己的、表头的、交替的或正文的),Canvas、HTML和PDF后端绘制的都是这个值。相邻的填充色之间没有接缝:浏览器在非整数像素比下、PDF阅读器在显示时,会分别对每块填充做抗锯齿,两个单元格之间就会透出一条细线,露出页面底色。所以HTML和PDF后端绘制的是tableCellFillRects(table):每个单元格的填充色都在它与后绘单元格共用的每条边上多出一条窄带,再由后绘的单元格盖住;Canvas则把填充对齐到设备像素。

#命名表格样式

一份文档里的表很少全都一个样:清单用藏青色网格加圆角外框,选项行只用外框围起来,数据表只用横线。tableStyles声明命名的变体,表格资源用table.styleId选用其中一个。样式没有设定的字段先从tableStyle读取,再从正文读取,所以一个样式只需写明它与众不同的地方。没有styleId的表,或者styleId指向未声明样式的表,仍用tableStyle;没有tableStyles的文档渲染结果与以前完全相同。

const config: PostextConfig = {
  tableStyle: {
    borderColor: { hex: '#163a76', model: 'hex' },
    borderWidth: { value: 1.3, unit: 'pt' },
    borderRadius: { value: 10, unit: 'pt' },
  },
  tableStyles: [
    {
      id: 'option',
      name: 'Option row',
      rules: 'outer',
      borderColor: { hex: '#7a9cc6', model: 'hex' },
      borderWidth: { value: 1, unit: 'pt' },
      borderRadius: { value: 8, unit: 'pt' },
      headerBackgroundEnabled: false,
    },
  ],
};
 
// 在资源中:这张表用"option"样式排。
const resource: Resource = {
  id: 'choices', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  table: { model: { rows: [/* … */] }, styleId: 'option' },
};

每个条目接受tableStyle的所有字段,外加id(供table.styleId引用)和可选的name(供编辑器显示,默认为id)。样式能设定的一切都按表生效:字体排印、填充色、边框、线型、圆角半径、内边距,以及溢出行为和它的续表文字。resolveTableStylesConfig(styles, tableStyle, resolvedBodyText, locale?)返回解析后的列表,pickTableStyle(resolved, styleId)返回某张表所用的样式,stripTableStylesDefaults去掉未设定的字段(等于内置默认值的字段会保留,因为它仍会覆盖tableStyle中不同的值)。

#比页面还高的表

浮动表如果放不进为它提供的新页面,既不会被压缩,也不会溢出:在overflow: 'split'(默认)下,引擎在放得下的最后一条行边界处把它按行切开,在后续页面上接续,需要几页就接几页。每个续表部分都重复表头行(TableModel.headerRowCount,未设置时为由表头单元格组成的开头几行),并再次带上题注,在描述之后加上continuedSuffix,如“Table 6-4. Title (cont.)”。每个还要接续的部分下方都有右对齐、用注释字体排的continuesMarker;表的注释留到最后一部分。切分点从不穿过合并单元格(跨行单元格整体移到下一部分);统领下面各行的一行(横跨整张表的单个单元格)会被带到下一部分,而不是孤零零地留在页面底部。

**第一部分在哪里结束。**如果在引用之后为表提供的是某个空栏的顶部,表就取那里放得下的行,余下的接到下一个空位。当它独占该栏时,会一直排到栏底:若某部分下方只剩不到三行正文的空间,它就把这些空间也占上,而不留下一小截文字。当该栏已有另一条浮动版带时(比如页顶横跨整页的通栏图),这一部分会在栏底之前至少留出三行正文的位置就停下,这是浮动体与其他浮动体共用一栏时都要留给文字的空间,于是该栏在表下仍有一些文字,而不是只有浮动体。要让长表一直排到栏底,应在起始页没有其他浮动体的地方引用它(例如在通栏图那一页之后),或者调整它的行使其适合栏高。

'clip'保留放得下的开头几行,不声不响地丢掉其余部分(注释仍在该部分末尾);'hide'则整张表都不排。二者只在表比一页还高时起作用:放得下的表在任何模式下都整体放置。行内(placement.position: 'here')的表不拆分。

续表文字的默认值随文档语言而定(locale,否则取断词语言区域):英语为(cont.) / Continued,西班牙语为(cont.) / Continúa,法语、德语、意大利语、葡萄牙语、加泰罗尼亚语和荷兰语也同样有对应文字(列在文档语言中)。

单元格内容是行内Markdown,单元格中的换行(换行符,或者像题注和注释中那样用\\)开始一个新段落。以项目符号或短横(•、-、*、–)或者数字(1.、1))加空格开头的段落排成列表项:标记按原样绘制,文字以文档的unorderedLists.gap为距悬挂在标记之后,折行与文字对齐,开头两个空格表示嵌套一级。所以写成• Ofrece elección\n• Acomoda a personas diestras y zurdas的单元格会排成两项的列表。只有普通空格的一行不产生任何内容;含不间断空格(U+00A0)的一行则算作单元格的一行,与CommonMark一致,所以1\n后面跟一个不间断空格会使这一表格行占两行高。单元格文字末尾的不间断空格保留其宽度:760加一个不间断空格,右对齐在(231)上方时,会在离边缘一个空格处结束,使0靠近1。只有空格与括号等宽时数字才完全对齐,而大多数字体里空格更窄。(postext 1.4及以前两者都会被丢掉。)

列宽属于表格模型,而不属于样式:TableModel.columnWidths是可选的相对权重数组,每列一个值,在排版时归一化,[2, 1, 1]让第一列占一半宽度。缺少数组、长度不对或权重不为正时,退回平均分配。表格编辑器在增删列时会保持该数组与列对应。

#构建表格模型

TableModel是按行优先排列的网格,每个单元格按它在网格中的位置排版:rows[r][c]位于第c列。因此合并单元格所覆盖的单元格仍留在网格中,每个都用hiddenBy指向其主单元格;这与HTML表格不同,HTML表格会把它们省去。从postext导出的模型辅助函数保持这种结构;它们都是纯函数,返回新模型:mergeCells(model, { start, end })和unmergeCell(model, at)、addRow、addColumn、removeRow、removeColumn、setCellContent、setCellImage、setCellBackground以及setAlignment。四个行列辅助函数保持合并区域完整:在合并块内部添加的行或列会使它变宽,在它之前添加的会使它移位,从中删除的会使它缩小(失去第一行或第一列的合并块把内容保留在新的左上角单元格中),每个hiddenBy始终指向其主单元格。

parseTSV(text, options?)从制表符分隔的文本构建模型,即从电子表格粘贴来的区域:按换行拆行,按制表符拆单元格,较短的行会补齐,使网格成为矩形。headerRows把开头几行变成表头行:其单元格获得isHeader,模型获得headerRowCount,这样跨页拆分的表会重复它们。

import { parseTSV, mergeCells } from 'postext';
 
let model = parseTSV('Part\tQty\tNote\nBolt\t4\tM6\nNut\t8\t', { headerRows: 1 });
// model.headerRowCount === 1;model.rows[0][0]为{ content: 'Part', isHeader: true }
model = mergeCells(model, { start: { row: 2, col: 1 }, end: { row: 2, col: 2 } });
// rows[2][1]获得colSpan: 2;rows[2][2]留在网格中,带hiddenBy: { row: 2, col: 1 }

tableGridIssues(model)检查网格。对于完好的模型,它返回空列表;否则按行序返回网格出问题的每一处:spanOverlap,即位于另一个单元格的colSpan / rowSpan之下的可见单元格(coveredBy指出那个单元格),像HTML那样省去被覆盖的单元格就会出现这种情况,因为其后的每个单元格都会移到合并区域上;以及missingCells,即在最后一列之前就结束、又没有合并单元格覆盖其余部分的行,这会留下空洞。

import { tableGridIssues } from 'postext';
 
tableGridIssues({
  rows: [
    [{ content: 'A', colSpan: 2 }, { content: 'C' }],
    [{ content: '1' }, { content: '2' }, { content: '3' }],
  ],
});
// => [{ kind: 'spanOverlap', row: 0, col: 1, coveredBy: { row: 0, col: 0 } },
//     { kind: 'missingCells', row: 0, col: 2 }]

文档使用的表若网格存在这类问题,会在doc.contentWarnings中报告为raggedTableGrid(见文档中的警告)。

#题注样式

captionStyle属性控制资源题注(图像、SVG和表格下方或上方的Figure 1 — …那一行)。带编号的标签和描述共用同一字体和字号,这是引擎的限制,但标签可以有自己的字重、斜体和颜色。字体、字号和颜色未设置时继承正文。题注可以放在资源上方(表格的通常做法),也可以排在横跨整个块宽的彩色色条上;可选的较小注释(来源、出处,即Resource.note)通过note子对象设定样式。资源类型可以通过ResourceType.captionStyle为本类型的资源覆盖其中任何字段(见资源类型)。

const config: PostextConfig = {
  captionStyle: {
    align: 'center',
    labelBold: true,
    labelColor: { hex: '#295AA3', model: 'hex' },
    descriptionItalic: true,
    position: 'above',
    backgroundEnabled: true,
    padding: { value: 0.35, unit: 'em' },
    note: { italic: true, align: 'left' },
  },
};
属性类型默认值说明
fontFamilystring正文字体题注字体(标签和描述)。
fontSizeDimension正文字号题注字号(标签和描述)。
colorColorValue正文颜色描述文字的颜色。
align'left' | 'center' | 'right' | 'justify''left'题注各行的水平对齐。'justify'把除最后一行外的每一行撑满整个宽度。在色条上时,各行在其内边距以内对齐;侧边题注在自身宽度内对齐。
gapDimension0.75em资源与题注之间的垂直间距。
labelBoldbooleantrue带编号的标签(例如Figure 1)用粗体。
labelItalicbooleanfalse带编号的标签用斜体。
labelColorColorValue题注的color带编号的标签的颜色。
descriptionItalicbooleanfalse描述文字用斜体。
position'above' | 'below''below'题注的位置。取'above'时,题注(及其色条)在前,资源主体下移题注高度加gap;注释则放在主体下方。
backgroundEnabledbooleanfalse在题注后面绘制色条。色条横跨整个块宽,四周包住题注各行并留出padding。
backgroundColorValue调色板主色色条的填充色(仅在启用时绘制)。
paddingDimension0.35em色条边缘与题注文字之间的内边距。色条关闭时忽略。
noteobject—资源注释的样式,见下面的子表。
labelNumberGapstring不间断空格在题注和行内:ref中,标签与编号之间的内容:Figure 1.7、Fig. 1.7。中文二者紧排:''得到图1-1。
labelSeparatorstring'. '编号之后、描述之前的内容:Figure 1.7. A caption。中文题注用一个全角空格' '(图1-1 标题)。没有编号的标签仍按自己的规则:加句点,除非前缀已以句点结尾。

note子对象为Resource.note设定样式,这是排在资源下方、字号较小的一小段文字(来源、出处、附注)。它接受与题注相同的行内格式和:ref标记,并继承题注的字体。题注在下方时,注释放在题注下面;题注在上方时,注释放在资源主体下面。注释的高度计入整个块,所以带注释的资源作为一个整体浮动。

属性类型默认值说明
note.fontSizeDimension题注字号的0.85倍注释字号。
note.colorColorValue题注的color注释文字的颜色。
note.italicbooleanfalse注释用斜体。
note.gapDimension0.35em注释与其前面内容(题注或主体)之间的间距。
note.align'left' | 'center' | 'right' | 'justify''left'注释各行的水平对齐,与题注的align相同。

按类型的覆盖设置用mergeCaptionStyle(resolvedCaptionStyle, override, palette?)合并;导出这个函数是为了让宿主在管线之外也能得到相同的解析结果。

#图示样式

diagramStyle属性控制嵌入的SVG图示(kind: 'svg'资源)如何着色。目前它只有一项功能:单色模式。这一步重新着色会把图示中的每种颜色映射为同一种油墨的某个浓淡,文档用单一专色印刷时,图也能如实还原。

const config: PostextConfig = {
  diagramStyle: {
    singleInk: true,
    inkColor: { hex: '#295AA3', model: 'hex' },
  },
};
属性类型默认值说明
singleInkbooleanfalse把每张嵌入的SVG图示重新着色为同一种油墨的浓淡。
inkColorColorValue主色(#295AA3)所用油墨。默认取文档调色板的主色(通过paletteId: 'main-color'与调色板关联),因此更换调色板色样时,图示会与标题、粗体文字一起换色。

#单色模式的工作原理

启用singleInk后,SVG标记中的每种颜色都会改写为inkColor的一个浓淡,其浓度为1 − 相对亮度(Rec. 709系数作用于经伽马编码的通道,这是一种感知上的近似,用于浓淡映射绰绰有余)。这种映射保留了感知明度:白色映射为纸白,黑色映射为满版油墨,浅色填充无论原来是什么色相都保持浅色。浅黄色背景变成油墨的浅淡色;深色描边接近满版油墨。

重新着色由导出的applySingleInkToSvg(svgText, inkHex)完成,它不依赖DOM,把SVG标记当作文本处理:

  • #rgb / #rgba / #rrggbb / #rrggbbaa十六进制字面量、rgb() / rgba()函数和hsl() / hsla()函数,无论出现在哪里都会改写:表现属性、内联style、渐变、<defs>。这些函数的通道可以是整数、小数或百分比,可以用逗号语法或空格语法,因此rgb(11.37%, 20%, 50.59%)(Cairo的写法)和rgb(51 102 153 / 50%)同样会重新着色。着色后的函数写回为rgb(…)或rgba(…)。
  • 关键字white和black只在作为绘制值(fill、stroke、stop-color、flood-color、color,无论是属性还是内联样式属性)出现时才替换,文本内容或标签中的不会替换。
  • none、transparent和currentColor保持不变,其他命名颜色(red、steelblue……)以及未设置填充的形状或文本的默认黑色也不变。要让这些元素重新着色,就给它们指定明确的颜色。
  • Alpha通道保留不变(#rgba / #rrggbbaa中的半字节和rgba(…)的alpha分量原样保留;百分比形式的alpha写成数字)。
  • inkHex无法解析时,原样返回输入。
  • 结果的根<svg>上带有data-postext-single-ink="#…"(即所用油墨);已带有这一标记的标记文本原样返回,不管它标明的是哪种油墨。这种映射不是幂等的:第二遍会让每种颜色变浅,黑色大约变成油墨的三分之二,所以一张图只着色一次,由你的代码和各后端中先处理到它的那一方完成。(这个标记从postext 1.5开始才有;由1.4重新着色的标记文本不带它。)
import { applySingleInkToSvg } from 'postext';
 
const recoloured = applySingleInkToSvg(svgText, '#295AA3');
applySingleInkToSvg(recoloured, '#295AA3') === recoloured; // true:绝不着色两次

单色模式在三个后端中都起作用:PDF后端在把resourceBytes交给它的SVG字节作为矢量绘制之前先重新着色;Canvas和HTML后端在你要求时为所绘制的SVG图片着色(见Canvas与HTML中的单色模式),因此导出的PDF与屏幕预览一致。

解析函数和精简函数与其他各节一致,另有DiagramStyleConfig / ResolvedDiagramStyleConfig类型:

import {
  DEFAULT_DIAGRAM_STYLE_CONFIG,
  resolveDiagramStyleConfig,
  stripDiagramStyleDefaults,
  applySingleInkToSvg,
} from 'postext';
import type { DiagramStyleConfig, ResolvedDiagramStyleConfig } from 'postext';
 
const resolved = resolveDiagramStyleConfig(config.diagramStyle);
// => { singleInk: false, inkColor: { hex: '#295AA3', model: 'hex', paletteId: 'main-color' } }
 
const minimal  = stripDiagramStyleDefaults(config.diagramStyle);
// => 全部与默认值相同时为undefined

#Canvas与HTML中的单色模式

Canvas和HTML后端拿到的图片是已解码的(registerResourceImage)或URL(resourceImageUrl),而不是SVG标记。在你要求时,它们对所绘制的内容应用同样的映射:

  • Canvas(renderPage、renderPageToCanvas、renderToCanvas)。凡是适用着色的SVG图片(图、表格单元格中的图片、设计图像、框的图标或标记),都按其放置尺寸栅格化,再把像素着色为油墨色,无论它是以<img>还是ImageBitmap注册的。着色后的位图和其他矢量栅格一样会被缓存。位图图片从不着色。
  • HTML(renderToHtml、renderToHtmlIndexed)。每个SVG <img>都会加上filter: url(#pt-ink-…),它指向所在页面携带的feColorMatrix:一个零尺寸的<svg>,内含<filter>,放在页面最前面,在索引输出中属于页面的decorationHtml。单色模式生效时,每一页都带有它,不管该页有没有图片,这样逐块修补的宿主就不会引入缺少滤镜的图片。

**绝不着色两次。**在postext 1.4及以前,Canvas和HTML后端按原样绘制图片,所以宿主先用applySingleInkToSvg自行重新着色标记,再交给后端。文件包适配器和沙盒现在仍然这样做,因为标记层面的处理能得到与PDF完全相同的颜色(见下文最后一段)。因此每张图片只着色一次,由以下三条规则保证,三个后端都遵循:

  • **已带标记的标记文本保持原样。**PDF后端用applySingleInkToSvg重新着色resourceBytes,所以已经重新着色过的SVG字节按原样绘制。在Canvas和HTML中,从SVG data URI加载、且标记文本带有该标记的图片也从不着色。
  • **在postext 1.x中,不要求就不着色。**对于注册时没有自带标志的SVG图片,Canvas只在渲染时传入singleInk: true(RenderPageOptions)才着色;以registerResourceImage(id, img, { singleInk: true })注册的图片则在任何渲染中都着色。HTML后端在renderToHtml收到singleInk: true,或其resourceImageUrl解析函数带有singleInk: true时着色。为1.4编写的宿主会重新着色标记文本,并在注册解码后的图片时不带标志,它的输出保持不变。下一个主版本将默认着色。
  • **singleInk: false从不着色。**通过blob或网络URL无法读回标记文本,所以你自己重新着色、又以这种方式解码的图片要以singleInk: false注册,registerBundleImages和沙盒就是这样做的。bundleImageUrl(bundle)返回一个带有singleInk: false的解析函数,而bundleResourceBytes把文件包自己的字节交给PDF,由PDF后端重新着色一次。

两个后端都从VDT得知每张图片的类型:图和单元格图片带有其资源的类型,设计图像块带有imageKind('svg'或'bitmap'),在排版时从其资源取得。对于imageKind出现之前构建的VDT,Canvas把以矢量源注册的设计图像视为SVG,HTML后端则把URL为SVG data URI或以.svg结尾的设计图像视为SVG。

要么自己重新着色标记,要么让后端为原始图片着色,两者不要同时用:

import { applySingleInkToSvg, registerResourceImage, renderPage, renderToHtml } from 'postext';
 
// 原始SVG:diagramStyle.singleInk开启时着色……
registerResourceImage('diagram.svg', rawImg, { singleInk: true });
// ……或者直接注册,在每次渲染时提出要求。
registerResourceImage('diagram.svg', rawImg);
const canvas = renderPage(doc.pages[0], doc, { singleInk: true });
 
// 解码前已重新着色(postext 1.4的宿主就是这样做的):按原样绘制。
const inked = applySingleInkToSvg(svgText, ink);
registerResourceImage('diagram.svg', await decode(inked), { singleInk: false });
 
// HTML后端,使用指向原始标记的URL。
const html = renderToHtml(doc, { resourceImageUrl: urlFor, singleInk: true });

renderToHtml的singleInk默认值取自解析函数自身的标志,所以bundleImageUrl(bundle)无需额外选项。

对于标记层面处理会改写的每种颜色(十六进制、rgb()和hsl()值、white和black;见单色模式的工作原理),像素映射给出相同的结果,抗锯齿边缘和渐变也不例外。两者的差别在于标记处理不改动的颜色:white和black以外的命名颜色、currentColor、没有填充的形状和文本(以默认黑色绘制),以及嵌入SVG中的位图,在屏幕上会着色,在PDF中却保留原色。给图示的每个元素指定明确的十六进制、rgb()或hsl()颜色,就能得到完全一致的输出。Canvas无法读回像素时(来自其他源、未启用CORS加载的<img>),图片不着色绘制。

#段落样式

paragraphStyles属性声明具名样式,文档用:::paragraphs{style="…"}容器把它们应用到一组段落上:参考文献、术语表、注释,以及任何需要自己的字体、字重、倾斜、字号、行距、大写、小型大写字母或悬挂缩进的条目块。每个排版字段都是可选的,未设置时继承正文,所以一个样式只需写出与正文不同的地方。

const config: PostextConfig = {
  paragraphStyles: [
    {
      id: 'bibliography',
      name: 'Bibliography',
      fontSize: { value: 7, unit: 'pt' },
      lineHeight: { value: 1.2, unit: 'em' },
      hangingIndent: { value: 2, unit: 'em' },
      spaceBetween: { value: 0.25, unit: 'em' },
      marginTop: { value: 1, unit: 'em' },
      marginBottom: { value: 1, unit: 'em' },
    },
  ],
};
## References
 
:::paragraphs{style="bibliography"}
Knuth, D. E. (1984). *The TeXbook*. Addison-Wesley.
 
Bringhurst, R. (2004). *The Elements of Typographic Style*. Hartley & Marks.
:::
属性类型默认值说明
idstring必填供:::paragraphs{style="…"}引用的标识符。
namestringid便于阅读的名称,仅用于编辑器界面。
fontFamilystring正文字体字体族。其字重由下面的fontWeight / boldFontWeight决定(未设置时取正文的字重)。
fontSizeDimension正文字号字号。
lineHeightDimension正文行距行距。em/rem相对于样式自身的字号,因此继承来的1.5em会随较小的字号一起收紧。
colorColorValue正文颜色文字颜色。粗体和斜体文字沿用正文的强调颜色,除非boldColor / italicColor为该样式另设颜色。
textAlign'left' | 'justify' | 'center' | 'right'正文对齐方式水平对齐。'center'和'right'让每一行从另一侧参差排列,适合献辞、署名块。
boldColorColorValuebodyText.boldColor粗体文字的颜色(例如作者名单中的姓名用出版社的标准色)。
italicColorColorValuebodyText.italicColor斜体(…)文字的颜色;在italic样式中,指转为正体的那些文字。它不跟随color:带颜色的样式若希望斜体保持同一颜色,两者都要设置。
fontWeightnumberbodyText.fontWeight常规文字的字重(100–900),例如练习册中用半粗体的题目、用细体的题词。
boldFontWeightnumberbodyText.boldFontWeight粗体(…)文字的字重。
italicbooleanfalse把段落排成斜体,例如舞台说明、题词。其中的斜体…文字会转为正体,与引文块中的做法相同。
smallCapsbooleanfalse把段落排成小型大写字母:小写字母按70%的字号排成大写,大写字母保持原字号,各后端的绘制方式一致(见小型大写字母),适合演员表、术语表的词头。
hyphenationboolean正文断词设置两端对齐时断词(使用文档的语言区域)。
indentDimension0每一行相对栏左边缘(或段落所在框的左边缘)的缩进;em取样式自身的字号。首行缩进和悬挂缩进都从这里量起,因此缩进的诗行可以让转行比自身起点缩得更深:indent: 1.5em配合hangingIndent: 2.5em,诗行从1.5 em处开始,转行从4 em处开始。负值按0处理。
firstLineIndentDimension正文首行缩进首行缩进,从indent量起。hangingIndent不为零时忽略。
hangingIndentDimension0除首行外每一行的缩进,从indent量起,即参考文献或术语表的经典形式。
spaceBetweenDimension0容器内相邻段落之间的垂直间距。0让条目紧挨着排。
marginTopDimension0容器第一段上方的空白。与已有的待定间距合并,在栏顶消失,与其他外边距相同。
marginBottomDimension0容器最后一段下方的最小空白。它与容器之后那个块的间距如何结合,由bodyText.paragraphContainerSpacing决定。
snapToGridbooleantrue在容器下方让文字流重新对齐基线网格,下方空白作为最小值。false保留精确的空白:容器之后的文字偏离网格,直到下一个会对齐网格的块(标题、列表结尾、行间公式),适用于不按网格排的文档,或自有行距的一组段落。在没有网格的标注框内,它不起作用。
textTransform'none' | 'uppercase''none'段落的大小写:'uppercase'把段落排成大写(演员表、一行舞台动作说明),行内标签中的文字和:ref的标签也包括在内。转换逐字等长,使编辑器的源映射保持一一对应:大写形式更长的字母(ß)保持原样。数学公式不受影响;把段落当作标记读取的书眉({firstMark.style})取原文;设计文本用自己的textTransform排成大写。

剧本把舞台说明排成斜体,把演员表排成小型大写字母:

paragraphStyles: [
  { id: 'direction', italic: true, fontSize: { value: 9, unit: 'pt' } },
  { id: 'cast', smallCaps: true, textAlign: 'center', fontWeight: 600 },
],
:::paragraphs{style="direction"}
Elsinore. A platform before the castle. *Francisco* at his post.
:::

舞台说明印成斜体,其中的人名印成正体;样式的字重、italic和smallCaps在标注框内同样适用。

诗集会缩进某些诗行,并让超出行长的诗行的转行比诗行本身缩得更深。indent把段落的每一行向内移,悬挂缩进从那里量起:

paragraphStyles: [
  { id: 'verse', textAlign: 'left', firstLineIndent: { value: 0, unit: 'em' }, hangingIndent: { value: 4, unit: 'em' } },
  { id: 'verse-indented', textAlign: 'left', indent: { value: 1.5, unit: 'em' }, hangingIndent: { value: 2.5, unit: 'em' } },
],

verse-indented中的诗行从1.5 em处开始,转行从4 em处开始,与verse中各行的转行对齐。没有indent时,一个样式只能缩进首行或悬挂其余各行,不能两者兼得:一旦设置了hangingIndent,firstLineIndent就被忽略。

#:::paragraphs容器

:::paragraphs{style="<id>"}一行打开容器,单独的:::一行关闭容器;其间的每个段落都采用指定样式,而其中的标题、列表和其他块保持各自的常规样式。容器可以嵌套在其他围栏容器中。未知的style id不算错误:这些段落按普通正文渲染。

在容器内,文字流离开基线网格(7pt、行距1.2em的条目无法落在8pt/1.5em的网格上),最后一段让文字流重新对齐网格(以网格为准;下方空白是最小值,与标题遵循的约定相同)。条目像正文段落一样跨栏、跨页拆分,并有同样的段首孤行和段末孤行保护;紧接在容器之前的标题与容器的第一段保持在一起。

容器下方的空白取样式的spaceBetween和marginBottom与周围文字段落间距(开启bodyText.paragraphSpacing时为一行)中的较大者,并与下一个块在自身上方保留的空白合并,就像两个正文段落之间的空白那样:参考文献之后的标题位于最后一个条目下方自身marginTop处(若样式的空白更大,则取样式的空白);一组较紧的条目之后的段落保留正文的段落间距。文字流先在文字下方重新对齐网格,对齐未覆盖的部分以整行网格顺延,因此容器之后的文字落在网格上。在postext 1.4及以前,样式的空白设在对齐之前的最后一行下方,下一个块上方的空白再加在其下,且不计段落间距;bodyText.paragraphContainerSpacing: 'add'保留这一规则,在它出现之前保存的配置也按它读取。设置了snapToGrid: false的样式不对齐网格:容器之后的文字恰好位于其下方指定空白处,偏离网格,直到下一个会对齐网格的块。以列表结尾的容器,在两种规则下都按1.4的方式排:列表保留自身下方的空白,marginBottom跟在这段空白之后,与下一个块的空白合并。

在:::callout内,容器以同样的方式采用其样式的外边距:marginTop和marginBottom与周围块的间距合并(负值会让它们靠得更近),位于框开头的容器不取上外边距,与栏顶相同。框内没有可重新对齐的基线网格,所以最后一段下方的空白取marginBottom、spaceBetween和框自身段落间距(其body.paragraphSpacing,即框内文字的一行;使用paragraphContainerSpacing: 'add'时不计)中的较大者,若下一个块自身的上外边距更大,则取后者;负的marginBottom则把下一个块往上拉。(在postext 1.4及以前,标注框内的容器忽略这两个外边距。)

const resolved = resolveParagraphStylesConfig(config.paragraphStyles, resolvedBodyText);
// => 每个未设置的字段都从解析后的正文填入
 
const minimal  = stripParagraphStylesDefaults(config.paragraphStyles);
// => 列表为空时为undefined;去掉为零的外边距和`name === id`

#行内标签样式

chipStyles属性声明行内:chip[text]{style="…"}的具名样式,即词库、键盘按键、标签所用的圆角着色小框(语法及其断行规则见文档格式参考)。默认提供一个样式chip(浅蓝色填充,主色细线描边,略带圆角,文字与周围词语相同),因此:chip[…]无需任何配置即可使用;声明chipStyles会替换这一默认列表。没有style的行内标签,或所用id未被任何样式声明的行内标签,采用第一个样式。

const config: PostextConfig = {
  chipStyles: [
    { id: 'chip', name: 'Word bank' },
    {
      id: 'key',
      name: 'Keyboard key',
      background: { hex: '#fff4d6', model: 'hex' },
      borderColor: { hex: '#8a6d1f', model: 'hex' },
      borderRadius: { value: 2, unit: 'pt' },
      bold: true,
    },
  ],
};
Classify: :chip[battery] :chip[cable] :chip[switch]
 
Press :chip[Ctrl]{style="key"} + :chip[C]{style="key"}.
属性类型默认值说明
idstring必填供:chip[…]{style="…"}引用的标识符。
namestringid便于阅读的名称,仅用于编辑器界面。
backgroundEnabledbooleantrue绘制框的填充。
backgroundColorValue#e8eef7框的填充色(可与调色板关联)。
borderColorColorValue调色板主色轮廓颜色。
borderWidthDimension0.5pt轮廓宽度;0表示不画轮廓。轮廓描在框边缘以内。
borderRadiusDimension0.3em圆角半径,最大为框高的一半(取大值得到胶囊形)。
paddingXDimension0.3em轮廓与文字左右两侧的间距。计入行内标签的前进宽度。
paddingYDimension0.1em文字带上下方的间距。绘制在行框之外:从不改变行高。
paddingTop、paddingBottomDimensionpaddingY文字带上方或下方的间距,分别取代paddingY。文字带从基线上方0.8 em延伸到基线下方0.25 em,所以它的中线位于基线上方0.275 em,低于大写字母的中线(多数字体约为0.35 em):圆形行内标签(borderRadius: 1em)中的大写字母或数字看起来偏高。让上内边距比下内边距大出这一差值的两倍,即可居中:对大写字母高0.7 em的字体,用paddingTop: 0.2em配合paddingBottom: 0.05em。
fontFamilystring周围文字行内标签文字的字体族。字重跟随周围文字。
fontSizeDimension周围文字行内标签文字的字号;em相对于周围文字。
colorColorValue周围文字行内标签文字的颜色。未设置时,粗体和斜体文字保留强调颜色。
boldbooleanfalse把行内标签文字排成粗体,叠加在其自身标记之上。
italicbooleanfalse把行内标签文字排成斜体,叠加在其自身标记之上。
gapDimension0.25em隔着词间空格时,框与相邻词语或行内标签之间保留的最小间距;较窄的空格在行内标签的前进宽度内补足,因此两端对齐永远不会把它吃掉。在行首行尾或紧贴标点处不额外添加。

框的em长度(paddingX、paddingY、borderRadius、borderWidth、gap)相对于行内标签自身的字号。框是基线上方0.8 em、下方0.25 em的一条带,再加上paddingY和轮廓;它绘制在行框之外,从不改变行距,因此基线网格保持不变。框最终高于行距时,行内标签可能与上一行或下一行的行内标签相碰:当不同行上的两个行内标签重叠时,沙盒会列出“行内标签碰到相邻行”警告,并给出以点为单位的重叠量,以便减小paddingY、轮廓或fontSize。上下都没有行内标签的高行内标签不会被标出。

在VDT中,行内标签是kind: 'chip'的行片段,其chip字段携带文字串(各带字体字符串和宽度)、框的几何数据(boxWidth、ascent、descent、paddingX、borderWidth、borderRadius、间隙外边距)及其颜色;片段的text是一个单字符占位符,所以纯文本偏移量和源映射把一个行内标签计为一个字符。

const resolved = resolveChipStylesConfig(config.chipStyles);
// => 未设置时为内置的`chip`样式;每个字段都已填入
 
const minimal  = stripChipStylesDefaults(config.chipStyles);
// => 内置默认值时为undefined;去掉静态默认值
 
const style = pickChipStyle(resolved, 'key');
// => `key`样式,否则取第一个

#标注框样式

calloutStyles属性声明一组具名的框样式,文档用:::callout{type="…"}容器来套用:注释、提示、警告、学习目标,以及任何需要用底色框或描边框与正文区分开的内容。默认只带一种中性样式note(浅灰底色,无边框、无色条、无图标、无标题),所以不做任何配置也能使用:::callout;一旦声明calloutStyles,就会替换这份默认列表。

const config: PostextConfig = {
  calloutStyles: [
    { id: 'note', name: 'Note' },
    {
      id: 'objectives',
      name: 'Learning objectives',
      title: 'Objectives',
      stripe: { enabled: true, side: 'left' },
      icon: { kind: 'glyph', glyph: '✓' },
      titleStyle: { textTransform: 'uppercase' },
      lists: { bulletChar: '–' },
    },
    {
      id: 'warning',
      title: 'Warning',
      backgroundEnabled: false,
      border: { enabled: true, color: { hex: '#AA0000', model: 'hex' }, width: { value: 1, unit: 'pt' } },
      borderRadius: { value: 1, unit: 'mm' },
      titleStyle: { color: { hex: '#AA0000', model: 'hex' } },
    },
  ],
};
:::callout{type="objectives"}
- Describe the parts of the lantern.
- Trim the wick at dusk.
:::
 
:::callout{type="warning" title="Do not touch the lens"}
The glass stays hot for an hour after the flame is out.
:::
属性类型默认值说明
idstring—由:::callout{type="…"}选用的标识符。type未知或缺失的围栏使用配置中的第一种样式(沙盒会标出未知类型)。
namestringid便于阅读的名称(仅用于编辑器界面)。
titlestring''默认标题文字;为空表示没有标题。围栏上的title属性可逐个覆盖它。
span'column' | 'page' | 'side''column'水平范围:一栏、整个版心宽度,或一栏半版式中只放浮动体的侧栏(layout.sideColumnRole: 'floats');后一种情况下,框离开文字流,叠放在侧栏中,紧挨着它所打断的正文。可用span属性逐个覆盖。在多栏版式中,'page'框会成为通栏块:它把页面切分成若干栏带,自己占一个通栏宽的栏(见下文容器一节)。
placement'here' | 'auto' | 'top' | 'bottom' | 'fixed''here'框的位置。'here'把它排在文字流中的原位;'top' / 'bottom'让它像资源一样浮动('auto'取先空出来的那个区带,页首或页脚都可以):它在出现处离开文字流,占用该处或其后第一个空闲区带(当前页的页脚,或文字流打开的下一页的页首 / 页脚),后面的正文围绕它填满页面;'fixed'通过下面的fixed把它锚定在页面坐标上,脱离栏内文字流。细节见容器一节。可用placement属性逐个覆盖。
sideAtColumnEnd'before' | 'after''before'侧栏框(span: 'side')在围栏之后的正文没有在同一栏接着排时的位置:栏里已经放不下,或断行规则把正文送走(被段首孤行、段末孤行规则整段移走的段落,与正文保持同栏的标题)。'before'让它留在围栏处,就在那一页、挨着前面的正文;围栏下方放不下时,从栏底往上挪:适合写在所解释段落之后的旁注。'after'把它与围栏之后正文的第一行平齐,排在那段正文接续的那一页的侧栏中:适合写在对应行之前的行号或边题。正文在同一栏接着排时,两者都把框排在围栏处。在本章中后面没有任何内容的框,无论哪种设置都留在前面正文旁边;连续围起的几个侧栏框保持原来的顺序。postext 1.4及以前,所有侧栏框的行为都等同于'before',这仍是默认值:旁注样式的框会留在它所解释段落的那一页。
fixed'fixed'框的位置:一个ElementAnchor(to:'container'为页面内容区,在偶数页上镜像;'page'为成品尺寸框;'bleed'为出血框;edge:容器九个边位之一),外加可选的offset(x / y尺寸)。
floatBarrierbooleanfalse把框设为浮动体屏障:在它之前被引用的每个图或表都排在它之前(放进本页的空闲位置,否则放在框之前新开的页面上),这样浮动体不会越过一章的收尾框(通常是“要点”小结)。章首页、:::part和文档末尾始终是屏障。
多栏页面上的span: 'page'框会在它所跟随的正文下方切断栏带;在它之前引用的通栏图先占用这个切口:正文被拉齐,图紧接在正文结束处,框排在图的下面(放不下时移到下一页)。图太高、无法跟在拉齐的正文后面时,图改到下一页开头,框跟在它后面,被离开的栏带仍然齐底结束。可拆分的框(keepTogether: false)在正文和图的下方开始,放进尽可能多的条目,其余接续到下一页。
width'fill' | 'auto''fill''fill'占满可用宽度;'auto'按标题收缩宽度(用作徽标),忽略子内容。
backgroundEnabled / backgroundboolean / ColorValuetrue / #f4f4f4框的底色。
border{ enabled, color, width }false、#cccccc、0.5pt框的轮廓线,与框元素的边框一样描在框边缘的内侧(见框元素)。
borderRadiusDimension0底色 / 边框的圆角半径(上限为框宽和框高的一半)。色条随之变化:在圆角框上,色条被裁切到圆角框形之内,就像CSS按border-radius裁切border-left一样。label标签保持直角。
padding{ top, right, bottom, left }各为0.75em框边缘与内容之间的内边距。em值相对于标注框正文的字号。
stripe{ enabled, side, width, color }false、'left'、1.5em、主色沿一条边的实色色条。'left' / 'right'色条会缩窄内容;'top'色条把内容往下推。在设了borderRadius的框上,色条的外侧角随框形一起变圆(postext 1.4及以前它们保持直角,会伸出圆角之外)。
icon{ kind, glyph, resourceId, fontFamily, fontWeight, size, width, color, align, position, cornerSide }'none'、标题字体、400、1.5em、主色、'top'、'inline'、'right'内容旁边的一个文字字形(kind: 'glyph')或一个位图 / SVG资源(kind: 'resource' + resourceId)。有侧边色条时,图标在色条上居中;否则它占用自己的一栏(size + titleStyle.gap)。align: 'center'让它相对内容垂直居中。资源图片按原有宽高比适配到一个正方形内;设了width时则适配到width × size的框内(一长条图标)。比内容高的图标会把框撑高以容纳它(配合align: 'center'时,内容相对图标居中)。position: 'corner'把图标作为徽标挂在上方的一个角上,一半伸出边框,不占内容的空间;宽图标(width)按绘制宽度在角上居中,挂在左角时,标题从它的内侧一半之后开始(postext 1.4及以前按高度定位,宽条会伸出框外并压住标题);cornerSide选择哪个角:'right' / 'left',或'outer' / 'inner',后两者随镜像页边距的页面奇偶变化(外侧在右页上是右边,在左页上是左边)。
marker{ kind, glyph, resourceId, fontFamily, fontWeight, size, color, align, gap, rule }'none'、标题字体、400、1.5em、主色、'center'、0.5em、不画线(0.5pt、主色、长度0)画在框外左侧一栏中的第二个图标,它与框之间可以有一条竖线rule,例如自测徽标旁边那只“点这里”的手。整体结构变为[marker][rule][gap][box],高度取三者中最高的一个;align让它们彼此居中或顶端对齐。rule.length是最小值:竖线至少与框一样高。
titleStyle{ fontFamily, fontSize, fontWeight, italic, color, textTransform, gap, letterSpacing, indent, lineHeight }标题字体、正文字号、700、false、主色、'none'、0.5em、0、0、1.2em标题的排版。gap是标题与第一个子块之间的距离(也是图标栏的间距)。lineHeight是标题各行的行距,em按标题自身的字号计;与正文一样,基线位于每行顶端往下0.8倍行距处。所以标题采用正文行距(12 pt网格上设lineHeight: 12pt)时,框的高度保持整数行、标题落在网格上;默认的1.2 em则会给每个框多出零点几行。textTransform: 'uppercase'不改变文字长度。letterSpacing调整标题的字距(canvas的letterSpacing / PDF的Tc);indent把标题从框的内侧边缘往右推。挂在标题一侧(左角)的角徽标先留出自己的空间,即徽标内侧一半加上gap,这样无论落在哪一页,标题都能避开它;indent只在此基础上再加。
body{ fontFamily, fontSize, lineHeight, color, boldColor, italicColor, fontWeight, boldFontWeight, italic, smallCaps, textAlign, hyphenation, paragraphSpacing, firstLineIndent }继承bodyText;italic / smallCaps为false框内段落和列表项的排版。每个字段未设置时都继承正文;italicColor设置斜体文字的颜色(例如用框的颜色排成斜体的引文)。fontWeight / boldFontWeight设置常规和粗体文字的字重;italic把整个框排成斜体,此时…内的文字改为正体;smallCaps把它排成小型大写字母。框内文字还会采用哪些设置,见框内的排版。
lists{ bulletChar, color, indent, gap, itemSpacing, bulletFontSize, bulletFontWeight }继承unorderedLists框内列表的排版(color、indent、gap和itemSpacing也作用于编号列表)。bulletFontSize / bulletFontWeight让项目符号字形以框的正文字体、按这个字号和字重排出(例如粗重的彩色项目符号)。
label{ fontFamily, fontSize, fontWeight, color, background, position, height, paddingX, offset, inset, icon, rule }未设置(无标签)框顶边上的一个标签,印出围栏的label属性,即编号框的编号(“BOX 1-1”)。它贴着position指定的角('top-right' / 'top-left'),内缩inset,高出框顶offset(这段空间属于整个块,加在marginTop之上,所以标签在栏顶也能保住它),高度为height,文字两侧各留paddingX;旁边可以带一个icon资源({ resourceId, width, gap },放在背离那个角的一侧),顶边上还可以有一条rule({ enabled, color, width }),从远端的角一直画到标签。默认值:标题字体、正文字号、700、主色底白字、高1.4em、内边距0.6em。标签是立在框形上的独立形状,所以无论框的borderRadius如何,它都保持直角。
columnGapDimension1.5em框内:::columns组的栏间距(见下文容器一节)。
marginTop / marginBottomDimension0.75em / 0.75em框上方的间距(与前一个块的外边距合并),以及框下方的最小间距(设snapToGrid: false时为精确间距)。浮动的框(placement: 'top'、'bottom'或'auto')在它的区带与正文之间保持浮动间距,即一行正文;比这更大的marginBottom决定顶部区带中框下方的间距,更大的marginTop决定底部区带中框上方的间距,并随区带一起向上取整到网格。postext 1.4及以前,浮动的框忽略自己的外边距。
snapToGridbooleantrue为true时,框之后的文字流回到基线网格上,因此框下方的间距是marginBottom向上取整到整数网格行。为false时,框保持精确的marginBottom,并与下一个块的上外边距合并:这种样式的两个相邻框之间恰好相距max(marginBottom, marginTop);框之后的正文可能偏离网格,直到下一个对齐点(标题、列表结尾),与headings.snapToGrid: false的标题之后一样。适用于由一个个框叠成的文档(练习册、表单)。它只作用于文字流中的框;多栏版式中的通栏框,以及浮动框、固定框和侧栏框都保持网格,因为栏带和浮动区都是在网格上排布的。各栏齐底的调节手段不变:收尾某一栏的框仍会被推到该栏最后一个网格位置。
keepTogetherbooleantrue为true时,框保持完整:剩余空间放不下的标注框整个移到下一栏或下一页。只有比一整个空栏还高的框(对span: 'page'框来说是一整页)无法保持完整:它从出现处开始,按下文false的规则拆分,而不是溢出;能放进一栏的接续部分随后整体移动。这么高的浮动框(placement: 'top' | 'bottom' | 'auto')不浮动,而是留在文字流中的原位。为false时,任何框都可以在子块之间、或段落和列表项的行与行之间断开:能放下的最深切口结束当前栏(对span: 'page'框来说结束当前页,与各栏底部齐平),其余部分在下一栏开头的一个独立框中继续,框形和色条相同,不带图标,也不带标题(除非repeatTitle重复它),仍然太高时再次拆分。每个片段的文字都保留行内图标在首段所占的那一栏(留空),因此框在每一页上的行长都相同。切口落在列表项内部时,项目符号留在切口之前的片段。每个片段的框形都共用围栏的contentIndex / containerId,并记录callout.part / callout.continued。可以把它用在一章末尾很长的“要点”框上,配合headings.balancing.beforeSpan;也可以用在绝不能把图挤出页面的注释样式上。嵌套的框(另一个框里的:::callout)是其父框的一个子块:切口可以落在它之前或之后;只有它自己的样式允许拆分时(keepTogether: false,或比一整栏还高),才会按它自己的splitMinLines在它内部断开。
splitMinLinesnumber2拆分框(keepTogether: false,或比一整栏还高的保持完整框)的片段在切口两侧至少各保留几行文字。它只约束文字:只要某一侧有至少一个图、表、独立公式或嵌套框,无论行数多少都可以接受,所以一个全是图片的框可以只在一页上留下一张图。切口落在段落或列表项内部时,仍然计算两侧的全部行数(其中的图或公式计为一行),并且在每一侧至少留下该段落或列表项的layout.boxChildSplitMinLines行(默认两行;splitMinLines更低时取它,所以设为1就允许留一行)。按默认值,两三行的列表项绝不拆分,四行的只能拆成两行加两行。按默认值,任何框都不会在栏底或下一栏开头留下孤零零的一行文字;没有切口能满足最小值时,框整体移动。(postext 1.5之前,段落内部的切口只检查整个一侧的行数,所以当框里其他行凑够最小值时,两行的列表项可能被拆成一行加一行。)
repeatTitlebooleanfalse在拆分框每个接续部分的开头重复标题,后接continuedSuffix(“Key points (cont.)”)。重复的标题采用标题样式。没有标题的框不重复任何内容。见拆分框的标记。
continuedSuffixstring'(cont.)'重复标题之后的文字,使用文档语言(locale,否则为断词语言),与拆分表格的做法相同,并像表格那样接在标题后面。
continuesMarkerEnabledbooleanfalse在拆分框每个还要接续的部分的最后一行下方、框内,排出continuesMarker。
continuesMarkerstring'Continued' / 'Continúa'该标记的文字(剧本里的“(MORE)”),使用框的正文字体和字号,随文档语言而定。
continuesMarkerAlign'left' | 'center' | 'right''right'标记在框内宽度中的位置。
continuesMarkerItalicbooleantrue把标记排成斜体。

#:::callout容器

:::callout{type="<id>"}一行打开框,单独一行:::关闭它。围栏接受四个属性:type(样式id)、title(覆盖样式的标题)、span和placement(覆盖样式中的值);两者之间的内容排在框内:先是可选的标题,然后是段落、列表、引文块、公式或资源嵌入,各自按样式的body / lists排版(标题保留它们平常的样式)。子块之间的外边距像正文中一样合并;框内部脱离基线网格,框之后文字流回到网格上,框下方至少留出marginBottom(以网格为准,外边距是最小值,资源遵循的也是这一约定)。snapToGrid: false的样式则保持精确的marginBottom,框之后的正文一直偏离网格,直到下一个标题或列表结尾。紧挨在标注框之前的标题与框保持同栏。

本版本的限制:

  • 标注框保持完整,除非其样式设置了keepTogether: false。栏中剩余空间放不下时,它整个移到下一栏或下一页;一个被浮动区带或区带上限截短的空栏,只要完整的一栏能容纳它,框也会从中移出。比一整栏还高的框改为拆分,与可拆分的框一样;只有任何切口都拆不开的框(比栏还高的图、表或:::columns组,或没有切口能满足的splitMinLines)才会照排并溢出;此时版面记录一条calloutOverflow警告(VDTDocument.warnings),沙盒会列出它。可拆分的框留下能放下的部分,即完整的子块,或段落的若干行,每侧不少于splitMinLines行(单独一个图、表或独立公式就足以构成一侧),然后在下一栏或下一页的一个不带图标的框中继续,除非样式重复标题,否则也不带标题,还可以在它留下的部分下方加一个标记(见拆分框的标记)。
  • 浮动体让位于不可拆分的框。当图的引用之后紧接的块是keepTogether标注框时,如果某个位置会让这个框在当前栏带中无栏可落(引用所在的栏,或它之后的空栏,在浮动之前本可容纳它),就不采用这个位置:图移到它的下一个位置,通常是下一页,框留在文字流中。这正是排版工人的做法:不会把框挤出页面,只在栏里留下那张图。
  • 在多栏版式中,span: 'page'让框成为通栏块:它按整个版心宽度排布,把页面切分成栏带。它上方的各栏文字在切线处结束,框自己占一个通栏宽的栏,下方新开一组文字栏,文字流在每一栏中都从框下继续。在各栏平齐的地方,即页面顶端、span: 'page'的章首标题正下方、另一个通栏块正下方,或顶部浮动区带正下方,框直接在那里切开。如果它在页面中部出现、各栏参差不齐,就按排版工人的方式处理:它上方的正文在所有栏中齐平切断(引擎把该栏带的各栏缩短到相同的网格行数后重新排布,于是文字自然地从一栏流向下一栏,所有段首孤行、段末孤行和与下文同栏的规则照样生效),框横跨整页,各栏在它下方继续。切断要多跑几遍排布;如果切线下方放不下框加上段首孤行(widow)规则所需的最少正文行数,或者尝试几次后仍没有合适的排法,框就移到下一页顶端。上方的正文遵守这条切线:一栏在图下方只剩几行、放不下一个段落的开头时,该段落移到下一栏(图于是单独占据那一栏);当某个块仍会越过切线时,切线下移一行,而不是移到那个块结束的地方。开启栏带的拆分框也在那里切断,所以它的其余部分不会越过切线。在headings.balancing.beforeSpan(默认开启)下,框离开的栏带在它后面齐平切断,就像一章的收尾栏带;样式允许拆分时(keepTogether: false),框中能放进拉齐后各栏下方的部分结束这一页,其余部分开启下一页。设beforeSpan: false时,框离开的页面只做常规的各栏齐底,不强制分页。紧挨在通栏块之前的标题不随它移动。在单栏版式中,span: 'page'就是普通的行内排布。
  • placement: 'fixed'把框移出文字流:先排出框(width: 'auto'按标题收缩宽度;'fill'取锚点下方那一栏的文字宽度),再把它固定在它在文字流中所在的页面上,位置由fixed.anchor / fixed.offset描述,默认为内容区的左下角。它覆盖的文字栏让出这块区域(从底部截去,栏仍为空时从顶部截去),与浮动区带完全相同;如果这块区域中已经有正文、浮动体或通栏块,框就移到下一页。收尾一章的固定框(下一个块是章首页、:::part、浮动体屏障框或文档末尾)会先把它上方的各栏拉齐(headings.balancing.trailing),这样较短的收尾页会齐底结束,下方是徽标。框形和子块放在page.floats中,在所有后端中都在栏的裁切区之外渲染。
  • placement: 'top' | 'bottom'让框像资源一样浮动:它在出现处离开文字流,占用该处之后第一个空闲区带,即当前页的页脚('bottom'),或文字流打开的下一页的页首 / 页脚,宽度为一栏(span: 'column')或整个版心(span: 'page');它之后的正文填满它离开的那一页。它的框形和子块进入page.floats,与固定框一样。位于新一页页首的浮动框排在等待该页的图之前;在较早一页引用、随后能放在它下方的图,即使剩下的正文不足三行,也会占满该页其余部分(图集页:框加图,中间没有正文)。span: 'side'框从不浮动:无论placement如何,它都叠放在正文旁边。
  • width: 'auto'只按标题收缩宽度,子内容被忽略。
  • 嵌套在另一个标注框中的:::callout是一个独立的框:它按自己的样式(底色、边框、圆角、内边距、色条、标题、图标、标记、标签、排版)以父框的全部内宽排布,作为父框的一个子块叠放,其marginTop / marginBottom与相邻块合并。它的span和placement(围栏或样式中的)被忽略,嵌套框总是在父框内顺排;floatBarrier和snapToGrid同样被忽略。框可以任意层级嵌套,也可以放在:::columns组中(每个框完整地位于一栏中)。父框拆分时,切口落在嵌套框之前或之后,嵌套样式允许拆分时也可以落在它内部;每个片段都会重画它切过的框形,从上一个片段接续过来的嵌套框不带标题和图标,与顶层的接续部分一样。
  • 子块中的:::columns{count=N} … :::组在框内把这些子块排成N个等宽的栏,间距为columnGap:这一串内容在最能让各栏平齐的块边界或行边界处切开(在中途切开的段落或列表项在下一栏开头继续,不带项目符号),每一栏都从组的顶部开始,组的高度等于最高的一栏;组之后的子块恢复全宽。拆分框(keepTogether: false)绝不在组内部切开。可用来排两栏的要点小结,或让宽框中的几张表并排。
  • 围栏的第五个属性label印在样式的标签上(见上文label)::::callout{type="box" label="BOX 1-1" title="The octet rule"};样式没有label时忽略该属性。

在VDT中,框是一个type: 'callout'的框形块,其装饰(底色、色条、图标、标题)放在designOverlay上,后面在同一栏中跟着它的子块;框形和每个子块都带有围栏的containerId。嵌套框是子块中一个独立的type: 'callout'框形,后面跟着它自己的块;这些块保留顶层围栏的containerId(排布和各栏齐底仍把它们看作一个单元),并加上calloutPath,即包围它们的各层嵌套围栏的容器id,由外到内排列(嵌套框形自己的id是最后一项)。带标签的PDF为每个嵌套框在其父框的Div内生成一个Div。图标图片与资源图片的解析方式相同:canvas图片注册表、HTML的resourceImageUrl选项和PDF的resourceBytes提供器。

const resolved = resolveCalloutStylesConfig(config.calloutStyles, resolvedBodyText, resolvedHeadings, resolvedUnorderedLists, config.locale);
// => 每个继承字段都从已解析的各节中填入;可选的
//    locale决定接续字符串的语言
 
const minimal  = stripCalloutStylesDefaults(config.calloutStyles);
// => 对内置的`note`默认值返回undefined;去掉静态默认值

#框内的排版

框用自己的body和lists排版设置内容,其余一切沿用文档的样式:

  • 段落采用框的body:字体、字号、行距、颜色、强调颜色、字重、italic、smallCaps、对齐、断词、缩进和段间距。未设置的字段继承bodyText。继承来的强调颜色保留与调色板的关联,所以框内的粗体、斜体和:ref标签与框外一样随colorPalette变化。(postext 1.4及以前,无论主色是什么,框内的粗体都保持#295AA3。)
  • 项目符号列表以框的lists(项目符号字符、颜色、字形大小和字重、缩进、间隔、条目间距)覆盖unorderedLists,文字是框的正文。lists.bulletChar或lists.color与文档的设置(unorderedLists)不同时,替换每一层的项目符号或颜色;与之相同或未设置时,每一层保留各自的设置(unorderedLists.levels),所以嵌套的破折号在框内依然保留。
  • 编号列表采用lists.indent、gap和itemSpacing;只要样式设置了lists.color,也采用它,即使它就是文档的项目符号颜色;未设置时,编号保持orderedLists.color。(postext 1.4及以前,只有当颜色与unorderedLists.color不同时才会作用到编号上,所以设成同一种颜色不起作用。)编号本身的字体、字号和分隔符来自全局的orderedLists,因为lists只有项目符号字段:框内编号的样式请在那里设置。
  • **框内的:::paragraphs**使用它们的段落样式,嵌套框中也是如此。段落样式未设置的字段继承文档的bodyText,而不是框的body(也不继承框的斜体或小型大写字母)。
  • :::columns组没有自己的样式:每一栏都共用框的正文和列表排版,columnGap设置栏间距。
  • 引文块采用框的正文字体、字号和字重(与正文中一样为灰色斜体),以及框的小型大写字母。标题保留标题样式;独立公式保留数学设置。
  • 图和表保留文档的题注和表格样式,宽度为框的内宽;它们的常规字重和粗体字重跟随框的body字重。
  • 行内标签保留自己的标签样式;em字号按框的正文字号计算。
  • **:::space**以框的正文行为单位计量(它在哪些位置被丢弃,见:::space)。
  • 嵌套框完整采用自己的样式;它的span、placement、floatBarrier和snapToGrid被忽略。

#拆分框的标记

框跨栏或跨页拆分时(keepTogether: false,或框比一栏还高),第一部分之后的每一部分都不带标题和图标开始,默认也没有任何提示告诉读者框还在继续。图书或剧本常用的标记可以通过两个选项加上:

  • **repeatTitle: true**在每个接续部分的开头重复标题,后接continuedSuffix,默认为“Key points (cont.)”。重复的标题采用标题样式,所以配合textTransform: 'uppercase'就得到剧本里的“HAMLET (CONT'D)”。图标和标签只留在第一部分。
  • **continuesMarkerEnabled: true**在每个还要接续的部分的最后一行下方、框内,排出continuesMarker:“Continued”,西班牙语文档中为“Continúa”。它使用框的正文字体和字号:除非continuesMarkerItalic为false,否则为斜体;除非continuesMarkerAlign设为'left'或'center',否则右对齐。标记占用它所结束的那一部分的空间,选择切口时会保证它放得下。
calloutStyles: [{
  id: 'speech',
  keepTogether: false,
  titleStyle: { textTransform: 'uppercase' },
  repeatTitle: true,
  continuedSuffix: "(CONT'D)",
  continuesMarkerEnabled: true,
  continuesMarker: '(MORE)',
  continuesMarkerAlign: 'center',
  continuesMarkerItalic: false,
}],
:::callout{type="speech" title="Hamlet"}
A speech long enough to run over the foot of the page…
:::

结束这一页的部分以“(MORE)”收尾,下一页以“HAMLET (CONT'D)”开始。两者都是分页附属物:在无障碍PDF中它们是artifact,在HTML中对辅助技术隐藏,所以标题只被朗读一次。在VDT中,它们是标记为artifact: true的designOverlay文本块。

#篇

parts属性配置篇章隔页。文档用:::part{number="…" title="…"}容器开启一篇,也就是把一组章节归在一起的“第一篇 基础”那种页面。篇隔页总是独占一页:容器先按配置的奇偶另起新页,把这一页改成单栏页面,正文区域取自parts.margins,再把篇首设计铺满整页;容器的结束标记之后再断一次页,让下一章(按它自己的breakBefore.parity)干净地开始。使用默认的一级标题设置时,得到的就是经典的排法:篇隔页在右页,左页空白,章从下一个右页开始。

const config: PostextConfig = {
  parts: {
    breakBefore: { parity: 'odd' },
    breakAfter: { enabled: true, parity: 'any' },
    margins: { top: { value: 9, unit: 'cm' }, left: { value: 3, unit: 'cm' }, right: { value: 3, unit: 'cm' } },
    design: {
      elements: [
        {
          kind: 'text', id: 'number', content: 'Part {numberRoman}',
          fontSize: { value: 12, unit: 'pt' }, fontWeight: 600, align: 'left',
          placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: { value: 3, unit: 'cm' }, y: { value: 5, unit: 'cm' } }, size: { width: 'auto', height: 'auto' } },
        },
        {
          kind: 'text', id: 'title', content: '{titleText}',
          fontSize: { value: 28, unit: 'pt' }, fontWeight: 700, align: 'left', overflow: 'wrap',
          placement: { anchor: { to: '#number', edge: 'below' }, size: { width: { value: 15, unit: 'cm' }, height: 'auto' } },
        },
      ],
    },
    bodyStyle: { fontSize: { value: 11, unit: 'pt' }, numberColor: { hex: '#AA0000', model: 'hex' } },
  },
};
:::part{number="I" title="Foundations"}
1. The lantern and its parts
2. Trimming the wick
3. Reading the weather
:::
 
# The lantern and its parts
属性类型默认值说明
pagebooleantrue:::part是否开启一张隔页。设为false时不开新页,容器内的正文也不排出:篇的编号、标题和调色板从后面的下一段内容起生效,本身不断页。典型用法是htmlViewer.overrides.parts.page: false,即不要篇隔页的屏幕版本。
breakBefore.parityHeadingBreakParity'odd'篇从哪种奇偶的页面开始。取值和空白页归属规则都与标题的breakBefore相同:为凑奇偶插入的空白页属于这一篇(页上的已经解析为新的篇);'always-*'强制插入的分隔空白页属于前面的内容。
breakAfter.enabledbooleantrue把结束标记之后的内容移到新的一页。设为false时,内容接着排在篇隔页的单栏里。
breakAfter.parityHeadingBreakParity'any'这一新页的奇偶。保持'any',让下一章自己的breakBefore.parity决定后面是否留一个空白左页。断页在排下一个块时才执行,所以文档以篇结尾时,末尾不会多出空页。
marginsPageMargins页面的页边距篇隔页的正文区域,即容器内各块排入的那一栏。未设置的一边沿用页面的页边距;mirror与页面页边距完全一样,在偶数页上交换内外两侧。
designDesignSlot空篇首设计。它的容器是页面的成品框,所以相对容器的锚点与'page'锚点重合,开启裁切线时'bleed'延伸到出血位。它只起装饰作用,从不占用正文空间:要让正文避开它,就加大margins.top。为空时,会在正文区域左上角按一级标题的字体样式生成默认文字,编号和标题之间用一级标题的numberSeparator。
versoDesignDesignSlot空篇隔页之后那张空白左页(隔页纸的背面)的设计,容器和占位符都与design相同。留空则是一张素白的左页。只有篇隔页的下一页空着时它才会绘制,而这需要一次带奇偶要求的断页,参见下文左页设计。
bodyStyle.fontFamily、fontSize、lineHeight、color、textAlign同bodyText继承bodyText容器内段落、引文和列表项的字体样式。字重、强调颜色和断词取自正文。
bodyStyle.bulletColorColorValueunorderedLists.color篇内无序列表的项目符号颜色。
bodyStyle.numberColorColorValueorderedLists.color篇内有序列表的编号颜色。编号总是用正文的粗体字重排,所以章节列表读起来就像一份目录。
bodyStyle.unorderedListsUnorderedListsConfig—在篇内叠加到文档unorderedLists之上的部分覆盖,在bulletColor之后应用。作用于整个列表的值会传给继承它们的各级;levels中的条目只作用于各自那一级。
bodyStyle.orderedListsOrderedListsConfig—在篇内叠加到文档orderedLists之上的部分覆盖,在numberColor和粗体字重之后应用。例如篇首页的章节列表可以用'•'作separator,并配上它自己的separatorFontFamily和separatorColor。

#篇设计的占位符

设计中的标题占位符集合按篇自身的值解析:{titleText}是容器的title;{number}是原样写出的number;{numberDecimal}、{numberRoman}、{numberRomanLower}、{numberAlpha}、{numberAlphaLower}把它换成别的格式。编号可以按十进制数、罗马数字或中文数字解析,带不带外围的字都行("IV"、"iv"、"4"、"4"、"四"、"卷四"和"第四卷"得到的{numberDecimal}都是4),其他写法一律解析为''。{partTitle} / {partNumber}、{chapterTitle} / {chapterNumber}(篇之前的那一章)、{pageNumber}、{totalPages}、{bookTotalPages}以及元数据占位符也都可用。{attr.<key>}读取当前章一级标题的属性。

#左页设计

versoDesign装饰篇隔页之后的那一页,条件是那一页没有内容,也就是隔页纸的背面。篇本身从不让这一页空着。breakAfter.parity默认为'any',所以除非有别的东西提出奇偶要求,结束标记之后的内容就从紧接着的下一页开始:

  • **下一章的标题。**一级标题默认的breakBefore是'always-odd';在右页的篇隔页之后,'odd'的效果相同:章移到下一个右页,左页留空,左页设计会绘制出来。
  • **parts.breakAfter: { enabled: true, parity: 'odd' }。**不管下一个标题怎么设置,篇自己要求下一个右页。章可能从任意一侧开始时(breakBefore.parity: 'any'或breakBefore.enabled: false)用这种写法。

两者都没有时,章从左页开始,不绘制左页设计。breakAfter.enabled: false时,内容就接着排在篇隔页上。左页采用篇的调色板,所以容器上的palette="band=#…"也会给它换色。

parts: {
  breakBefore: { parity: 'odd' },
  breakAfter: { enabled: true, parity: 'odd' },   // 总会留出一张可以绘制的空白左页
  versoDesign: {
    elements: [{
      kind: 'box', id: 'field',
      style: { backgroundColor: { hex: '#b07d2b', model: 'hex', paletteId: 'band' } },
      placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: 'fill' } },
    }],
  },
}

**结束一章的篇。**在逐章排版的书中(沙盒、buildBundle),一个:::part容器可以单独成为一章,也可以是某一章的结尾。这时篇隔页就是该章的最后一页,篇还欠着的事由下一章接手:它在第一个块之前执行breakAfter,并在自己的第一页空着时在上面绘制versoDesign。排出的页面与整本书放在一个文档里时完全一样。结束标记之后只能跟不放置任何东西的指令(:::numbering、:::space);其他任何东西都算这一章的内容,于是由这一章自己断页。紧跟在篇后面的空章自成一页,那一页就是左页。自行逐章排版的宿主程序可以从continuationAfter()得到这一信息:以篇结尾的章会报告afterPartPage: true,把它传进下一章的continuation即可。

#:::part容器

:::part{number="…" title="…"}一行开启一篇,单独的:::结束它;两个属性都可省略(默认为'')。中间的各块(通常是章节列表)以bodyStyle排在篇隔页的单栏里,从margins.top开始;正文超出一页时,在普通页面上接排。这一页被归为role: 'part'(VDTPage.partInfo带有编号和标题),所以页眉页脚元素可以用pages: 'part' / pages: 'body'专门针对它或跳过它;PDF后端会把篇加到书签大纲中,位于它的各章之上。正文为空(:::part{…}后面直接是:::)是常见情况,照样生成这一页,相邻的两篇从不共用一页。嵌套在另一篇中的:::part会并入外层的篇。

第三个属性palette="<id>=<hex>[, <id>=<hex>…]"给篇指定自己的颜色:在篇隔页以及其后直到下一篇的每一页上,凡是链接到这些调色板id的设计颜色(页眉、页脚、章首色带、篇设计和左页设计),都改用篇给出的值,而不用文档调色板的值。书中各部分就是这样在不另做设计的情况下,给角标、书眉圆点和章首色带换色的::::part{number="II" title="…" palette="band=#f6c297"}。正文流也随之变化:在这些页面上,凡是等于某个被覆盖条目基准值的流颜色,包括标题,粗体、斜体和引用的颜色,项目符号和列表编号,题注标签和题注色条,表格的文字、线条和填充(表头、表体、斑马纹行以及单元格自己的填充),标注框(背景、边框、色条、标题)和行内标签(填充、轮廓和文字),都改用篇的值;因此把headings.levels[1].color链接到band,每个部分的标题就会用各自的颜色排出。行内色块保留其中写明的颜色。各组之间用逗号、分号或空格分隔,id和颜色之间用=或:连接,#可以省略。篇在结束标记之后继续生效:{partTitle}、{partNumber}和调色板随正文流进入其后的各章,并通过continuationAfter()报告的continuation.part进入单独排版的章,所以一个部分的第二章在书眉中显示该部分的方式与第一章完全相同。

两个调色板条目可以有相同的基准值,而在篇中取不同的值:篇只覆盖其中一个,或给两者不同的颜色。单看数值无法判断某个流颜色来自哪个条目,所以流中的每个颜色都会与可能产生它的设置逐一对应,并取这些设置所链接的值。下面这些是区分开的:

  • 一个块的文字颜色:文字颜色,粗体、斜体和引用的颜色,列表标记(项目符号或编号)以及编号后的分隔符。假设bodyText.color链接到ink、bodyText.boldColor链接到accent,两者都是#1a1a1a,那么带palette="accent=#b8413d"的篇会给粗体片段换色而保留正文颜色;如果链接到accent的是unorderedLists.color,它就给项目符号换色而保留列表项的文字;
  • 每一级标题,以及每个设了颜色的标题样式;
  • 目录行的文字、编号,以及它的页码和副标题;
  • 每个标注框样式中的填充(背景、色条、标签页签)、边框、线条(标记和标签的线条)和文字(标题、图标、标记字形、标签);
  • 每个表格样式、行内标签样式和题注样式的每一种颜色。具名表格样式与tableStyle分开,资源类型的题注样式与captionStyle分开。单元格自己的填充按它自己的链接处理。

仍有一种情况按数值判断:不同位置的设置给一个块设定同一种颜色。例如bodyText.color、bodyText.blockquote.color、段落样式的color和标注框样式的body.color都设定块的文字颜色。当其中两个链接到基准值相同的条目,而篇把它们区分开时,颜色取覆盖值(两个都被覆盖时取最后写的那个)。遇到这种情况,请给这些条目各自不同的基准值。

const resolved = resolvePartsConfig(config.parts, resolvedPage, resolvedBodyText, resolvedUnorderedLists, resolvedOrderedLists);
// => margins从页面补全,bodyStyle取自正文和列表配置
 
const minimal = stripPartsDefaults(config.parts);
// => 只剩静态默认值时为undefined

#标题样式

headingStyles属性声明具名样式,文档用# Title {style="<id>"}把它应用到标题上。样式做两件事。一是覆盖标题所在级别的字体样式、设计和编号,即级别条目中除level之外的所有字段(字体、字号、颜色、breakBefore、span、advancedDesign、textTransform、hidden、numberingTemplate…);二是管理标题开启的区段:从这个标题到下一个同级或更高级标题之间的页面,采用该样式的书眉、页面几何、正文字体样式和调色板。一本书的前置部分(序言排成一个宽栏,罗马数字页码,蓝色色带)就是这样放进一本双栏、十进制编号的手册里,而无须第二套配置。

const config: PostextConfig = {
  headingStyles: [
    {
      id: 'front-matter',
      numbered: false,
      breakBefore: { enabled: true, parity: 'odd' },
      span: 'page',
      advancedDesign: { enabled: true, minHeight: { value: 52, unit: 'mm' }, slot: { elements: [/* 色带、`{titleText}` */] } },
      header: { elements: [/* 页码 | 线条 | `{title}. {subtitle}` */] },
      margins: { left: { value: 50, unit: 'mm' }, right: { value: 17, unit: 'mm' } },
      layout: { layoutType: 'single' },
      bodyStyle: { fontSize: { value: 10.5, unit: 'pt' }, textAlign: 'justify' },
      palette: { band: '#547396' },
    },
  ],
};
# Preface {style="front-matter"}
属性类型默认值说明
idstring—标题行上引用的标识符。未知的id不改变标题。
namestringid便于阅读的名称(仅用于编辑器界面)。
numberedbooleantrue标题是否计数:推进所在级别的计数器(numberingTemplate的编号、资源编号中的)、背后的章序号,以及目录中印出的编号。序言、作者名单、索引设为false:它们之后的第一个编号章仍是第1章,它们各页上的为空。
tocbooleantrue:::toc是否列出该标题。单个标题可以用 / 覆盖它。
runningChapterbooleantrue这种样式的一级标题是否成为当前章,即在它所在页及之后各页上,、、及其…AtTop形式所指的那一章。章内以一级标题排的图版、地图或封面设为false:书眉会越过它,连它自己那一页也是,继续显示被它打断的那一章;它也不设置h1检索词。numbered时它仍然计数(它自己的设计读取它自己的),toc时仍然列入:::toc。若同时设了toc: false,它不生成PDF书签:章首页前一页的图版把书签留给各章。其他级别的标题忽略此项。保持默认时,图版之后各页印的是图版的标题。numbered: false的序言或引子仍然自成一章,保持默认即可。这个选项只改变占位符所指的章:样式照样像所有标题样式那样开启自己的区段,所以直到下一个一级标题为止,各页采用图版样式的书眉槽位、页边距、栏、正文样式和调色板(样式未设置的项取文档的),而不是被打断的那一章所开启的样式区段的设置。在逐章排版的书中,书眉不会从一个章文件带到下一个,所以开启某个文件的图版,在该文件第一个章标题之前显示的章占位符都是空的。
级别字段同headings.levels[]该级别的值fontFamily、fontSize、lineHeight、fontWeight、italic、color、marginTop、marginBottom、snapToGrid、breakBefore、span、advancedDesign、textTransform、letterSpacing、hidden:设置了哪一项,这种样式的标题就用它替换标题级别的值。breakBefore逐个字段合并到级别的设置上:只设parity的样式保留级别的enabled,只设enabled: true的样式保留级别的奇偶(postext 1.4及以前,缺少的字段取自不断页的默认值)。
numberingTemplatestring该级别的模板这种样式的标题所用的编号模板,取代所在级别的模板(记号与levels[].numberingTemplate相同)。计数器仍是该级别的:五章之后使用'Appendix '的附录样式会印出Appendix F,所以要在第一个附录上用重新计数。''不印编号,但标题照样计数:对于没有模板的一级标题,连目录和显示的章序号也不印。编号会出现在正文流、样式设计中的、目录和中。
header、footerDesignSlot文档的设置区段各页的书眉,在这些页上取代header / footer(元素的parity和pages过滤仍然有效)。空槽位会去掉书眉。
marginsPageMargins页面的页边距区段各页的正文区域;未设置的一边沿用页面的页边距,mirror也一样。它在区段开启的页面上生效,所以要与breakBefore配合使用。
layoutLayoutConfiglayout区段各页的分栏(layoutType、gutterWidth…):例如在双栏的书里把序言排成一个宽栏。它的columnRule画在区段的各页上,未设置的字段取文档layout.columnRule的值,所以只改分栏的区段会保留文档的栏线(参见栏线)。
bodyStylePartsBodyStyleConfig继承bodyText区段中段落、引文和列表的字体样式,字段与parts.bodyStyle相同。
paletteRecord<string, string>区段各页的调色板覆盖(id → 十六进制色值),叠加在当前篇的覆盖之上。机制与篇的palette属性相同,作用范围也相同:不仅是排在这些页上的设计槽位(书眉、章首色带,即所有链接到被覆盖id的颜色),也按数值作用于正文流:凡是等于某个被覆盖条目基准值的流颜色,包括标题,粗体、斜体和引用的颜色,项目符号和列表编号,题注标签和题注色条,表格的文字、线条和填充,标注框(背景、边框、色条、标题)和行内标签(填充、轮廓和文字),都改用区段的值,与篇之下的规则一样,包括两个条目共用基准值时的规则(参见:::part容器)。行内色块保留其中写明的颜色。

区段在下一个同级或更高级标题处结束:带样式的标题之后出现不带样式的#,就回到文档的书眉和页面几何;带样式的标题则开启它自己的区段。区段为凑奇偶留下的空白页属于该区段,这一点与章标题相同。

**分页符不能代替标题自身的断页。**样式继承所在级别的breakBefore(一级的默认值是{ enabled: true, parity: 'always-odd' }),标题无论出现在哪里都会执行它,紧跟在:::pagebreak之后也一样。分页符开启新页,标题随后仍然要求落在跨页中它那一侧。使用parity: 'odd'时,如果分页落在左页,后面会跟一张空白页,标题从下一个右页开始。使用'always-odd'时还会多出分隔空白页,而分页符不起任何作用,因为标题本来就会开启那一页。如果一个样式要从手动分页开启的那一页开始,比如扉页之后的目录页,就关掉它自己的断页:

headingStyles: [
  // 从正文安排的位置开始:即前面的`:::pagebreak`开启的那一页。
  { id: 'contents', numbered: false, toc: false, breakBefore: { enabled: false } },
],

如果想独占一页又不指定左右,改用breakBefore: { parity: 'any' },并去掉:::pagebreak。

**页面属于哪个区段。**书眉和调色板按页选择,而不是按标题选择。一页采用该页上最后一次区段切换之后生效的区段:一个区段结束、另一个区段在同一页开始时(比如词典中两个很短的字母部分),这一页使用第二个区段的书眉和调色板;带样式的区段在页中遇到不带样式的标题而结束时,这一页回到文档的设置。{chapterTitle}遵循同样的规则:两章交汇的页面显示后一章的标题。空白页遵循章标题的规则:为凑奇偶留的空白页(blankForParity)属于其后开启的区段,'always-odd' / 'always-even'断页添加的分隔页(blankForForce)属于其前的区段。篇隔页会结束当前打开的区段。

# A {style="letter"}
 
Aardvark, abacus.
 
# B {style="letter"}
 
Babble, badger… (runs on to the next page)

两个字母都从第1页开始,所以第1页采用B区段的书眉:样式页眉里设置的书口索引标签在这一页上显示“B”,没有哪一页带“A”的标签。如果每个区段都需要自己的标签,就给每个区段单独一页(breakBefore)。 下面是排在编号章之后、用字母编号的附录,以及一张目录和PDF书签都列出、但页面上不印标题的献词页:

headingStyles: [
  { id: 'appendix', numberingTemplate: 'Appendix {1:A}' },
  { id: 'silent', hidden: true, numbered: false },
],
# Dedication {style="silent"}
 
For M., who read every draft.
 
# Method
 
…
 
# Survey instrument {style="appendix" startAt=1}
 
# Raw data {style="appendix"}

一级设为numberingTemplate: '{1}.'时,各章印作1.、2.…,附录印作Appendix A和Appendix B。献词开启自己的一页(按所在级别的breakBefore),只印出它的段落,但仍以Dedication出现在:::toc、{chapterTitle}书眉和PDF书签大纲中;给样式加上toc: false,就能把它排除在目录之外。章中间以一级标题排的图版或地图,需要的恰好与献词相反:使用runningChapter: false的样式(通常同时设numbered: false和toc: false),书眉就会保持在被它打断的那一章上。

const resolved = resolveHeadingStylesConfig(config.headingStyles, resolvedPage, resolvedBodyText, resolvedUnorderedLists, resolvedOrderedLists);
// => 级别覆盖已规范化,margins从页面补全,bodyStyle取自正文
 
const minimal = stripHeadingStylesDefaults(config.headingStyles);
// => 没有剩下任何样式时为undefined

#目录

toc属性配置:::toc指令印出的内容(参见文档格式)。目录由文档的大纲汇编而成,大纲包含每个标题及其编号和页码标签,以及每个:::part,所以目录会跟着各章变化:给某章改名、把它移到另一篇、修改它的作者,条目都会随之改变。一个条目依次是:单独一栏的标题编号、标题、前导符、右边缘的页码标签,然后是可选的副标题行;篇则是由parts.design设计的一行。

const config: PostextConfig = {
  toc: {
    levels: [{ level: 1, fontWeight: 700, color: { hex: '#00507b', model: 'hex' }, numberWidth: { value: 7.4, unit: 'mm' } }],
    unnumbered: { color: { hex: '#000000', model: 'hex' } },
    pageNumber: { fontWeight: 400, width: { value: 8, unit: 'mm' } },
    leader: { char: '.', gap: { value: 1, unit: 'mm' } },
    subtitle: { enabled: true, attr: 'author', italic: true, fontSize: { value: 8.5, unit: 'pt' } },
    parts: {
      height: { value: 23, unit: 'pt' },
      marginTop: { value: 11.5, unit: 'pt' },
      design: { elements: [/* 一个色带框、'SECTION {number}'、'{titleText}'、'{pageNumber}' */] },
    },
  },
};
属性类型默认值说明
levelsTocLevelConfig[]一级列出的标题级别,每级带有条目的字体样式:fontFamily、fontSize、lineHeight(默认为正文行距,使目录落在网格上)、fontWeight、italic、color、indent(整个条目的缩进)、numberWidth / numberGap(编号栏的宽度和间隔,标题从编号栏之后开始;编号在栏内右对齐)、numberFontFamily、numberFontSize、numberFontWeight、numberColor、marginTop、marginBottom。未设置的字段继承正文。无论编号用什么字体和字号,它都落在标题第一行的基线上,Canvas、HTML和PDF中都是如此(postext 1.4及以前,编号像列表项目符号那样以x字高居中,所以展示字体或较大的字号会浮在标题上方)。自己编写的渲染器可以从条目块的bulletBaselineY取得这条基线;bulletY仍是编号em框的中点,与1.4相同,所以早于这个新字段的渲染器会把编号画在一直以来的位置。
unnumberedTocEntryStyleConfig—针对样式为numbered: false的标题(如序言)的覆盖:它们不印编号,从该级别的indent处齐头开始。
pageNumberobject一级条目的字体,正文字重页码标签的fontFamily、fontSize、fontWeight、italic、color,以及width(默认2em):右边缘为它预留的栏宽,页码在栏内右对齐。
leaderobjectchar在标题和页码之间的空当里重复排出,并且右对齐,使相邻条目的点对齐('. '会把点拉开);gap是标题和前导符之间至少保留的距离。前导符排入能放下的全部字符,按它所用字体把整串字符作为一个整体来测量,所以如果某种字体会把相邻句点的字偶距拉开,得到的是更少的点,而不是一直顶到页码的点。(postext 1.4及以前按单个点计数,在这类字体中前导符会从标题一直延伸进页码。)标题长到不给页码标签留位置时,会稍早一点换行。
subtitleobject条目下的第二行,取自标题属性(attr),比如章的作者,有自己的fontFamily、fontSize、fontWeight、italic(默认true)、color和额外的indent。这一行使用条目的行距,从不与标题分开。
parts.enabledbooleantrue篇隔页是否在目录中占一行。
parts.breakBeforebooleanfalse除第一篇外,每个篇行之前另起新页,使每一篇的各章单独列在一页上。
parts.designDesignSlot空篇行的设计;它的容器就是这一行(栏宽 × height)。占位符有、、…、和(篇隔页的页码标签;使用parts.page: false时不开篇隔页,取篇的内容开始的那一页,即书眉切换到这一篇的那一页;在逐章排版的书中,结束一章的篇容器指向下一章的第一张内容页)。链接到调色板的颜色采用篇自己的palette,所以每个部分的行都用各自的颜色。为空时,以一级条目的字体样式排出(中间是一级标题的numberSeparator)和页码。
parts.height、marginTop、marginBottomDimension2em、0、0行高及其上下的空白。em是正文字号,所以默认行高是正文字号的两倍,而不是两行正文:正文为9.5/13.5 pt时它是19 pt。要让一行占两行正文,就用pt给出高度(这里是27pt)。

页码标签就是文档实际印出的那些。buildDocument()会用上一遍的标签重新排版带:::toc的文档,直到标签稳定(最多额外三遍);逐章排版整本书的宿主程序则改为以PostextContent.outline提供全书大纲,大纲由contentOutline()(只从文字得出的标题和篇)和outlineFromDoc()(同样的条目,带上某次排版的页码标签)汇编而成,每当该大纲的outlineKey()变化时就重新排版目录所在的章。每个标题条目都带有目录印出的number;编号标题还带有counter,即该级别在任何startAt之后的累计数,与模板印出什么无关,宿主程序在自己的列表中标在章旁的就是它。

#索引

index属性配置:::index指令印出的内容(参见文档格式):正文中用:index[…]和:index{term="…"}标记的词条,经过排序,按首字母分组(中文按拼音首字母或笔画数分组,参见groupBy),每条附上它所在的页码。一个条目由词条、分隔符和页码组成;其下的子条目逐级缩进一步,折行按turnoverIndent悬挂缩进,因此不会与子条目对齐。

const config: PostextConfig = {
  headingStyles: [
    // 索引排成两栏,放在它自己的标题下。
    { id: 'index', numbered: false, layout: { layoutType: 'double', gutterWidth: { value: 6, unit: 'mm' } } },
  ],
  index: {
    fontSize: { value: 8.5, unit: 'pt' },
    lineHeight: { value: 11, unit: 'pt' },
    rangeFormat: 'chicago',
    groups: { fontFamily: 'Source Sans 3', fontWeight: 700, color: { hex: '#8a1c1c', model: 'hex' } },
  },
};
属性类型默认值说明
fontFamily、fontSize、lineHeight、fontWeight、color—正文的设置条目的字体样式。索引的每一行(包括字母标题)都按lineHeight排;索引保持自己的节奏,不对齐基线网格。
indentDimension1em每一级子条目的缩进。
turnoverIndentDimension2em条目折行在其所在级别之外的额外缩进。
entrySpacingDimension0每个主条目上方的空白。
separator、locatorSeparator、rangeSeparatorstring', '、', '、'–'分别印在词条与第一个页码之间、两个页码之间,以及页码范围两端之间的内容。
mergeRangesbooleantrue把同一编号格式的连续页码合并成范围:12, 13, 14印作12–14。主要页码从不合并。
rangeFormat'full' | 'chicago''full'页码范围中第二个数字的写法:写全(234–237),或按The Chicago Manual of Style(9.64)的要求省去与第一个数字相同的位:71–72、100–104、101–8、321–28、1496–500。罗马数字标签总是写全。
main粗体主要页码(标记上带main)的排法。
see按语言,斜体参见项前面的引导词。未设置时随文档语言而定:See / See also、Véase / Véase también、Voir / Voir aussi、见 / 另见(繁体中文为見 / 另見)等。中文索引把参见项放在句号之后,不加空格:贾琏 12。见贾政。
localestring文档的语言按哪种语言的字母顺序给条目排序(BCP 47标签,由Intl.Collator读取)。在西班牙语中,ñ排在n之后,并单独成组;重音符号从不影响顺序。
groupBy'auto' | 'letter' | 'pinyin' | 'stroke' | 'none''auto'组标题是什么。'letter':排序键的首字母。'pinyin':以汉字开头的条目归入其拼音读音的拉丁首字母之下(贾宝玉归入J),拉丁字母的排序键归入其字母,排在该字母的中文条目之后(排序器把拉丁字母排在汉字之后):sort="jia mu"排在J组末尾。'stroke':按第一个字的笔画数分组,一畫、二畫…(简体中文为一画…)。'none':不设组标题;符号、数字和词语之间只用groups.marginTop隔开。'auto'让简体中文索引(zh、zh-Hans、zh-CN)按拼音分组,繁体中文索引(zh-Hant、zh-TW、zh-HK)按笔画分组,其他语言按字母分组。条目按组标题所依据的排序规则排序:按拼音分组的zh-Hant索引按拼音排序。读音和笔画数来自排序器(CLDR);遇到它读错的字(把重阳的重读作zhòng,把行业的行读作xíng),就给标记一个sort键,用只有你想要的读音的字来写,它会排在正确的位置:重阳用sort="崇阳",行业用sort="航业"。没有中文排序数据的浏览器排出的拼音或笔画索引不带组标题。
groups.enabledbooleantrue在每组条目上方印一个组标题(A、B…或笔画数,数字为0–9,其余为Symbols;中文为数字 / 數字和符号 / 符號)。
groups.fontFamily、fontSize、fontWeight、italic、color—条目的设置,字重700字母标题的字体。它按条目的行距排。
groups.marginTopDimension索引的一行每组上方的空白,不论有没有组标题;第一组上方没有(它与标题的距离由标题决定),栏顶也没有。
groups.symbolsLabel、numbersLabelstring按语言;'0–9',中文为'数字' / '數字'以符号开头和以数字开头的条目的组标题。

页码与目录一样来自全书大纲:computeOutline()和contentOutline()把一段文字中的索引标记列为'indexMark'类型的条目(带有indexMark.path、sort、see、seeAlso、main、range和index),outlineFromDoc()给每个条目加上它落在的页,这些页由排版过程记录在doc.indexMarks中(每个标记一个{ sourceStart, pageIndex })。buildDocument()会重新排版印出自身索引的文档,直到页码稳定;逐章排版整本书的宿主程序把全书大纲作为PostextContent.outline交给包含:::index的章。tocOutline()和indexOutline()把大纲拆成目录和索引各自读取的部分,宿主程序可以据此给每一章设定与其印出内容相应的键:沙盒只在某个标记移动时重新排版索引章,只在某个标题移动时重新排版目录章。contentOutline()还会说明一段文字是否印出索引(hasIndex)。

#单位与颜色

#尺寸

Postext中所有物理量都用Dimension类型表示,即一个数值加一个单位:

interface Dimension {
  value: number;
  unit: DimensionUnit; // 'cm' | 'mm' | 'in' | 'pt' | 'px' | 'em' | 'rem'
}

绝对单位:cm、mm、in、pt、px,按配置的DPI换算成像素。在300 DPI下,1 cm约等于118 px。

相对单位:em、rem,随当前字号缩放。em相对于元素自身的字号,rem相对于正文字号。

#颜色

颜色同时保存一个十六进制表示和一个目标颜色模型:

interface ColorValue {
  hex: string;        // '#ff0000'、'transparent' 等
  model: ColorModel;  // 'hex' | 'rgb' | 'cmyk' | 'hsl'
}

model字段指明预期的色彩空间。网页渲染通常用'hex'或'rgb'。印刷流程中,'cmyk'表明这个颜色在导出PDF时应当以CMYK指定。

Postext面向出版级输出,所以正文的默认颜色带有model: 'cmyk'(#000000)。标题、粗体、斜体和列表的颜色默认链接到调色板中的主色(#295AA3,model: 'hex')。页面背景和界面叠加层(基线网格、裁切标记、调试标记)默认为model: 'hex'。如果需要不同的导出语义,可以在任意字段上覆盖color.model。

#透明度

颜色可以是半透明的。hex接受带alpha通道的写法:#rgba或#rrggbbaa。它也接受rgb() / rgba()颜色,逗号语法和空格语法都可以,alpha可以写成数字或百分比。transparent表示完全透明:

const config: PostextConfig = {
  header: {
    elements: [{
      kind: 'box',
      id: 'veil',
      placement: {
        anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: 'fill', height: { value: 40, unit: 'mm' } },
      },
      style: { backgroundColor: { hex: '#ffffffb3', model: 'hex' } }, // 白色,不透明度70 %
    }],
  },
  bodyText: { color: { hex: 'rgba(0, 0, 0, 0.85)', model: 'rgb' } },
};

三个后端的绘制结果相同。Canvas和HTML查看器把这个值当作CSS颜色。PDF后端把颜色的不透明度设为常量alpha,即一个ExtGState,填充用ca,描边用CA。文字、线条、框、表格填充和边框、行内标签、色块和公式都适用。半透明颜色叠加在它之前绘制的内容之上。页眉或页脚中的框最后绘制,因此会遮在其下的文字上;章首页色带中的框最先绘制,因此会给文字下方的页面着色。当PDF被强制转换到另一色彩空间时(pdfGeneration.forceColorSpace配合colorSpace: 'cmyk'或'grayscale'),颜色会被转换,alpha保持不变。在沙盒中,取色器的不透明度滑块以#rrggbbaa写入这些值,取色器也能读取其他写法。

#自定义字体

Postext把每个fontFamily字符串同时与Google Fonts字体目录和文档的customFonts列表进行匹配。名称冲突时自定义字体优先:如果你声明了customFonts: [{ name: 'Roboto', … }],Postext会使用你上传的文件,而不是Google Fonts的“Roboto”。

以下情况适合使用自定义字体:

  • 文档需要品牌字体或授权字体,而Google Fonts上没有。
  • 运行环境无法访问Google Fonts CDN(离线、内网、对隐私敏感)。
  • 字体文件必须保密,不能上传给第三方。

#配置结构

type CustomFontFormat = 'woff2' | 'woff' | 'ttf' | 'otf';
type CustomFontStyle = 'normal' | 'italic';
 
interface CustomFontVariant {
  weight: number;           // CSS font-weight,100..900
  style: CustomFontStyle;
  fileId: string;           // 二进制文件在外部存储中的不透明id
  format: CustomFontFormat;
  fileName?: string;        // 上传时的原始文件名(可选,显示在界面中)
}
 
interface CustomFontFamily {
  name: string;             // 可用在任何能填Google Fonts字体家族名的地方
  variants: CustomFontVariant[];
}
 
interface PostextConfig {
  // ...
  customFonts?: CustomFontFamily[];
}

各个变体的二进制文件不嵌入配置本身。配置只保存fileId指针,字节数据存放在配置之外。在沙盒里,这指的是IndexedDB(键值存储,仅限浏览器,归该文档私有)。把Postext嵌入其他宿主的集成方可以用任何方式解析fileId,例如服务器接口、service worker缓存等,只要字节数据在buildDocument运行前到达主线程即可。

#在沙盒中管理自定义字体

从左侧活动栏打开字体面板(位于图表和版面设计之间)。其中的本书使用的字体列表列出版面设计用到的每个字体家族,以及它的用途、来自Google Fonts还是你的文件。在你的字体文件下,对每个字体家族:

  1. 添加字体家族:新建一个空的字体家族,可直接在原处重命名。
  2. 上传变体:选择字重(100–900)和样式(normal / italic),然后选择一个或多个.woff2、.woff、.ttf或.otf文件。每个文件成为一个独立变体,绑定到当前选中的(字重,样式);上传的文件名会被记住并显示在该行,便于区分各个变体。之后随时可以用下拉菜单重新调整变体的字重或样式。
  3. 允许变体重复。如果两个文件落在同一个(字重,样式)位置,两者都会保留,并出现字体变体重复警告,提醒你为多出来的文件调整设置加以区分。
  4. 删除变体或删除家族:从配置中移除该条目,同时删除IndexedDB中保存的字节数据。

字体家族一经声明,所有字体选择器都会把它归入自定义组,排在Google Fonts列表上方。选中它,就会把这个字体家族写入你应用它的每个字体家族字段。

#渲染行为

内部机制如下:

  • customFonts变化时,每个已声明的字体家族都会自动以FontFace条目注册到document.fonts上。这样HTML查看器、Canvas视口(它通过document.fonts测量)以及任何直接引用它的CSS都能用上自定义字体,用户无需先打开字体选择器。
  • 排版工作线程通过现有的字体数据传输通道接收同样的ArrayBuffer,因此测量(buildFontString、pretext)得到的度量与Google Fonts完全一致。
  • 修改或删除某个变体时,工作线程会丢弃该字体家族的缓存字体,并在下次构建时重新注册,使预览与当前的变体集合保持同步。
  • PDF导出:上传的二进制文件走同一条PdfFontProvider流程。.woff2文件会被解压;.ttf和.otf直接传入。.woff会被拒绝并给出明确的错误(pdf-lib无法嵌入原始WOFF,请改为上传.woff2/.ttf/.otf)。CFF风格的OpenType(带OTTO标识的.otf)不做子集化直接嵌入,因为pdf-lib的CFF子集化程序在save()时会遍历每个字形,对实际字体可能卡住好几分钟;跳过子集化,PDF会稍大一些,换来稳定的渲染时间。

#缺失字体警告

沙盒的检查面板在其字体组中列出三种自定义字体特有的问题(都由同一个debug.warnings.missingFont开关控制,这个开关原本就管着通用的“未加载”警告):

  • 未知字体家族:某个fontFamily引用的名称既不是已知的Google Fonts字体,也不是当前已声明的自定义字体家族。当你删除一个仍被某个fontFamily字段引用的自定义字体家族时,这条警告也会立刻出现,不必等DOM察觉。
  • 缺少字体变体:字体家族存在,但标准的字重/样式位置(400 / 700,normal / italic)中至少有一个没有上传文件。警告会列出具体缺少哪些组合。
  • 字体变体重复:同一字体家族中,两个或更多上传文件占用同一个(字重,样式)位置。渲染时只会用到其中一个文件;警告提醒你重新调整其余条目。

点击其中任何一条警告都会打开字体面板,方便你上传缺少的变体、重新添加字体家族或区分重复的变体。

#调色板

PostextConfig上的colorPalette属性让你定义一组可复用的命名颜色,并在配置中任何ColorValue里引用它们。它相当于Postext中的CSS自定义属性,或InDesign的色板面板:改一次调色板条目,所有指向它的颜色都会在整个文档中随之更新。

interface ColorPaletteEntry {
  id: string;       // 稳定的标识符,由ColorValue.paletteId引用
  name: string;     // 在沙盒界面中显示的名称
  value: ColorValue;
}

#默认调色板

Postext自带一个只有一个条目的默认调色板,名为主色(id: 'main-color',十六进制值#295AA3)。多项默认值,包括标题颜色、正文粗体/斜体颜色、:ref颜色、项目符号和编号的颜色,都通过paletteId: 'main-color'引用这个条目,所以改动这一个色板,文档中所有用到它的地方都会重新着色。

可以通过三个导出项查看、复制默认调色板,或与之比较:

import {
  DEFAULT_COLOR_PALETTE,
  cloneDefaultColorPalette,
  isDefaultColorPalette,
} from 'postext';
 
// 内置调色板的只读快照。
DEFAULT_COLOR_PALETTE;
// => [{ id: 'main-color', name: 'Main Color', value: { hex: '#295AA3', model: 'hex' } }]
 
// 独立副本:修改这个,不要修改DEFAULT_COLOR_PALETTE。
const palette = cloneDefaultColorPalette();
 
// 检测用户是否改动过调色板。
isDefaultColorPalette(palette); // true

调色板位于配置的顶层:

const config: PostextConfig = {
  colorPalette: [
    { id: 'ink',    name: 'Ink',    value: { hex: '#0a0a0a', model: 'cmyk' } },
    { id: 'accent', name: 'Accent', value: { hex: '#b8860b', model: 'hex' } },
  ],
  bodyText: { color: { hex: '#000000', model: 'cmyk', paletteId: 'ink' } },
  headings: { color: { hex: '#000000', model: 'hex', paletteId: 'accent' } },
};

#引用调色板条目

配置中的任何ColorValue都可以带一个可选的paletteId字段,指向colorPalette中的某个条目:页面背景、正文颜色(包括:ref颜色)、标题颜色、栏间线、列表颜色,表、题注、行内标签和标注框的颜色(框、色条、图标、标记、标签、标题、正文),裁切标记和基线网格的颜色,调试标记,以及版面设计中的所有颜色:书眉、标题章首页和栏内设计、标题样式的版面设计和书眉、篇名页以及目录中的篇条目(文字、线条、框的填充和边框、轮廓、首字下沉)。存在paletteId时,调色板条目的hex / model优先于一起保存的备用hex / model。只有在调色板缺失、为空或不含该id时才会使用内联的备用值;如果配置要交给不理解调色板的工具读取,这一点很有用。

**postext 1.5中的变更。**在postext 1.4及以前,调色板只作用于一份固定的设置清单:版面设计的颜色(书眉、章首页、标题样式、篇、目录条目)、bodyText.referenceColor、标注框标签的颜色以及标注框正文的粗体/斜体颜色,都保留与其paletteId一起保存的hex。如果文档中保存的值与调色板条目不同(凡是在条目改动后又在沙盒中编辑过的文档,以及主色不是#295AA3时的所有:ref),现在会按链接的含义印出调色板中的颜色。要让某个颜色保持原样,删除它的paletteId。

#调色板如何生效

buildDocument在两处应用调色板,这样无论是你明确写出的覆盖值,还是之后才填入的默认值,引用的颜色都能生效:

  1. applyPaletteToConfig(config):解析原始用户配置中每个带paletteId的ColorValue。想查看引擎实际看到的配置时可以用它。
  2. applyPaletteToResolvedConfig(resolved, palette):在默认值解析之后运行,把链接到调色板的默认值(标题颜色、正文粗体/斜体颜色、:ref颜色、列表颜色、默认版面设计的颜色)改写为当前调色板的值。

两者都会遍历整个配置,不会遗漏任何链接到调色板的颜色。文本流中的大多数颜色(正文、标题、列表、表、题注、行内标签,以及标注框的框、标题和正文)输出为普通值。其余颜色,即版面设计的颜色、:ref颜色和标注框标签,取调色板的hex / model,并保留各自的paletteId。篇的palette属性和标题样式的palette正是通过这个链接在各自的页面上覆盖颜色(见篇),所以必须保留它。htmlViewer.overrides保持原样:HTML查看器先合并它,它带的调色板随后会作用于所有内容,版面设计也包括在内。

你很少需要自己调用这两个函数,但它们都已导出,便于查看或复用:

import {
  applyPaletteToConfig,
  applyPaletteToResolvedConfig,
  resolveColorValue,
} from 'postext';
 
const flat = applyPaletteToConfig(config);
// 原始配置中每个带paletteId的ColorValue现在都带有
// 调色板条目的hex/model(版面设计的颜色保留其paletteId)。
 
// `applyPaletteToResolvedConfig`通常由buildDocument处理;如果你自己构建
// ResolvedConfig并希望应用调色板,可以直接调用它。

resolveColorValue(value, palette, fallback)是处理单个值的版本,适合以命令式方式组装配置、需要逐个解析颜色的场合。

#编辑调色板

如果某个颜色的paletteId找不到对应条目,就会印出它保存的hex / model,而这个值可能早于条目赋予它的颜色。因此,删除条目之前,应先把链接到它的每个ColorValue改写为普通颜色,填入该条目的当前值。沙盒的调色板部分(版面设计 → 颜色)在你删除条目时会自动这样做,无论该颜色位于何处(包括版面设计和标注框标签),并在确认对话框中列出所有用到该条目的设置:按名称,或按它在配置中的路径(header.elements[2].color)。

#HTML查看器

htmlViewer属性控制HTML后端如何在屏幕上排列页面。它只在使用renderToHtml / renderToHtmlIndexed渲染时生效;Canvas和PDF路径完全忽略它,直接使用配置的page.width、page.height和page.dpi。

interface HtmlViewerConfig {
  maxCharsPerLine?: number;     // 目标栏宽,以正文字体的字符数计。
  columnGap?: number;            // 多栏模式下的栏间距(px)。
  optimalLineBreaking?: boolean; // 在HTML查看器中使用Knuth–Plass,而不是贪心算法。
  overrides?: HtmlViewerOverrides; // 仅用于屏幕的部分配置,合并到文档配置之上。
}
 
type HtmlViewerOverrides = Omit<PostextConfig, 'htmlViewer'>;
属性类型默认值说明
maxCharsPerLinenumber70每个渲染栏的目标行长,以正文字体的字符数表示。视口按这个长度取样一段有代表性的正文字符串,由此算出实际的像素宽度,因此结果能适应任何比例字体与字号的组合。
columnGapnumber50查看器处于多栏模式时的栏间距,单位为CSS像素。单栏模式下忽略。
optimalLineBreakingbooleanfalse在HTML查看器中启用Knuth–Plass断行。默认关闭,因为查看器每次改变窗口大小或字号都会重新排版,贪心的首次适配算法足够快,感觉不到延迟。如果想得到与Canvas后端相同的最优断行,就打开它。
overridesHtmlViewerOverrides—只在屏幕上生效的部分文档配置。HTML查看器在排版前把它合并到文档配置之上(applyHtmlViewerOverrides);Canvas和PDF忽略它。对象递归合并;levels数组(标题、列表、目录)按level逐条合并;其他所有数组,如版面设计位置的elements、calloutStyles、colorPalette……整体替换基础数组。典型用法:去掉印刷用色带的章首页,或篇名页的标题依数字换行,而不是按固定的成品框宽度换行。沙盒以JSON形式编辑它。
const config: PostextConfig = {
  headings: { levels: [{ level: 1, span: 'page', breakBefore: { enabled: true } }] },
  htmlViewer: {
    // 在屏幕上,各章连续排下去,不用通栏章首页。
    overrides: { headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false } }] } },
  },
};

解析函数和精简函数与其他部分的模式相同:

import {
  DEFAULT_HTML_VIEWER_CONFIG,
  resolveHtmlViewerConfig,
  stripHtmlViewerDefaults,
} from 'postext';
 
const resolved = resolveHtmlViewerConfig(config.htmlViewer);
// => { maxCharsPerLine: 70, columnGap: 50, optimalLineBreaking: false }
 
const minimal = stripHtmlViewerDefaults(config.htmlViewer);
// => 所有值都与默认值相同时为undefined

完整示例见下文的集成HTML查看器。

#PDF生成(配置)

pdfGeneration属性控制PDF后端如何输出最终文档。这些设置由postext-pdf包在导出时使用;Canvas和HTML查看器会忽略它们。

buildDocument把它们放进VDT,即doc.config.pdfGeneration;renderToPdf对每项设置,从下列来源中第一个给出该设置的地方取值:

  1. 它自己的选项(outlines、accessible、colorSpace);
  2. 它渲染的第一个文档的pdfGeneration(对于一本书,第一章的设置作用于整个文件);
  3. 默认值:开启书签和标签,RGB颜色。

所以renderToPdf(doc, { fontProvider })遵循配置,而传给renderToPdf的选项只在该项设置上优先。forceColorSpace和colorSpace合起来对应colorSpace选项:forceColorSpace开启时采用配置中的colorSpace,关闭时PDF为RGB。早期版本的postext-pdf只读取选项;现在,设置了pdfGeneration的配置会改变不传选项的调用方所生成的PDF。

type PdfColorSpace = 'rgb' | 'cmyk' | 'grayscale';
 
interface PdfGenerationConfig {
  outlines?: boolean;          // 根据标题树生成PDF书签。
  forceColorSpace?: boolean;   // 把所有颜色转换为`colorSpace`。
  colorSpace?: PdfColorSpace;  // `forceColorSpace`为true时使用的目标色彩空间。
  accessible?: boolean;        // 带标签、面向PDF/UA的输出(结构树、替代文本、语言)。
}
属性类型默认值说明
outlinesbooleantrue根据标题层级生成PDF大纲(书签),读者可以从PDF阅读器的侧边栏直接跳到任一标题。标题树没有意义的文档(例如单页海报)可以关闭它。
forceColorSpacebooleanfalse为true时,渲染出的PDF中所有颜色都在导出时转换为colorSpace。以屏幕为主、输入颜色已经处于目标色彩空间的PDF可以不开;要确保来源混杂的颜色统一到一个色彩空间,就打开它。
colorSpace'rgb' | 'cmyk' | 'grayscale''cmyk'forceColorSpace开启时使用的目标色彩空间。胶印用'cmyk',仅供屏幕阅读的PDF用'rgb',黑白打样用'grayscale'。forceColorSpace为false时不起作用。
accessiblebooleantrue生成面向PDF/UA-1的无障碍、带标签的PDF:按阅读顺序排列的逻辑结构树(不跳级的标题、段落、列表、块引用、标注框、带表头单元格的表、带替代文本和题注的图、公式、作为链接的可点击引用;:::toc的内容作为一个TOC,每行一个TOCI,其中行的编号为Lbl,标题和页码为包含链接的Reference),文档标题和语言(顶层的locale),XMP元数据中的PDF/UA标识;所有装饰性标记(页面背景、线条、基线网格、页眉页脚、裁切标记、重复的表头、拆分标注框中重复的标题和续接标记)都标为artifact,屏幕阅读器会跳过它们。没有altText的图依次退而使用题注、标签。浮动的图或表紧接在首次引用它的文字之后阅读,或紧接在其::resource行之前的文字之后;浮动的框紧接在其围栏之前的文字之后阅读,即使浮动体排在后面的页面上也是如此;越过浮动体继续排下去的列表或目录仍是一个元素。只有在不需要额外结构的印刷母版中才关闭它。
pdfGeneration: {
  outlines: true,
  accessible: true,
  forceColorSpace: true,
  colorSpace: 'cmyk',
}

解析函数和精简函数与其他部分一致:

import {
  DEFAULT_PDF_GENERATION_CONFIG,
  resolvePdfGenerationConfig,
  stripPdfGenerationDefaults,
} from 'postext';
 
const resolved = resolvePdfGenerationConfig(config.pdfGeneration);
// => { outlines: true, forceColorSpace: false, colorSpace: 'cmyk', accessible: true }
 
const minimal  = stripPdfGenerationDefaults(config.pdfGeneration);
// => 所有值都与默认值相同时为undefined

完整的导出流程见下文的生成PDF。

#调试

debug属性包含两类写作辅助:一是让源文本与渲染版面保持同步的可视叠加层,二是一组警告,在沙盒的检查面板中指出排版或结构上的问题。两者都不影响导出结果。

属性类型说明
cursorSyncSyncIndicatorConfig映射到渲染版面中的光标,见可视叠加层。
selectionSyncSyncIndicatorConfig在页面上高亮源文本中的选区,见可视叠加层。
looseLineHighlightLooseLineHighlightConfig覆盖在稀松的两端对齐行上的叠加层,见可视叠加层。
pageNegative页面的高对比度负片,见可视叠加层。
warningsWarningsToggleConfig编辑器中每类写作警告各对应一个布尔值,见警告。

#可视叠加层

属性类型默认值说明
cursorSync.enabledbooleantrue在渲染版面中显示一个光标,对应源文本中光标的位置。
cursorSync.colorColorValue#2563eb该光标的颜色。
selectionSync.enabledbooleantrue高亮渲染结果中与源文本选区对应的范围。
selectionSync.colorColorValue#fde04780高亮的颜色,默认为半透明黄色。
looseLineHighlight.enabledbooleanfalse在词间距超过正常空格宽度threshold倍的两端对齐行上绘制叠加层。
looseLineHighlight.colorColorValue#ff000040该叠加层的颜色。
looseLineHighlight.thresholdnumber3正常空格宽度的倍数,两端对齐行的空格超过它就算作稀松行。looseLines警告使用同一阈值。空格需要拉伸到3倍以上的两端对齐行会改排为齐左,所以在默认值下,叠加层和警告在连续正文中几乎找不到什么;把它调低(1.5或2),就能看到稀松但仍两端对齐的行。
pageNegative.enabledbooleanfalse在页面上渲染一层高对比度负片,用来一眼检查跨页的整体形态(文字密度、各栏是否齐底、留白),不受字形细节干扰。

每个SyncIndicatorConfig都是{ enabled: boolean; color?: ColorValue }。LooseLineHighlightConfig是{ enabled: boolean; color?: ColorValue; threshold?: number }。pageNegative是一个最简的{ enabled: boolean }开关。

debug: {
  cursorSync: { enabled: true, color: { hex: '#ff0066', model: 'hex' } },
  selectionSync: { enabled: false, color: { hex: '#fde04780', model: 'hex' } },
  looseLineHighlight: { enabled: true, color: { hex: '#ff000040', model: 'hex' }, threshold: 3 },
  pageNegative: { enabled: true },
}

这些叠加层由沙盒绘制在它的Canvas预览之上,不属于页面:renderPage、HTML输出和PDF都不会绘制它们。

#在你自己的Canvas中标出稀松行

引擎把稀松行高亮导出为两个辅助函数,供你在自己绘制的页面上使用:

import { buildDocument, renderPageToCanvas, drawLooseLines, findLooseLines } from 'postext';
 
const doc = buildDocument(content, config);
const canvas = document.querySelector('canvas')!;
renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });
drawLooseLines(canvas.getContext('2d')!, doc.pages[0], doc, { threshold: 2.5 });
 
// 同样的行也可以作为数据使用:生成报告、SVG叠加层、按页计数。
for (const { ratio, line, block } of findLooseLines(doc, { threshold: 2.5 })) {
  console.log(`page ${block.pageIndex + 1}: ${ratio.toFixed(2)}× — ${line.text}`);
}
  • **findLooseLines(doc, { threshold?, pageIndex? })**按阅读顺序返回每一个justifiedSpaceRatio超过threshold的两端对齐行:{ block, line, ratio, x, y, width, height }。这个矩形以页面像素计,就是高亮覆盖的横条:在该行的高度上横跨整个块宽。沙盒高亮的、并在检查面板中报告为looseLine的正是这些行。
  • **drawLooseLines(ctx, page, doc, { threshold?, color? })**在一页上填充这些横条,并返回它绘制的行。它在上下文当前的变换下以页面像素绘制,所以要在同一个canvas上紧接着renderPage或renderPageToCanvas调用它:这两个函数都会让上下文保持按页面缩放。color可以是任何canvas填充样式。
  • **默认值。**两个辅助函数都使用默认阈值(3)和默认颜色(#ff000040),而不是文档的debug.looseLineHighlight:那项设置属于沙盒。要遵循某个配置,传入resolveDebugConfig(config.debug).looseLineHighlight.threshold和.color.hex。

#警告

debug.warnings控制哪些写作问题出现在沙盒的检查面板中(在版面设计 → 高级 → 警告中编辑)。每个键都是独立的布尔开关;把某个键设为false,就只关闭这一条警告,其他警告不受影响。

这些开关只过滤沙盒的面板。引擎自己记录的警告,即doc.warnings中溢出所在栏的框,doc.contentWarnings中未知的资源id、指令、嵌入和样式id以及不规则的表格网格,无论开关如何设置都会存在;渲染器也会报告它们以占位图绘制的图像。见文档中的警告。

interface WarningsToggleConfig {
  missingFont?: boolean;
  looseLines?: boolean;
  headingHierarchy?: boolean;
  consecutiveHeadings?: boolean;
  listAfterHeading?: boolean;
  designIssues?: boolean;
}
属性类型默认值说明
missingFontbooleantrue配置引用的字体在浏览器中加载失败时报告。它能及早发现fontFamily中的拼写错误和缺失的@fontsource/...包,免得它们在渲染结果中悄悄变成后备字体替换。
looseLinesbooleantrue报告词间距超过debug.looseLineHighlight.threshold的两端对齐行。它与叠加层配合使用:警告在面板中逐条列出,叠加层在原位显示。
headingHierarchybooleantrue报告跳级的标题层级,例如H1后面直接跟H3。标题结构上的断层通常说明标题层级写错了,或者对文档大纲的理解有误。
consecutiveHeadingsbooleanfalse一个标题后面紧跟另一个标题、中间没有段落或列表时报告。默认关闭,因为在许多模板中连续的标题是正当的(标题 + 副标题,章 + 题词);如果文稿中每个标题都应引出正文,就打开它。
listAfterHeadingbooleanfalse列表紧跟在标题之后、没有引导段落时报告。默认关闭,因为参考资料经常这样写;在每个列表都应由正文引出的叙述性写作中打开它。
designIssuesbooleantrue报告版面设计位置中的完整性问题,包括页眉、页脚、篇名页及其后的空白左页、目录中的篇条目、标题的高级设计位置,以及每个标题样式的版面设计和分节书眉。涵盖循环的锚点链、悬空的锚点引用(元素锚定到已不存在的#id)、breakBefore被禁用的通栏标题,以及已启用但其元素从不渲染的高级设计。
debug: {
  warnings: {
    missingFont: true,
    looseLines: true,
    headingHierarchy: true,
    consecutiveHeadings: true,
    listAfterHeading: false,
    designIssues: true,
  },
}

除此之外,面板始终会列出版面本身产生的警告(VDTDocument.warnings),例如溢出所在栏的标注框(calloutOverflow),以及引擎替换掉的配置值(VDTDocument.configWarnings或collectConfigWarnings(config);见下文的配置警告)。

#配置警告

配置本身的四类错误从不静默,也没有开关能隐藏它们。引擎不会因此失败:它会替换一个值,或略去该设置,并给出说明:

  • 未知编号格式:有序列表的numberFormat、page.pageNumbering.format或资源类型的counterFormat不属于任何一种编号格式写法。此时按十进制编号。
  • 字体家族中写了字体栈:fontFamily(或任何…FontFamily)中写了CSS字体栈。文字用字体栈中的第一个字体家族排(见每个fontFamily只写一个字体家族)。
  • 侧栏没有空间:'oneAndHalf'版式的sideColumnPercent(文档的,或标题样式自己的layout中的)会使其中一栏不足内容宽度的1%,或者不是数字。各栏按两者都能接受的最接近的值截取,used给出该值(sideColumnPercentClamped;见'oneAndHalf'版式)。
  • 字符网格过大:cjk.grid的每行字数或每页行数超出了页边距内能容纳的数量。网格按能容纳的最大数量排,used给出这个数(cjkGridClamped;见字符网格)。
  • 未知标题设置:headings、headings.balancing、某个标题级别、标题样式或段落样式中没有的键:拼错的letterSpacng、从其他工具借来的tracking、标题样式上的level、段落样式上的fontStyle: 'italic'(段落样式要用italic: true)。引擎会忽略它(postext 1.4及以前是悄无声息地忽略)。value是这个键,used为空,如果某个设置与它只差一两个字母或只有大小写不同,suggestion会给出最接近的那个设置(unknownConfigKey)。

沙盒在检查面板中列出这些警告,并附上设置的路径。在代码中,buildDocument把它们作为configWarnings放在文档上(配置没有问题时不存在该字段),collectConfigWarnings(config)则不做排版直接返回它们:

import { buildDocument, collectConfigWarnings } from 'postext';
 
// 纯JavaScript:在TypeScript中,'roman'根本通不过类型检查。
const config = { bodyText: { fontFamily: 'EB Garamond, serif' }, orderedLists: { numberFormat: 'roman' } };
const doc = buildDocument({ markdown }, config);
doc.configWarnings;
// [{ kind: 'fontFamilyStack', path: 'bodyText.fontFamily', value: 'EB Garamond, serif', used: 'EB Garamond' },
//  { kind: 'unknownNumberFormat', path: 'orderedLists.numberFormat', value: 'roman', used: 'arabic' }]
collectConfigWarnings(config); // 同一个列表

所有嵌套的部分配置也会被检查:标题样式、篇内的列表、htmlViewer.overrides、设计元素。

formatWarning(见文档中的警告)同样会描述这些警告,并把设置的路径放在最前面,例如bodyText.fontFamily: font stack "EB Garamond, serif" — set in "EB Garamond"、headingStyles[0].letterSpacng: unknown setting "letterSpacng" — ignored (did you mean "letterSpacing"?),这样宿主程序可以用一个循环记录一次构建返回的全部三个列表。

#以编程方式使用

**推荐做法:使用Web Worker。**在浏览器中,绝大多数集成都应通过postext/worker导出的createLayoutWorker()驱动排版流水线,而不是在主线程上直接调用buildDocument。工作线程在构建期间让界面保持响应,在增量重建之间缓存文本测量结果,并实现“后到者胜出”的取消机制:新的一次按键会中止仍在进行的过时构建。标准用法请直接看在Web Worker中运行排版。本节其余内容(直接调用buildDocument、解析函数、剥离函数、缓存)依然有用,因为工作线程的输入和输出与之完全相同;但对界面代码来说,正确的起点是工作线程封装。只有在一次性导出、服务端渲染(Node)或测试中,才退回到在主线程上调用buildDocument。

#构建文档

buildDocument函数运行完整的排版流水线,返回一棵虚拟文档树(VDT),其中每个元素都带有精确坐标。这是最底层的入口;界面代码应优先使用Web Worker封装,它在专用工作线程中以相同参数调用buildDocument。

import { buildDocument } from 'postext';
 
const content = {
  markdown: '# Chapter One\n\nThe story begins here...',
};
 
const config = {
  page: { sizePreset: '17x24' },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt覆盖默认的8 pt
};
 
// 排版:生成VDT,每一页在`vdt.pages`中对应一项
const vdt = buildDocument(content, config);
console.log(`Document has ${vdt.pages.length} pages`);

#文档中的警告

buildDocument遇到错误的引用或未知的样式时不会停下:它采用回退方案,并把所做的处理记录在doc.contentWarnings中。排版时不得不强行放置的框记录在doc.warnings中,其结构与postext 1.4相同:其中每一项都是calloutOverflow,带有pageIndex、columnIndex和overflowPx。没有可报告的内容时,相应字段不存在。每一项都有kind。内容类警告带有对应结构的源码范围sourceStart / sourceEnd,即在你传入的markdown中的偏移量(包括frontmatter);该结构落在某一页上时,还带有它的pageIndex。

类型触发条件输出如何处理
calloutOverflow:::callout框放不进任何一栏,也没有可以拆分它的切分点。照样放置,超出所在栏overflowPx(位于pageIndex / columnIndex)。这是doc.warnings中唯一的类型;下面各类型都在doc.contentWarnings中。
unknownResourceId::resource嵌入(usage: 'embed')、行内:ref('ref')或表格单元格的图片('cellImage')指定的id不属于任何资源。嵌入被省略;引用印出?(或其text=标签),不带编号和链接;单元格只保留文字。inResource指出引用位于哪个资源的题注、注释或单元格中。
unknownDirective:::name行中的名称既不是指令也不是容器。该行按正文排出。
malformedEmbed::name行不是格式正确、独立成行的嵌入:::resource的id没有加引号或用了单引号,或带有其他属性;或者该行紧贴在段落下方,中间没有空行。该行按正文排出。
fullwidthMarkup某一行含有用中文或日文输入法键入的标记::::围栏、#标题、[^…]脚注标记、围栏或标题后的{…}属性,或**…**粗体。typed是原样写下的标记,ascii是应当键入的形式。每行一条。该行按正文排出,不做任何转换。
attributeKeyInvalid属性键含有ASCII以外的字母(作者=曹雪芹);位置指向该键。忽略该属性。
unknownParagraphStyle:::paragraphsstyle指定的段落样式不存在。这些段落按正文排出。
unknownCalloutType:::callouttype指定的名称不在calloutStyles中;只有配置了标注框样式后才会触发。该框采用第一个标注框样式。
unknownChipStyle:chip[…]style指定的行内标签样式不存在。该行内标签采用第一个行内标签样式。
undefinedFootnote脚注标记[^id]没有对应的[^id]:段落定义(id是该脚注的id)。编号照常印出,脚注为空。
unusedFootnote脚注定义[^id]:没有被任何标记引用。不排该脚注。
indexMarkInvalid索引标记没有词条::index,或者没有方括号文字的标记带有属性却缺少term。该标记不编入任何索引。
indexSeeUnknownsee或seealso的目标(target)不是所在索引(index,主索引为'')中的词条。位置指向:::index行。参见照样印出。
indexRangeUnclosedrange="start"标记没有对应的range="end",或者反过来(missing说明缺的是哪一端,term给出词条)。位置指向:::index行。该范围只印出它的那一页。
unknownHeadingStyle标题的style="…"指定的标题样式不存在(level是该标题的级别)。标题及其所在章节保留该级别自身的设置。
unknownTableStyle表格资源的table.styleId在tableStyles中找不到对应项。该表按tableStyle排出。
raggedTableGrid把合并单元格计算在内后,表格的网格不是矩形(见构建表格模型)。单元格会移到合并区域上,或留下空洞。reason('spanOverlap' / 'missingCells')、row和col定位第一处问题;count给出问题总数。

关于某个资源的警告(它的表格样式、网格,或其题注、注释、单元格中的引用)都指向正文中该资源的第一次嵌入或引用,并且每个资源只列出一次。只检查文档用到的资源:书中的一章只报告它引用的表格,而不是书中的所有表格。

import { buildDocument, formatWarning } from 'postext';
 
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.\n\n:::sidebar\nNotes.' }, config);
for (const w of [...(doc.warnings ?? []), ...(doc.contentWarnings ?? []), ...(doc.configWarnings ?? [])]) console.warn(formatWarning(w));
// Unknown resource id "fig-map" in :ref — it prints "?" (or its text= label), with no number or link (page 1, offset 4)
// Unknown directive ":::sidebar" — the line is set as text (page 1, offset 25)
 
// 按`kind`收窄类型,才能读取该类型的字段。
const missing = (doc.contentWarnings ?? []).flatMap((w) => (w.kind === 'unknownResourceId' ? [w.resourceId] : []));

formatWarning(w)返回一行英文描述。需要本地化消息的宿主应改为按kind分支处理,并保留一个默认分支,因为次版本可能增加新的类型。collectContentWarnings(markdown, config, resources)不做任何排版,直接返回内容类警告(即构建时加入的那份列表,但不带pageIndex),供编辑器在输入时检查文本。collectHeadingDesignCuts(doc)检查已完成的版面,找出文字超出所在页或栏底部的标题设计(kind: 'headingDesignCut';见预留高度)。排版本身不报告这类问题,formatWarning同样可以描述它的结果。沙盒的检查面板会列出所有这些警告。

渲染器通过onWarning选项报告无法按要求绘制的内容:renderPageToCanvas、renderPage和renderToCanvas(RenderPageOptions),renderToHtml和renderToHtmlIndexed(RenderHtmlOptions),以及renderToPdf(RenderToPdfOptions,经由PDF工作线程时也一样)。目前只有一种渲染警告:missingImage。一张图片(图、表格单元格中的图片、标注框图标、设计图片)没有可绘制的内容时,会画成一个中性的占位框并报告,每个fileId在每次渲染调用中报告一次,带有它的pageIndex;绘制方知道resourceId时(图和单元格图片)也会带上;在PDF中,多文档渲染还会带上documentIndex。“没有可绘制的内容”指:在Canvas上,该fileId没有调用过registerResourceImage;在HTML中,resourceImageUrl没有给出URL;在PDF中,resourceBytes没有给出字节,或给出的字节无法解码。根本没有指定fileId的位图或SVG资源无从请求,会直接画成占位框而不报告。渲染警告不存储在VDT中,因为宿主能提供的内容在排版之后还会变化。

import { buildDocument, renderPage, type RenderWarning, type Resource } from 'postext';
 
const map: Resource = {
  id: 'fig-map', typeId: 'figure', kind: 'bitmap', caption: 'The route.', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'map-file', format: 'png', width: 1200, height: 800 },
};
const doc = buildDocument({ markdown: 'See :ref{id="fig-map"}.', resources: [map] }, config);
 
const warnings: RenderWarning[] = [];
const canvas = renderPage(doc.pages[0], doc, { onWarning: (w) => warnings.push(w) });
// 在用registerResourceImage注册'map-file'之前:
// [{ kind: 'missingImage', fileId: 'map-file', resourceId: 'fig-map', pageIndex: 0 }]

#把页面渲染为位图

每一页都可以单独栅格化。用renderPage(page, doc)为指定页得到一个HTMLCanvasElement。这个canvas是一张位图,尺寸与页面的像素尺寸(按配置的DPI计算)完全一致,可以直接显示、导出,或送入任何图像处理流程:

import { buildDocument, renderPage } from 'postext';
 
const vdt = buildDocument(content, config);
 
// 把第3页(从0开始计数)渲染为位图canvas
const pageNumber = 2;
const page = vdt.pages[pageNumber];
if (!page) throw new Error(`Page ${pageNumber} does not exist`);
 
const canvas = renderPage(page, vdt);
// canvas.width / canvas.height是页面位图的像素尺寸
 
// 显示在DOM中
document.body.appendChild(canvas);
 
// ……或导出为PNG data URL
const pngDataUrl = canvas.toDataURL('image/png');
 
// ……或取得Blob,用于下载或上传
canvas.toBlob((blob) => {
  if (blob) saveAs(blob, `page-${pageNumber + 1}.png`);
}, 'image/png');
 
// ……或读取原始RGBA像素
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

如果你想绘制到自己已有的canvas上(例如一个已挂到DOM、有特定布局的canvas),使用renderPageToCanvas(page, doc, canvas):它会调整你传入的canvas的尺寸并在上面绘制,而不是新建一个。

要渲染所有页面,遍历vdt.pages即可:

const bitmaps = vdt.pages.map((page) => renderPage(page, vdt));

在线示例:把一页变成图片

上面的全部内容在浏览器中运行的效果。这个pen从CDN导入最新发布的postext,等待网络字体加载完成,排出一份简短的双栏文档,把第一页绘制到canvas上,并提供这张位图的PNG。点击Run on CodePen加载编辑器,即可修改markdown或配置;每次编辑后页面都会重绘。

Postext · 把页面渲染为图片
import { buildDocument, renderPage } from 'https://esm.sh/postext';
 
const markdown = `# The Lantern
 
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.
 
## Two columns
 
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
 
The light it gave was small, but it was enough to find the step. 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.
 
The light it gave was small, but it was enough to find the step. 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.`;
 
const config = {
  // 150 dpi: crisp enough for a preview, light enough to paint instantly.
  page: { sizePreset: '17x24', dpi: 150 },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
 
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('italic 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
 
// The whole layout: one entry per page in doc.pages, with exact coordinates.
const doc = buildDocument({ markdown }, config);
 
// Rasterise the first page. The canvas is sized to the page at the configured dpi.
const canvas = renderPage(doc.pages[0], doc);
document.getElementById('page').replaceChildren(canvas);
document.getElementById('status').textContent =
  `${doc.pages.length} page(s) · page 1 is ${canvas.width} × ${canvas.height} px`;
 
// The same bitmap as a PNG file.
canvas.toBlob((blob) => {
  const link = document.getElementById('download');
  link.href = URL.createObjectURL(blob);
  link.hidden = false;
}, 'image/png');
index.html
<p id="status">Laying out…</p>
<a id="download" download="page-1.png" hidden>Download page 1 as PNG</a>
<div id="page"></div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#page canvas {
  display: block;
  max-width: 100%;
  height: auto;
  margin-top: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

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

#React

postext/react导出createLayout(content, config?):这个组件在挂载时对文档排版一次,把每一页显示为<div>中的一个<canvas>。

import { createLayout } from 'postext/react';
 
const Article = createLayout(
  { markdown: '# Hello\n\nThe first paragraph of the article.' },
  { page: { sizePreset: '17x24' } },
);
 
export function ArticlePage() {
  return <Article className="pages" style={{ maxWidth: 480 }} />;
}
  • **在主线程上,只排一次。**页面按文档的分辨率绘制,再缩放到容器宽度。content和config在调用createLayout时就已固定;要显示别的内容,就再创建一个组件。实时预览应在Web Worker中构建,再用renderPageToCanvas绘制,做法见那里的React示例。
  • **先准备字体和图片。**在组件挂载之前加载文档用到的网络字体,并用registerResourceImage注册其中的图片。markdown中含有$时,组件会自行启动数学引擎。
  • 主入口不引入React。postext从不导入React,只有postext/react会。createLayout仍从postext导出,以便已有代码继续工作,但它已被弃用:调用它时才加载postext/react,组件在加载完成前处于挂起状态(之后React会自行再次渲染它)。请从postext/react导入它。
  • **弃用的组件会挂起。**在postext/react加载完成之前,从postext导入的createLayout需要并发根(createRoot),或在其上方有一个<Suspense>边界。在旧式的ReactDOM.render根中,或在renderToString中,如果没有边界,React会报错。react仍是必需的对等依赖,这样打包工具才能解析这个延迟导入。

#解析默认值

解析函数为不完整的配置对象补上默认值。需要一份完整配置用于检查或比较时,这很有用:

import { resolvePageConfig, resolveBodyTextConfig } from 'postext';
 
const fullPage = resolvePageConfig({ sizePreset: '21x28' });
// => { sizePreset: '21x28', width: { value: 21, unit: 'cm' }, height: { value: 28, unit: 'cm' },
//      margins: { top: { value: 2, unit: 'cm' }, ... }, dpi: 300, cutLines: { enabled: false, ... }, ... }
 
const fullBody = resolveBodyTextConfig({ fontFamily: 'Inter' });
// => { fontFamily: 'Inter', fontSize: { value: 8, unit: 'pt' }, lineHeight: { value: 1.5, unit: 'em' }, ... }

可用的解析函数,每个顶层分区一个:resolvePageConfig、resolveLayoutConfig、resolveBodyTextConfig、resolveHeadingsConfig、resolveHeadingStylesConfig、resolveTocConfig、resolvePartsConfig、resolveUnorderedListsConfig、resolveOrderedListsConfig、resolveMathConfig、resolveTableStyleConfig、resolveCaptionStyleConfig、resolveDiagramStyleConfig、resolveParagraphStylesConfig、resolveCalloutStylesConfig、resolveHeaderFooterConfig、resolveDebugConfig、resolveHtmlViewerConfig、resolvePdfGenerationConfig,另有用于单个设计槽位的resolveDesignSlot。调色板通过applyPaletteToConfig(config)、applyPaletteToResolvedConfig(resolved, palette)和resolveColorValue(value, palette, fallback)单独应用,见调色板。

如果某个分区的默认值由另一个分区级联而来,它的解析函数会把那个已解析的分区作为额外参数。resolveUnorderedListsConfig和resolveOrderedListsConfig接收已解析的正文,因为列表的fontFamily和color默认值由正文级联而来;resolveCalloutStylesConfig接收已解析的正文、标题和无序列表(见标注框样式中的示例);resolveHeadingStylesConfig接收已解析的页面、正文和两个列表分区。各函数的确切签名请查看包的类型声明:

import { resolveBodyTextConfig, resolveUnorderedListsConfig } from 'postext';
 
const body = resolveBodyTextConfig({ fontFamily: 'Inter' });
const lists = resolveUnorderedListsConfig({ bulletChar: '—' }, body);
// => lists.fontFamily === 'Inter'(继承而来)

静态默认值集合(不涉及级联时使用的值)也一并导出:DEFAULT_PAGE_CONFIG、DEFAULT_CUT_LINES、DEFAULT_PAGE_NUMBERING、PAGE_SIZE_PRESETS、DEFAULT_LAYOUT_CONFIG、DEFAULT_COLUMN_RULE、DEFAULT_COLUMN_BALANCING、DEFAULT_BODY_TEXT_CONFIG、DEFAULT_HYPHENATION_CONFIG、DEFAULT_HEADINGS_CONFIG、DEFAULT_UNORDERED_LISTS_STATIC、DEFAULT_ORDERED_LISTS_STATIC、DEFAULT_PARAGRAPH_STYLES、DEFAULT_CALLOUT_STYLES、DEFAULT_CALLOUT_STYLE_STATIC、DEFAULT_PARTS_CONFIG、DEFAULT_HEADING_STYLES、DEFAULT_TOC_CONFIG、DEFAULT_MATH_CONFIG、DEFAULT_DIAGRAM_STYLE_CONFIG、DEFAULT_DEBUG_CONFIG、DEFAULT_HTML_VIEWER_CONFIG、DEFAULT_PDF_GENERATION_CONFIG、DEFAULT_COLOR_PALETTE、DEFAULT_MAIN_COLOR、DEFAULT_MAIN_COLOR_ID、DEFAULT_MAIN_COLOR_NAME、DEFAULT_MAIN_COLOR_HEX,还有页眉页脚元素的默认值(DEFAULT_HEADER_FOOTER_SLOT、DEFAULT_HEADER_SLOT、DEFAULT_FOOTER_SLOT、DEFAULT_TEXT_ELEMENT、DEFAULT_RULE_ELEMENT、DEFAULT_BOX_ELEMENT),以及随语言区域变化的defaultResourceTypes(locale)(见资源类型)。

#剥离默认值

持久化配置(例如保存到localStorage或文件)时,用stripConfigDefaults去掉与默认值相同的值。这样存储的配置最精简,只保存有意做出的覆盖:

import { stripConfigDefaults } from 'postext';
 
const minimal = stripConfigDefaults(fullConfig);
// 只保留与默认值不同的属性

也提供单独的剥离函数,与解析函数一一对应:stripPageDefaults、stripLayoutDefaults、stripBodyTextDefaults、stripHeadingsDefaults、stripHeadingStylesDefaults、stripTocDefaults、stripPartsDefaults、stripUnorderedListsDefaults、stripOrderedListsDefaults、stripMathDefaults、stripTableStyleDefaults、stripCaptionStyleDefaults、stripDiagramStyleDefaults、stripParagraphStylesDefaults、stripCalloutStylesDefaults、stripHeaderFooterDefaults、stripDesignSlotDefaults、stripDebugDefaults、stripHtmlViewerDefaults、stripPdfGenerationDefaults。

#解析markdown

引擎公开了它的markdown分词器和frontmatter读取器。可以用它们在构建之前检查文档,或者把与Postext所见相同的块结构提供给其他工具:

import { parseMarkdown, extractFrontmatter } from 'postext';
 
const source = '---\ntitle: Chapter One\n---\n\n# Opening\n\nThe story begins here.';
 
const { metadata, content } = extractFrontmatter(source);
// metadata.title === 'Chapter One'
 
const blocks = parseMarkdown(content);
// => [ { type: 'heading', level: 1, text: 'Opening', … },
//      { type: 'paragraph', text: 'The story begins here.', … } ]

Postext能识别的markdown结构完整列表见文档格式页面。

#测量缓存

文本测量是排版中开销最大的步骤。有两类缓存让它保持低开销,两者的清除方式不同:

  • 由你持有的块缓存。createMeasurementCache()返回一个MeasurementCache,它记住每个测量过的段落,键由段落的文本、字体、宽度、断行选项和当前的断词词典组成。把它作为buildDocument(或buildDocumentAsync)的第三个参数传入,就能在各轮收敛之间和多次构建之间复用测量结果:每次按键都重新排版的编辑器,只需测量改动过的段落。不传缓存时,每一轮都会重新测量所有块。从缓存读出的段落与重新测量的段落完全相同,因此带缓存的构建与不带缓存的构建排出的每一行都一样;在postext 1.4.1中,缓存的段落会丢失“末行是孤字”的标记,孤字收紧和各栏齐底随后可能以不同方式断行。布局工作线程在多次构建之间保留一个缓存,字体变化时换成新的。
  • **全局宽度缓存。**单词宽度按字体字符串缓存在模块状态中,页面中的所有构建共享;pretext也有自己的一份缓存。如果文本先用回退字体测量,之后网络字体才到达,这些缓存就会过时。
import { buildDocument, createMeasurementCache, clearMeasurementCache } from 'postext';
import type { MeasurementCache } from 'postext';
 
let cache: MeasurementCache = createMeasurementCache();
let doc = buildDocument(content, config, cache);
 
// 一个网络字体加载完成:用回退字体测得的宽度已经过时。
await document.fonts.ready;
clearMeasurementCache();           // 不带参数:清除全局宽度缓存
cache = createMeasurementCache();  // 块缓存没有clear();新建一个
doc = buildDocument(content, config, cache);

clearMeasurementCache()不接受参数,也不会动MeasurementCache:块缓存里的块同样是用旧宽度测量的,所以要丢掉它,新建一个。字体加载后不清除缓存就重新构建,得到的仍是按回退字体测量的断行。

对于逐段测量文本的应用,cachedMeasureBlock(text, font, maxWidthPx, lineHeightPx, options, cache)和cachedMeasureRichBlock(spans, normalFont, boldFont, italicFont, boldItalicFont, maxWidthPx, lineHeightPx, options, cache)接受与measureBlock、measureRichBlock相同的参数,最后再加上缓存。

#同一页面中共享的全局状态

Postext的部分状态存放在模块级变量中。同一个JavaScript realm中导入postext的所有代码共享这些状态:一个页面和它的脚本共享一份,而每个iframe、每个工作线程各有自己的一份。一个页面只有一个文档时不会察觉到这一点;一个页面中有多个文档(两个实时预览、一组示例)时就会:

  • 资源图片。registerResourceImage(fileId, image)填充一个以fileId为键的注册表,renderPage和renderPageToCanvas从中读取。两个文档都注册figure.svg时会共用这一项:最后一次注册生效,对两个文档都是如此。请给文件id加上各文档自己的前缀,并在文档移除时调用unregisterResourceImage(fileId)或clearResourceImages()。Canvas后端缓存的栅格图也按同样的键存放,随图片一起丢弃。
  • **文本测量。**测得的宽度按字体字符串和文本缓存,整个realm共享。在网络字体加载完成之前测量的文本,会一直沿用回退字体的宽度,所有文档都是如此,直到clearMeasurementCache()(不带参数)清空这些缓存为止:字体加载完成后调用一次,然后重新构建。
  • **已解析的配置。**每个配置对象只解析一次,结果按该对象缓存。原地修改配置后再次构建,排版仍使用旧设置:每次修改都应传入一个新对象({ ...config, … }或structuredClone(config))。
  • **断词语言。**每次构建都会把进程级的断词语言设为该文档的bodyText.hyphenation.locale。导出的hyphenateText(text)和layoutDesignSlot使用最近一次构建的语言,除非你显式传入:调用hyphenateText(text, 'es')。
  • **数学引擎。**整个realm只有一个MathJax引擎和一份已渲染公式的缓存;initMathEngine()为所有文档启动它。

最简单的隔离方式是每个文档一个realm:每个实时示例一个iframe(CodePen嵌入就是一个iframe),或者每个文档一个布局工作线程,用于隔离测量和断词(图片仍在页面上注册)。

#在Web Worker中运行排版

**这是在浏览器中使用Postext的推荐方式。**只要你构建的是交互式的东西,比如实时预览、编辑器、随尺寸变化的查看器或沙盒式的练习场,就应通过postext/worker导出的createLayoutWorker()驱动流水线。界面代码不要在主线程上直接调用buildDocument。

在主线程上调用buildDocument,会在调用它的线程上运行完整的流水线:解析、测量、七轮处理、最多五次收敛迭代。一次性导出这样做没有问题。对交互式界面来说,这是错误的线程:一次150 ms的排版会阻塞输入事件,按键排队,滚动卡顿。工作线程把这些时间全部移到后台线程。

Postext提供一个专用的Web Worker入口postext/worker,把流水线移出主线程。我们预计大多数集成都会走这条路:沙盒的Canvas、HTML和PDF视口通过同一个useLayoutWorker hook(packages/postext-sandbox/src/worker/useLayoutWorker.ts)共享同一个createLayoutWorker()句柄,并以“后到者胜出”的方式取消:新的一次按键会在进行中的构建完成之前就把它中止。

概括起来,标准集成是:

  1. 创建:每个视口用createLayoutWorker()创建一次工作线程。
  2. 注册字体:每个字族一次,通过registerFonts(payloads)发送可转移的ArrayBuffer。
  3. 构建:调用build(content, config, { signal }),每次都传入新的AbortSignal,以便取消过时的构建。
  4. 取代:在开始下一次构建之前中止上一次构建的signal,这就是“后到者胜出”模式。
  5. 释放:持有工作线程的组件卸载时释放它。

build(...)返回的同一个VDTDocument可供下游所有渲染器使用:Canvas用renderPage/renderPageToCanvas,HTML用renderToHtmlIndexed,PDF用renderToPdf(来自postext-pdf)。在工作线程中构建一次,然后按界面需要在主线程上栅格化任意多次。

#工作线程带来什么

  • **主线程保持空闲。**解析、测量和七轮收敛循环都在工作线程中运行。只有在完成的VDTDocument发回时才会用到主线程。
  • “后到者胜出”的取消。build(content, config, { signal })把AbortSignal传进工作线程。在完成前中止,主线程一侧会抛出AbortError;工作线程内部,流水线会在下一个逐块取消检查点抛出BuildCancelledError并立即停止。
  • **每个工作线程一份测量缓存。**工作线程在整个生命周期内保留一个MeasurementCache。之后字体、文本和宽度相同的构建会复用缓存的行测量结果:在长文档中输入一个字符,只重新测量输入真正改变的块。
  • **与主线程完全相同的度量。**字体以可转移的ArrayBuffer送入工作线程,并通过new FontFace(...)注册到工作线程自己的FontFaceSet上。工作线程使用与主线程相同的canvas字体度量进行测量,因此断行和栏高逐字节一致。
  • **数学公式栅格缓存在工作线程构建之间保留。**数学渲染器除了按对象标识索引的缓存外,还提供一份按内容索引的栅格缓存。否则,MathRender经结构化克隆跨越工作线程边界后,每次重建都会错过标识缓存。

#公共API

工作线程客户端位于postext/worker子路径下,只有几个名称:

  • createLayoutWorker(opts?): LayoutWorkerHandle:创建一个专用工作线程(或包装你通过opts.worker传入的工作线程,或从opts.url启动工作线程入口),返回一个带类型的句柄。见从CDN加载工作线程。
  • LayoutWorkerHandle.registerFonts(faces: FontPayload[]): Promise<void>:把字体字节送入工作线程。缓冲区会被转移,如果之后需要重新发送,请在主线程上保留一份新的副本。
  • LayoutWorkerHandle.build(content, config?, { signal? }): Promise<VDTDocument>:运行流水线。中止signal会取消进行中的构建。
  • LayoutWorkerHandle.dispose(): void:终止工作线程,并以AbortError拒绝所有待完成的构建。
  • FontPayload:{ family, weight, style, unicodeRange?, buffer: ArrayBuffer }。weight是CSS字重字符串('700'、'bold');registerFonts也接受数字形式(700)。调用registerFonts时,buffer会被转移给工作线程。
  • BuildCancelledError(从postext重新导出):options.shouldCancel返回true时buildDocument在内部抛出的错误。通常你在主线程上看不到它:工作线程协议在它到达你的代码之前就把它转换成了AbortError。

包还发布了指向编译后工作线程脚本的postext/worker/entry路径。createLayoutWorker()会自动解析这个URL;只有当你的打包工具要求手写new Worker(new URL(...), { type: 'module' })调用,或者你自己提供入口文件(opts.url)时,才需要显式引用它。

#最小集成

import { createLayoutWorker } from 'postext/worker';
import type { FontPayload, LayoutWorkerHandle } from 'postext/worker';
import type { PostextConfig, VDTDocument } from 'postext';
 
// 1. 只创建一次工作线程,在视口的整个生命周期内保留句柄。
const layout: LayoutWorkerHandle = createLayoutWorker();
 
// 2. 每个字族注册一次字体(可转移的ArrayBuffer)。
//    getConfigFontFamilies(config)是一个辅助函数,列出你的配置要渲染的字族。
const payloads: FontPayload[] = await collectFontPayloadsForFamilies([
  'EB Garamond',
  'Open Sans',
]);
await layout.registerFonts(payloads);
 
// 3. 以“后到者胜出”的取消方式驱动构建:开始新构建之前,
//    先中止上一个signal。过时的构建在工作线程内被丢弃。
let pending: AbortController | null = null;
 
async function rebuild(
  markdown: string,
  config: PostextConfig,
): Promise<VDTDocument | null> {
  pending?.abort();
  pending = new AbortController();
  try {
    return await layout.build({ markdown }, config, { signal: pending.signal });
  } catch (err) {
    if ((err as { name?: string } | null)?.name === 'AbortError') return null;
    throw err;
  }
}
 
// 4. 持有工作线程的组件卸载时释放它。
//    待完成的构建以AbortError拒绝。
layout.dispose();

封装成React组件,大致是这样:

import { useEffect, useRef } from 'react';
import { createLayoutWorker } from 'postext/worker';
import type { LayoutWorkerHandle } from 'postext/worker';
import { renderPageToCanvas } from 'postext';
import type { PostextConfig } from 'postext';
 
export function CanvasPreview({
  markdown,
  config,
}: {
  markdown: string;
  config: PostextConfig;
}) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const workerRef = useRef<LayoutWorkerHandle | null>(null);
  const pendingRef = useRef<AbortController | null>(null);
 
  // 挂载:启动工作线程,并只发送一次字体。
  useEffect(() => {
    const handle = createLayoutWorker();
    workerRef.current = handle;
    (async () => {
      const payloads = await collectFontPayloadsForFamilies(
        getConfigFontFamilies(config),
      );
      await handle.registerFonts(payloads);
    })();
    return () => {
      pendingRef.current?.abort();
      handle.dispose();
    };
  }, []); // 字体只注册一次;只有字族集合变化时才重新注册
 
  // 每次按键或配置变化:取代进行中的构建,启动新的构建。
  useEffect(() => {
    const handle = workerRef.current;
    if (!handle) return;
    pendingRef.current?.abort();
    const ac = new AbortController();
    pendingRef.current = ac;
    (async () => {
      try {
        const vdt = await handle.build({ markdown }, config, { signal: ac.signal });
        const canvas = canvasRef.current;
        if (!canvas || !vdt.pages[0]) return;
        renderPageToCanvas(vdt.pages[0], vdt, canvas); // 在主线程上栅格化
      } catch (err) {
        if ((err as { name?: string } | null)?.name !== 'AbortError') throw err;
      }
    })();
  }, [markdown, config]);
 
  return <canvas ref={canvasRef} />;
}

模式始终相同:创建一次,注册字体一次,带AbortSignal构建多次,卸载时释放。

#从CDN加载工作线程

工作线程脚本必须来自页面自己的源,所以由CDN提供的postext/worker无法启动它旁边的layout.worker.js文件。createLayoutWorker()会处理这个问题:

  • **esm.sh,无需任何选项。**如果postext/worker本身是从esm.sh加载的(其模块URL形如https://esm.sh/postext@1.5.0/es2022/worker.mjs),客户端会通过一个同源的单行blob模块导入并启动对应的https://esm.sh/postext@1.5.0/worker/entry。带有?deps=、?external=或?alias=的导入,以及https://esm.sh/*postext@1.5.0/worker形式,也同样适用。工作线程总是获取该版本的普通构建,因为工作线程没有import map来解析外部依赖。
  • **其他服务器,使用url。**其他CDN,例如jsDelivr(/+esm)或unpkg,不会被自动识别。createLayoutWorker({ url })从url启动工作线程入口模块:同源URL直接启动,其他源的URL通过同样的blob包装启动。该服务器必须允许跨源请求(CORS)。
  • **使用打包工具时无需改动。**使用Vite、webpack或Next.js时,继续不带选项调用createLayoutWorker()即可:它们会把工作线程作为应用的一个chunk输出。
import { createLayoutWorker } from 'https://esm.sh/postext/worker';
 
const layout = createLayoutWorker();
const face = async (weight, style) => ({
  family: 'EB Garamond',
  weight,
  style,
  buffer: await (await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/eb-garamond@5/files/eb-garamond-latin-${weight}-${style}.woff2`)).arrayBuffer(),
});
await layout.registerFonts(await Promise.all([face(400, 'normal'), face(700, 'normal'), face(400, 'italic')]));
const doc = await layout.build({ markdown }, { bodyText: { fontFamily: 'EB Garamond' } });

**工作线程看不到页面的字体。**它有自己的字体集,只包含通过registerFonts发送的字体和系统中安装的字体。构建时如果某段文字使用的字族在工作线程中找不到,这段文字就用回退字体测量,断行会与页面对不上。此时客户端会为每个字族在控制台打印一条警告("EB Garamond" is not available inside the layout worker…),并把这些字族列在BuildStats.missingFonts中,build的onStats回调会收到它。

#收集字体载荷(Fontsource / Google Fonts)

registerFonts接收原始字体字节。获取这些字节应在主线程上进行,因为Google Fonts只向类似浏览器的User-Agent字符串返回WOFF2,而且集中缓存可以让多个工作线程实例共享同一份字节。

沙盒的collectFontPayloadsForFamilies(packages/postext-sandbox/src/controls/fontLoader.ts)是一份可以直接拿来用的参考实现。它:

  1. 查询https://api.fontsource.org/v1/fonts/{family-id},得知可用的字重,以及该字族是否提供可变轴。
  2. 构造一个Google Fonts CSS2 URL,覆盖该字族声明的所有字重和样式。
  3. 获取生成的@font-face样式表,提取每条src: url(...) format('woff2')声明,并下载原始字节。
  4. 返回一个FontPayload[],每次调用时其中的buffer都是新的ArrayBuffer。这一点很重要,因为registerFonts会转移缓冲区,发送方的副本随之失效。

配合getConfigFontFamilies(config)使用,可以得到某个PostextConfig实际要渲染的字族列表(正文、标题、列表项目符号、有序列表编号)。

#引擎内部的协作式取消

如果你自己驱动buildDocument(例如在自定义的工作线程中),可以直接使用流水线提供的shouldCancel钩子:

import { buildDocument, BuildCancelledError } from 'postext';
 
let superseded = false;
try {
  const vdt = buildDocument(content, config, cache, {
    shouldCancel: () => superseded,
  });
} catch (err) {
  if (err instanceof BuildCancelledError) return; // 更新的构建已接手
  throw err;
}

shouldCancel在放置阶段对每个顶层块调用一次。这个钩子有意设计为协作式的:它无法在pretext自身的排版调用进行到一行中间时将其打断,但它让取消的粒度足够小(毫秒级),打字再快的用户也不会等待过时的构建。

#从工作线程驱动PDF导出

PDF后端接收一个现成的VDTDocument,把它转成PDF字节。它不会重新排版。因此,浏览器中的标准PDF流程与工作线程配合得很好:在工作线程中构建VDT(不占主线程、可取消、复用缓存),再在主线程上对同一个VDT调用renderToPdf。

import type { LayoutWorkerHandle } from 'postext/worker';
import { renderToPdf } from 'postext-pdf';
import type { PostextConfig } from 'postext';
import { createPdfFontProvider } from './pdfFontProvider';
 
const fontProvider = createPdfFontProvider();
 
export async function exportPdf(
  layout: LayoutWorkerHandle,
  markdown: string,
  config: PostextConfig,
): Promise<Uint8Array> {
  // 1. 在工作线程中构建VDT:排版各轮进行期间界面保持响应。
  const vdt = await layout.build({ markdown }, config);
 
  // 2. 在主线程上输出PDF。VDT一旦存在,renderToPdf就很快,
  //    因为它遍历的是预先算好的坐标,而不是重新测量文本。
  return renderToPdf(vdt, {
    fontProvider,
    // `vdt.config`中的pdfGeneration配置会自动生效。
  });
}

如果你已经为实时预览维护了一个工作线程句柄,导出时复用它,不要再启动第二个工作线程:工作线程内的测量缓存使得紧随屏幕预览之后的PDF导出几乎不费时间。

对一本长书来说,写出PDF本身也要花几秒;postext-pdf/worker会在它自己的工作线程上运行这一步(见在工作线程上渲染PDF)。

#何时使用工作线程,何时不用

以下情况使用工作线程:

  • **实时预览、编辑器和练习场。**凡是文档随用户输入而重建的场景。
  • 随尺寸变化的HTML查看器,每次ResizeObserver触发都重新排版。
  • 浏览器内的PDF导出,从已有实时预览的界面触发:复用现有的工作线程句柄,让导出借用测量缓存。
  • 多个输出标签页,都需要同一个VDT(沙盒的Canvas / HTML / PDF视口在每次挂载视口时共享一个工作线程句柄)。

以下情况不用工作线程:

  • 服务端生成:Node没有浏览器的FontFaceSet,而且线程本来就由你掌控。
  • 独立的一次性导出(CLI、无界面导出脚本、Cloud Function),没有会被阻塞的交互界面。直接调用buildDocument更简单,也省去了初次传输字体的开销。

#集成HTML查看器

HTML查看器是Postext面向屏幕的渲染器。它不把页面栅格化成位图,而是输出绝对定位的DOM节点,节点的几何位置来自生成印刷输出的同一条流水线。如果你想在浏览器里呈现可读、可选中、能随窗口尺寸变化的排版,又不想引入PDF阅读器,比如做一个阅读应用、产品内预览或嵌入式文档界面,它就是合适的选择。

公共API中的关键部分:

  • buildDocument(content, config, cache?):运行完整的排版流水线,返回一个VDTDocument。
  • renderToHtmlIndexed(doc, options):把VDT转成一个HTML字符串,外加按页、按块的拆分结果。两次渲染之间只有少数块变化时,可以借助这份拆分廉价地修补DOM。
  • resolveHtmlViewerConfig(partial):补齐HTML查看器的默认值(maxCharsPerLine、columnGap、optimalLineBreaking)。
  • buildFontString + measureGlyphWidth + dimensionToPx:测量原语,用来根据目标字符数算出实际的栏宽像素值。
  • createMeasurementCache / clearMeasurementCache:可插拔的缓存,让多次重新排版复用测量结果。

#在线示例:HTML字符串

在进入下面的React集成之前,先用纯JavaScript走一遍完整流程:构建文档,把VDTDocument交给renderToHtml,再把字符串放进一个容器。mode: 'single'把页面竖向堆叠;页面默认透明,所以用background给它们一个颜色。这个pen还会打印生成的标记,你可以看到渲染器输出的绝对定位的行:浏览器绘制它们,但从不重排它们。

Postext · 把文档渲染为HTML
import { buildDocument, renderToHtml } from 'https://esm.sh/postext';
 
const markdown = `# The Lantern
 
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.
 
## Two columns
 
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
 
The light it gave was small, but it was enough to find the step. 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.
 
The light it gave was small, but it was enough to find the step. 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.`;
 
const config = {
  // 96 dpi: page pixels are CSS pixels, so the HTML shows at its real size.
  page: { sizePreset: '17x24', dpi: 96 },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
 
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('italic 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
 
const doc = buildDocument({ markdown }, config);
 
// One HTML string for the whole document. Every line is an absolutely
// positioned element, so the browser never reflows the text.
const html = renderToHtml(doc, { mode: 'single', background: '#ffffff' });
 
document.getElementById('viewer').innerHTML = html;
document.getElementById('source').textContent = html;
document.getElementById('status').textContent =
  `${doc.pages.length} page(s) · ${(html.length / 1024).toFixed(1)} KB of HTML`;
index.html
<p id="status">Laying out…</p>
<div id="viewer"></div>
<details>
  <summary>Generated HTML</summary>
  <pre id="source"></pre>
</details>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
/* The page is wider than this pane: let it scroll instead of clipping it.
   The renderer centres pages with an inline style, hence the !important. */
#viewer {
  overflow: auto;
}
#viewer .pt-doc {
  align-items: flex-start !important;
}
/* Each page is a .pt-page block; the renderer positions every line inside it. */
#viewer .pt-page {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
details {
  margin-top: 16px;
}
#source {
  max-height: 240px;
  overflow: auto;
  padding: 8px;
  background: #fff;
  font-size: 11px;
  white-space: pre-wrap;
  word-break: break-all;
}

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

#最简集成

下面的代码是最短的实用集成:按当前视口尺寸构建文档,渲染进一个容器,尺寸变化时重新运行。

import { useEffect, useRef } from 'react';
import {
  buildDocument,
  renderToHtmlIndexed,
  resolveHtmlViewerConfig,
  buildFontString,
  measureGlyphWidth,
  dimensionToPx,
  createMeasurementCache,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
 
// 适合屏幕的DPI:在144 DPI下,8pt的正文字号换算为16 px。
const HTML_DPI = 144;
const PADDING_PX = 24;
 
// 用来测量目标栏宽的正文样本。比例字体下“N × 平均宽度”并不可靠,
// 所以测量一段有代表性的字符串。
const SAMPLE =
  'The quick brown fox jumps over the lazy dog. Sphinx of black quartz, judge my vow.';
 
function sampleForChars(n: number): string {
  let s = SAMPLE;
  while (s.length < n) s += ' ' + SAMPLE;
  return s.slice(0, n);
}
 
export function PostextHtmlViewer({
  markdown,
  config,
  mode = 'multi',
}: {
  markdown: string;
  config: PostextConfig;
  mode?: 'single' | 'multi';
}) {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const cacheRef = useRef<MeasurementCache>(createMeasurementCache());
 
  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;
 
    const relayout = () => {
      const rect = host.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
 
      const viewer = resolveHtmlViewerConfig(config.htmlViewer);
      const fontFamily = config.bodyText?.fontFamily ?? 'EB Garamond';
      const fontWeight = config.bodyText?.fontWeight ?? 400;
      const fontSize = config.bodyText?.fontSize ?? { value: 8, unit: 'pt' as const };
      const fontSizePx = dimensionToPx(fontSize, HTML_DPI);
 
      // 测量N个正文字符的*实际*栏宽。
      const targetColumnPx = measureGlyphWidth(
        sampleForChars(viewer.maxCharsPerLine),
        buildFontString(fontFamily, fontSizePx, String(fontWeight), 'normal'),
      );
 
      const inner = Math.max(rect.width - PADDING_PX * 2, 100);
      let columnWidthPx: number;
      if (mode === 'single') {
        columnWidthPx = Math.min(targetColumnPx, inner);
      } else {
        // 按目标栏宽尽量多放几栏。
        const count = Math.max(
          1,
          Math.floor((inner + viewer.columnGap) / (targetColumnPx + viewer.columnGap)),
        );
        columnWidthPx = (inner - viewer.columnGap * (count - 1)) / count;
      }
      columnWidthPx = Math.max(Math.floor(columnWidthPx), 80);
 
      // single模式用一个很高的页面;multi模式用视口高度,
      // 这样每个VDT“页面”就成为一栏。
      const pageHeightPx =
        mode === 'single' ? Math.max(rect.height * 20, 200_000) : Math.max(rect.height - PADDING_PX * 2, 400);
 
      const override: PostextConfig = {
        ...config,
        page: {
          ...config.page,
          dpi: HTML_DPI,
          width: { value: columnWidthPx, unit: 'px' },
          height: { value: pageHeightPx, unit: 'px' },
          margins: {
            top: { value: 0, unit: 'px' },
            bottom: { value: 0, unit: 'px' },
            left: { value: 0, unit: 'px' },
            right: { value: 0, unit: 'px' },
          },
        },
        layout: { ...config.layout, layoutType: 'single' },
        bodyText: {
          ...config.bodyText,
          optimalLineBreaking: viewer.optimalLineBreaking,
        },
      };
 
      const doc = buildDocument({ markdown }, override, cacheRef.current);
      const { html } = renderToHtmlIndexed(doc, {
        mode,
        columnGap: viewer.columnGap,
        padding: PADDING_PX,
        background: 'transparent',
      });
 
      host.innerHTML = html;
    };
 
    relayout();
 
    const ro = new ResizeObserver(() => relayout());
    ro.observe(host);
 
    // Web字体加载完成后重新测量,避免字形宽度取自后备字体。
    const onFontsDone = () => relayout();
    document.fonts?.addEventListener?.('loadingdone', onFontsDone);
 
    return () => {
      ro.disconnect();
      document.fonts?.removeEventListener?.('loadingdone', onFontsDone);
    };
  }, [markdown, config, mode]);
 
  return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}

关于这个示例做了什么,有几点说明:

  • 测量栏宽,而不是估算。maxCharsPerLine是以字符数表示的目标,实际的像素宽度取决于正文字体。measureGlyphWidth针对所选字体给出真实的测量值,换字体后行长也保持一致。
  • **改写页面。**HTML查看器把每个VDT“页面”当作屏幕上的一栏。示例用测得的栏宽覆盖page.width,把页边距设为0(内边距放在页面外,由外层的.pt-doc div提供),并使用HTML_DPI = 144,使8pt的正文换算为16px。
  • **感知字体加载。**新请求的Web字体到达时会触发document.fonts.loadingdone。如果不重新排版,首次渲染用的是后备字体的度量,真正的字体到达后版面会跳动。
  • **复用测量缓存。**每个组件只创建一次缓存,调整尺寸或改变字体缩放时就能复用上一次渲染的测量结果,不必重新测量每个段落。

#进一步

上面的示例刻意保持简单。用于生产的集成通常还会加上:

  • Shadow DOM隔离:渲染到host.attachShadow({ mode: 'open' })里,外层页面的CSS就不会渗入查看器。
  • 增量修补:renderToHtmlIndexed返回pages[i].blocks,每个块都有稳定的id和该块的外层HTML。两次渲染之间只有少数块不同时,可以原地替换这些块的包装元素,而不必重建innerHTML。
  • 叠加层:在每个.pt-page上叠一个绝对定位的SVG,用来显示光标、选区或基线网格。
  • 链接:Markdown链接中的词被包在<a href="…" rel="noopener noreferrer">里,沿用文字颜色,没有下划线;见文档格式 › 链接。在类似编辑器的查看器中,拦截对不以#开头的a[href]的点击,在新标签页中打开(:ref锚点链接到文档内部)。
  • 单色图片:打开diagramStyle.singleInk后,SVG的<img>会加上CSS滤镜;对于已经重新着色的URL,传入singleInk: false即可跳过;见Canvas与HTML中的单色模式。

沙盒的HtmlPreview组件(packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx)在这里展示的同一套API之上实现了以上全部功能,可以作为参考。它还把每次构建都交给一个共享的排版工作线程(见在Web Worker中运行排版),所以实时编辑和调整尺寸都不会阻塞主线程。准备把排版移出主线程时,把上面代码中直接调用的buildDocument(...)换成layoutWorker.build(...)即可。

#HTML输出与Canvas和PDF的区别

renderToHtml把每一行、每个图和每个设计元素放在与Canvas和PDF完全相同的位置,但在它们周围画的东西更少:

功能Canvas(renderPage)HTML(renderToHtml)PDF(renderToPdf)
页面背景白色,page.backgroundColor铺满成品区域和出血。透明,除非传入background或设置page.backgroundColor(此时填满整个页面框,包括裁切线外的区域)。白色,page.backgroundColor铺满成品区域和出血。
基线网格(page.baselineGrid)绘制不绘制绘制
栏间线(layout.columnRule)绘制不绘制绘制
裁切标记(page.cutLines)绘制不绘制;页面框仍包含成品尺寸外围的区域。绘制
页面反色pageNegative选项不支持pageNegative选项
文字像素绝对定位元素中的可选中文字,使用CSS字体族排版:页面必须加载相同的字体。来自fontProvider的嵌入字体;可选中、可搜索、带标签。
竖排文字(layout.writingMode: 'vertical-rl')逐个字格绘制字符并转回直立;竖排字形取自一个孪生字体(loadVerticalAlternates)。一个框中的文字流整体旋转四分之一圈;每一行再转回直立,并以writing-mode: vertical-rl排版,由浏览器选用竖排字形并让字符直立;短数字用text-combine-upright: all;破折号、省略号、间隔号或波浪线放在所在字格的一个框里(否则浏览器会按它的横排宽度推进),破折号按flow.dashAdvances拉长以填满字格。通过每种字体的Identity-V孪生字体排出直立字符;见PDF中的竖排文字。
图像registerResourceImageresourceImageUrl(fileId)选项;未提供时显示灰色占位框。resourceBytes(fileId)选项。
公式矢量路径内联<svg>矢量路径
链接无:ref引用链接到对应资源;目录行不带链接。:ref引用和目录行,外加文档大纲(书签)。

在深色网站上,透明页面会带来问题:不传background的预览会在网站的深色背景上显示黑色文字。可以传入renderToHtml(doc, { background: '#ffffff' }),或给文档设置page.backgroundColor。

**宿主页面的文字样式不会进入输出。**每一行都按引擎测得的宽度排版,如果输出从周围页面继承了letter-spacing、word-spacing、text-transform或font-variant,字形串就会变宽,行与行相互叠印。因此.pt-doc根元素在自己的排版声明之前,先重置所有可继承的文字属性:字距和词间距、大小写、缩进、空白处理、字体样式、变体、字重、宽度、特性与字偶间距、行高、对齐、文字阴影与着重号、断词、方向、书写模式、文字描边与填充,以及移动端的文字放大。这样无论放在shadow root里还是带样式的元素下,输出看起来都一样。这份列表以HTML_TEXT_RESET导出,是一串CSS声明:如果宿主把各页的innerHtml(来自renderToHtmlIndexed)挂载到自己的容器中,就在这些容器的根元素上设置它。postext 1.4及以前,根元素不做任何重置,变通办法是加一个all: initial的包装元素。

#生成PDF

PDF输出位于单独的包**postext-pdf**中,只面向Web的集成因此不必承担pdf-lib和@pdf-lib/fontkit的开销。PDF后端不会重新测量文字:它读取的正是你交给renderToCanvas或renderToHtml的同一个VDTDocument,并把其中的像素坐标换算成PDF的点。因此三种输出在断行、栏高和资源位置上必定一致。

**在浏览器中,请通过Web Worker构建VDT。**VDT一旦存在,renderToPdf本身很快,耗时的是生成VDT的排版流水线。在工作线程上运行这条流水线,界面能保持响应,PDF导出也能复用实时预览已经预热好的测量缓存。推荐的流程见从工作线程驱动PDF导出。下面在主线程上的示例用来说明各参数的含义;在界面代码中,先在工作线程里构建VDT,再直接调用renderToPdf。

#安装

npm install postext postext-pdf

postext是postext-pdf的peer依赖。每个postext-pdf版本都需要与之一起发布的postext,或同一主版本中更新的版本(它的peer范围是该版本前加^,1.5.0对应^1.5.0),因为它会导入postext在那个版本中新增的辅助函数。两者要一起升级;使用CDN时,把它们固定到同一个版本。

#公共API

这个包只有一个入口,外加几个类型:

  • renderToPdf(doc, options): Promise<Uint8Array>:接收一个VDTDocument(或一本书各章组成的数组),返回原始的PDF字节。
  • PdfFontProvider:回调签名(family, weight, style, request?) => Promise<Uint8Array | Uint8Array[]>,renderToPdf需要嵌入新的family/weight/style组合时,用它请求字体字节。request.codePoints包含页面中用该字体排出的字符;返回值是一个文件,或者合起来构成该字体的多个文件(见中文、日文和韩文字体)。
  • RenderToPdfOptions:{ fontProvider, resourceBytes?, outlines?, accessible?, colorSpace?, pageNegative?, characterGrid?, onProgress?, onWarning?, rasterizeSvg? }。省略outlines、accessible和colorSpace时,取文档的pdfGeneration(见PDF生成(配置))。resourceBytes的说明见资源字节与印刷母版;onWarning见向字体提供函数请求哪些字体和文档中的警告。characterGrid: true会把cjk.grid.show在屏幕上画的网格印进PDF,否则PDF不含这层网格(见字符网格)。
  • PdfWarning:通过onWarning报告的非致命问题;按kind区分:'fontFallback'(PdfFontFallbackWarning),某个字体改用了同一家族的另一款字体;'missingGlyph'(PdfMissingGlyphWarning),字体的所有文件都没有字形的字符;'variableFontDefaultInstance'(PdfVariableFontWarning),以默认实例之外的字重请求可变字体;'cffEmbeddedWhole'(PdfCffEmbeddedWholeWarning),超过2 MB的CFF字体被整体嵌入;或'missingImage',没有字节、画成占位框的图像(只报告给你传入的onWarning;未传入时,字体类警告输出到console.warn)。
  • decompressWoff2(bytes): Uint8Array:辅助函数,把WOFF2文件转成TTF字节,pdf-lib可以直接嵌入这种格式。
  • createPdfWorker(options?),来自postext-pdf/worker:在Web Worker上进行同样的渲染;见在工作线程上渲染PDF。

#最简示例

import { buildDocument } from 'postext';
import { renderToPdf } from 'postext-pdf';
 
const vdt = buildDocument(
  { markdown: '# Chapter One\n\nThe story begins here…' },
  {
    page: { sizePreset: '17x24' },
    layout: { layoutType: 'double' },
    bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 9, unit: 'pt' } }, // 9 pt覆盖默认的8 pt
  },
);
 
const pdfBytes = await renderToPdf(vdt, {
  fontProvider: async (family, weight, style) => {
    // 返回这个family/weight/style对应的TTF字节。
    // 真实的实现见下文的“字体提供函数”一节。
    const res = await fetch(`/fonts/${family}-${weight}${style === 'italic' ? 'i' : ''}.ttf`);
    return new Uint8Array(await res.arrayBuffer());
  },
});
 
// `pdfBytes`是一个Uint8Array,可以保存、下载或以流的方式发送。
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url);

#为什么需要字体提供函数?

pdf-lib把真实的字体文件嵌入PDF:渲染时无法使用浏览器已安装的字体,而仅为屏幕测量加载的字体,本身也不足以生成自包含的PDF。renderToPdf扫描页面中绘制用到的每一种字体(每个family|weight|style组合一种,见向字体提供函数请求哪些字体),每个不同的组合调用一次你的提供函数。提供函数返回一个包含TTF或OTF字节的Uint8Array;如果字体由多个文件提供,则返回它们组成的列表(见中文、日文和韩文字体)。pdf-lib对TrueType轮廓做子集化,CFF(.otf)文件则整体嵌入。提供函数用同一个文件回应的多种字体(例如某个家族缺少粗体,用常规体代替)共享一份嵌入字体。最终没有任何页面用来绘制的字体不会写进文件,例如SVG图作为图片绘制时,其中文字所用的字体。

**使用按字重分开的静态字体,而不是单个可变字体。**Google Fonts通常为每个家族提供一个覆盖整个字重轴的可变WOFF2文件。pdf-lib只能嵌入可变文件的默认实例,于是粗体段落会以常规字重渲染。Fontsource发布按字重分开的静态WOFF2文件,可以干净地解决这个问题,沙盒用的就是这种方式。以默认实例之外的字重请求可变文件时,会报告variableFontDefaultInstance警告。

**文字中的每个词都落在排版确定的位置上。**在段落、列表项、引文、框和其他流动文字中,每个词都从VDT测得的位置开始,所以浏览器宽度与嵌入字体宽度之间的差异不会沿着一行累积。没有行内格式的行作为一个文本对象绘制,在词之间移动画笔;两端对齐的行、居中的行和带格式的行逐词绘制。字体中没有字形的字符,浏览器用另一种字体测量,PDF则画成该字体的缺字框,它不会移动后面的任何词,并且每种字体报告一次missingGlyph警告。字体缺少的空格,例如窄不换行空格或数字空格,取浏览器给出的宽度;词连接符、零宽空格等不可见字符不绘制。字体缺少的不换行连字符(U+2011)用该字体的连字符(U+2010)绘制,连字符也没有时用连字符减号,与浏览器的显示一致;Open Sans和Outfit等字体两者都缺。这两种情况都不算缺字。有两个例外。含有从右向左字母的行仍作为一整串绘制(见语言与文字)。由版面设计放置的文字(书眉和页脚、章首页、框标题及其他设计元素)用嵌入字体自身的宽度排版,所以在那里字体缺少的字形仍会移动该行其余的文字。

#向字体提供函数请求哪些字体

renderToPdf按绘制页面的方式遍历页面,只向提供函数请求实际绘制用到的字体:

  • 排出任何一行的块所用的常规字体,以及实际以粗体、斜体或粗斜体排出的每一串文字所用的对应字体;
  • 行内标签的文字、列表标记,以及设计槽位中的文字(书眉、页码、章首页和篇的色带);
  • 每个资源的题注、注释和表格单元格文字;
  • 嵌入SVG图时,其<text>指定的字体。提供函数完全无法提供的家族,会依次落到SVG的font-family列表中的下一个家族。

所以,一个没有人设为斜体的标题家族永远不会被请求斜体,没有注释的图也永远不需要注释所用的字体。

提供函数拒绝某种字体时,渲染会继续。同一家族的另一种字体会嵌入代替它,并报告一条PdfWarning。替代字体是第一个加载成功的字体,按CSS字体匹配的顺序尝试九个标准字重(100到900),这也是浏览器在预览中显示的字体:

  1. 先试同一样式。请求的字重在400到500之间时,先试不超过500的字重,然后从最近的开始往下试更细的字重,再从600开始往上试更粗的字重。请求的字重低于400时,先从最近的开始往下试更细的字重,再试更粗的字重。请求的字重高于500时,先试更粗的字重,再试更细的字重;
  2. 再试另一种样式,即直立体请求试斜体、斜体请求试直立体,先试请求的字重,其他字重按同样的顺序。

因此,没有斜体的家族把斜体文字排成直立体;只提供400和700的家族把600当作700;只有一款字体的家族全部用它排。提供函数按这个顺序一次只被请求一种字体,同一种字体不会被请求两次,所以没用到的字体永远不会被嵌入。提供函数完全无法提供的家族,要把这18种字体都请求一遍,渲染才会失败。

const bytes = await renderToPdf(doc, {
  fontProvider,
  onWarning: (w) => {
    // { kind: 'fontFallback', family: 'Oswald', weight: 700, style: 'italic',
    //   fallback: { weight: 700, style: 'normal' }, reason: '…', message: '…' }
    console.info(w.message);
  },
});

没有onWarning时,消息输出到console.warn。文字保持原来的位置,这些位置来自VDT,是用浏览器当时拥有的字体测得的,所以宽度不同的替代字体可能显得偏紧或偏松。提供真正的字体即可解决。只有当提供函数无法以任何标准字重提供某个家族的任何字体(直立或斜体)时,渲染才会失败(postext-pdf: failed to load font(s): …)。

#浏览器字体提供函数(Fontsource + WOFF2)

沙盒附带createPdfFontProvider()(packages/postext-sandbox/src/viewport/pdfFontProvider.ts),你可以把它复制到任何浏览器应用中。核心部分如下:

import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
 
const bytesCache = new Map<string, Promise<Uint8Array>>();
 
function fontsourceId(family: string): string {
  return family.toLowerCase().replace(/\s+/g, '-');
}
 
function fontsourceWoff2Url(
  family: string,
  weight: number,
  style: 'normal' | 'italic',
): string {
  const id = fontsourceId(family);
  return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
}
 
export function createPdfFontProvider(): PdfFontProvider {
  return async (family, weight, style) => {
    const key = `${family}|${weight}|${style}`;
    const cached = bytesCache.get(key);
    if (cached) return cached;
 
    const promise = (async (): Promise<Uint8Array> => {
      const url = fontsourceWoff2Url(family, weight, style);
      const res = await fetch(url, { mode: 'cors' });
      if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
      // pdf-lib需要TTF字节,所以在客户端解开WOFF2封装。
      return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
    })();
 
    bytesCache.set(key, promise);
    return promise;
  };
}

用于生产的版本还应当:

  • 查询可用的字重(通过https://api.fontsource.org/v1/fonts/{id}),把请求的字重对齐到该家族实际提供的最接近的字重,这样对只有{400, 700}的家族请求weight: 600也能成功。
  • 从斜体回退到正体:某个家族在请求的字重下没有斜体时,回退而不是让整个渲染失败。
  • 跨渲染复用缓存(把bytesCache放在模块作用域,而不是每次调用新建),这样修改配置后重新生成PDF几乎没有额外开销。

#中文、日文和韩文字体

CJK字体不是一个小文件。Fontsource把Noto Serif SC的每个字重拆成约一百个文件,每个文件包含一部分字符,并在该家族的样式表(@fontsource/noto-serif-sc/400.css)中用unicode-range声明;浏览器只下载页面文字用到的文件。上面的提供函数获取的latin文件完全不含汉字,而命名的子集也不完整:Noto Serif SC的chinese-simplified缺少“釵”,Noto Serif TC的chinese-traditional则连(),!?:;这些全角标点一个也没有。

所以提供函数可以用多个文件回应一种字体。renderToPdf把页面中用该字体排出的字符(request.codePoints)传给它,这些字符在绘制任何内容之前就从所有章节收集好;提供函数按浏览器查找的顺序返回包含这些字符的文件。每个文件各自作为子集嵌入,每个字符取自第一个有其字形的文件:一章用到60个切片,就嵌入60个小子集。再次请求同一种字体时(比如为SVG图中的文字),只请求它的文件所缺的字符。返回单个Uint8Array的提供函数照常工作。沙盒的提供函数读取对应字重和样式的Fontsource样式表,获取范围覆盖文字的文件;核心代码如下:

import type { PdfFontProvider } from 'postext-pdf';
import { decompressWoff2 } from 'postext-pdf';
 
type Slice = { url: string; ranges: Array<[number, number]> };
 
async function fontsourceSlices(family: string, weight: number, style: 'normal' | 'italic'): Promise<Slice[]> {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const cssUrl = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  const css = await (await fetch(cssUrl)).text();
  return [...css.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => ({
    url: new URL(/url\(\.?\/?([^)]+\.woff2)\)/.exec(rule)![1], cssUrl).href,
    ranges: /unicode-range:\s*([^;]+);/.exec(rule)![1].split(',').map((part) => {
      const [lo, hi = lo] = part.trim().slice(2).split('-');
      return [parseInt(lo, 16), parseInt(hi, 16)] as [number, number];
    }),
  }));
}
 
export const sliceFontProvider: PdfFontProvider = async (family, weight, style, request) => {
  // 范围重叠时,浏览器先尝试最后一条规则。
  const slices = (await fontsourceSlices(family, weight, style)).reverse();
  const picked = new Set<Slice>();
  for (const cp of request?.codePoints ?? []) {
    const slice = slices.find((s) => s.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
    if (slice) picked.add(slice);
  }
  if (picked.size === 0) picked.add(slices[0]!);
  return Promise.all(slices.filter((s) => picked.has(s)).map(async (s) =>
    decompressWoff2(new Uint8Array(await (await fetch(s.url)).arrayBuffer()))));
};

拉丁字母家族也走同样的代码:英文文字只取latin文件,捷克文取latin和latin-ext。

字体的所有文件都没有字形的字符,画成字体的.notdef字形(多数字体中是一个空框),renderToPdf在绘制完页面后按字体各报告一次:

// { kind: 'missingGlyph', family: 'Noto Serif TC', weight: 400, style: 'normal',
//   characters: [',', '!', '?'], message: '…' }

沙盒每生成一个PDF,就在检查面板中列出这些字符以及下面两种警告。书、书的设置或资源发生变化后,这些条目会标为来自之前的PDF,直到下一个PDF取代它们;打开另一本书会清除它们。

  • **粗体需要每个字重一个静态文件。**Fontsource把Noto Serif SC和TC的每个字重作为单独的静态文件提供,所以在沙盒中粗体可以正常使用。Google Fonts的文件(NotoSerifSC[wght].ttf,25 MB)是可变字体:pdf-lib嵌入其默认实例,700的字体会按400印出,renderToPdf将其报告为variableFontDefaultInstance。制作文件包时,用fontTools为每个字重切出一个静态实例(fonttools varLib.instancer NotoSerifSC[wght].ttf wght=700),再用pyftsubset把它子集化到书中用到的字符。
  • **使用TrueType版本。**思源宋体(Source Han Serif)和Noto Serif CJK的.otf文件是CFF轮廓,postext-pdf会整体嵌入,每个字重8到25 MB;超过2 MB的CFF字体会报告为cffEmbeddedWhole。TrueType版本(Google Fonts、Fontsource)会子集化到实际用到的字形。

一个家族缺少的字符不会从另一个家族借用:Noto Serif TC不会借用Noto Serif SC的字形。字符覆盖要在制作字体文件时解决;《红楼梦》示例就把TC子集缺少的字形从SC字体复制了过去。

#服务端字体提供函数(Node,本地文件)

在Node中可以完全跳过WOFF2这一步,直接从磁盘读取TTF/OTF文件:

import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { PdfFontProvider } from 'postext-pdf';
 
const FONT_DIR = '/path/to/fonts';
 
function filename(family: string, weight: number, style: 'normal' | 'italic'): string {
  const slug = family.replace(/\s+/g, '');
  const styleSuffix = style === 'italic' ? 'Italic' : '';
  const weightName =
    weight >= 700 ? 'Bold'
    : weight >= 600 ? 'SemiBold'
    : weight >= 500 ? 'Medium'
    : weight >= 300 ? 'Light'
    : 'Regular';
  return `${slug}-${weightName}${styleSuffix}.ttf`;
}
 
export const localFontProvider: PdfFontProvider = async (family, weight, style) => {
  const buf = await readFile(join(FONT_DIR, filename(family, weight, style)));
  return new Uint8Array(buf);
};

#资源字节与印刷母版

resourceBytes(fileId)返回图片的原始字节,后端会识别其格式:

  • PNG、JPEG、GIF和WebP作为图像嵌入;
  • SVG标记绘制为矢量路径;如果用到矢量子集之外的特性,则在浏览器中以600 dpi栅格化;
  • PDF的第一页原样嵌入,作为一个表单XObject。

每张图片在文件中只存一次,不论绘制多少次。绘制为矢量路径的SVG会成为一个表单XObject,由每一页绘制,所以一份三十页的文档,页面设计中的边框或标志只写入一次,而不是三十次;每多一页只增加几百字节。postext-pdf 1.4及以前,每一页都带着自己的一份路径。

SVG图可以在svg.pdfFileId中指定一个印刷母版:同一张图的单页PDF,通常就是导出该SVG的原稿。renderToPdf先向resourceBytes请求母版的id。只要SVG被绘制,无论是作为图、表格单元格中的图片(TableCell.image)、设计图像还是框的图标(包括它的marker),都会用母版的那一页代替SVG嵌入,其中的字体、渐变和色彩空间都完好保留。VDT在每一种用法上都带有母版的id(图的资源上是svg.pdfFileId,单元格图像和设计图像块上是pdfFileId),所以在一本书中每一章都能拿到母版。Canvas和HTML后端仍然绘制SVG。以下三种情况改用SVG自身的字节:母版缺失、母版不是PDF,或打开了单色模式(diagramStyle.singleInk只重新着色SVG标记)。

const resources: Resource[] = [{
  id: 'map', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'map.svg', width: 800, height: 600, pdfFileId: 'map.pdf' },
}];
const files = new Map([['map.svg', svgBytes], ['map.pdf', masterPdfBytes]]);
const pdf = await renderToPdf(buildDocument({ markdown, resources }, config), {
  fontProvider,
  resourceBytes: (fileId) => files.get(fileId),
});

宿主也可以像bundleResourceBytes那样,对SVG自己的id返回母版的字节;两种方式都可行。

#PDF中的竖排文字

竖排页面(layout.writingMode: 'vertical-rl')通过一个旋转四分之一圈的坐标系绘制,与Canvas的画法相同,文字沿栏向下排:

  • 直立字符通过同一个嵌入文件的第二个Type0字体显示:相同的CIDFont、宽度和ToUnicode映射,使用Encoding /Identity-V(竖排模式)。一串字符是一个文本对象,其字形自行沿栏向下每次推进一个em(DW2 [880 −1000]),所以阅读器把一栏作为一行来选取和提取。字形用OpenType的vert和fwid特性塑形,括号、引号、大陆的顿号、省略号和破折号由此得到竖排字形;本身就直立的字符保留横排字形。字体的任何部分都不会嵌入两次。
  • 拉丁单词和长数字用横排字体侧转排出;占一个字格的数字直立,比em宽时横向压缩到一个em;字体中没有竖排字形的标点会旋转或移位,与Canvas上相同。
  • 字符之间的字距写成TJ中的数字,在竖排模式下它们沿栏向下移动画笔。
  • 每一个竖排行都用/ActualText标出其文字,所以复制和文字提取按书写顺序读取。pdftotext和pdf.js从上到下、从右到左读取各栏;遇到占一个字格的数字时,pdf.js会另起一行。
  • 链接、书签和目标位置映射到页面上:竖排行上的链接是一个又高又窄的矩形,书签打开页面时定位到其标题所在栏的顶端。
  • 带标签的PDF在其Document元素上声明书写模式(Layout属性WritingMode /TbRl,所有元素都继承它);竖排章节可以通过PDF/UA-1验证(veraPDF)。
  • **阅读器:**Acrobat、Preview、Chrome(PDFium)、pdf.js和Poppler都能渲染竖排字体。右侧装订的书(page.binding)还会请求阅读器从右向左排列跨页(/Direction /R2L、/PageLayout /TwoPageRight);Acrobat和Foxit照办,Chrome不会。

一个用Noto Serif TC(书中用到的字符,TrueType)排的43页章节约为820 KB,其中大部分是两个字体子集。

#PDF中的链接

Markdown链接中的词(见文档格式 › 链接)成为URI链接注释,一行中每一串相连的链接词对应一个注释。每个注释覆盖行框,没有边框。在无障碍渲染中,每一串是一个Link元素,其/Contents为该串文字。只有绝对的http:、https:、mailto:、tel:和ftp:目标会生成链接,因为在PDF中相对URL没有基准地址。可打印ASCII以外的字符会做百分号编码。:ref引用和目录行保留指向文档内部的链接。

#完整的浏览器示例:构建、渲染、下载

把所有部分组合起来:构建VDT,渲染为PDF,并在浏览器中触发下载:

import { buildDocument, createMeasurementCache } from 'postext';
import { renderToPdf } from 'postext-pdf';
import { createPdfFontProvider } from './pdfFontProvider';
 
const fontProvider = createPdfFontProvider();
 
export async function downloadPdf(markdown: string, config: PostextConfig) {
  const cache = createMeasurementCache();
  const vdt = buildDocument({ markdown }, config, cache);
 
  const bytes = await renderToPdf(vdt, { fontProvider });
 
  const blob = new Blob([bytes.slice().buffer], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'document.pdf';
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

**重要:**如果配置引用了Web字体,要在buildDocument之前调用ensureConfigFontsLoaded(config)(或等效的做法)。排版按浏览器当时拥有的该家族字体度量来测量;如果真正的字体还没到达,VDT就会按后备字体测量,PDF将与Canvas或HTML输出不一致。沙盒在每次渲染前都显式做了这一步(见packages/postext-sandbox/src/viewport/PdfViewport.tsx)。

#在线示例:在浏览器中生成PDF

上面的完整流程在浏览器中运行:这个pen从CDN导入postext和postext-pdf,加载Web字体,构建文档,通过字体提供函数嵌入Fontsource的字体,再把字节交给一个在新标签页中打开文件的链接和一个下载链接。得到的PDF与Canvas和HTML输出断行相同,嵌入了真实字体,并带有大纲书签。

Postext · 在浏览器中生成PDF
import { buildDocument } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
 
const markdown = `# The Lantern
 
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.
 
## Two columns
 
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
 
The light it gave was small, but it was enough to find the step. 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.`;
 
const config = {
  page: { sizePreset: '17x24' },
  layout: { layoutType: 'double' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
 
// The PDF embeds real font files. Fontsource publishes one static WOFF2 per
// weight and style; decompress it to the TTF bytes pdf-lib can embed.
const fontProvider = async (family, weight, style) => {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${url}`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
};
 
// Layout is measured with the browser's fonts, so load them before building:
// otherwise the PDF would not match the canvas or HTML output.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('italic 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
 
const doc = buildDocument({ markdown }, config);
 
// Same VDT, now translated to PDF points: identical line breaks and placement.
const bytes = await renderToPdf(doc, { fontProvider });
 
// A PDF viewer cannot run inside this sandboxed result frame,
// so hand the file to a new tab and to a download link.
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;
document.getElementById('status').textContent =
  `${doc.pages.length} page(s) · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
index.html
<p id="status">Rendering…</p>
<p id="links" hidden>
  <a id="open" target="_blank" rel="noopener">Open lantern.pdf in a new tab</a> ·
  <a id="download" download="lantern.pdf">Download it</a>
</p>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
}

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

#在工作线程上渲染PDF

postext-pdf/worker把renderToPdf移出主线程。工作线程写入文字、矢量图、结构树和文件本身。有两项工作需要页面,所以工作线程会请主线程来做:获取字体,以及通过<img>栅格化SVG。对于几百页的书,渲染要花几秒钟,否则页面会在这段时间卡住;如果只有几页,直接调用renderToPdf更简单。

import { createPdfWorker } from 'postext-pdf/worker';
 
const pdfWorker = createPdfWorker();
const bytes = await pdfWorker.render(docs, {
  fontProvider,                                     // 在当前线程运行
  resourceBytes: new Map([['map.svg', svgBytes]]),  // 一个Map;其中的buffer会转移给工作线程
  onProgress: ({ phase, pages, totalPages }) => showProgress(phase, pages, totalPages),
  onWarning: (w) => console.info(w.message),
});
pdfWorker.dispose();
  • render(docs, options)接收一个文档,或一本书各章文档组成的数组。它接受renderToPdf的选项,有两处不同。resourceBytes是一个Map<string, Uint8Array>,其中的buffer会被转移,所以对你还要保留的字节,请传入副本。rasterizeSvg如果提供,在主线程上运行;默认由页面自己的Image和canvas完成这项工作。
  • 一个句柄一次渲染一个文档。dispose()终止工作线程,并拒绝所有仍在进行的渲染。
  • createPdfWorker({ worker })接收你自己创建的Worker,适用于控制worker URL的构建工具。这个worker必须运行postext-pdf/worker/entry。

**从CDN加载。**默认情况下,worker脚本从包自身的URL加载(new URL('./pdf.worker.js', import.meta.url))。其他源上的页面可能无法启动它:从esm.sh导入时,createPdfWorker()会抛出Failed to construct 'Worker': Script at 'https://esm.sh/postext-pdf@…/pdf.worker.js' cannot be accessed from origin …。这时改为启动一个同源的模块worker,由它导入入口(如果固定了版本,两个URL要固定到同一个版本):

import { createPdfWorker } from 'https://esm.sh/postext-pdf/worker';
 
const entry = URL.createObjectURL(new Blob(
  ["import 'https://esm.sh/postext-pdf/worker/entry';"],
  { type: 'text/javascript' },
));
const pdfWorker = createPdfWorker({ worker: new Worker(entry, { type: 'module' }) });

postext/worker中的排版工作线程也需要用同样的方式包装postext/worker/entry(见在Web Worker中运行排版)。

#可付印的PDF

用于正式印刷流程时,在渲染前调整以下配置选项:

  • page.cutLines.enabled: true:在成品尺寸周围加上出血区域和裁切标记,并给每一页设置TrimBox和BleedBox。见裁切线。
  • colorSpace: 'cmyk'(或pdfGeneration: { forceColorSpace: true, colorSpace: 'cmyk' }):把postext绘制的颜色(文字、线条、填充、矢量图)写成DeviceCMYK,并用套准色绘制裁切标记,使其印在每一块印版上。位图和PDF印刷母版原样嵌入,所以RGB照片仍是RGB:请在添加之前先转换。
  • page.dpi: 300(或更高):PDF的点固定为每英寸72点,但Postext的排版计算以像素为单位;较高的DPI能为以mm或cm度量的元素提供更细的划分。
  • colors.model: 'cmyk':保留颜色是在CMYK空间中定义的这一意图。目前实际绘制到PDF时仍使用hex后备值;这里记录model,是因为它会随VDT一起传给下游工具。
  • RenderToPdfOptions中的**{ pageNegative: true }**:用Difference混合模式反转成品区域(裁切标记不反转)。适合对浅底深字的排版做印前检查。

#参考实现

沙盒的PdfViewport组件(packages/postext-sandbox/src/viewport/PdfViewport.tsx)把上述各部分接成一个实时预览,带有重新生成、下载和打印按钮,是任何浏览器内PDF集成的良好起点。它通过共享的排版工作线程构建VDT(见在Web Worker中运行排版),所以点击重新生成时,流水线运行期间界面不会卡住;主线程只负责renderToPdf(VDT一旦存在,它已经很快)。

#文件包(.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覆盖。
warnings不致命的问题:不支持的字体文件、缺失的印刷母版等。

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

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

createBundle和沙盒写出的每份清单都带有configVersion: 8,表示其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)。

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": 8;给旧文件包的清单加上这个标记,也是用现行规则读取它的一行做法(此时没有版本号的文件包也会失去标题分页的固定值)。

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 } }
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重新存储:公式的固定值会乘以缩放,迁移两次就会放大两次。

#排版和渲染文件包

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

  • **loadBundleFonts(bundle)**把文件包的字面注册到document.fonts。排版前要等它完成,因为排版时是用浏览器已有的字体测量文字的。文件包中提到但没有携带的字体家族(Google Fonts)仍需你自己加载,和其他文档一样。
  • **registerBundleImages(bundle)为Canvas后端(renderPage、renderToCanvas)解码图片。bundleImageUrl(bundle)**是renderToHtml的resourceImageUrl解析器。开启diagramStyle.singleInk时,两者都会对SVG图重新着色一次:它们改写标记本身并给图片打上标记,任何后端都不会再次着色(见Canvas与HTML中的单色墨)。
  • **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:`

#在线示例:打开文件包

这个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'要求查看器把整本书排成一张画布。
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及更早版本写出的文件包),以及固有尺寸的测量方式。
  • planBundle(meta, content) / resolveBundleFiles(plan, sources):写出的一侧,分为一个纯粹的计划(文件名和清单)和通过readBlob / readFont回调解析字节两步。
  • isBundleManifest(value)、语言选择函数(pickChapterSpecs、pickLocaleOverrides、pickBundleView、resolveBundleLocale、resolveBundleConfigLocale)、svgSize / bitmapSize,以及格式类型(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页数记录。