From 1cb7d7b1555518738b364cc191be9a11162a84e7 Mon Sep 17 00:00:00 2001 From: fcbwilliams Date: Thu, 10 Sep 2026 15:34:46 +0200 Subject: [PATCH] feat(ooxml.js): associate a docx Caption paragraph with the figure it describes A figure's caption is usually the most informative text about it in the whole document -- more so than alt text, and unlike alt text it is usually actually present. The reader already surfaces the figure and the caption paragraph, but nothing connects them, so a consumer holding a `ContentImageBlock` has no way to know which of the surrounding paragraphs describes it. `associateFigureCaptions` records it on the image block's new optional `caption`, matching a `Caption`-styled paragraph -- what Word's Insert Caption produces -- case-insensitively, since `styleId` is documented as a producer's own spelling. **Associated, never moved or copied.** The caption paragraph stays exactly where it is: it is real prose the document contains, so removing it would lose text a reader expects to find, and copying it into the image would make every flat-text projection carry it twice. What the image gains is the association -- enough to caption a figure, describe it to a model, or use it as an accessible name. The paragraph below the figure wins, because that is where Word puts a figure caption; the one above is a fallback, since an author who typed their own often puts it there. A caption between two figures is claimed by the earlier one only, so the later is left uncaptioned rather than given words about someone else's figure. The pass is length-preserving by construction -- no block added, removed or reordered, only an image block replaced with a copy carrying `caption`. That is load-bearing: it runs immediately before `insertConstructMarkers` at both block-scope entry points, and the extent list handed alongside indexes into the same array. Applying it to each section's own slice also means a caption never associates across a section break, matching how an extent straddling one is dropped. `ContentSheetImage` extends `ContentImageBlockSchema`, so it inherits the field; both hand-authored JSON Schema fragments are updated to match (their live-`z.toJSONSchema()` comparison test is what caught it). Refs #1197, which asks for a fuller provenance/interpretation model -- this is the one piece of it that stands alone and needed no new concept. --- .../src/content-json-schema-defs.ts | 2 + packages/document-schema.js/src/content.ts | 1 + .../src/typed/docx/figure-captions.test.ts | 105 ++++++++++++++++++ .../src/typed/docx/figure-captions.ts | 64 +++++++++++ packages/ooxml.js/src/typed/docx/read.ts | 8 +- 5 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 packages/ooxml.js/src/typed/docx/figure-captions.test.ts create mode 100644 packages/ooxml.js/src/typed/docx/figure-captions.ts diff --git a/packages/document-schema.js/src/content-json-schema-defs.ts b/packages/document-schema.js/src/content-json-schema-defs.ts index c5ec7ca21..de3757578 100644 --- a/packages/document-schema.js/src/content-json-schema-defs.ts +++ b/packages/document-schema.js/src/content-json-schema-defs.ts @@ -496,6 +496,7 @@ export const CONTENT_DEFS: Record = { minimum: 0, maximum: MAX_SAFE_INTEGER, }, + caption: { type: "string" }, floatPosition: { $ref: "#/$defs/ContentFloatPosition" }, sourcePath: { type: "string" }, source: { $ref: "#/$defs/SourceResidue" }, @@ -1456,6 +1457,7 @@ export const CONTENT_DEFS: Record = { minimum: 0, maximum: MAX_SAFE_INTEGER, }, + caption: { type: "string" }, floatPosition: { $ref: "#/$defs/ContentFloatPosition" }, sourcePath: { type: "string" }, source: { $ref: "#/$defs/SourceResidue" }, diff --git a/packages/document-schema.js/src/content.ts b/packages/document-schema.js/src/content.ts index c840ef730..ccd048055 100644 --- a/packages/document-schema.js/src/content.ts +++ b/packages/document-schema.js/src/content.ts @@ -275,6 +275,7 @@ export const ContentImageBlockSchema = z.object({ original: ContentImageOriginalSchema.optional(), // the source's own compressed bytes for a no-encoder filter (JBIG2, JPEG 2000) -- see ContentImageOriginalSchema above. base64 stays the canonical decoded representation every consumer renders; a same-format writer re-embeds these bytes verbatim instead of re-encoding, and a cross-format consumer ignores the field entirely, since the only writers that can re-embed a JBIG2/JPX stream are the ones whose source format carried it anchorRunIndex: z.number().int().nonnegative().optional(), // for an image a reader LIFTED out of a paragraph's own run stream (media found inside a paragraph's runs, surfaced as its own sibling block because ContentRun has no field to carry it): the index of the run in that sibling paragraph's own runs array whose text the image originally followed. anchorOffset then names the character position within that run's text after which the image sat, so the position becomes recoverable rather than structural -- "the image in paragraph 12, after 'approved by'" instead of adjacency guesswork. The run whose text PRECEDES the image is the one named (an image at the paragraph's very start is (0, 0); one at its end is (last, last.text.length); one between two runs is (i, runs[i].text.length)); the paragraph itself is the sibling block the image was lifted into this list from, associated by adjacency exactly as before. Absent when the image was authored as its own block-level figure (the common case -- position within a paragraph is meaningless for it) or when the lifting reader does not know the position anchorOffset: z.number().int().nonnegative().optional(), // the character position within runs[anchorRunIndex].text after which the image sat -- always present with anchorRunIndex, never alone (the pair is one fact) + caption: z.string().optional(), // the visible caption written beside this figure (a docx Caption-styled paragraph, which is what Word's Insert Caption produces) -- ASSOCIATED with the image, never moved into it: the caption is real prose the document contains and stays its own paragraph block, so a flat-text projection carries it exactly once. Distinct from altText, which is invisible accessibility text existing nowhere else in the block list. Absent when the figure has no caption beside it, which is the common case floatPosition: ContentFloatPositionSchema.optional(), // this image's own source-native anchored position (docx w:drawing/wp:anchor; ODF draw:frame) -- absent for an inline image (docx wp:inline; ODF text:anchor-type="as-char"/"char"), which has no anchored position of its own to record, placed in block flow at the point it was encountered instead sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader source: SourceResidueSchema.optional(), // quarantined residue -- opaque text this format carries and no other format interprets (src/source.ts) diff --git a/packages/ooxml.js/src/typed/docx/figure-captions.test.ts b/packages/ooxml.js/src/typed/docx/figure-captions.test.ts new file mode 100644 index 000000000..f54ef759b --- /dev/null +++ b/packages/ooxml.js/src/typed/docx/figure-captions.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import type { ContentBlock, ContentImageBlock } from "document-schema.js"; +import { associateFigureCaptions } from "./figure-captions"; + +function paragraph(text: string, styleId?: string): ContentBlock { + return { + kind: "paragraph", + runs: text === "" ? [] : [{ text }], + ...(styleId === undefined ? {} : { styleId }), + }; +} + +function image(): ContentImageBlock { + return { + kind: "image", + format: "png", + base64: "iVBORw0KGgo=", + widthPt: 100, + heightPt: 80, + }; +} + +const captionsOf = (blocks: ContentBlock[]): (string | undefined)[] => + associateFigureCaptions(blocks) + .filter((block) => block.kind === "image") + .map((block) => block.caption); + +describe("associateFigureCaptions", () => { + it("takes the Caption-styled paragraph below the figure", () => { + const blocks = [ + paragraph("Body text"), + image(), + paragraph("Figure 1: Revenue by region", "Caption"), + ]; + + expect(captionsOf(blocks)).toEqual(["Figure 1: Revenue by region"]); + }); + + it("falls back to the one above when there is none below", () => { + const blocks = [ + paragraph("Figure 1: Current state", "Caption"), + image(), + paragraph("Body text"), + ]; + + expect(captionsOf(blocks)).toEqual(["Figure 1: Current state"]); + }); + + it("leaves the caption paragraph in place rather than moving or removing it", () => { + // The caption is real prose the document contains: a consumer projecting the block list to text + // must still find it, and must find it exactly once. + const blocks = [image(), paragraph("Figure 1: Revenue", "Caption")]; + + const result = associateFigureCaptions(blocks); + + expect(result).toHaveLength(2); + expect(result[1]).toEqual(paragraph("Figure 1: Revenue", "Caption")); + }); + + it("lets only one figure claim a caption sandwiched between two", () => { + const blocks = [ + image(), + paragraph("Figure 1: Only one of us gets this", "Caption"), + image(), + ]; + + expect(captionsOf(blocks)).toEqual([ + "Figure 1: Only one of us gets this", + undefined, + ]); + }); + + it("ignores an ordinary paragraph beside a figure, and a blank Caption-styled one", () => { + expect(captionsOf([image(), paragraph("Ordinary body prose")])).toEqual([ + undefined, + ]); + expect(captionsOf([image(), paragraph(" ", "Caption")])).toEqual([ + undefined, + ]); + }); + + it("matches the style id case-insensitively", () => { + // w:pStyle/@w:val is a producer's own spelling, and ContentParagraph.styleId documents it as such. + expect( + captionsOf([image(), paragraph("Figure 1: Lowercased", "caption")]), + ).toEqual(["Figure 1: Lowercased"]); + }); + + it("preserves the block count and order, which the extent indices depend on", () => { + const blocks = [ + paragraph("A"), + image(), + paragraph("Figure 1", "Caption"), + paragraph("B"), + image(), + ]; + + const result = associateFigureCaptions(blocks); + + expect(result).toHaveLength(blocks.length); + expect(result.map((block) => block.kind)).toEqual( + blocks.map((block) => block.kind), + ); + }); +}); diff --git a/packages/ooxml.js/src/typed/docx/figure-captions.ts b/packages/ooxml.js/src/typed/docx/figure-captions.ts new file mode 100644 index 000000000..606fc83be --- /dev/null +++ b/packages/ooxml.js/src/typed/docx/figure-captions.ts @@ -0,0 +1,64 @@ +import type { ContentBlock } from "document-schema.js"; + +// The style Word's Insert Caption applies. Matched case-insensitively because the style id a producer +// writes is its own spelling ("Caption", "caption", localised builds vary), and w:pStyle/@w:val is +// explicitly documented on ContentParagraph as producer-specific. +const CAPTION_STYLE_ID = "caption"; + +// Associates a Caption-styled paragraph with the figure it describes, recording it on the image block's +// own `caption` while leaving the paragraph itself exactly where it is. +// +// Associated rather than moved or copied, for two reasons. A caption is real prose the document +// contains, so removing it would lose text a reader expects to find; and copying it into the image as +// well would duplicate it in every flat-text projection and search index built from the block list. +// What the image gains is the *association* — enough for a consumer to caption a figure, describe it to +// a model, or use it as an accessible name, without the caption being said twice. +// +// The paragraph below the figure wins, because that is where Word's own Insert Caption puts a figure +// caption; the one above is a fallback, since an author who typed their own often puts it there. A +// caption between two figures is claimed by the earlier one only — the later is left uncaptioned rather +// than given words about someone else's figure. +// +// Length-preserving by construction: no block is added, removed or reordered, only an image block +// replaced with a copy carrying `caption`. That matters because the extent list this output is handed to +// (insertConstructMarkers) indexes into the same array. +export function associateFigureCaptions( + blocks: readonly ContentBlock[], +): ContentBlock[] { + const claimed = new Set(); + return blocks.map((block, index) => { + if (block.kind !== "image") { + return block; + } + for (const candidate of [index + 1, index - 1]) { + if (claimed.has(candidate)) { + continue; + } + const caption = captionTextAt(blocks, candidate); + if (caption !== undefined) { + claimed.add(candidate); + return { ...block, caption }; + } + } + return block; + }); +} + +// The text of blocks[index] when it is a non-empty Caption-styled paragraph, else undefined. +function captionTextAt( + blocks: readonly ContentBlock[], + index: number, +): string | undefined { + const block = blocks[index]; + if ( + block?.kind !== "paragraph" || + block.styleId?.toLowerCase() !== CAPTION_STYLE_ID + ) { + return undefined; + } + const text = block.runs + .map((run) => run.text) + .join("") + .trim(); + return text === "" ? undefined : text; +} diff --git a/packages/ooxml.js/src/typed/docx/read.ts b/packages/ooxml.js/src/typed/docx/read.ts index 624093bf1..c23822d6d 100644 --- a/packages/ooxml.js/src/typed/docx/read.ts +++ b/packages/ooxml.js/src/typed/docx/read.ts @@ -62,6 +62,7 @@ import { NumberingDefinitionSchema, readNumberingDefinitions, } from "./numbering"; +import { associateFigureCaptions } from "./figure-captions"; import { readCellShading } from "./shading"; import type { ConstructExtent, @@ -1561,7 +1562,7 @@ function readBlockScope( ): ContentBlock[] { const state = newFlowState(); collectFlowNodes(nodes, ctx, state, carryDeletions); - return insertConstructMarkers(state.blocks, [ + return insertConstructMarkers(associateFigureCaptions(state.blocks), [ ...state.extents, ...resolveRangeMarkerExtents(state.rangeMarkerEvents), ]); @@ -1661,7 +1662,10 @@ function readSections( pageSize, margins, ...(breakType === undefined ? {} : { breakType }), - blocks: insertConstructMarkers(state.blocks.slice(from, to), contained), + blocks: insertConstructMarkers( + associateFigureCaptions(state.blocks.slice(from, to)), + contained, + ), }; }