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
59 changes: 47 additions & 12 deletions packages/archive-codec/README.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/archive-codec/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "archive-codec",
"version": "1.3.0",
"description": "ZIP-in-ZIP recursive walking with depth and cumulative decompressed-size guards, plus bounded classic OLE compound-file ([MS-CFB]) reading - zero document-format knowledge, the archive and container utility package for the documents.js family.",
"description": "ZIP-in-ZIP recursive walking with depth and cumulative decompressed-size guards, bounded classic OLE compound-file ([MS-CFB]) reading and writing, and [MS-OLEPS] Property Set Stream reading and writing - zero document-format knowledge, the archive and container utility package for the documents.js family.",
"type": "module",
"repository": {
"type": "git",
Expand Down Expand Up @@ -67,6 +67,7 @@
},
"packageManager": "pnpm@11.6.0",
"dependencies": {
"document-schema.js": "^5.5.0",
"fflate": "^0.8.2"
},
"devDependencies": {
Expand Down
21 changes: 21 additions & 0 deletions packages/archive-codec/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
isCompoundFile,
isZipArchive,
readCompoundFile,
readSummaryInformation,
unzipPackage,
writeCompoundFile,
writeSummaryInformationStream,
zipPackage,
walkArchive,
} from "./index";
Expand Down Expand Up @@ -49,4 +51,23 @@ describe("archive-codec barrel smoke", () => {
ArchiveWalkLimitError,
);
});

it("exposes the SummaryInformation property-set surface, composed with the compound-file surface", () => {
const summaryStream = writeSummaryInformationStream({
title: "Barrel smoke",
author: "archive-codec",
});
const bytes = writeCompoundFile([
{ path: "\x05SummaryInformation", bytes: summaryStream },
]);
const stream = readCompoundFile(bytes).find(
(s) => s.path === "\x05SummaryInformation",
);
if (stream === undefined) {
throw new Error("expected a \\x05SummaryInformation stream");
}
const metadata = readSummaryInformation(stream.bytes);
expect(metadata.title).toBe("Barrel smoke");
expect(metadata.author).toBe("archive-codec");
});
});
7 changes: 6 additions & 1 deletion packages/archive-codec/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
// The archive and container utility package for the documents.js family: ZIP container read/write, archive-format detection, recursive ZIP-in-ZIP walking under explicit depth and cumulative decompressed-size guards, and classic OLE compound-file (CFB) reading and writing including the OLE Package stream wrapper an embed's real file rides in -- with zero document-format knowledge (it knows bytes and container structure, never that any entry or stream is a document). Motivated by documents.js#564: OOXML embedded-object packages are genuinely separate ZIP blobs inside the outer ZIP, and nothing in the family recursed into them safely before this.
// The archive and container utility package for the documents.js family: ZIP container read/write, archive-format detection, recursive ZIP-in-ZIP walking under explicit depth and cumulative decompressed-size guards, classic OLE compound-file (CFB) reading and writing including the OLE Package stream wrapper an embed's real file rides in, and [MS-OLEPS] Property Set Stream reading and writing (generic, the SummaryInformation-specific mapping every legacy binary Office format's metadata lives in, and the LayoutMetadata mapping on top of that, shared across every codec since the mapping itself is format-agnostic) -- with zero document-format knowledge (it knows bytes and container structure, never that any entry or stream is a document). Motivated by documents.js#564: OOXML embedded-object packages are genuinely separate ZIP blobs inside the outer ZIP, and nothing in the family recursed into them safely before this.
export * from "./cfb/detect";
export * from "./cfb/ole-package";
export * from "./cfb/read";
export * from "./cfb/write";
export * from "./oleps/layout-metadata";
export * from "./oleps/read";
export * from "./oleps/summary-information";
export type { PropertySet, PropertyValue } from "./oleps/wire";
export * from "./oleps/write";
export * from "./zip/container";
export * from "./zip/detect";
export * from "./zip/walk";
122 changes: 122 additions & 0 deletions packages/archive-codec/src/oleps/layout-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { LayoutMetadata } from "document-schema.js";
import { describe, expect, it } from "vitest";
import type { SummaryInformationProperties } from "./summary-information";
import {
hasSummaryInformationFields,
layoutMetadataToSummaryInformation,
summaryInformationToLayoutMetadata,
} from "./layout-metadata";

// Direct unit coverage for the mapping doc-codec/xls-codec/ppt-codec's own metadata.ts modules used to duplicate three times before it moved here (ExaDev/documents.js#887 review finding 5). Each package's own write.test.ts still round-trips this through a real "\x05SummaryInformation" stream; these tests cover the mapping itself in isolation, including the two permanent gaps.

describe("summaryInformationToLayoutMetadata", () => {
it("maps every field SummaryInformation and LayoutMetadata share, including the lastSavedIso -> modifiedIso rename", () => {
const info: SummaryInformationProperties = {
title: "Quarterly report",
subject: "Finance",
author: "Joe",
keywords: ["finance", "quarterly"],
createdIso: "2024-01-15T09:00:00.000Z",
lastSavedIso: "2024-03-20T14:30:00.000Z",
};
expect(summaryInformationToLayoutMetadata(info)).toEqual({
title: "Quarterly report",
subject: "Finance",
author: "Joe",
keywords: ["finance", "quarterly"],
createdIso: "2024-01-15T09:00:00.000Z",
modifiedIso: "2024-03-20T14:30:00.000Z",
});
});

it("drops comments/lastPrintedIso, which LayoutMetadata has no field for", () => {
const info: SummaryInformationProperties = {
title: "Report",
comments: "Draft only",
lastPrintedIso: "2024-02-01T00:00:00.000Z",
};
const metadata = summaryInformationToLayoutMetadata(info);
expect(metadata).not.toHaveProperty("comments");
expect(metadata).not.toHaveProperty("lastPrintedIso");
});

it("copies keywords into a new array rather than aliasing the input", () => {
const keywords = ["a", "b"];
const info: SummaryInformationProperties = { keywords };
const metadata = summaryInformationToLayoutMetadata(info);
expect(metadata.keywords).toEqual(keywords);
expect(metadata.keywords).not.toBe(keywords);
});
});

describe("layoutMetadataToSummaryInformation", () => {
it("maps every field SummaryInformation and LayoutMetadata share, including the modifiedIso -> lastSavedIso rename", () => {
const metadata: LayoutMetadata = {
title: "Quarterly report",
subject: "Finance",
author: "Joe",
keywords: ["finance", "quarterly"],
createdIso: "2024-01-15T09:00:00.000Z",
modifiedIso: "2024-03-20T14:30:00.000Z",
};
expect(layoutMetadataToSummaryInformation(metadata)).toEqual({
title: "Quarterly report",
subject: "Finance",
author: "Joe",
keywords: ["finance", "quarterly"],
createdIso: "2024-01-15T09:00:00.000Z",
lastSavedIso: "2024-03-20T14:30:00.000Z",
});
});

it("drops creator/producer/language, which SummaryInformation has no field for", () => {
const metadata: LayoutMetadata = {
creator: "Some Tool",
producer: "Some Producer",
language: "en-GB",
};
const info = layoutMetadataToSummaryInformation(metadata);
expect(info).not.toHaveProperty("creator");
expect(info).not.toHaveProperty("producer");
expect(info).not.toHaveProperty("language");
});

it("does not validate createdIso/modifiedIso as real dates -- that is each caller's own responsibility", () => {
const metadata: LayoutMetadata = { createdIso: "not-a-real-date" };
expect(() => layoutMetadataToSummaryInformation(metadata)).not.toThrow();
expect(layoutMetadataToSummaryInformation(metadata).createdIso).toBe(
"not-a-real-date",
);
});
});

describe("hasSummaryInformationFields", () => {
it("is false for metadata carrying only fields SummaryInformation cannot hold", () => {
expect(
hasSummaryInformationFields({
creator: "Some Tool",
producer: "Some Producer",
language: "en-GB",
}),
).toBe(false);
});

it("is false for an empty metadata object", () => {
expect(hasSummaryInformationFields({})).toBe(false);
});

it("is false for an empty keywords array", () => {
expect(hasSummaryInformationFields({ keywords: [] })).toBe(false);
});

it.each([
["title", { title: "x" }],
["subject", { subject: "x" }],
["author", { author: "x" }],
["keywords", { keywords: ["x"] }],
["createdIso", { createdIso: "2024-01-15T09:00:00.000Z" }],
["modifiedIso", { modifiedIso: "2024-01-15T09:00:00.000Z" }],
])("is true when metadata carries %s", (_field, metadata) => {
expect(hasSummaryInformationFields(metadata)).toBe(true);
});
});
49 changes: 49 additions & 0 deletions packages/archive-codec/src/oleps/layout-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { LayoutMetadata } from "document-schema.js";
import type { SummaryInformationProperties } from "./summary-information";

// Maps between the seven fields ./summary-information.ts reads from and writes to a "\x05SummaryInformation" stream, and document-schema.js's own LayoutMetadata -- the shared metadata shape every codec's ContentDocument carries. This lives here rather than in doc-codec/xls-codec/ppt-codec because it used to be three byte-identical copies: LayoutMetadata is format-agnostic (document-schema.js is a foundation package, not tied to .doc/.xls/.ppt specifically), so nothing about this mapping is specific to any one of the three legacy binary formats that happen to consume it today.
//
// The mapping is not 1:1 in either direction, and each gap is a genuine, permanent one rather than a TODO:
// - SummaryInformation's `comments` and `lastPrintedIso` have no LayoutMetadata field to land in -- LayoutMetadata was designed around what every format in the family can supply, and no codec has a "last printed" or free-text "comments" concept, so these are read from the stream but never reach a ContentDocument.
// - LayoutMetadata's `creator`, `producer`, and `language` have no SummaryInformation equivalent to write into -- `producer` is a PDF-only concept in this schema, `creator`/`language` are not among the seven fields this package's own SummaryInformation support covers.
// - SummaryInformation's own `lastSavedIso` is LayoutMetadata's `modifiedIso`: the same fact ("when was this last written"), named differently by the two vocabularies.
//
// Deliberately does not validate createdIso/modifiedIso as real dates: this module has no error vocabulary of its own to report a malformed one through (see PropertySetFormatError's own read-side scope), and each caller reports that failure through its own named write-side error class (DocFormatError/BiffWriteError/PptUnsupportedContentError). A caller validates both fields itself, immediately before calling layoutMetadataToSummaryInformation.

export function summaryInformationToLayoutMetadata(
info: SummaryInformationProperties,
): LayoutMetadata {
return {
title: info.title,
subject: info.subject,
author: info.author,
keywords: info.keywords === undefined ? undefined : [...info.keywords],
createdIso: info.createdIso,
modifiedIso: info.lastSavedIso,
};
}

export function layoutMetadataToSummaryInformation(
metadata: LayoutMetadata,
): SummaryInformationProperties {
return {
title: metadata.title,
subject: metadata.subject,
author: metadata.author,
keywords: metadata.keywords,
createdIso: metadata.createdIso,
lastSavedIso: metadata.modifiedIso,
};
}

/** Whether a LayoutMetadata carries anything SummaryInformation can actually represent -- `creator`/`producer`/`language` alone should not force a stream carrying nothing but the CodePage property into existence, since a reader would see that back as `{}` regardless (see each caller's own write-side entry point). */
export function hasSummaryInformationFields(metadata: LayoutMetadata): boolean {
return (
metadata.title !== undefined ||
metadata.subject !== undefined ||
metadata.author !== undefined ||
(metadata.keywords !== undefined && metadata.keywords.length > 0) ||
metadata.createdIso !== undefined ||
metadata.modifiedIso !== undefined
);
}
Loading