Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/document-schema.js/src/content-json-schema-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
minimum: 0,
maximum: MAX_SAFE_INTEGER,
},
caption: { type: "string" },
floatPosition: { $ref: "#/$defs/ContentFloatPosition" },
sourcePath: { type: "string" },
source: { $ref: "#/$defs/SourceResidue" },
Expand Down Expand Up @@ -1456,6 +1457,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
minimum: 0,
maximum: MAX_SAFE_INTEGER,
},
caption: { type: "string" },
floatPosition: { $ref: "#/$defs/ContentFloatPosition" },
sourcePath: { type: "string" },
source: { $ref: "#/$defs/SourceResidue" },
Expand Down
1 change: 1 addition & 0 deletions packages/document-schema.js/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions packages/ooxml.js/src/typed/docx/figure-captions.test.ts
Original file line number Diff line number Diff line change
@@ -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),
);
});
});
64 changes: 64 additions & 0 deletions packages/ooxml.js/src/typed/docx/figure-captions.ts
Original file line number Diff line number Diff line change
@@ -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<number>();
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;
}
8 changes: 6 additions & 2 deletions packages/ooxml.js/src/typed/docx/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
NumberingDefinitionSchema,
readNumberingDefinitions,
} from "./numbering";
import { associateFigureCaptions } from "./figure-captions";
import { readCellShading } from "./shading";
import type {
ConstructExtent,
Expand Down Expand Up @@ -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),
]);
Expand Down Expand Up @@ -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,
),
};
}

Expand Down