# 配置：资源与表格

> 资源类型及其编号，表格样式与题注样式，单色图示和印在纸上的视频

- HTML版本: https://postext.dev/zh/docs/configuration-resources
- 最后更新: 2026-10-10
- 阅读时间: 5分钟
- 其他语言: [en](https://postext.dev/en/docs/configuration-resources.md), [es](https://postext.dev/es/docs/configuration-resources.md), [ca](https://postext.dev/ca/docs/configuration-resources.md), [pt](https://postext.dev/pt/docs/configuration-resources.md), [ja](https://postext.dev/ja/docs/configuration-resources.md), [ar](https://postext.dev/ar/docs/configuration-resources.md)

## 简单来说

本页讲图、表格、图示和视频的设置。Postext把它们称为资源，每一类各自编号。你可以设定表格的样子：线条、底色、字体，以及跨页时怎样拆分。你可以设定图和表格的题注怎样写。你还可以用一种油墨颜色印图示，并选择视频在纸上的呈现方式。

## 资源类型

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

未设置`config.resourceTypes`时，Postext提供三个内置默认类型：**图**、**表**和**视频**，各自独立按`{h1}.{n}`编号（每遇到一级标题重新计数），计数器为十进制。列表中没有`video`类型时（例如在有视频之前保存的书），类型为`video`的视频资源照样编号：引擎为它们补上内置的视频类型（`effectiveResourceTypes(config, resources)`），`defaultVideoResourceType(locale)`则单独返回这个类型。它们的名称采用文档语言：先取`config.locale`，没有则取`bodyText.hyphenation.locale`，再没有则用英语（见[文档语言](https://postext.dev/zh/docs/configuration-text.md#文档语言)）。

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

```ts
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', … },
//     { id: 'video',  name: 'Vídeo',  shortLabel: 'Vídeo', captionPrefix: 'Vídeo', … }]
```

```ts
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版式的侧栏中（仅限栏内浮动体）
  columns?: number;                              // 'column'浮动体横跨的相邻栏数（1.18起）
  wrap?: 'none' | 'left' | 'right' | 'start' | 'end'; // 正文排在资源旁边，资源在栏的这一侧（1.24起）
  wrapGap?: Dimension;                           // 绕排资源与正文之间的距离（1.24起）
}

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`。`shrink`（`'never'`、`'page'`、`'slot'`）和`minScale`（未设时为0.7）把浮动图片缩小到所在位置的空间，而不是移到后面；`captionMeasure: 'body'`让比所在位置窄的图片的题注和说明与图片同宽（见[文档格式 › 位置](https://postext.dev/zh/docs/document-format.md#位置)）；`layout.floatShrink`给出前两项的文档级默认值。`wrap`把行内嵌入或宽一栏的浮动体放在栏的一侧，正文排在旁边，相隔`wrapGap`；`layout.wrap`存放默认值（见[文档格式 › 文字绕排](https://postext.dev/zh/docs/document-format.md#文字绕排)）。`citingPage`让`top`或`auto`浮动体置于其引用行所在页或栏的顶部，而不是引用行之后的第一个空位；`layout.floatsAtCitingPage`给出文档默认值，`layout.maxTopFraction`给出它可以占用的栏高比例（参见[文档格式 › 位置](https://postext.dev/zh/docs/document-format.md#位置)）。

`columns`（postext 1.18起）让`span: 'column'`浮动体在多栏页面上横跨这么多相邻的栏，例如报纸五栏中横跨两栏的照片。它的宽度是这些栏加上其间的栏间距。它占用一组顶端平齐的空栏的栏首，或者引用它的那一栏及其后空栏的栏脚；栏数等于或大于页面的栏数时，它就是通栏浮动体。`'page'`和`'side'`跨度、旋转的资源（`rotate`）以及行内嵌入（`here`）忽略此项；`captionSide`只对一栏宽的浮动体有效。

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

### 模板标记

`numberingTemplate`与标题编号使用同一个渲染器（见[标题](https://postext.dev/zh/docs/configuration-text.md#标题)）。它识别两类标记：

- `{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时 | 说明 |
| --- | --- | --- |
| `{n}` | `3` | 单一连续计数。配`resetOn: 'never'`使用。 |
| `{h1}.{n}` | `2.3` | 按章编号。配`resetOn: 'h1'`使用。 |
| `{h1}.{h2}.{n}` | `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`选项）见[文档格式 › 资源](https://postext.dev/zh/docs/document-format.md#资源)，其中也说明了第一次引用的顺序如何决定计数。

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

### 哪些资源会编号

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

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

```ts
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`的[标题样式](https://postext.dev/zh/docs/configuration-styles.md#标题样式)：这样的标题不推进`{h1}`，而是保持原值（在第一个计数的H1之前为空，此时`{h1}.{n}`退化为单独的计数器），也从不触发`resetOn: 'h1'`，所以计数会越过它继续。在`# Introduction`及其图1.1之后，未编号的`# Appendix`下的第一张图是1.2，而不是2.1。

```ts
// 单篇文章文档中的图1、2、3……
resourceTypes: defaultResourceTypes('en').map((t) => ({ ...t, numberingTemplate: '{n}', resetOn: 'never' })),
```

## 表格样式

`tableStyle`属性控制表格资源的字体排印和装饰，作用于每张表，除非该表选用了某个[命名表格样式](https://postext.dev/zh/docs/configuration-resources.md#命名表格样式)。正文单元格和表头单元格分别设定样式。字体、字号和颜色未设置时继承解析后的正文，因此没有`tableStyle`的文档会用正文的字体排印来渲染表格。

```ts
const config: PostextConfig = {
  tableStyle: {
    headerBold: true,
    headerBackground: { hex: '#f0f0f0', model: 'hex' },
    borders: true,
    borderWidth: { value: 0.75, unit: 'pt' },
  },
};
```

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `bodyFontFamily` | `string` | 正文字体 | 正文单元格的字体。 |
| `bodyFontSize` | `Dimension` | 正文字号 | 正文单元格的字号。 |
| `bodyColor` | `ColorValue` | 正文颜色 | 正文单元格的文字颜色。 |
| `headerFontFamily` | `string` | 正文字体 | 表头单元格的字体。 |
| `headerFontSize` | `Dimension` | 正文字号 | 表头单元格的字号。 |
| `headerColor` | `ColorValue` | 正文颜色 | 表头单元格的文字颜色。 |
| `headerBold` | `boolean` | `true` | 表头单元格用粗体。 |
| `headerItalic` | `boolean` | `false` | 表头单元格用斜体。 |
| `headerLetterSpacing` | `Dimension` | `0pt` | 表头单元格中每个字符（包括空格）之后的字距，相当于CSS的`letter-spacing`。正值把字母拉开（全大写的表头通常取`0.05em`到`0.1em`），负值收紧。`em`以表头字号为准。表头各行按这个字距测量，因此换行、居中和对齐都考虑了字距，Canvas、HTML和PDF的绘制结果一致。它作用于所有表头单元格：表头行，以及任何标记了`isHeader`的单元格。 |
| `headerTextTransform` | `'none' \| 'uppercase'` | `'none'` | 表头单元格用大写字母排。文字长度保持不变，因此沙盒仍能把每个字母对应到源文本：大写形式更长的字母（`ß`）保持原样。资源引用保留其标签。 |
| `headerBackgroundEnabled` | `boolean` | `true` | 在表头行后面绘制填充色。 |
| `headerBackground` | `ColorValue` | `#f0f0f0` | 表头行的填充色。 |
| `bodyBackgroundEnabled` | `boolean` | `false` | 在正文行后面绘制填充色。 |
| `bodyBackground` | `ColorValue` | `#ffffff` | 正文行的填充色（仅在启用时绘制）。 |
| `bodyAlternateBackgroundEnabled` | `boolean` | `false` | 斑马纹：每隔一行正文用`bodyAlternateBackground`填充。见[斑马纹](https://postext.dev/zh/docs/configuration-resources.md#斑马纹)。 |
| `bodyAlternateBackground` | `ColorValue` | `#f2f2f2` | 交替正文行的填充色（仅在启用时绘制）。 |
| `borders` | `boolean` | `true` | 绘制单元格边框。 |
| `borderColor` | `ColorValue` | 正文颜色 | 边框线的颜色。 |
| `borderWidth` | `Dimension` | `0.75pt` | 边框线的粗细（96 DPI下约为1px；随页面DPI缩放）。`'booktabs'`不使用它，而用自己的线宽。 |
| `cellPadding` | `Dimension` | `0.375em` | 每个单元格的内边距。 |
| `rules` | `'grid' \| 'horizontal' \| 'outer' \| 'none' \| 'booktabs'` | `'grid'` | `borders`开启时绘制哪些线：完整的单元格网格、只有横线（每行的上下边，无竖线）、只有外框、都不画，或期刊表格的三条线（见[三线表](https://postext.dev/zh/docs/configuration-resources.md#三线表)）。 |
| `borderRadius` | `Dimension` | `0` | 表格外框的圆角半径。外框画成圆角（用`grid`或`outer`线型时），单元格填充色和表头背景按它裁切（即使`rules: 'none'`或关闭边框也是如此），横线裁到其外轮廓为止；内部的线保持直线。跨页拆分的表，第一部分圆上方两角，最后一部分圆下方两角。半径不超过表格宽度和高度的一半。`'booktabs'`的线保持直线（填充色仍按圆角裁切）。 |
| `heavyRuleWidth` | `Dimension` | `0.08em` | 三线表：表格上方和末行下方的线。`em`以正文单元格字号计。 |
| `lightRuleWidth` | `Dimension` | `0.05em` | 三线表：表头行下的线，以及分组线。 |
| `spanRuleWidth` | `Dimension` | `0.03em` | 三线表：跨多列的表头单元格下的线。 |
| `spanRules` | `'trimmed' \| 'full' \| 'none'` | `'trimmed'` | 三线表：最后一行表头之上、跨多列的表头单元格下的线：两端各缩短`spanRuleTrim`、贯穿整个单元格，或不画。 |
| `spanRuleTrim` | `Dimension` | `0.5em` | 三线表：两端缩短的跨栏线每端缩进多少。 |
| `groupRules` | `boolean` | `false` | 三线表：在每个作为分组标题的表体行上方画一条细线。 |
| `continuedFootRule` | `'bottom' \| 'light' \| 'none'` | `'light'` | 三线表：跨页表格中延续到下一页的部分以什么线收尾。 |
| `overflow` | `'split' \| 'clip' \| 'hide'` | `'split'` | 比页面还高的表如何处理：在后续页面上接续、只保留放得下的行，或者不排。开启`splitInline`时，排在正文中、放不进本栏剩余空间的表也按此处理。见下文。 |
| `splitInline` | `boolean` | `true` | 对排在`here`的表也应用`overflow`：放不进本栏剩余空间的行内表在行与行之间切开，在下一栏顶部接续。`false`把这样的表整体移到下一栏，与postext 1.24及以前相同；早期版本存储的配置，如果其中的章嵌入了资源，读入时按`false`处理。见[比页面还高的表](https://postext.dev/zh/docs/configuration-resources.md#比页面还高的表)。postext 1.25起。 |
| `continuedSuffix` | `string` | `'(cont.)'` | 加在拆分表格每个续表部分的题注之后，用斜体，前面隔一个空格；以汉字或全角字符开头的后缀（`'（续）'`）则与题注紧排。 |
| `continuesMarkerEnabled` | `boolean` | `true` | 在每个转下页的部分下方排一个标记。 |
| `continuesMarker` | `string` | `'Continued'` / `'Continúa'` | 该标记的文字，以注释字体（见[题注样式](https://postext.dev/zh/docs/configuration-resources.md#题注样式)）右对齐排在该部分下方。默认值随文档语言区域而定（八种语言见[文档语言](https://postext.dev/zh/docs/configuration-text.md#文档语言)）。 |

边框粗细保留小数：`0.5pt`的线在PDF和屏幕上都画成细线，而不会向上取整到整像素（最小为0.25px）。

### 斑马纹

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

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

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

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

### 三线表

期刊和教材里的表格通常只用三条线，不画竖线：表格上方一条粗线，表头下一条细线，末行下一条粗线；表头中跨几列的分组标题下另有短线（LaTeX 的`booktabs`宏包：`\toprule`、`\midrule`、`\cmidrule`、`\bottomrule`）。`rules: 'booktabs'`画的就是这种样式：

```ts
const config: PostextConfig = {
  tableStyle: {
    rules: 'booktabs',
    borderColor: { hex: '#000000', model: 'hex' },
    headerBackgroundEnabled: false,
  },
};
```

- 表格上方的线和末行下方的线粗`heavyRuleWidth`（`0.08em`），表头行下的线粗`lightRuleWidth`（`0.05em`）。没有表头行的表格不画表头线。
- 在最后一行表头之上、跨多列的表头单元格下画一条粗`spanRuleWidth`（`0.03em`）的线。`spanRules: 'trimmed'`（默认）时，这条线两端各缩短`spanRuleTrim`（`0.5em`），相邻两个分组标题下的线因而互不相接；`'full'`让它贯穿整个单元格，`'none'`则不画。
- `groupRules: true`在每个作为分组标题的表体行（一个单元格横贯全表，或一整行表头单元格）上方加一条细线；该行位于表格开头或页首时除外，那里已有表头线。
- 各线宽以正文单元格字号（`bodyFontSize`）换算，表头字号较大也不会加粗表头线。线宽为`0`时不画该线。
- 线的颜色取`borderColor`（关联调色板的颜色随调色板和`:::part palette`变化），`borders: false`关闭所有线。`borderWidth`不起作用，`borderRadius`也不起作用：线保持直线，单元格填充色仍按圆角外框裁切。表头填充、斑马纹和单元格自己的`background`与其他线型一样有效，画在线的下面。
- 跨页拆分的表格每部分都重复表头行，所以每部分都以粗线和表头线开头。末行下的粗线只收尾最后一部分；延续到下一页的部分以`continuedFootRule`收尾：细线（`'light'`，默认）、粗线（`'bottom'`）或不画（`'none'`）。

排版只计算一次这些线。VDT 中的表格以`strokes`（`{ x1, y1, x2, y2, widthPx }`，相对于表体左上角）携带它们，画布、HTML 查看器、PDF 和固定版式 EPUB 照此绘制；在带标签的 PDF 中它们是版面工件。流式 EPUB 把它们写成表格和表头的 CSS 边框，两端缩短的线用背景线绘制。在 Sandbox 中，于*线条*下拉框选择**三线表**（booktabs）会显示这些字段，并隐藏*边框宽度*和*圆角半径*。

### 命名表格样式

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

```ts
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`中不同的值）。在流式 EPUB 中，命名样式是表格上的一个类（`pt-table-<id>`），由书的样式表设置样式。

### 比页面还高的表

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

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

`'clip'`保留放得下的开头几行，不声不响地丢掉其余部分（注释仍在该部分末尾）；`'hide'`则整张表都不排。二者只在表比一页还高时起作用：放得下的表在任何模式下都整体放置。

**排在正文中的表**。自postext 1.25起，排在`here`（用`::resource`嵌入）的表也遵循同样的规则（`splitInline`，默认开启）。它放不进本栏剩余空间时，就在行与行之间切开：第一部分保留上方的浮动体间距，至少带表头行和两行表体（若不够，整张表照旧从下一栏开始）；之后每一部分都在下一栏顶部开头，上方不留间距，按该栏的宽度排（一栏半版式把某一部分排进窄栏时，按窄栏的宽度排）；`::resource`那一行之后的文字接在最后一部分后面。重复表头、带后缀的题注、续接标记、最后一部分下的注释、至少三行的尾部，以及避开合并单元格和分组标题行的切分，都与浮动表相同。表体不足五行的表从不切开。`'clip'`保留比一栏还高的行内表放得下的开头几行，排在某一栏的顶部；`'hide'`不排这样的表；较矮的表在这两种模式下都整体移到下一栏。行内表开启的页面，只为表的第一部分保留不被该页浮动体占用的空间，因此等待中的通栏图仍排在该页顶部，表接在图下继续。竖排页面上的行内表以及框内的表不切开。`splitInline: false`把行内表整体移到下一栏，与postext 1.24及以前相同。

续表文字的默认值随文档语言而定（`locale`，否则取断词语言区域）：英语为`(cont.)` / `Continued`，西班牙语为`(cont.)` / `Continúa`，法语、德语、意大利语、葡萄牙语、加泰罗尼亚语和荷兰语也同样有对应文字（列在[文档语言](https://postext.dev/zh/docs/configuration-text.md#文档语言)中）。

单元格内容是行内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`，这样跨页拆分的表会重复它们。

```ts
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`，即在最后一列之前就结束、又没有合并单元格覆盖其余部分的行，这会留下空洞。

```ts
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`（见[文档中的警告](https://postext.dev/zh/docs/configuration-programmatic-usage.md#文档中的警告)）。

## 题注样式

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

```ts
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' },
  },
};
```

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `fontFamily` | `string` | 正文字体 | 题注字体（标签和描述）。 |
| `fontSize` | `Dimension` | 正文字号 | 题注字号（标签和描述）。 |
| `color` | `ColorValue` | 正文颜色 | 描述文字的颜色。 |
| `align` | `'left' \| 'center' \| 'right' \| 'justify' \| 'start' \| 'end'` | `'left'` | 题注各行的水平对齐。`'justify'`把除最后一行外的每一行撑满整个宽度。在色条上时，各行在其内边距以内对齐；侧边题注在自身宽度内对齐。 |
| `gap` | `Dimension` | `0.75em` | 资源与题注之间的垂直间距。 |
| `labelBold` | `boolean` | `true` | 带编号的标签（例如`Figure 1`）用粗体。 |
| `labelItalic` | `boolean` | `false` | 带编号的标签用斜体。 |
| `labelColor` | `ColorValue` | 题注的`color` | 带编号的标签的颜色。 |
| `descriptionItalic` | `boolean` | `false` | 描述文字用斜体。 |
| `position` | `'above' \| 'below'` | `'below'` | 题注的位置。取`'above'`时，题注（及其色条）在前，资源主体下移题注高度加`gap`；注释则放在主体下方。 |
| `backgroundEnabled` | `boolean` | `false` | 在题注后面绘制色条。色条横跨整个块宽，四周包住题注各行并留出`padding`。 |
| `background` | `ColorValue` | 调色板主色 | 色条的填充色（仅在启用时绘制）。 |
| `padding` | `Dimension` | `0.35em` | 色条边缘与题注文字之间的内边距。色条关闭时忽略。 |
| `note` | `object` | — | 资源注释的样式，见下面的子表。 |
| `labelNumberGap` | `string` | 不间断空格；日文文档中为`''` | 在题注和行内`:ref`中，标签与编号之间的内容：*Figure 1.7*、*Fig. 1.7*。中文和日文二者紧排：`''`得到图1-1，这也是日文文档中的默认值（図1-1）。 |
| `labelSeparator` | `string` | `'. '`；日文文档中为`'　'` | 编号之后、描述之前的内容：*Figure 1.7. A caption*。中文题注用一个全角空格`'　'`（图1-1　标题），日文题注默认也是如此（図1-1　東京の地図，JLReq §4.3）。没有编号的标签仍按自己的规则：加句点，除非前缀已以句点结尾。 |

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

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `note.fontSize` | `Dimension` | 题注字号的0.85倍 | 注释字号。 |
| `note.color` | `ColorValue` | 题注的`color` | 注释文字的颜色。 |
| `note.italic` | `boolean` | `false` | 注释用斜体。 |
| `note.gap` | `Dimension` | `0.35em` | 注释与其前面内容（题注或主体）之间的间距。 |
| `note.align` | `'left' \| 'center' \| 'right' \| 'justify' \| 'start' \| 'end'` | `'left'` | 注释各行的水平对齐，与题注的`align`相同。 |

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

## 图示样式

`diagramStyle`属性控制嵌入的SVG图示（`kind: 'svg'`资源）如何着色，以及其中的文字用什么字体排。**单色模式**是一步重新着色，把图示中的每种颜色映射为同一种油墨的某个浓淡，文档用单一专色印刷时，图也能如实还原。**内嵌字体**把每个SVG的文字所指定的字体嵌入该SVG，使其中的标注在Canvas、HTML和EPUB中都以文档的字体排出（见[SVG文字中的字体](https://postext.dev/zh/docs/configuration-resources.md#svg文字中的字体)）。

```ts
const config: PostextConfig = {
  diagramStyle: {
    singleInk: true,
    inkColor: { hex: '#295AA3', model: 'hex' },
  },
};
```

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `singleInk` | `boolean` | `false` | 把每张嵌入的SVG图示重新着色为同一种油墨的浓淡。 |
| `inkColor` | `ColorValue` | 主色（`#295AA3`） | 所用油墨。默认取文档调色板的主色（通过`paletteId: 'main-color'`与调色板关联），因此更换调色板色样时，图示会与标题、粗体文字一起换色。 |
| `inlineFonts` | `boolean` | `true` | 每个SVG作为图片显示之前，把其文字指定的字体（`font-family`）以`@font-face` data URI的形式嵌入其中：Canvas、HTML、EPUB以及PDF的栅格化后备都是如此。存储的文件从不改写。单个资源可用`svg.inlineFonts: false`退出（自postext 1.25起）。 |

### 单色模式的工作原理

启用`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重新着色的标记文本不带它。）

```ts
import { applySingleInkToSvg } from 'postext';

const recoloured = applySingleInkToSvg(svgText, '#295AA3');
applySingleInkToSvg(recoloured, '#295AA3') === recoloured; // true：绝不着色两次
```

单色模式在三个后端中都起作用：PDF后端在把`resourceBytes`交给它的SVG字节作为矢量绘制之前先重新着色；Canvas和HTML后端在你要求时为所绘制的SVG图片着色（见[Canvas与HTML中的单色模式](https://postext.dev/zh/docs/configuration-resources.md#canvas与html中的单色模式)），因此导出的PDF与屏幕预览一致。

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

```ts
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。

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

```ts
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`；见[单色模式的工作原理](https://postext.dev/zh/docs/configuration-resources.md#单色模式的工作原理)），像素映射给出相同的结果，抗锯齿边缘和渐变也不例外。两者的差别在于标记处理不改动的颜色：`white`和`black`以外的命名颜色、`currentColor`、没有填充的形状和文本（以默认黑色绘制），以及嵌入SVG中的位图，在屏幕上会着色，在PDF中却保留原色。给图示的每个元素指定明确的十六进制、`rgb()`或`hsl()`颜色，就能得到完全一致的输出。Canvas无法读回像素时（来自其他源、未启用CORS加载的`<img>`），图片不着色绘制。

### SVG文字中的字体

SVG图片是通过图像显示的：在Canvas、HTML和EPUB中都是一个`<img>`。图像文档看不到页面的网络字体，所以`<text font-family="IBM Plex Sans">`会退回到系统字体。自postext 1.25起，引擎在图片解码或作为URL交出之前，把文字指定的字体嵌入标记：每个字体文件一条`@font-face`规则，字节写成data URI，放在紧跟根`<svg>`标签的`<style>`中。PDF不需要这一步：它把SVG文字作为真正的文字，用其嵌入的字体排出（见[资源字节与印刷母版](https://postext.dev/zh/docs/configuration-programmatic-usage.md#资源字节与印刷母版)）。

文字要求什么字体，从`font-family`、`font-weight`、`font-style`和`font`读取，无论它们写成属性、写在`style`属性中，还是从外层的组继承；`<style>`中指定了字体族的规则也算在内。通用字体族（`serif`、`sans-serif`……）以及`<title>`或`<desc>`中的文字不计入；SVG自己用`@font-face`声明的字体族保持不动。一段文字沿着它的`font-family`列表往下找，用第一个有字体可用的字体族。先做单色重新着色，再嵌入字体。

字体来自一个**提供函数**，其约定与postext-pdf的`PdfFontProvider`相同，因此一个提供函数可以两处共用：调用时传入字体族、字重和样式，以及SVG用这种字体排出的字符，它返回一个或多个文件。以unicode-range切片提供的字体族（Fontsource、Google Fonts）只返回这些字符所需的切片，所以标注全是拉丁字母的SVG只带`latin`文件。默认的提供函数读取引擎的字体注册表：`loadBundleFonts`把文件包的字体注册在那里，宿主则用`registerFontBytes(family, weight, style, bytes, { unicodeRange })`注册自己的字体，或者用`registerFontUrl(…)`注册一个在SVG首次需要时才获取的文件。注册表中没有的字体族，会到页面可读样式表的`@font-face`规则中查找。从字节添加到`document.fonts`的`FontFace`不保留字节，引擎无法把它读回，所以这些字体也要注册。

```ts
import { registerFontBytes, registerSvgImage, renderPage } from 'postext';

registerFontBytes('IBM Plex Sans', 700, 'normal', plexBoldWoff2);
await registerSvgImage('chart.svg', svgText);   // 重新着色、嵌入字体、解码、注册
const canvas = renderPage(doc.pages[0], doc);
```

在哪里进行：

- **Canvas**。`registerSvgImage(fileId, svgText, options)`对图片重新着色（`inkHex`）、嵌入字体（`fonts`，一个提供函数；`inlineFonts: false`跳过这一步）、解码，并把它注册为矢量源，返回的Promise给出每种字体的处理结果。`registerBundleImages(bundle)`对文件包中的SVG做同样的事，优先使用文件包自己的字体。`prepareSvgMarkup(svgText, options)`返回处理好的标记，供自行解码的宿主使用。
- **HTML**。`bundleImageUrl(bundle)`提供已嵌入文件包字体的SVG标记。`renderToHtml(doc, { inlineSvgFonts: true })`把注册表在内存中持有的字体嵌入`resourceImageUrl`返回的SVG `data:` URI（也可写成`inlineSvgFonts: { fonts, maxBytes, withhold }`）。对象URL无法同步读取，所以提供blob URL的宿主要在生成URL之前嵌入。
- **EPUB**。`postext-epub`在写出SVG之前，先从书的`fonts`、再从`svgFonts.provider`取字体嵌入（见[EPUB电子书](https://postext.dev/zh/docs/configuration-programmatic-usage.md#epub电子书postext-epub)）。
- **PDF**。SVG文字作为真正的文字，用嵌入的字体排出。只含`@font-face`规则的`<style>`（作者嵌入的字体）不再使图退回栅格。图确实退回栅格时（滤镜、渐变），其栅格图用从PDF的`fontProvider`取来的字体嵌入后生成。

较底层的函数也已导出：`svgFontRequests(svgText)`列出每段文字的字体族、字重、样式和字符；`inlineSvgFonts(svgText, provider, options)`和`inlineSvgFontsSync(svgText, syncProvider, options)`返回标记；`inlineSvgFontsDetailed`另外报告每种字体的结果（`inlined`、`declared`、`unavailable`、`withheld`、`tooLarge`）。

| 选项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `maxBytes` | `number` | 2 MiB | 一个SVG中嵌入的字体字节数上限（按base64编码之前计，编码会再增加三分之一）。几种字体合计超出上限时一种也不嵌入，并报告`svgFontsTooLarge`。 |
| `formats` | `('woff2' \| 'woff' \| 'ttf' \| 'otf')[]` | 四种全部 | 要嵌入的文件格式；其他格式的文件跳过。 |
| `withhold` | `(family) => boolean` | 无 | 文件要离开应用时不写入其中的字体族（不可再分发）。对它们的引用保留，阅读端退回其他字体；每扣下一个，都会通知`onWithheld(family)`。 |
| `onWarning` | `(warning) => void` | 无 | 接收没有字体可嵌入的字体族（`svgFontUnavailable`）和超出大小上限（`svgFontsTooLarge`）的通知。 |

**退出**。`diagramStyle.inlineFonts: false`让每个SVG保持存储时的样子；资源上的`svg.inlineFonts: false`让这一个SVG逐字节保持原样，适用于自带字体或不得改动的SVG。文件包把资源的退出写在`preset.json`中，即`"inlineFonts": false`。

**许可证**。嵌入会把字体文件放进可能离开应用的图片中（HTML导出、EPUB）。它只在图片显示或导出时进行，从不写入存储的资源字节；`withhold`则把许可证不允许转交的字体族挡在外面：EPUB写出器扣下标记为`redistributable: false`的字体，沙盒扣下标记为不可再分发的自定义字体族。

## 视频样式

`videoStyle`属性设定[视频资源](https://postext.dev/zh/docs/document-format.md#视频)如何印出：封面上的播放标记和二维码，以及封面是否链接到视频；还设定它们的播放器在HTML查看器和EPUB中提供哪些功能。

```ts
const config: PostextConfig = {
  videoStyle: {
    playMark: { shape: 'rounded', position: 'top-left', size: { value: 10, unit: 'mm' } },
    qr: { position: 'bottom-right', size: { value: 20, unit: 'mm' }, errorCorrection: 'Q' },
    player: { download: false, privacy: true },
  },
};
```

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `playMark` | `VideoPlayMarkConfig` | 见下文 | 印在封面上、表明它可以播放的标记。 |
| `qr` | `VideoQrConfig` | 见下文 | 印在封面上的二维码：打开视频的YouTube或Vimeo页面，或文件的正式发布地址。 |
| `linkPoster` | `boolean` | `true` | 让封面链接到视频：PDF中在封面上加一个链接注释，HTML和EPUB中凡显示封面处都用`<a>`包住它。 |
| `html` | `'player'` · `'poster'` | `'player'` | HTML输出为视频放什么：它的播放器，或带叠加元素的印刷封面。 |
| `player` | `VideoPlayerOptions` | 见下文 | 所有视频的播放器选项；单个视频自己的`video.player`覆盖在它上面。 |

### 播放标记

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | 印出标记。 |
| `shape` | `'circle'` · `'rounded'` · `'triangle'` | `'circle'` | 圆盘加三角形，圆角矩形加三角形（宽为高的1.45倍），或只有三角形，以背景色描边。 |
| `position` | `VideoOverlayPosition` | `'center'` | `'center'`、一个角（`'top-left'`、`'top-right'`、`'bottom-left'`、`'bottom-right'`）或一条边的中点（`'top'`、`'bottom'`、`'left'`、`'right'`）。位置是物理方位：在从右到左的书中，右上角也还是右上角。 |
| `size` | `Dimension` | `12mm` | 标记的高度；最多占封面短边的40%。 |
| `inset` | `Dimension` | `4mm` | 标记位于角落或边上时，与封面边缘的距离。 |
| `color` | `ColorValue` | 白色 | 三角形。 |
| `background` | `ColorValue` | 调色板主色 | 三角形后面的圆盘或矩形；只有三角形时则是它的描边。默认与调色板关联。 |
| `backgroundOpacity` | `number` | `0.9` | 背景的不透明度，0–1。 |

### 二维码

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | 印出二维码。没有正式发布地址的文件不印。 |
| `position` | `VideoOverlayPosition` | `'bottom-right'` | 同播放标记。两者要设在不同的位置。 |
| `size` | `Dimension` | `18mm` | 含留白的二维码边长；最多占封面短边的45%。手机摄像头能读出三分之一毫米及以上的模块：30个字符的地址生成29个模块的码，所以18 mm加2个模块的留白，模块为0.55 mm。 |
| `inset` | `Dimension` | `3mm` | 与封面边缘的距离。 |
| `errorCorrection` | `'L'` · `'M'` · `'Q'` · `'H'` | `'M'` | 二维码受损或被遮住多少仍能读出：约7%、15%、25%或30%。只要模块数不变，会自动提高。 |
| `quietZone` | `number` | `2` | 二维码周围、底板上的浅色模块数（0–8）。底板与封面区分开来，所以不需要标准为直接印在纸上的码规定的四个模块。 |
| `color` | `ColorValue` | 黑色 | 深色模块。浅色底板上的模块要保持深色：大多数读码器读不了反色的码。 |
| `background` | `ColorValue` | 白色 | 底板。 |
| `radius` | `Dimension` | `1mm` | 底板的圆角半径。 |

二维码由引擎自己编码（`encodeQr(text, level)`：字节模式、UTF-8、版本1到40，选罚分最低的掩码），并以矢量绘制：Canvas填充一条由模块段组成的路径，PDF用一次`drawSvgPath`，HTML用一个带`shape-rendering="crispEdges"`的`<path>`，所以无论印多大都保持清晰。

### 播放器选项

`VideoPlayerOptions`，用在`videoStyle.player`和每个视频的`video.player`中。文件的HTML5播放器支持全部选项；YouTube和Vimeo的播放器支持其嵌入参数所允许的那些。

| 属性 | 默认值 | 适用于 | 说明 |
| --- | --- | --- | --- |
| `controls` | `true` | YouTube、Vimeo、文件 | 显示播放器控件。 |
| `download` | `true` | 文件 | 提供浏览器的下载按钮（关闭时为`controlslist="nodownload"`）。它只隐藏按钮，并不保护文件。YouTube和Vimeo从不提供下载。 |
| `fullscreen` | `true` | YouTube、Vimeo、文件 | 允许全屏（`fs=0`、iframe的`allowfullscreen`、`nofullscreen`）。 |
| `playbackRate` | `true` | Vimeo、文件 | 提供倍速菜单（`speed=0`、`noplaybackrate`）。 |
| `pictureInPicture` | `true` | Vimeo、文件 | 允许画中画（`pip=0`、`disablepictureinpicture`）。 |
| `remotePlayback` | `true` | 文件 | 允许投屏到其他屏幕（`disableremoteplayback`）。 |
| `autoplay` | `false` | YouTube、Vimeo、文件 | 自动开始播放，按浏览器的要求始终静音。 |
| `muted` | `false` | YouTube、Vimeo、文件 | 开始时关闭声音。 |
| `loop` | `false` | YouTube、Vimeo、文件 | 播完后从头再播。 |
| `exclusive` | `true` | Folio、HTML查看器、EPUB（运行脚本时）；文件 | 开始播放这段视频会暂停正在显示的其他视频，一次只播放一段。`false`让它与其他视频同时播放：一页上无声循环的短片，几段一起播放。postext 1.18起。 |
| `preload` | `'metadata'` | 文件 | 播放前浏览器预先加载多少：`'none'`、`'metadata'`或`'auto'`。 |
| `privacy` | `true` | YouTube、Vimeo | 增强隐私的嵌入：YouTube从`youtube-nocookie.com`加载，Vimeo带`dnt=1`。 |

EPUB只保留其规范认识的属性：文件播放时带`controls`、`autoplay`、`muted`、`loop`、`playsinline`和`preload`，另加`data-pt-alongside`标记，其余由阅读系统决定。

不独占的视频在HTML输出中带有`data-pt-alongside`。`coordinateVideoPlayback(root)`让`root`（容纳`renderToHtml`输出的元素）下的播放器遵守这一规则：开始播放独占的视频会暂停其他所有正在播放的视频，开始播放同时播放的视频只暂停独占的视频。它返回一个停止监听的函数。`playsAlongside(el)`和`videosToPause(started, videos, alongside)`为自带播放器的宿主提供同样的规则。在[Folio](https://postext.dev/zh/docs/document-format.md#folio视图中的视频)中，自动播放且与其他视频同时播放的视频（`autoplay`加`exclusive: false`）每次翻到它所在的页面时都静音开始，翻走时停止，几段可以同时播放；`loop`让它播完后从头再播。EPUB中，有视频需要协调的页面或章节（两段或以上，其中至少一段独占）以一个小脚本`scripts/videos.js`（即导出的`VIDEO_PLAYBACK_SCRIPT`）引入同一规则，包文件把该文档声明为`scripted`。运行脚本的阅读系统让该文档中的视频遵守这一规则，但对面的页面是另一个文档，不受影响；不运行脚本的阅读系统按自己的规则播放每段视频，YouTube和Vimeo播放器也始终如此。

解析函数和精简函数与其他各节一致，另有`VideoStyleConfig` / `ResolvedVideoStyleConfig`类型：

```ts
import {
  DEFAULT_VIDEO_STYLE_CONFIG,
  DEFAULT_VIDEO_PLAYER_OPTIONS,
  resolveVideoStyleConfig,
  resolveVideoPlayerOptions,
  stripVideoStyleDefaults,
} from 'postext';

const resolved = resolveVideoStyleConfig(config.videoStyle);
const player = resolveVideoPlayerOptions(resource.video?.player, resolved.player);
const minimal = stripVideoStyleDefaults(config.videoStyle); // 全部与默认值相同时为undefined
```
