Quarto docx 表题与图题出框

拆掉 1×1 浮层表,用 Word「表题」「图题」居中独立段落

经验来自《中国旱区农业技术发展报告2026》第5章。复用入口:../lua/docx-table-caption.lua(拆壳)与 ../lua/docx-table-layout.lua(列宽/表头)。前提与挂载见 harness 长文。图号空格是另一件事,见 quarto-crossref-tight-num.qmd。

书稿 docx 是出版社交付件。表题须在表外、用 Word 段落样式「表题」、居中;图题须在图下、用「图题」、居中。Quarto 交叉引用默认把题与主体塞进同一张 1×1 Word 表,样式还走 Image Caption 且左对齐。format.docx 的 YAML 调不出这个结构。

发生情形

章文件按书稿协议写 kable / ggplot,并给 chunk 加交叉引用:

#| label: tbl-ai-projects
#| tbl-cap: !expr tbl_cap
#| label: fig-scatter-mtcars-probe
#| fig-cap: !expr fig_cap

tbl_cap / fig_cap 仍是中文 + <br> + Table 5-N / Figure 5-N。html 预览看起来正常。打开 docx 则:

位置 未修时 期望
表题 在 1×1 外层表单元格里,与表体同属一张表 表外独立段落
图题 同上,与图同属一张表 图外独立段落,位于图下
Word 样式 Image Caption(图、表都是) 「表题」/「图题」
对齐 段落里写死 jc=left,看起来靠左 居中
书签 包住整块外壳单元格 锚在题段落上

第2章同一套 #| label: tbl- / fig- 会同样中招。只改 reference-doc 的 Table 边框会让题更像表的一部分。

根因

三层叠在一起。源文件的 glue / kable() / ggplot 没有写错。

1×1 浮层表(主因)

#| label: tbl-... / fig-... 加 #| tbl-cap / fig-cap 会生成 Quarto FloatRefTarget。docx 渲染器(floatreftarget.lua)为了对齐,用 pandoc.SimpleTable 包一层:

  • 外层 w:tbl(样式 Table,一列一行)
  • 单元格里是一个带 identifier 的 Div
  • Div 里先(或后)放题,再放表或图

这是已知行为,不是本仓写错。相关讨论:quarto-cli #11756、#10587、#4004、#9655。

没有交叉引用的裸 Pandoc 表,题会写成表外段落、样式 Table Caption。加上 #| label: tbl- 后走 float,这条路径被换掉。

题用错样式

Pandoc 默认表题是 Table Caption。Quarto 交叉引用一律走 docxDivCaption(),往段落里塞一段 OpenXML,写死 w:pStyle=ImageCaption。图、表都是 Image Caption。模板里即使有 Table Caption,成品也用不上。

左对齐盖住样式

同一段 OpenXML 还写死 w:jc w:val="left"(渲染器把 divCaption 的 align 固定成 "left")。模板里「表题」「图题」即使设了居中,段落级 jc=left 仍会盖过去。只改 reference-doc 的样式定义,Word 里看起来还是靠左。

模板起初没有中文样式名

template/create-template-tiny.docx 在本包中对应 ../reference-doc.docx。原有的是 Pandoc 英文化名 Table Caption / Image Caption。出版社要的是样式名「表题」「图题」。custom-style="表题" 而模板没有该样式时,Word 会静默回落正文。

修复方案

Lua 滤镜拆壳 + reference-doc 备齐样式。不要改成 officedown,也不要去掉 #| label:。

滤镜做什么

../lua/docx-table-caption.lua,仅 docx,at: post-render(须排在 crossref-tight-num.lua 后面,此时「表5-1」「图5-1」已经写好)。

  1. 认出 1 列 1 行的外壳 Table。
  2. 摊平单元格里 Quarto 放的 Div,取出 tbl- / fig- identifier。
  3. 把题与主体分开:主体是数据 Table 或含图的 Para/Image;题是「表N-」/「图N-」或带 ImageCaption OpenXML 的段落。
  4. 表:先题后表。图:先图后题。
  5. 题包进 Div,custom-style 为「表题」或「图题」;书签放在题上。
  6. 把 OpenXML 里的 ImageCaption 改成 BiaoTi / TuTi,并把 jc=left 改成 center。

不要给 Para / Table 赋 .attr:Quarto 1.8 的 walker 会报 Trying to set unavailable property attr。题用带 custom-style 的 Div。

html 不跑这套拆壳(quarto.doc.is_format("docx") 或 FORMAT 匹配 docx)。专栏/callout 也用表画框,但没有「表N-」题 + 内层数据表,不会被拆。

模板里要有两个样式

在 ../reference-doc.docx 的 word/styles.xml:

styleId w:name 克隆来源 用途
BiaoTi 表题 Table Caption 表外题
TuTi 图题 Image Caption 图下题

二者都 basedOn caption(ab),jc=center,等线、加粗、小五(sz=21),并加 qFormat 以便 Word 样式廊看得见。滤镜的 custom-style 对的是 样式名「表题」「图题」,不是 styleId。

linebreak.lua 仍负责把 fig_cap / tbl_cap 里的 <br> 变成 Word 换行。单章 YAML 若不挂它,中英双行会挤在一段里。

挂载位置(三处都要)

被渲的章若不在 _quarto.yml 的 project.render 列表里,只写根配置时单独 quarto render path/to/chapter.qmd 可能吃不到滤镜。

文件 路径
项目 _quarto.yml lua/docx-table-caption.lua、lua/docx-table-layout.lua
子目录 _metadata.yaml ../lua/…
章 YAML 与该文件相对 lua/ 的路径

都带 at: post-render。章 YAML 建议写成:

filters:
  - ../lua/linebreak.lua
  - path: ../lua/crossref-tight-num.lua
    at: post-render
  - path: ../lua/docx-table-caption.lua
    at: post-render
  - path: ../lua/docx-table-layout.lua
    at: post-render

从仓库根目录渲染。章内有行内 r 时不要用 --no-execute。

源文件怎么写

表、图的 qmd 协议不变:

  1. num_tbl / num_fig 加一。
  2. tbl_cap / fig_cap:中文 + <br> + Table 5-N / Figure 5-N。
  3. 制表/绘图 chunk:#| label: tbl-... 或 fig-...,tbl-cap / fig-cap: !expr ...。
  4. add_footer_asis(cap_note, cap_source)。
  5. 正文 见 @tbl-... / 见 @fig-...(@ 两侧留空格)。

不要为了躲浮层而去掉 #| label:,正文交叉引用会断。不要把题改成手工 Markdown 段落来「骗」Pandoc。

第5章曾用 mtcars 散点图(fig-scatter-mtcars-probe)验收图题出框;定稿前可删。表题用章内三张 kable 表即可验收。

表列宽与表头

拆壳之后,内层数据表的列宽问题会更明显:Pandoc 按 Markdown 字符数估列宽(CJK 与 ASCII 都算 1),再写 tblLayout=fixed。第5章「序号」「年份」只有两三个字,列宽落到约 346 twips;Table 样式左右边距各 108,内容区只剩约 130 twips,小五字号下「2024」会折成三四行,整张表被撑高。

kable(align="c") 和拆壳滤镜都不是主因:前者只管对齐,后者把内层 colspecs 原样带出。模板 Table 样式原先只有 band1Horz,没有 tblStylePr type="firstRow",所以 Word 即使写了 tblHeader / firstRow=1,表头看起来仍与表体一样(Compact、不加粗)。

../lua/docx-table-layout.lua 排在拆壳之后、仅 docx:

  1. 按显示宽度量列(CJK=2,ASCII=1),短列(显示宽度 ≤ 6)保底约 11%,再归一化到 100%。
  2. 表头 inlines 包进 Strong,单元格仍用 Plain(Pandoc 才会写成 Compact / 五号)。不要改成 Para,否则丢掉 Compact,回落到文档默认小四(sz=24)。
  3. 跳过不足 2 列的表(callout 外壳)。

模板 Table 样式补 firstRow 加粗,并钉 sz=21 / szCs=21(五号,与 Compact 表体一致)。不要把每张表手写 tbl-colwidths 当默认方案,也不要为此改成 flextable / officedown。

不要做的事

  • 指望 _metadata.yaml 的 format.docx 再加几个键拆壳。
  • 只用 reference-doc 改 Table 边框来「修」题(题会跟着吃边框)。
  • 去掉 #| label: tbl- / fig- 或改成 officedown / flextable 另写一套交叉引用。
  • 渲完用 Python 改 document.xml(书签和升级都难收;只当滤镜失败的后备)。
  • 滤镜里给 Para/Table 写 .attr。
  • 见 1 列表就拆:会拆掉 callout。
  • 同一滤镜对 html 拆壳。
  • 承诺 Word「插入题注」那种 SEQ 域。Quarto 的「表5-1」是静态字,更新域不会重编号。
  • 靠每张表手写 tbl-colwidths 或改成 flextable / officedown 来修列宽。

验收

渲一章 --to docx(来源仓曾用 report/chapter5-special.qmd)。抽查 OOXML 或 Word:

  1. 表题在表外,pStyle 为 BiaoTi,jc=center,下一元素是数据表。
  2. 图题在图下,pStyle 为 TuTi,jc=center;图与题都不在 1×1 外壳里。
  3. 无 ImageCaption 落在题上。
  4. 书签 tbl-... / fig-... 在题段落上;正文「见表5-1」「见图5-1」仍能跳转。
  5. 表下/图下的 add_footer_asis 注仍紧跟主体。
  6. 中英双行中间有 w:br。
  7. html 预览结构无变化。
  8. 数据表「年份」「序号」列宽足够「2024」单行;表头加粗,字号与表体同为五号(Compact / sz=21),不要回落小四。

可用 Python 抽 word/document.xml:搜 BiaoTi / TuTi,确认题段落的 inside_tbl 为假;抽 tblGrid 看短列不再是 346 twips。

复用清单

新章若仍交付 docx:

  1. 模板:确认有「表题」「图题」,居中;reference-doc 指向该模板。
  2. 三处挂 docx-table-caption.lua 与 docx-table-layout.lua,at: post-render;同时挂 linebreak.lua 与 crossref-tight-num.lua。布局滤镜须排在拆壳之后。
  3. 源文件继续用 #| label: + tbl-cap / fig-cap,不要改协议。不要靠每张表手写 tbl-colwidths 当默认方案。
  4. 先渲一章 docx,抽查表外/图下、样式名、居中、书签、短列(年份)不折成多行、表头加粗。