Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
2922ffc
test(documents.js): raise findCellRegions' 200k-row test timeout
Mearman Sep 13, 2026
6dbfa55
test(documents.js): assert every rich ODS fixture cell's displayText …
Mearman Sep 14, 2026
6dbfacf
test(documents.js): remove four dead test-support fixture wrappers
Mearman Sep 14, 2026
e3cb2a3
test(documents.js): cover DocxTableCell.borders getter and heightPt u…
Mearman Sep 14, 2026
eb36a5b
test(documents.js): cover the replace-existing-value path for colSpan…
Mearman Sep 14, 2026
156bb72
test(documents.js): assert every literal in the empty docx scaffold
Mearman Sep 14, 2026
6cf8a46
test(documents.js): cover ensureColumnDefaultWidth/ensureRowDefaultHe…
Mearman Sep 14, 2026
5e89681
test(documents.js): add a print-settings.ts test suite and simplify i…
Mearman Sep 14, 2026
32f74e5
test(documents.js): assert every literal in the empty ods scaffold
Mearman Sep 14, 2026
8711d7e
fix(documents.js): narrow the undefined manifest:file-entry lookup in…
Mearman Sep 14, 2026
22681bc
fix(documents.js): drop the unobservable empty-string user-agent fall…
Mearman Sep 14, 2026
3959591
test(documents.js): cover applyOdfGeometry and buildTransformAttr dir…
Mearman Sep 14, 2026
fc4e631
test(documents.js): assert the .name of three named error classes
Mearman Sep 14, 2026
3f16112
test(documents.js): cover the PptEditor/writePptContent non-presentat…
Mearman Sep 14, 2026
3a22889
test(documents.js): assert collectDocumentFormulas' presentation loca…
Mearman Sep 14, 2026
0d99751
test(documents.js): assert odbTablesToSpreadsheetDocument's row-sizin…
Mearman Sep 14, 2026
adb296f
test(documents.js): cover odbReportGroupChain's held-no-group guard
Mearman Sep 14, 2026
18efd25
test(documents.js): assert findChildElement rejects a non-matching tag
Mearman Sep 14, 2026
69ca142
test(documents.js): cover CONTENT_READERS.markdown/readDocumentLayout…
Mearman Sep 14, 2026
f531534
test(documents.js): cover DOCUMENT_FORMAT_CODECS.xls/pdf write-half g…
Mearman Sep 14, 2026
620252b
fix(documents.js): drop dataCell's redundant empty-field inference guard
Mearman Sep 14, 2026
c383f88
test(documents.js): assert SvgInvalidUtf8Error's exact name and message
Mearman Sep 14, 2026
93a6af4
fix(documents.js): drop formatPathNumber's redundant zero/-0 special …
Mearman Sep 14, 2026
8a47ad1
test(documents.js): assert treeEmbeddedFontsOf returns undefined for …
Mearman Sep 14, 2026
e11c63f
test(documents.js): cover markdownBlock's table-recursion and formula…
Mearman Sep 14, 2026
b857e7c
test(documents.js): extract groupVectorsByShapePosition for direct te…
Mearman Sep 14, 2026
50e10fa
test(documents.js): assert decodeLegacyEmbeddedObject's isCompoundFil…
Mearman Sep 14, 2026
9410b4c
test(documents.js): export slidePathsInOrder and cover its two malfor…
Mearman Sep 14, 2026
bb8c6d1
test(documents.js): assert standardFontDocxBytes genuinely requests A…
Mearman Sep 14, 2026
9f91367
test(documents.js): export promoteBlock and cover its style/join boun…
Mearman Sep 14, 2026
b21c0fa
test(documents.js): pin richMarkdownText's own block boundaries exactly
Mearman Sep 14, 2026
e3f730d
test(documents.js): assert the root element tag of every createEmptyD…
Mearman Sep 14, 2026
1198a5c
test(documents.js): cover strike's default value and buildRun's remai…
Mearman Sep 14, 2026
ed83edc
test(documents.js): add direct coverage for spliceOut and registerIma…
Mearman Sep 14, 2026
1fc8564
test(documents.js): cover XlsEditor's two constructor guards
Mearman Sep 14, 2026
f2fba9a
test(documents.js): pin the Gregorian century-correction and tick-to-…
Mearman Sep 14, 2026
4350753
test(documents.js): combine translateVector's identical rect/ellipse/…
Mearman Sep 14, 2026
45b7914
test(documents.js): assert compareCellKeys' tie-breaking is a strict …
Mearman Sep 14, 2026
0b79c46
test(documents.js): drop syncOdfManifest's dead root-content.xml comp…
Mearman Sep 14, 2026
db76b0a
test(documents.js): export nextMediaIndex and cover its prefix/escapi…
Mearman Sep 14, 2026
42ac003
test(documents.js): export nextPictureIndex and cover it, mirroring o…
Mearman Sep 14, 2026
5f83c8f
test(documents.js): export allocateRelationshipId and assert the crea…
Mearman Sep 14, 2026
69691a5
test(documents.js): assert the created Types root's tag/xmlns and the…
Mearman Sep 14, 2026
3db9783
test(documents.js): add direct coverage for shiftItems, drop its dead…
Mearman Sep 14, 2026
6b2ebc0
test(documents.js): assert sourcePath on odp's recovered formula and …
Mearman Sep 14, 2026
2c3da3b
test(documents.js): spy on odf.js's decodeOdfText to assert the wrapp…
Mearman Sep 14, 2026
65d79af
test(documents.js): cover the table cell kind filter, width division,…
Mearman Sep 14, 2026
2835724
test(documents.js): add direct coverage for the bin.ts launcher entry…
Mearman Sep 14, 2026
e2d1154
test(documents.js): cover hasWritableMetadataOverride's four presence…
Mearman Sep 14, 2026
c33690d
test(documents.js): assert an empty keywords array omits cp:keywords
Mearman Sep 14, 2026
daa4e4f
test(documents.js): cover insertAfter's mid-container and reference-n…
Mearman Sep 14, 2026
ba54830
test(documents.js): add direct coverage for buildVectorShape's nvSpPr…
Mearman Sep 14, 2026
0d6e23b
test(documents.js): pin parseSvgLengthPt/parseSvgViewBox's trim, fini…
Mearman Sep 14, 2026
487dcb8
test(documents.js): add direct coverage for decimalToRational and red…
Mearman Sep 14, 2026
ce32b09
test(documents.js): add direct coverage for the mathml node-walking p…
Mearman Sep 14, 2026
9c772a2
fix(documents.js): drop readFirebirdBackup's unreachable schema-looku…
Mearman Sep 14, 2026
2dd35a7
test(documents.js): add direct coverage for OdgPage.remove and its po…
Mearman Sep 14, 2026
4328858
test(documents.js): cover buildRelativeTarget from a root-level part
Mearman Sep 14, 2026
af8ff55
fix(documents.js): make nextObjectIndex's running max unconditional
Mearman Sep 14, 2026
c3bf1d9
test(documents.js): cover shapeDetail's fallback label and a path's e…
Mearman Sep 14, 2026
d5adf1a
fix(documents.js): stop chaining a non-optional call off an optional …
Mearman Sep 14, 2026
f18fb42
test(documents.js): cover CONTENT_READERS.rtf's abort-signal forwarding
Mearman Sep 14, 2026
0597baa
fix(documents.js): make localName branchless to remove an equivalent …
Mearman Sep 14, 2026
4bc949b
test(documents.js): cover gcd(0, 0) and a leading-sign literal rejection
Mearman Sep 14, 2026
cf4a5ae
test(documents.js): cover mergeMetadata preserving subject and keywords
Mearman Sep 14, 2026
6f691d4
fix(documents.js): compare cell keys less-than-first to avoid equival…
Mearman Sep 14, 2026
b51a182
test(documents.js): pin nextPictureIndex's Pictures/ prefix check
Mearman Sep 14, 2026
377af5e
fix(documents.js): drop readOdpContent's redundant empty-groups guard
Mearman Sep 14, 2026
fe02913
test(documents.js): pin ensureDefaultContentType's tag check
Mearman Sep 14, 2026
3ad1c2a
test(documents.js): pin nextMediaIndex's escaping and prefix check
Mearman Sep 14, 2026
3942f27
fix(documents.js): remove insertInSchemaOrder's redundant not-in-orde…
Mearman Sep 14, 2026
c982860
fix(documents.js): drop the space-run scan's redundant length bound
Mearman Sep 14, 2026
5974883
test(documents.js): pin ods scaffold's root tags and namespace/styles…
Mearman Sep 14, 2026
672f1dd
test(documents.js): pin markdownBlock's inline vs display formula mar…
Mearman Sep 14, 2026
2e04346
test(documents.js): pin core-properties' declaration and xmlns attrib…
Mearman Sep 14, 2026
762e9bd
fix(documents.js): use one combined bound in buildRelativeTarget's scan
Mearman Sep 14, 2026
ecf8f09
test(documents.js): cover MarkdownEditor's guards, defaults, and geom…
Mearman Sep 14, 2026
920301e
test(documents.js): cover from-pdf's signal/images forwarding and out…
Mearman Sep 14, 2026
75198d8
test(documents.js): cover local converter's odf route guard and font-…
Mearman Sep 14, 2026
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
12 changes: 7 additions & 5 deletions packages/documents.js/src/bin-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ const RUNNERS: Readonly<Record<PackageManager, RunnerSpec>> = {
};

function detectPackageManager(userAgent: string | undefined): PackageManager {
const ua = userAgent ?? "";
if (ua.startsWith("yarn/")) {
// No npm_config_user_agent at all (Deno never sets it; running the bin via bare `node` sets nothing) falls back to npm exactly like every other unrecognised value below -- handled as its own branch, rather than defaulting `userAgent` to an empty string first, so there is no fallback string literal whose own value is unobservable (every one of the startsWith checks below is false for it) and therefore untestable.
if (userAgent === undefined) return "npm";

if (userAgent.startsWith("yarn/")) {
// Yarn classic (1.x) has no `dlx` subcommand -- it is Yarn Berry (2+) only -- so classic is treated as npm and runs through npx rather than a command that fails.
return ua.startsWith("yarn/1.") ? "npm" : "yarn";
return userAgent.startsWith("yarn/1.") ? "npm" : "yarn";
}
if (ua.startsWith("pnpm/")) return "pnpm";
if (ua.startsWith("bun/")) return "bun";
if (userAgent.startsWith("pnpm/")) return "pnpm";
if (userAgent.startsWith("bun/")) return "bun";
// npm, and any agent that doesn't identify itself (Deno doesn't set this env var at all; running the bin via bare `node` sets nothing), falls back to npx.
return "npm";
}
Expand Down
83 changes: 83 additions & 0 deletions packages/documents.js/src/bin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";

// bin.ts is a real executable entry point: importing it runs its top-level code immediately, which spawns a child process and calls process.exit. Every test here mocks node:child_process's spawnSync and stubs process.exit/argv/env before a fresh dynamic import, then restores them.

interface SpawnSyncCall {
readonly command: string;
readonly args: readonly string[];
readonly options: unknown;
}

async function runBin(
argv: readonly string[],
userAgent: string | undefined,
status: number | null,
): Promise<{ readonly call: SpawnSyncCall; readonly exitCode: unknown }> {
vi.resetModules();
let call: SpawnSyncCall | undefined;
vi.doMock("node:child_process", () => ({
spawnSync: (command: string, args: readonly string[], options: unknown) => {
call = { command, args, options };
return { status };
},
}));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
return undefined as never;
});
const originalArgv = process.argv;
const originalUserAgent = process.env.npm_config_user_agent;
process.argv = ["node", "documents.js", ...argv];
if (userAgent === undefined) {
delete process.env.npm_config_user_agent;
} else {
process.env.npm_config_user_agent = userAgent;
}
try {
await import("./bin");
} finally {
process.argv = originalArgv;
if (originalUserAgent === undefined) {
delete process.env.npm_config_user_agent;
} else {
process.env.npm_config_user_agent = originalUserAgent;
}
}
if (call === undefined) {
throw new Error("expected spawnSync to have been called");
}
const exitCode = exitSpy.mock.calls[0]?.[0];
exitSpy.mockRestore();
vi.doUnmock("node:child_process");
return { call, exitCode };
}

describe("bin", () => {
it("strips the node/script argv[0..1] before resolving dispatch, not the full argv", () => {
return runBin(["mcp"], "npm/10.2.4 node/v20", 0).then(({ call }) => {
// Without process.argv.slice(2), argv[0] would be "node" (not "mcp"), never triggering the mcp dispatch path -- this only resolves to document-mcp because the strip happened.
expect(call.command).toBe("npx");
expect(call.args).toEqual(["-y", "document-mcp"]);
});
});

it("spawns with the exact resolved args array and { stdio: 'inherit' } options", () => {
return runBin(["convert", "a.docx"], "npm/10.2.4 node/v20", 0).then(
({ call }) => {
expect(call.args).toEqual(["-y", "document-cli", "convert", "a.docx"]);
expect(call.options).toEqual({ stdio: "inherit" });
},
);
});

it("exits with the spawned process's own non-zero status, not always the same code", () => {
return runBin([], "npm/10.2.4 node/v20", 2).then(({ exitCode }) => {
expect(exitCode).toBe(2);
});
});

it("exits with 1 when spawnSync reports no status at all (e.g. killed by a signal)", () => {
return runBin([], "npm/10.2.4 node/v20", null).then(({ exitCode }) => {
expect(exitCode).toBe(1);
});
});
});
62 changes: 62 additions & 0 deletions packages/documents.js/src/codecs/read.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { CONTENT_READERS, readDocumentLayout } from "./read";
import { encodeMarkdownText } from "../markdown/text";

describe("CONTENT_READERS.markdown", () => {
it("forwards the images resolver through to readMarkdownContent", () => {
const resolver = vi.fn(() => undefined);
CONTENT_READERS.markdown(encodeMarkdownText("![alt](img.png)"), {
images: resolver,
});
expect(resolver).toHaveBeenCalledWith("img.png", expect.anything());
});

it("forwards the abort signal through to readMarkdownContent, which checks it before parsing", () => {
const controller = new AbortController();
controller.abort();
let caught: unknown;
try {
CONTENT_READERS.markdown(encodeMarkdownText("hi"), {
signal: controller.signal,
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(DOMException);
expect((caught as DOMException).name).toBe("AbortError");
});
});

describe("CONTENT_READERS.rtf", () => {
it("forwards the abort signal through to readRtfContent, which checks it before tokenizing", () => {
const controller = new AbortController();
controller.abort();
let caught: unknown;
try {
CONTENT_READERS.rtf(new TextEncoder().encode("{\\rtf1 hi}"), {
signal: controller.signal,
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(DOMException);
expect((caught as DOMException).name).toBe("AbortError");
});
});

describe("readDocumentLayout", () => {
it("forwards the signal option through to readPdf, which checks it before parsing", () => {
const controller = new AbortController();
controller.abort();
// A real "%PDF-" header but otherwise garbage bytes: readPdf checks the header first, then the abort signal, before it ever opens the document -- if the signal were not forwarded (an empty options object), this would fail trying to parse the document instead.
const bytes = new TextEncoder().encode("%PDF-1.4\n%garbage");
let caught: unknown;
try {
readDocumentLayout(bytes, { signal: controller.signal });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(DOMException);
expect((caught as DOMException).name).toBe("AbortError");
});
});
24 changes: 23 additions & 1 deletion packages/documents.js/src/codecs/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
import { PAGE_SIZE_LETTER } from "document-schema.js";
import { describe, expect, it } from "vitest";
import type { XlsContentDocument } from "xls-codec";
import { odsToXlsx } from "../convert/convert";
import { docxToPdf, odsToXlsx } from "../convert/convert";
import { readOdfFormulaContent } from "../odf/formula/read";
import { FRACTION_FORMULA, odfFormulaBytes } from "../test-support/odf";
import { minimalDocxBytes } from "../test-support/docx";
Expand Down Expand Up @@ -300,6 +300,18 @@ describe("DOCUMENT_FORMAT_CODECS: content read/write round trips", () => {
expect(roundTripped).toEqual(expected);
});

it("xls: content.write refuses a non-spreadsheet ContentDocument by name", () => {
const codec = requireContentCodec("xls");
const wordprocessing: ContentDocument = {
kind: "wordprocessing",
metadata: {},
sections: [],
};
expect(() => codec.write!(wordprocessing)).toThrow(
"DOCUMENT_FORMAT_CODECS.xls.content.write: expected a spreadsheet ContentDocument",
);
});

// Mirrors ppt-codec's own write.test.ts fixture shape. The writer's own scope is text-box slides only (see that package's README scope note); like pptx/odp above, a black-box substantive-text check is the right-scoped proof of wiring here rather than exact equality -- ppt-codec's own reader always reports PowerPoint's fixed default text insets (0.1in left/right, 0.05in top/bottom) regardless of what a shape actually carries, since it does not yet read a shape's own OfficeArtFOPT inset override (see read.ts's own DEFAULT_INSET_LEFT_RIGHT_PT/DEFAULT_INSET_TOP_BOTTOM_PT comment), a pre-existing, documented gap this registry wiring did not introduce.
it("ppt: read -> write -> read carries the source slide text through", () => {
const codec = requireContentCodec("ppt");
Expand Down Expand Up @@ -353,6 +365,16 @@ describe("DOCUMENT_FORMAT_CODECS: pdf has a layout codec, not a content codec",
expect(DOCUMENT_FORMAT_CODECS.pdf.content).toBeUndefined();
expect(DOCUMENT_FORMAT_CODECS.pdf.layout).toBeDefined();
});

it("layout.write forwards the abort signal through to writePdf's own per-page check", () => {
const codec = DOCUMENT_FORMAT_CODECS.pdf.layout!;
const layout = codec.read(docxToPdf(minimalDocxBytes()));
const controller = new AbortController();
controller.abort();
expect(() => {
codec.write(layout, { signal: controller.signal });
}).toThrow(DOMException);
});
});

describe("DOCUMENT_FORMAT_CODECS: xlsx has a content codec, no layout codec", () => {
Expand Down
22 changes: 22 additions & 0 deletions packages/documents.js/src/convert/bridges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,20 @@ describe("ods <-> xlsx: ods -> xlsx (one hop, the character-width-unit conversio
const original = odsContentOf(richOdsBytes());
const originalSheet = original.sheets[0]!;

// The source ODS fixture's own header row and every cell's rendered displayText: buildRichFixturePackage (test-support/ods.ts) writes a distinct text:p run for every cell alongside its office:value, and none of it is exercised by any assertion below (those check only the CONVERTED xlsx side's `.value`) -- so a header cell silently losing its label, or a cell's displayText silently losing its rendered text, would go undetected without checking the source fixture directly.
expect(cellAt(originalSheet, 0, 0)?.displayText).toBe("Name");
expect(cellAt(originalSheet, 0, 1)?.displayText).toBe("Amount");
expect(cellAt(originalSheet, 0, 2)?.displayText).toBe("Active");
expect(cellAt(originalSheet, 1, 0)?.displayText).toBe("Widget");
expect(cellAt(originalSheet, 1, 1)?.displayText).toBe("42.5");
expect(cellAt(originalSheet, 1, 2)?.displayText).toBe("TRUE");
expect(cellAt(originalSheet, 2, 0)?.displayText).toBe("15%");
expect(cellAt(originalSheet, 2, 1)?.displayText).toBe("$9.99");
expect(cellAt(originalSheet, 2, 2)?.displayText).toBe("2026-01-15");
expect(cellAt(originalSheet, 3, 0)?.displayText).toBe("14:30");
expect(cellAt(originalSheet, 3, 1)?.displayText).toBe("85");
expect(cellAt(originalSheet, 4, 0)?.displayText).toBe("Merged Cell");

const xlsxBytes = odsToXlsx(richOdsBytes());
const xlsx = xlsxContentOf(xlsxBytes);
const sheet = xlsx.sheets[0]!;
Expand All @@ -624,6 +638,14 @@ describe("ods <-> xlsx: ods -> xlsx (one hop, the character-width-unit conversio
kind: "string",
value: "Name",
});
expect(cellAt(sheet, 0, 1)?.value).toEqual({
kind: "string",
value: "Amount",
});
expect(cellAt(sheet, 0, 2)?.value).toEqual({
kind: "string",
value: "Active",
});
expect(cellAt(sheet, 1, 0)?.value).toEqual({
kind: "string",
value: "Widget",
Expand Down
20 changes: 19 additions & 1 deletion packages/documents.js/src/convert/convert-fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import type { FontSubstitution } from "pdf-codec";
import { createStandardFontMeasurer, loadMathFont, writePdf } from "pdf-codec";
const mathMetricsAt = (sizePt: number) => loadMathFont().metricsAt(sizePt);
import { decodePackage as decodeOdfPackage } from "odf.js";
import { encodePackage as encodeOoxmlPackage } from "ooxml.js";
import {
decodePackage as decodeOoxmlPackage,
encodePackage as encodeOoxmlPackage,
} from "ooxml.js";
import { openDocx } from "../edit/docx/editor";
import { openPptx } from "../edit/pptx/editor";
import { buildDocumentBytes } from "./from-package";
Expand Down Expand Up @@ -146,6 +149,21 @@ describe("X -> PDF: caller-supplied faces", () => {
});
expect(substitutions).toEqual([]);
});

it("standardFontDocxBytes genuinely requests Arial, not merely a request no vendored substitute happens to claim", () => {
const content = readDocxContent(
decodeOoxmlPackage(standardFontDocxBytes()),
);
if (content.kind !== "wordprocessing") {
throw new Error("expected a wordprocessing ContentDocument");
}
const paragraph = content.sections[0]?.blocks[0];
expect(
paragraph?.kind === "paragraph"
? paragraph.runs[0]?.fontFamily
: undefined,
).toBe("Arial");
});
});

// The backward-compatibility guarantee this phase had to keep: wiring a FontRegistry into all six conversions must not change a single byte of output for a document that embeds no fonts and asks for no family a vendored substitute claims. Each reference below reproduces the exact pre-registry pipeline -- createStandardFontMeasurer() into the format's own layout engine, then writePdf with no `fonts` option at all -- so this is a genuine before/after byte comparison rather than a self-consistency check of the new code against itself.
Expand Down
1 change: 1 addition & 0 deletions packages/documents.js/src/convert/document-fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,6 @@ describe("extractSourceFontsForFormat", () => {
);
}
expect(caught.format).toBe("xlsx");
expect(caught.name).toBe("UnsupportedFontSourceFormatError");
});
});
Loading