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
8 changes: 4 additions & 4 deletions packages/ooxml.js/README.md

Large diffs are not rendered by default.

69 changes: 49 additions & 20 deletions packages/ooxml.js/src/typed/document-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,20 +746,12 @@ describe("readXlsx / buildXlsxPackage: the xlsx DocumentTree boundary", () => {
};
}

it("reads general defined names and table objects into the tree's definitions table, excluding the two _xlnm names print settings already carry", () => {
const tree = readXlsx(workbookWithTablesAndNames());
expect(tree.definitions).toEqual({
"namedRange:TaxRate": {
kind: "namedRange",
name: "TaxRate",
refersTo: "Summary!$B$1",
},
"namedRange:ReportTitle": {
kind: "namedRange",
name: "ReportTitle",
refersTo: "Data!$A$1",
localSheetId: 0,
},
it("reads table objects into the tree's definitions table, with defined names riding the tree's own names field instead", () => {
const wide = readXlsx(workbookWithTablesAndNames());
if (wide.kind !== "spreadsheet") {
throw new Error("expected a spreadsheet DocumentTree");
}
expect(wide.definitions).toEqual({
"table:SalesTable": {
kind: "table",
name: "SalesTable",
Expand All @@ -768,15 +760,39 @@ describe("readXlsx / buildXlsxPackage: the xlsx DocumentTree boundary", () => {
columns: ["Item", "Amount"],
},
});
// The names field assembleTree spliced onto the root: every definedName including the _xlnm built-in, refersTo verbatim, localSheetId mapped onto scopeSheetIndex.
expect(wide.names).toEqual([
{ name: "TaxRate", refersTo: "Summary!$B$1" },
{ name: "ReportTitle", refersTo: "Data!$A$1", scopeSheetIndex: 0 },
{
name: "_xlnm.Print_Area",
refersTo: "Data!$A$1:$C$4",
scopeSheetIndex: 0,
},
]);
});

it("leaves the definitions field absent for a workbook carrying no general names and no tables (the kitchen-sink fixture carries only Print_Area/Print_Titles)", () => {
expect(
readXlsx(decodePackage(fixtureBytes("kitchen-sink.xlsx"))).definitions,
).toBeUndefined();
it("leaves the definitions field absent for a workbook carrying no tables (the kitchen-sink fixture's defined names all ride names, which the fixture's two _xlnm print names populate)", () => {
const wide = readXlsx(decodePackage(fixtureBytes("kitchen-sink.xlsx")));
if (wide.kind !== "spreadsheet") {
throw new Error("expected a spreadsheet DocumentTree");
}
expect(wide.definitions).toBeUndefined();
expect(wide.names).toEqual([
{
name: "_xlnm.Print_Area",
refersTo: "Data!$A$1:$I$20",
scopeSheetIndex: 0,
},
{
name: "_xlnm.Print_Titles",
refersTo: "Data!$A:$A,Data!$1:$1",
scopeSheetIndex: 0,
},
]);
});

it("writes the tree's own definitions table back out (ExaDev/documents.js#973): buildXlsxPackage closes the row flattenTree itself cannot carry, while the flat write pair still emits neither a general defined name nor an xl/tables part", () => {
it("writes the tree's own definitions table back out (ExaDev/documents.js#973): buildXlsxPackage closes the row flattenTree itself cannot carry, while the flat write pair still emits no xl/tables part", () => {
const pkg = workbookWithTablesAndNames();
const treePackage = buildXlsxPackage(readXlsx(pkg));
const flatPackage = buildXlsxPackageFromContent(readXlsxContent(pkg));
Expand All @@ -789,11 +805,24 @@ describe("readXlsx / buildXlsxPackage: the xlsx DocumentTree boundary", () => {
expect(flattenTree(readXlsx(pkg))).toEqual(readXlsxContent(pkg));
});

it("round-trips the tree's own definitions table through a real byte encode/decode: reading the freshly-built package back recovers the same general defined names and table object", () => {
it("round-trips the tree's own definitions table through a real byte encode/decode: reading the freshly-built package back recovers the same table object", () => {
const pkg = workbookWithTablesAndNames();
const rebuilt = decodePackage(
encodePackage(buildXlsxPackage(readXlsx(pkg))),
);
expect(readXlsx(rebuilt).definitions).toEqual(readXlsx(pkg).definitions);
});

it("round-trips the tree's own names field through a real byte encode/decode: reading the freshly-built package back recovers every defined name, scope and verbatim refersTo", () => {
const pkg = workbookWithTablesAndNames();
const rebuilt = decodePackage(
encodePackage(buildXlsxPackage(readXlsx(pkg))),
);
const reread = readXlsx(rebuilt);
const original = readXlsx(pkg);
if (reread.kind !== "spreadsheet" || original.kind !== "spreadsheet") {
throw new Error("expected spreadsheet DocumentTrees");
}
expect(reread.names).toEqual(original.names);
});
});
4 changes: 2 additions & 2 deletions packages/ooxml.js/src/typed/document-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,14 @@ export function readPptx(pkg: Package): DocumentTree {

// A decoded xlsx Package -> the tree-form DocumentTree, via readXlsxContent (the geometry- and print-settings-rich reader), which already returns a full ContentDocument envelope and so needs no envelope wrap here. Not to be confused with readXlsxWorkbook (typed/xlsx.ts): that is a different reading view of the same bytes -- cell values only, no write side, no ContentDocument shape to decompose.
//
// The one thing this reader carries that its flat half cannot: the workbook's general defined names and table/List objects ride the tree root's definitions table (typed/xlsx/definitions.ts), the landing document-schema.js's own verdict gives a sheet-scoped named range -- no block-flow extent to wrap, so a definitions entry naming its range, and the definitions facility is tree-only. flattenTree drops the table on the way back down (document-schema.js's own rule -- the flat ContentDocument structurally cannot carry it), so buildXlsxPackage passes it to buildXlsxPackageFromContent as a SEPARATE option (typed/xlsx/build.ts's own BuildXlsxContentOptions) rather than through the flattened content itself: the write pair now closes this row (ExaDev/documents.js#973), while buildXlsxPackageFromContent(readXlsxContent(pkg)) alone still emits no xl/tables part and only the two _xlnm print names, exactly as before.
// The one thing this reader carries that its flat half cannot: the workbook's table/List objects ride the tree root's definitions table (typed/xlsx/definitions.ts), the landing document-schema.js's own verdict gives a sheet-scoped construct with no block-flow extent to wrap. Defined names no longer need that landing -- they ride the ContentDocument's own names field, which assembleTree splices onto the root and flattenTree splices back, so they cross the flat/tree boundary natively and a tree round trip writes them exactly once. flattenTree still drops the definitions table on the way back down (document-schema.js's own rule -- the flat ContentDocument structurally cannot carry it), so buildXlsxPackage passes it to buildXlsxPackageFromContent as a SEPARATE option (typed/xlsx/build.ts's own BuildXlsxContentOptions) rather than through the flattened content itself: the write pair now closes this row (ExaDev/documents.js#973), while buildXlsxPackageFromContent(readXlsxContent(pkg)) alone still emits no xl/tables part, exactly as before.
export function readXlsx(pkg: Package): DocumentTree {
const definitions = readWorkbookDefinitions(pkg);
const tree = assembleTree(readXlsxContent(pkg));
return definitions === undefined ? tree : { ...tree, definitions };
}

// The inverse: a spreadsheet DocumentTree -> a complete, freshly-built xlsx Package. Exactly buildXlsxPackageFromContent's own fidelity, PLUS the tree's own definitions table (general defined names and Table/List objects, ExaDev/documents.js#973) -- flattenTree drops that table on the way to a flat ContentDocument, so it is threaded through as buildXlsxPackageFromContent's own options argument instead.
// The inverse: a spreadsheet DocumentTree -> a complete, freshly-built xlsx Package. Exactly buildXlsxPackageFromContent's own fidelity, PLUS the tree's own definitions table (Table/List objects, ExaDev/documents.js#973) -- flattenTree drops that table on the way to a flat ContentDocument, so it is threaded through as buildXlsxPackageFromContent's own options argument instead. The tree root's names field needs no such threading: it IS the flattened document's own names field, carried across by flattenTree itself.
export function buildXlsxPackage(document: DocumentTree): Package {
const content = flattenTree(document);
if (content.kind !== "spreadsheet") {
Expand Down
212 changes: 212 additions & 0 deletions packages/ooxml.js/src/typed/docx/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,116 @@ describe("readDocxContent: run text with tab/break", () => {
});
});

describe("readDocxContent: verticalAlign and direction (w:vertAlign/w:rtl/w:bidi)", () => {
it("reads w:vertAlign superscript/subscript onto ContentRun.verticalAlign, and w:rtl onto ContentRun.direction", () => {
const paragraph = el("w:p", {}, [
el("w:r", {}, [
el("w:rPr", {}, [el("w:vertAlign", { "w:val": "superscript" })]),
el("w:t", {}, [txt("above")]),
]),
el("w:r", {}, [
el("w:rPr", {}, [el("w:vertAlign", { "w:val": "subscript" })]),
el("w:t", {}, [txt("below")]),
]),
el("w:r", {}, [
el("w:rPr", {}, [el("w:rtl")]),
el("w:t", {}, [txt("right to left")]),
]),
el("w:r", {}, [
el("w:rPr", {}, [el("w:rtl", { "w:val": "0" })]),
el("w:t", {}, [txt("explicitly ltr")]),
]),
textRun("plain"),
]);
const doc = readDocxContent(paragraphPackage(paragraph));
const runs = firstParagraph(doc).runs;
expect(runs.map((run) => run.verticalAlign)).toEqual([
"superscript",
"subscript",
undefined,
undefined,
undefined,
]);
expect(runs.map((run) => run.direction)).toEqual([
undefined,
undefined,
"rtl",
"ltr",
undefined,
]);
});

it("reads a baseline vertAlign as the explicit override of an inherited position, stating nothing on the run", () => {
// The named character style supersedes its basedOn chain: the chain says superscript, the direct rPr turns it back off, and the resolved run carries no verticalAlign -- baseline, the schema's own spelling of the field's absence.
const styles = el("w:styles", {}, [
el(
"w:style",
{ "w:type": "paragraph", "w:styleId": "Normal", "w:default": "1" },
[],
),
el("w:style", { "w:type": "character", "w:styleId": "Sup" }, [
el("w:basedOn", { "w:val": "Normal" }),
el("w:rPr", {}, [el("w:vertAlign", { "w:val": "superscript" })]),
]),
]);
const paragraph = el("w:p", {}, [
el("w:r", {}, [
el("w:rPr", {}, [
el("w:rStyle", { "w:val": "Sup" }),
el("w:vertAlign", { "w:val": "baseline" }),
]),
el("w:t", {}, [txt("flattened")]),
]),
]);
const doc = readDocxContent(
paragraphPackage(paragraph, {
"word/styles.xml": { kind: "xml", nodes: [styles] },
}),
);
expect(firstParagraph(doc).runs[0]?.verticalAlign).toBeUndefined();
});

it("reads w:bidi onto ContentParagraph.direction, both on and explicitly off", () => {
const on = readDocxContent(
paragraphPackage(
el("w:p", {}, [
el("w:pPr", {}, [el("w:bidi")]),
textRun("rtl paragraph"),
]),
),
);
expect(firstParagraph(on).direction).toBe("rtl");
const off = readDocxContent(
paragraphPackage(
el("w:p", {}, [
el("w:pPr", {}, [el("w:bidi", { "w:val": "0" })]),
textRun("explicitly ltr paragraph"),
]),
),
);
expect(firstParagraph(off).direction).toBe("ltr");
});

it("round-trips verticalAlign, run direction, and paragraph direction through buildDocxPackageFromContent", () => {
const paragraph = el("w:p", {}, [
el("w:pPr", {}, [el("w:bidi")]),
el("w:r", {}, [
el("w:rPr", {}, [
el("w:vertAlign", { "w:val": "superscript" }),
el("w:rtl"),
]),
el("w:t", {}, [txt("everything at once")]),
]),
]);
const before = readDocxContent(paragraphPackage(paragraph));
const after = readDocxContent(buildDocxPackageFromContent(before));
const roundTripped = firstParagraph(after);
expect(roundTripped.direction).toBe("rtl");
expect(roundTripped.runs[0]?.verticalAlign).toBe("superscript");
expect(roundTripped.runs[0]?.direction).toBe("rtl");
});
});

describe("readDocxContent: tables", () => {
it("reads column widths and a horizontally-merged cell's colSpan and background", () => {
const doc = readDocxContent(buildFixturePackage());
Expand Down Expand Up @@ -1207,6 +1317,108 @@ describe("readDocxContent: images", () => {
});
});

describe("readDocxContent: lifted-image anchors (anchorRunIndex/anchorOffset)", () => {
function imageParts(): Package["parts"] {
return {
"word/_rels/document.xml.rels": {
kind: "xml",
nodes: [
rels([{ id: "rIdImg", type: IMAGE_REL, target: "media/image1.png" }]),
],
},
"word/media/image1.png": { kind: "binary", base64: TINY_PNG_BASE64 },
};
}

function imageRun(): XmlElement {
return el("w:r", {}, [
drawingElement("wp:inline", "rIdImg", "Anchored alt text"),
]);
}

it("anchors an image in its own run to the previous run at that run's full length", () => {
const paragraph = el("w:p", {}, [
el("w:r", {}, [el("w:t", {}, [txt("Hello ")])]),
imageRun(),
el("w:r", {}, [el("w:t", {}, [txt("World")])]),
]);
const doc = readDocxContent(paragraphPackage(paragraph, imageParts()));
// The image sits between two runs: anchor names the run whose text it followed (index 0, "Hello ") and the position after that run's whole text.
const image = asImage(doc.sections[0]?.blocks[1]);
expect(image.anchorRunIndex).toBe(0);
expect(image.anchorOffset).toBe(6);
});

it("anchors an image at the paragraph's very start to (0, 0)", () => {
const paragraph = el("w:p", {}, [
imageRun(),
el("w:r", {}, [el("w:t", {}, [txt("Trailing text")])]),
]);
const doc = readDocxContent(paragraphPackage(paragraph, imageParts()));
const image = asImage(doc.sections[0]?.blocks[1]);
expect(image.anchorRunIndex).toBe(0);
expect(image.anchorOffset).toBe(0);
});

it("anchors an image sharing a run with text to that run at the length of the text preceding it", () => {
const sharedRun = el("w:r", {}, [
el("w:t", {}, [txt("foo")]),
drawingElement("wp:inline", "rIdImg", "Mid-run alt text"),
el("w:t", {}, [txt("bar")]),
]);
const doc = readDocxContent(
paragraphPackage(el("w:p", {}, [sharedRun]), imageParts()),
);
const image = asImage(doc.sections[0]?.blocks[1]);
expect(image.anchorRunIndex).toBe(0);
expect(image.anchorOffset).toBe(3);
});

it("anchors an image inside a hyperlink through the run the walk emitted for it", () => {
const paragraph = el("w:p", {}, [
el("w:r", {}, [el("w:t", {}, [txt("See ")])]),
el("w:hyperlink", { "r:id": "rIdLink" }, [
el("w:r", {}, [el("w:t", {}, [txt("the proof")])]),
imageRun(),
]),
]);
const parts = imageParts();
const relsPart = parts["word/_rels/document.xml.rels"];
if (relsPart?.kind !== "xml") {
throw new Error("expected document rels");
}
relsPart.nodes = [
rels([
{ id: "rIdImg", type: IMAGE_REL, target: "media/image1.png" },
{
id: "rIdLink",
type: HYPERLINK_REL,
target: "https://example.invalid/",
external: true,
},
]),
];
const doc = readDocxContent(paragraphPackage(paragraph, parts));
// Runs as walked: [0] "See ", [1] "the proof" (hyperlink-wrapped), [2] the image's own empty run -- the anchor names run 1 at its full length.
const image = asImage(doc.sections[0]?.blocks[1]);
expect(image.anchorRunIndex).toBe(1);
expect(image.anchorOffset).toBe(9);
});

it("round-trips an end-of-paragraph image's anchor through buildDocxPackageFromContent", () => {
// The writer re-inlines a lifted image as the last run of its containing paragraph, so an image that already sat at the paragraph's end keeps its anchor through a round trip: (last run, that run's full length) is exactly where the written run lands.
const paragraph = el("w:p", {}, [
el("w:r", {}, [el("w:t", {}, [txt("Signed: ")])]),
imageRun(),
]);
const before = readDocxContent(paragraphPackage(paragraph, imageParts()));
const after = readDocxContent(buildDocxPackageFromContent(before));
const image = asImage(after.sections[0]?.blocks[1]);
expect(image.anchorRunIndex).toBe(0);
expect(image.anchorOffset).toBe(8);
});
});

// An inline OLE object's real-world spelling: a w:r carries a w:object whose w:dxaOrig/w:dyaOrig (twips) size it, whose v:shape > v:imagedata names the raster preview picture rendered in its place (a VML spelling this reader has no path for, so the preview contributes no image block), and whose o:OLEObject names the payload part through its own relationship. The payload relationship is parameterised so a test can point rIdOle at whatever part shape it needs (the ZIP-payload case targets the default embeddings/oleObject1.xlsx; the classic-OLE case retargets to a .bin; the linked case goes external) -- the fixture itself ships no embeddings part, so each test adds exactly the payload bytes it wants. extraRuns splices additional runs after the object run inside the same paragraph.
function oleObjectFixturePackage(
oleRel: { target: string; external?: boolean },
Expand Down
Loading