From a5360cebd4dbb5925c6a78b3955a104f973380c3 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 18:52:52 +0200 Subject: [PATCH 1/2] Add DOCX figure caption and alt text baseline tests Pins the visible caption of every way an image reaches a DOCX document (plain, cross-ref, subfigure panel, div float, non-crossref layout, linked, table cell, callout, knitr and Jupyter cells), so changes to how alt text is handled in DOCX can be checked against unchanged captions. Every alt value carries a marker that must never show up as document text. The image description (descr) is asserted only for images without fig-alt, where it must not change: the caption for a plain figure, empty for a cross-referenced one and for the callout icon. Caption text is compared with string-join(.//w:t, '') rather than normalize-space(.): cross-ref caption paragraphs carry a raw second w:pPr with whitespace text nodes, and fontoxpath's normalize-space turns the non-breaking space after "Figure" into a plain space. --- .../smoke-all/docx/figure-captions/image.png | Bin 0 -> 70 bytes .../docx/figure-captions/jupyter.qmd | 71 +++++++++++++ .../smoke-all/docx/figure-captions/knitr.qmd | 64 ++++++++++++ .../docx/figure-captions/markdown.qmd | 94 ++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 tests/docs/smoke-all/docx/figure-captions/image.png create mode 100644 tests/docs/smoke-all/docx/figure-captions/jupyter.qmd create mode 100644 tests/docs/smoke-all/docx/figure-captions/knitr.qmd create mode 100644 tests/docs/smoke-all/docx/figure-captions/markdown.qmd diff --git a/tests/docs/smoke-all/docx/figure-captions/image.png b/tests/docs/smoke-all/docx/figure-captions/image.png new file mode 100644 index 0000000000000000000000000000000000000000..22a565bf6e0cb372365d07711dcce83775ed3d21 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYf8c{W11kgL|JCQr PKoSg|u6{1-oD!M<-boQ0 literal 0 HcmV?d00001 diff --git a/tests/docs/smoke-all/docx/figure-captions/jupyter.qmd b/tests/docs/smoke-all/docx/figure-captions/jupyter.qmd new file mode 100644 index 00000000000..6335ba40ad6 --- /dev/null +++ b/tests/docs/smoke-all/docx/figure-captions/jupyter.qmd @@ -0,0 +1,71 @@ +--- +title: DOCX figure captions and alt text (Jupyter) +format: docx +jupyter: python3 +_quarto: + tests: + docx: + ensureDocxXpath: + - + # cross-referenced cell + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='Figure\u00a01: J1 caption']" + # unlabelled cell with fig-cap + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='J2 caption']" + # fig-subcap with layout-ncol: both subcaptions and the panel caption + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='(a) J4a sub caption']" + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='(b) J4b sub caption']" + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='Figure\u00a02: J4 caption']" + # cross-referenced cell without fig-alt + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='Figure\u00a03: J5 caption']" + # descr of a cross-referenced cell without fig-alt is empty + - "//w:tbl[.//w:t[contains(., 'J5 caption')]]//wp:docPr[@descr='']" + - + # alt text never becomes visible text + - "//w:t[contains(., 'ALT-ONLY')]" + - "//w:tbl[.//w:t[contains(., 'J5 caption')]]//wp:docPr[@descr!='']" +--- + +Pins how figure captions and alt text from Jupyter cells render in DOCX. +Visible captions must stay unchanged whatever the filter pipeline does with alt text, and every alt value carries a marker that must never appear in the document text. +The image description (`descr`) is checked only for the cell without `fig-alt`. + +```{python} +#| label: fig-j1 +#| fig-cap: "J1 caption" +#| fig-alt: "J1 ALT-ONLY" +import matplotlib.pyplot as plt +plt.plot([1, 2]) +plt.show() +``` + +```{python} +#| fig-cap: "J2 caption" +#| fig-alt: "J2 ALT-ONLY" +plt.plot([1, 2]) +plt.show() +``` + +```{python} +#| fig-alt: "J3 ALT-ONLY" +plt.plot([1, 2]) +plt.show() +``` + +```{python} +#| label: fig-j4 +#| fig-cap: "J4 caption" +#| fig-subcap: ["J4a sub caption", "J4b sub caption"] +#| fig-alt: ["J4a ALT-ONLY", "J4b ALT-ONLY"] +#| layout-ncol: 2 +plt.plot([1, 2]) +plt.show() +plt.plot([2, 1]) +plt.show() +``` + +```{python} +#| label: fig-j5 +#| fig-cap: "J5 caption" +plt.plot([1, 2]) +plt.show() +``` diff --git a/tests/docs/smoke-all/docx/figure-captions/knitr.qmd b/tests/docs/smoke-all/docx/figure-captions/knitr.qmd new file mode 100644 index 00000000000..14a7d8986a3 --- /dev/null +++ b/tests/docs/smoke-all/docx/figure-captions/knitr.qmd @@ -0,0 +1,64 @@ +--- +title: DOCX figure captions and alt text (knitr) +format: docx +engine: knitr +_quarto: + tests: + docx: + ensureDocxXpath: + - + # cross-referenced cell + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='Figure\u00a01: K1 caption']" + # unlabelled cell with fig-cap + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='K2 caption']" + # fig-subcap with layout-ncol: both subcaptions and the panel caption + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='(a) K4a sub caption']" + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='(b) K4b sub caption']" + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='Figure\u00a02: K4 caption']" + # cross-referenced cell without fig-alt + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='Figure\u00a03: K5 caption']" + # descr of a cross-referenced cell without fig-alt is empty + - "//w:tbl[.//w:t[contains(., 'K5 caption')]]//wp:docPr[@descr='']" + - + # alt text never becomes visible text + - "//w:t[contains(., 'ALT-ONLY')]" + - "//w:tbl[.//w:t[contains(., 'K5 caption')]]//wp:docPr[@descr!='']" +--- + +Pins how figure captions and alt text from knitr cells render in DOCX. +Visible captions must stay unchanged whatever the filter pipeline does with alt text, and every alt value carries a marker that must never appear in the document text. +The image description (`descr`) is checked only for the cell without `fig-alt`. + +```{r} +#| label: fig-k1 +#| fig-cap: "K1 caption" +#| fig-alt: "K1 ALT-ONLY" +plot(1) +``` + +```{r} +#| fig-cap: "K2 caption" +#| fig-alt: "K2 ALT-ONLY" +plot(2) +``` + +```{r} +#| fig-alt: "K3 ALT-ONLY" +plot(3) +``` + +```{r} +#| label: fig-k4 +#| fig-cap: "K4 caption" +#| fig-subcap: ["K4a sub caption", "K4b sub caption"] +#| fig-alt: ["K4a ALT-ONLY", "K4b ALT-ONLY"] +#| layout-ncol: 2 +plot(4) +plot(5) +``` + +```{r} +#| label: fig-k5 +#| fig-cap: "K5 caption" +plot(6) +``` diff --git a/tests/docs/smoke-all/docx/figure-captions/markdown.qmd b/tests/docs/smoke-all/docx/figure-captions/markdown.qmd new file mode 100644 index 00000000000..d6049f78083 --- /dev/null +++ b/tests/docs/smoke-all/docx/figure-captions/markdown.qmd @@ -0,0 +1,94 @@ +--- +title: DOCX figure captions and alt text (markdown) +format: docx +_quarto: + tests: + docx: + ensureDocxXpath: + - + # plain figure + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='C03 plain caption']" + # cross-referenced figure + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='Figure\u00a01: C04 xref caption']" + # subfigure panel: both subcaptions and the panel caption + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='(a) C05a sub caption']" + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='(b) C05b sub caption']" + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='Figure\u00a02: C05 panel caption']" + # div float with fig-alt on the div + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='Figure\u00a03: C06 div caption']" + # non-crossref layout-ncol + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='C07a layout caption']" + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='C07b layout caption']" + # empty fig-alt + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='C08 empty alt caption']" + # linked image + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='C09 linked caption']" + # fig-alt with markdown and XML special characters + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='C11 special caption']" + # cross-referenced figure with a rich caption keeps its emphasis + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][starts-with(string-join(.//w:t, ''), 'Figure\u00a04: C12 rich caption')][.//w:r[w:rPr/w:i]/w:t='rich']" + # image in a callout + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='C13 callout caption']" + # both fig-alt and alt + - "//w:p[w:pPr/w:pStyle/@w:val='ImageCaption'][string-join(.//w:t, '')='C14 both caption']" + # descr of images without fig-alt: plain figure uses its caption, cross-ref figure is empty + - "//wp:docPr[@descr='C03 plain caption']" + - "//w:tbl[.//w:t[contains(., 'C04 xref caption')]]//wp:docPr[@descr='']" + # descr of the callout icon is empty + - "//w:tbl[.//w:t[.='Note']]//wp:docPr[@descr='']" + - + # alt text never becomes visible text + - "//w:t[contains(., 'ALT-ONLY')]" + - "//w:tbl[.//w:t[contains(., 'C04 xref caption')]]//wp:docPr[@descr!='']" +--- + +Pins how figure captions and alt text render in DOCX for every way an image can reach the document. +Visible captions must stay unchanged whatever the filter pipeline does with alt text, and every alt value carries a marker that must never appear in the document text. +The image description (`descr`) is checked only for images without `fig-alt`. + +C01 inline image: text ![C01 inline caption](image.png){fig-alt="C01 ALT-ONLY"} text. + +C02 inline image with alt attribute: text ![C02 inline caption](image.png){alt="C02 ALT-ONLY"} text. + +![C03 plain caption](image.png) + +![C04 xref caption](image.png){#fig-c04} + +::: {#fig-c05 layout-ncol=2} + +![C05a sub caption](image.png){#fig-c05a fig-alt="C05a ALT-ONLY"} + +![C05b sub caption](image.png){#fig-c05b} + +C05 panel caption +::: + +::: {#fig-c06 fig-alt="C06 ALT-ONLY"} +![](image.png) + +C06 div caption +::: + +::: {layout-ncol=2} +![C07a layout caption](image.png){fig-alt="C07a ALT-ONLY"} + +![C07b layout caption](image.png) +::: + +![C08 empty alt caption](image.png){fig-alt=""} + +[![C09 linked caption](image.png){fig-alt="C09 ALT-ONLY"}](https://quarto.org) + +| col | +|-----| +| ![C10 table caption](image.png){fig-alt="C10 ALT-ONLY"} | + +![C11 special caption](image.png){fig-alt="C11 ALT-ONLY a *b* & d"} + +![C12 *rich* caption $x^2$](image.png){#fig-c12 fig-alt="C12 ALT-ONLY"} + +::: callout-note +![C13 callout caption](image.png){fig-alt="C13 ALT-ONLY"} +::: + +![C14 both caption](image.png){fig-alt="C14 ALT-ONLY fig-alt" alt="C14 ALT-ONLY alt"} From c6dd91cd37503c18c5caf7725f224fedae7958a3 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 20:51:09 +0200 Subject: [PATCH 2/2] Replace corrupt PNG fixture in DOCX caption tests The hand-built image.png had a bad IDAT CRC and zlib checksum, so the DOCX could embed an undecodable image while the XML assertions still passed. Reuse an existing valid 1x1 fixture instead. --- .../docs/smoke-all/docx/figure-captions/image.png | Bin 70 -> 70 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/docs/smoke-all/docx/figure-captions/image.png b/tests/docs/smoke-all/docx/figure-captions/image.png index 22a565bf6e0cb372365d07711dcce83775ed3d21..08cd6f2bfd1b53ec5a4db72bed55f40907e8bdfa 100644 GIT binary patch delta 31 lcmZ>Bo1i0=^5c8}KLc|cV?&flZ8ifCc)I$ztaD0e0sxw^310vJ delta 31 lcmZ>Bo1i20=lp>W@(ipDjQ>}kGwWmk0#8>zmvv4FO#r^v3oHNt