feat: release Office Tools 0.1.0

This commit is contained in:
2026-08-31 22:37:01 +02:00
commit a5524e27e3
90 changed files with 16328 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
export const MAX_SOURCE_BYTES = 100 * 1024 * 1024;
export const OFFICE_ACCEPT = [
".docx",
".odt",
".xlsx",
".ods",
".pptx",
".odp",
].join(",");
export type OfficeFormat = "docx" | "odt" | "xlsx" | "ods" | "pptx" | "odp";
export type OfficeFamily = "document" | "spreadsheet" | "presentation";
const FORMAT_BY_EXTENSION: Record<string, OfficeFormat> = {
docx: "docx",
odt: "odt",
xlsx: "xlsx",
ods: "ods",
pptx: "pptx",
odp: "odp",
};
const LEGACY_EXTENSIONS = new Set(["doc", "dot", "xls", "xlt", "ppt", "pps"]);
export class OfficeFileError extends Error {
readonly code:
"empty-file" | "file-too-large" | "legacy-format" | "unsupported-format";
constructor(
message: string,
code:
"empty-file" | "file-too-large" | "legacy-format" | "unsupported-format",
) {
super(message);
this.name = "OfficeFileError";
this.code = code;
}
}
export function extensionOf(name: string): string {
const lastDot = name.lastIndexOf(".");
return lastDot >= 0 ? name.slice(lastDot + 1).toLowerCase() : "";
}
export function detectOfficeFormat(
file: Pick<File, "name" | "size">,
): OfficeFormat {
if (file.size === 0) {
throw new OfficeFileError("This file is empty.", "empty-file");
}
if (file.size > MAX_SOURCE_BYTES) {
throw new OfficeFileError(
`This first viewer slice accepts files up to ${formatBytes(MAX_SOURCE_BYTES)}.`,
"file-too-large",
);
}
const extension = extensionOf(file.name);
if (LEGACY_EXTENSIONS.has(extension)) {
throw new OfficeFileError(
`.${extension} is a legacy binary Office format. Convert it to a modern OOXML or OpenDocument file before opening it here.`,
"legacy-format",
);
}
const format = FORMAT_BY_EXTENSION[extension];
if (!format) {
throw new OfficeFileError(
"Choose a DOCX, ODT, XLSX, ODS, PPTX, or ODP file.",
"unsupported-format",
);
}
return format;
}
export function familyForFormat(format: OfficeFormat): OfficeFamily {
if (format === "docx" || format === "odt") return "document";
if (format === "xlsx" || format === "ods") return "spreadsheet";
return "presentation";
}
export function formatLabel(format: OfficeFormat): string {
const labels: Record<OfficeFormat, string> = {
docx: "Word document",
odt: "OpenDocument text",
xlsx: "Excel workbook",
ods: "OpenDocument spreadsheet",
pptx: "PowerPoint presentation",
odp: "OpenDocument presentation",
};
return labels[format];
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KiB", "MiB", "GiB"];
let value = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && value >= 1024; index += 1) {
value /= 1024;
unit = units[index];
}
return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${unit}`;
}
+146
View File
@@ -0,0 +1,146 @@
import type { SafeZipArchive } from "./zip";
import { canonicalPackagePath } from "./zip";
import type { OdfAsset, OdfParseLimits } from "./types";
import { OdfParseError } from "./types";
const MEDIA_TYPES: Readonly<Record<string, string>> = Object.freeze({
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
bmp: "image/bmp",
tif: "image/tiff",
tiff: "image/tiff",
});
export class OdfReadContext {
readonly assets: OdfAsset[] = [];
readonly warnings: string[] = [];
readonly archive: SafeZipArchive;
readonly limits: OdfParseLimits;
readonly mediaTypes: ReadonlyMap<string, string>;
private readonly assetByPath = new Map<string, OdfAsset>();
private textChars = 0;
private cellCount = 0;
private totalAssetBytes = 0;
constructor(
archive: SafeZipArchive,
limits: OdfParseLimits,
mediaTypes: ReadonlyMap<string, string>,
) {
this.archive = archive;
this.limits = limits;
this.mediaTypes = mediaTypes;
}
consumeText(text: string): string {
this.textChars += text.length;
if (this.textChars > this.limits.maxTextChars) {
throw new OdfParseError(
"limit-exceeded",
`Document text exceeds the ${this.limits.maxTextChars}-character limit`,
);
}
return text;
}
consumeCells(count: number): void {
if (!Number.isSafeInteger(count) || count < 0) {
throw new OdfParseError(
"invalid-document",
"Invalid expanded cell count",
);
}
this.cellCount += count;
if (
!Number.isSafeInteger(this.cellCount) ||
this.cellCount > this.limits.maxCells
) {
throw new OdfParseError(
"limit-exceeded",
`Document exceeds the ${this.limits.maxCells}-cell limit`,
);
}
}
resolveAsset(href: string | undefined): OdfAsset {
const path = normalizeResourcePath(href);
const existing = this.assetByPath.get(path);
if (existing) return existing;
const storedPath = this.archive.pathLookup.get(path);
const bytes = storedPath ? this.archive.entries.get(storedPath) : undefined;
if (!bytes || !storedPath || storedPath.endsWith("/")) {
throw new OdfParseError(
"invalid-document",
`Referenced package asset does not exist: ${path}`,
);
}
if (this.assets.length >= this.limits.maxAssets) {
throw new OdfParseError(
"limit-exceeded",
`Document exceeds the ${this.limits.maxAssets}-asset limit`,
);
}
if (bytes.byteLength > this.limits.maxAssetBytes) {
throw new OdfParseError(
"limit-exceeded",
`Asset exceeds the per-asset size limit: ${path}`,
);
}
this.totalAssetBytes += bytes.byteLength;
if (this.totalAssetBytes > this.limits.maxTotalAssetBytes) {
throw new OdfParseError(
"limit-exceeded",
"Document assets exceed the total asset size limit",
);
}
const extension = storedPath.split(".").pop()?.toLowerCase() ?? "";
const mediaType =
this.mediaTypes.get(path) ||
this.mediaTypes.get(storedPath) ||
MEDIA_TYPES[extension] ||
"application/octet-stream";
if (!mediaType.startsWith("image/")) {
throw new OdfParseError(
"external-resource",
`Only local image assets may be rendered: ${path}`,
);
}
const data = bytes.buffer;
const asset: OdfAsset = {
id: `asset-${this.assets.length + 1}`,
path: storedPath,
mediaType,
byteLength: data.byteLength,
data,
};
this.assets.push(asset);
this.assetByPath.set(path, asset);
return asset;
}
}
export function normalizeResourcePath(href: string | undefined): string {
if (!href)
throw new OdfParseError(
"external-resource",
"An image has no package path",
);
const trimmed = href.trim();
if (
!trimmed ||
trimmed.startsWith("#") ||
trimmed.startsWith("//") ||
/^[a-z][a-z0-9+.-]*:/iu.test(trimmed)
) {
throw new OdfParseError(
"external-resource",
`External or active resource is prohibited: ${href}`,
);
}
const path = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
return canonicalPackagePath(path);
}
+26
View File
@@ -0,0 +1,26 @@
import { parsePreparedOdp } from "./odp";
import { parsePreparedOds } from "./ods";
import { parsePreparedOdt } from "./odt";
import { prepareOdfPackage } from "./package";
import type { OdfDocument, OdfParseOptions } from "./types";
export { DEFAULT_ODF_LIMITS } from "./limits";
export { parseOdp } from "./odp";
export { parseOds } from "./ods";
export { parseOdt } from "./odt";
export { OdfParseError } from "./types";
export type * from "./types";
export function parseOpenDocument(
input: ArrayBuffer,
options: OdfParseOptions = {},
): OdfDocument {
const prepared = prepareOdfPackage(input, options);
if (prepared.format === "odt") return parsePreparedOdt(prepared);
if (prepared.format === "ods") return parsePreparedOds(prepared);
return parsePreparedOdp(prepared);
}
export function collectOdfTransferables(document: OdfDocument): ArrayBuffer[] {
return document.assets.map((asset) => asset.data);
}
+30
View File
@@ -0,0 +1,30 @@
import type { OdfParseLimits } from "./types";
export const DEFAULT_ODF_LIMITS: Readonly<OdfParseLimits> = Object.freeze({
maxEntryBytes: 64 * 1024 * 1024,
maxTotalBytes: 256 * 1024 * 1024,
maxEntries: 4096,
maxCells: 250_000,
// LibreOffice commonly represents the unused tail of a sheet with repeats
// matching the application's full row or column capacity.
maxRepeat: 2_000_000,
maxXmlDepth: 128,
maxXmlNodes: 1_000_000,
maxTextChars: 10_000_000,
maxAssets: 1024,
maxAssetBytes: 64 * 1024 * 1024,
maxTotalAssetBytes: 128 * 1024 * 1024,
maxCompressionRatio: 1000,
});
export function resolveLimits(
overrides: Partial<OdfParseLimits> | undefined,
): OdfParseLimits {
const limits = { ...DEFAULT_ODF_LIMITS, ...overrides };
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new TypeError(`${name} must be a positive safe integer`);
}
}
return limits;
}
+165
View File
@@ -0,0 +1,165 @@
import { prepareOdfPackage, type PreparedOdfPackage } from "./package";
import { parseFrameImage, parseTextBlocks, parseTextTable } from "./text";
import type {
OdfParseOptions,
OdfPresentationDocument,
OdfPresentationShape,
OdfPresentationShapeType,
OdfPresentationSlide,
} from "./types";
import { OdfParseError } from "./types";
import {
NS,
attribute,
childElements,
childrenNamed,
firstChildNamed,
parseLength,
type XmlElement,
} from "./xml";
export function parseOdp(
input: ArrayBuffer,
options: Omit<OdfParseOptions, "expectedFormat"> = {},
): OdfPresentationDocument {
const prepared = prepareOdfPackage(input, {
...options,
expectedFormat: "odp",
});
return parsePreparedOdp(prepared);
}
export function parsePreparedOdp(
prepared: PreparedOdfPackage,
): OdfPresentationDocument {
if (prepared.format !== "odp") {
throw new OdfParseError(
"type-mismatch",
"Prepared package is not an ODP document",
);
}
const body = firstChildNamed(
prepared.content.documentElement!,
NS.office,
"body",
);
const presentation = body && firstChildNamed(body, NS.office, "presentation");
if (!presentation) {
throw new OdfParseError(
"type-mismatch",
"ODP content.xml does not contain an office:presentation document body",
);
}
const slides = childrenNamed(presentation, NS.draw, "page").map(
(page, index) => parseSlide(page, index, prepared.context),
);
return {
format: "odp",
mediaType: prepared.mediaType,
metadata: prepared.metadata,
styles: prepared.styles,
assets: prepared.context.assets,
warnings: prepared.context.warnings,
slides,
};
}
function parseSlide(
page: XmlElement,
index: number,
context: ReturnType<typeof prepareOdfPackage>["context"],
): OdfPresentationSlide {
const notesElement = firstChildNamed(page, NS.presentation, "notes");
const shapes: OdfPresentationShape[] = [];
let shapeIndex = 0;
for (const element of childElements(page)) {
if (element === notesElement || element.namespaceURI !== NS.draw) continue;
shapes.push(
parseShape(element, `slide-${index + 1}-shape-${++shapeIndex}`, context),
);
}
return {
name: attribute(page, NS.draw, "name") ?? `Slide ${index + 1}`,
styleName: attribute(page, NS.draw, "style-name"),
masterPageName: attribute(page, NS.draw, "master-page-name"),
layoutName: attribute(
page,
NS.presentation,
"presentation-page-layout-name",
),
shapes,
notes: notesElement ? parseTextBlocks(notesElement, context) : [],
};
}
function parseShape(
element: XmlElement,
fallbackId: string,
context: ReturnType<typeof prepareOdfPackage>["context"],
): OdfPresentationShape {
const image =
element.localName === "frame"
? parseFrameImage(element, context)
: undefined;
const textBox = firstChildNamed(element, NS.draw, "text-box");
const tableElement =
firstChildNamed(element, NS.table, "table") ??
(textBox ? firstChildNamed(textBox, NS.table, "table") : undefined);
const table = tableElement
? parseTextTable(tableElement, context)
: undefined;
const children: OdfPresentationShape[] = [];
if (element.localName === "g") {
let index = 0;
for (const child of childElements(element)) {
if (child.namespaceURI === NS.draw) {
children.push(parseShape(child, `${fallbackId}-${++index}`, context));
}
}
}
const blocks = textBox
? parseTextBlocks(textBox, context).filter(
(block) => block.kind !== "table",
)
: parseTextBlocks(element, context);
return {
id: attribute(element, NS.draw, "id") ?? fallbackId,
type: shapeType(
element,
image !== undefined,
table !== undefined,
textBox !== undefined,
),
name: attribute(element, NS.draw, "name"),
styleName: attribute(element, NS.draw, "style-name"),
textStyleName: attribute(element, NS.draw, "text-style-name"),
layer: attribute(element, NS.draw, "layer"),
x: parseLength(attribute(element, NS.svg, "x")),
y: parseLength(attribute(element, NS.svg, "y")),
width: parseLength(attribute(element, NS.svg, "width")),
height: parseLength(attribute(element, NS.svg, "height")),
transform: attribute(element, NS.draw, "transform"),
blocks,
image,
table,
children,
};
}
function shapeType(
element: XmlElement,
hasImage: boolean,
hasTable: boolean,
hasTextBox: boolean,
): OdfPresentationShapeType {
if (hasImage) return "image";
if (hasTable) return "table";
if (hasTextBox) return "text-box";
if (element.localName === "rect") return "rectangle";
if (element.localName === "ellipse" || element.localName === "circle")
return "ellipse";
if (element.localName === "line") return "line";
if (element.localName === "custom-shape") return "custom";
if (element.localName === "g") return "group";
return "unknown";
}
+249
View File
@@ -0,0 +1,249 @@
import { prepareOdfPackage, type PreparedOdfPackage } from "./package";
import { parseParagraph, parseTextBlocks } from "./text";
import type {
OdfParseOptions,
OdfSpreadsheetCell,
OdfSpreadsheetDocument,
OdfSpreadsheetRow,
OdfSpreadsheetSheet,
} from "./types";
import { OdfParseError } from "./types";
import {
NS,
attribute,
boundedRepeat,
childElements,
childrenNamed,
firstChildNamed,
positiveSpan,
type XmlElement,
} from "./xml";
export function parseOds(
input: ArrayBuffer,
options: Omit<OdfParseOptions, "expectedFormat"> = {},
): OdfSpreadsheetDocument {
const prepared = prepareOdfPackage(input, {
...options,
expectedFormat: "ods",
});
return parsePreparedOds(prepared);
}
export function parsePreparedOds(
prepared: PreparedOdfPackage,
): OdfSpreadsheetDocument {
if (prepared.format !== "ods") {
throw new OdfParseError(
"type-mismatch",
"Prepared package is not an ODS document",
);
}
const body = firstChildNamed(
prepared.content.documentElement!,
NS.office,
"body",
);
const spreadsheet = body && firstChildNamed(body, NS.office, "spreadsheet");
if (!spreadsheet) {
throw new OdfParseError(
"type-mismatch",
"ODS content.xml does not contain an office:spreadsheet document body",
);
}
const sheets = childrenNamed(spreadsheet, NS.table, "table").map(
(table, index) => parseSheet(table, index, prepared.context),
);
return {
format: "ods",
mediaType: prepared.mediaType,
metadata: prepared.metadata,
styles: prepared.styles,
assets: prepared.context.assets,
warnings: prepared.context.warnings,
sheets,
};
}
function parseSheet(
table: XmlElement,
sheetIndex: number,
context: ReturnType<typeof prepareOdfPackage>["context"],
): OdfSpreadsheetSheet {
const rowElements: XmlElement[] = [];
collectSheetRows(table, rowElements);
let rows: OdfSpreadsheetRow[] = [];
let rowIndex = 0;
let expandedCellCount = 0;
for (const rowElement of rowElements) {
const rowRepeat = boundedRepeat(
attribute(rowElement, NS.table, "number-rows-repeated"),
context.limits,
"Repeated spreadsheet row count",
);
const cells: OdfSpreadsheetCell[] = [];
let column = 0;
for (const cellElement of childElements(rowElement)) {
if (
cellElement.namespaceURI !== NS.table ||
(cellElement.localName !== "table-cell" &&
cellElement.localName !== "covered-table-cell")
) {
continue;
}
const columnRepeat = boundedRepeat(
attribute(cellElement, NS.table, "number-columns-repeated"),
context.limits,
"Repeated spreadsheet column count",
);
const cell = parseCell(cellElement, column, columnRepeat, context);
cells.push(cell);
column += columnRepeat;
if (!Number.isSafeInteger(column)) {
throw new OdfParseError(
"limit-exceeded",
"Expanded spreadsheet width is too large",
);
}
}
const lastMeaningfulCell = cells.findLastIndex(isMeaningfulCell);
cells.splice(lastMeaningfulCell + 1);
rows.push({
index: rowIndex,
rowRepeat,
styleName: attribute(rowElement, NS.table, "style-name"),
cells,
});
rowIndex += rowRepeat;
if (!Number.isSafeInteger(rowIndex)) {
throw new OdfParseError(
"limit-exceeded",
"Expanded spreadsheet row count is too large",
);
}
}
const lastMeaningfulRow = rows.findLastIndex((row) => row.cells.length > 0);
rows = rows.slice(0, lastMeaningfulRow + 1);
for (const row of rows) {
const lastCell = row.cells.at(-1);
const representedColumns = lastCell
? lastCell.column + lastCell.columnRepeat
: 0;
const expandedForRow = representedColumns * row.rowRepeat;
if (!Number.isSafeInteger(expandedForRow)) {
throw new OdfParseError(
"limit-exceeded",
"Expanded spreadsheet cell count is too large",
);
}
context.consumeCells(expandedForRow);
expandedCellCount += expandedForRow;
}
const expandedRowCount = rows.at(-1)
? rows.at(-1)!.index + rows.at(-1)!.rowRepeat
: 0;
return {
name: attribute(table, NS.table, "name") ?? `Sheet ${sheetIndex + 1}`,
styleName: attribute(table, NS.table, "style-name"),
protected: attribute(table, NS.table, "protected") === "true",
rows,
expandedRowCount,
expandedCellCount,
};
}
function isMeaningfulCell(cell: OdfSpreadsheetCell): boolean {
return (
cell.kind === "covered" ||
cell.columnSpan > 1 ||
cell.rowSpan > 1 ||
cell.formula !== undefined ||
cell.valueType !== undefined ||
cell.value !== undefined ||
cell.display !== "" ||
cell.paragraphs.length > 0 ||
(cell.annotation?.length ?? 0) > 0
);
}
function parseCell(
element: XmlElement,
column: number,
columnRepeat: number,
context: ReturnType<typeof prepareOdfPackage>["context"],
): OdfSpreadsheetCell {
const paragraphs = childrenNamed(element, NS.text, "p").map((paragraph) =>
parseParagraph(paragraph, context),
);
const annotation = firstChildNamed(element, NS.office, "annotation");
const display = paragraphs.map((paragraph) => paragraph.text).join("\n");
const valueType = attribute(element, NS.office, "value-type");
return {
kind: element.localName === "covered-table-cell" ? "covered" : "cell",
column,
columnRepeat,
columnSpan: positiveSpan(
attribute(element, NS.table, "number-columns-spanned"),
"Spreadsheet column span",
),
rowSpan: positiveSpan(
attribute(element, NS.table, "number-rows-spanned"),
"Spreadsheet row span",
),
styleName: attribute(element, NS.table, "style-name"),
formula: attribute(element, NS.table, "formula"),
valueType,
value: parseCellValue(element, valueType, display, context),
display,
paragraphs,
annotation: annotation ? parseTextBlocks(annotation, context) : undefined,
};
}
function parseCellValue(
element: XmlElement,
valueType: string | undefined,
display: string,
context: ReturnType<typeof prepareOdfPackage>["context"],
): string | number | boolean | undefined {
if (
valueType === "float" ||
valueType === "percentage" ||
valueType === "currency"
) {
const raw = attribute(element, NS.office, "value");
if (raw === undefined) return undefined;
const number = Number(raw);
return Number.isFinite(number) ? number : raw;
}
if (valueType === "boolean") {
const raw = attribute(element, NS.office, "boolean-value");
if (raw === "true") return true;
if (raw === "false") return false;
return raw;
}
if (valueType === "date") return attribute(element, NS.office, "date-value");
if (valueType === "time") return attribute(element, NS.office, "time-value");
if (valueType === "string") {
const value = attribute(element, NS.office, "string-value");
return value === undefined || value === display
? display
: context.consumeText(value);
}
return display || undefined;
}
function collectSheetRows(parent: XmlElement, result: XmlElement[]): void {
for (const child of childElements(parent)) {
if (child.namespaceURI !== NS.table) continue;
if (child.localName === "table-row") {
result.push(child);
} else if (
child.localName === "table-header-rows" ||
child.localName === "table-row-group" ||
child.localName === "table-rows"
) {
collectSheetRows(child, result);
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import { prepareOdfPackage, type PreparedOdfPackage } from "./package";
import { collectNotes, parseTextBlocks } from "./text";
import type { OdfParseOptions, OdfTextDocument } from "./types";
import { OdfParseError } from "./types";
import { NS, firstChildNamed } from "./xml";
export function parseOdt(
input: ArrayBuffer,
options: Omit<OdfParseOptions, "expectedFormat"> = {},
): OdfTextDocument {
const prepared = prepareOdfPackage(input, {
...options,
expectedFormat: "odt",
});
return parsePreparedOdt(prepared);
}
export function parsePreparedOdt(
prepared: PreparedOdfPackage,
): OdfTextDocument {
if (prepared.format !== "odt") {
throw new OdfParseError(
"type-mismatch",
"Prepared package is not an ODT document",
);
}
const body = firstChildNamed(
prepared.content.documentElement!,
NS.office,
"body",
);
const text = body && firstChildNamed(body, NS.office, "text");
if (!text) {
throw new OdfParseError(
"type-mismatch",
"ODT content.xml does not contain an office:text document body",
);
}
const blocks = parseTextBlocks(text, prepared.context);
return {
format: "odt",
mediaType: prepared.mediaType,
metadata: prepared.metadata,
styles: prepared.styles,
assets: prepared.context.assets,
warnings: prepared.context.warnings,
blocks,
notes: collectNotes(blocks),
};
}
+280
View File
@@ -0,0 +1,280 @@
import { OdfReadContext } from "./context";
import { resolveLimits } from "./limits";
import type {
OdfFormat,
OdfMetadata,
OdfParseOptions,
OdfStyle,
} from "./types";
import { OdfParseError } from "./types";
import {
NS,
attribute,
childElements,
descendantsNamed,
parseXmlPart,
type XmlDocument,
type XmlElement,
} from "./xml";
import { openSafeZip, type SafeZipArchive } from "./zip";
import { canonicalPackagePath } from "./zip";
const FORMAT_MEDIA_TYPES: Readonly<Record<OdfFormat, string>> = Object.freeze({
odt: "application/vnd.oasis.opendocument.text",
ods: "application/vnd.oasis.opendocument.spreadsheet",
odp: "application/vnd.oasis.opendocument.presentation",
});
export interface PreparedOdfPackage {
format: OdfFormat;
mediaType: string;
content: XmlDocument;
context: OdfReadContext;
metadata: OdfMetadata;
styles: OdfStyle[];
}
export function prepareOdfPackage(
input: ArrayBuffer,
options: OdfParseOptions = {},
): PreparedOdfPackage {
const limits = resolveLimits(options.limits);
const archive = openSafeZip(input, limits);
const mimeBytes = requireEntry(archive, "mimetype");
let mediaType: string;
try {
mediaType = new TextDecoder("utf-8", { fatal: true }).decode(mimeBytes);
} catch {
throw new OdfParseError(
"type-mismatch",
"The ODF mimetype entry is not valid UTF-8",
);
}
const format = formatForMediaType(mediaType);
const expectedFromName = inferFormatFromName(options.fileName);
const expected = options.expectedFormat ?? expectedFromName;
if (expected && expected !== format) {
throw new OdfParseError(
"type-mismatch",
`Package is ${format.toUpperCase()}, but ${expected.toUpperCase()} was expected`,
);
}
const manifestBytes = archive.entries.get("META-INF/manifest.xml");
const mediaTypes = manifestBytes
? parseManifest(manifestBytes, limits, archive)
: new Map<string, string>();
const declaredRootType = mediaTypes.get("/");
if (declaredRootType && declaredRootType !== mediaType) {
throw new OdfParseError(
"type-mismatch",
`Manifest media type ${declaredRootType} disagrees with ${mediaType}`,
);
}
const context = new OdfReadContext(archive, limits, mediaTypes);
if (!manifestBytes)
context.warnings.push("Package has no META-INF/manifest.xml");
const content = parseXmlPart(
requireEntry(archive, "content.xml"),
"content.xml",
limits,
);
const omittedObjects =
descendantsNamed(content.documentElement!, NS.draw, "object").length +
descendantsNamed(content.documentElement!, NS.draw, "object-ole").length;
if (omittedObjects > 0) {
context.warnings.push(
`${omittedObjects} embedded ${omittedObjects === 1 ? "object was" : "objects were"} kept inert and omitted`,
);
}
const metaBytes = archive.entries.get("meta.xml");
const metadata = metaBytes
? parseMetadata(parseXmlPart(metaBytes, "meta.xml", limits), context)
: emptyMetadata();
const styleDocuments: XmlDocument[] = [content];
const stylesBytes = archive.entries.get("styles.xml");
if (stylesBytes)
styleDocuments.unshift(parseXmlPart(stylesBytes, "styles.xml", limits));
const styles = parseStyles(styleDocuments);
return { format, mediaType, content, context, metadata, styles };
}
function requireEntry(
archive: SafeZipArchive,
path: string,
): Uint8Array<ArrayBuffer> {
const entry = archive.entries.get(path);
if (!entry)
throw new OdfParseError(
"missing-part",
`Required ODF package part is missing: ${path}`,
);
return entry;
}
function formatForMediaType(mediaType: string): OdfFormat {
for (const [format, expected] of Object.entries(FORMAT_MEDIA_TYPES)) {
if (mediaType === expected) return format as OdfFormat;
}
throw new OdfParseError(
"type-mismatch",
`Unsupported OpenDocument media type: ${mediaType || "(empty)"}`,
);
}
function inferFormatFromName(name: string | undefined): OdfFormat | undefined {
if (!name) return undefined;
const match = /\.([^.]+)$/u.exec(name.trim().toLowerCase());
const extension = match?.[1];
return extension === "odt" || extension === "ods" || extension === "odp"
? extension
: undefined;
}
function parseManifest(
bytes: Uint8Array<ArrayBuffer>,
limits: ReturnType<typeof resolveLimits>,
archive: SafeZipArchive,
): Map<string, string> {
const document = parseXmlPart(bytes, "META-INF/manifest.xml", limits);
if (
descendantsNamed(document.documentElement!, NS.manifest, "encryption-data")
.length > 0
) {
throw new OdfParseError(
"encrypted",
"Encrypted OpenDocument package entries are not supported",
);
}
const result = new Map<string, string>();
for (const entry of descendantsNamed(
document.documentElement!,
NS.manifest,
"file-entry",
)) {
const path = attribute(entry, NS.manifest, "full-path");
const mediaType = attribute(entry, NS.manifest, "media-type") ?? "";
if (!path) continue;
const canonicalPath = path === "/" ? "/" : canonicalPackagePath(path);
const implicitDirectory =
canonicalPath.endsWith("/") &&
[...archive.pathLookup.keys()].some((entryPath) =>
entryPath.startsWith(canonicalPath),
);
if (
path !== "/" &&
!archive.pathLookup.has(canonicalPath) &&
!implicitDirectory
) {
throw new OdfParseError(
"invalid-document",
`Manifest references a missing package entry: ${path}`,
);
}
result.set(canonicalPath, mediaType);
}
return result;
}
function emptyMetadata(): OdfMetadata {
return { keywords: [], statistics: {} };
}
function parseMetadata(
document: XmlDocument,
context: OdfReadContext,
): OdfMetadata {
const metadata = emptyMetadata();
const firstText = (
namespace: string,
localName: string,
): string | undefined => {
const element = descendantsNamed(
document.documentElement!,
namespace,
localName,
)[0];
const value = element?.textContent?.trim();
return value ? context.consumeText(value) : undefined;
};
metadata.title = firstText(NS.dc, "title");
metadata.subject = firstText(NS.dc, "subject");
metadata.description = firstText(NS.dc, "description");
metadata.creator = firstText(NS.dc, "creator");
metadata.initialCreator = firstText(NS.meta, "initial-creator");
metadata.language = firstText(NS.dc, "language");
metadata.createdAt = firstText(NS.meta, "creation-date");
metadata.modifiedAt = firstText(NS.dc, "date");
metadata.generator = firstText(NS.meta, "generator");
metadata.keywords = descendantsNamed(
document.documentElement!,
NS.meta,
"keyword",
)
.map((element) => element.textContent?.trim() ?? "")
.filter(Boolean)
.map((value) => context.consumeText(value));
const statistic = descendantsNamed(
document.documentElement!,
NS.meta,
"document-statistic",
)[0];
if (statistic) {
for (let index = 0; index < statistic.attributes.length; index += 1) {
const item = statistic.attributes.item(index);
if (!item || item.namespaceURI !== NS.meta || !/^\d+$/u.test(item.value))
continue;
const value = Number(item.value);
if (Number.isSafeInteger(value) && item.localName) {
const key = item.localName.replace(
/-([a-z])/gu,
(_match, letter: string) => letter.toUpperCase(),
);
metadata.statistics[key] = value;
}
}
}
return metadata;
}
function parseStyles(documents: readonly XmlDocument[]): OdfStyle[] {
const styles = new Map<string, OdfStyle>();
for (const document of documents) {
for (const element of descendantsNamed(
document.documentElement!,
NS.style,
"style",
)) {
const family = attribute(element, NS.style, "family");
const name = attribute(element, NS.style, "name");
if (!name) continue;
const style: OdfStyle = {
name,
family,
displayName: attribute(element, NS.style, "display-name"),
parentName: attribute(element, NS.style, "parent-style-name"),
nextName: attribute(element, NS.style, "next-style-name"),
listStyleName: attribute(element, NS.style, "list-style-name"),
masterPageName: attribute(element, NS.style, "master-page-name"),
properties: readStyleProperties(element),
};
styles.set(`${family ?? ""}\0${name}`, style);
}
}
return [...styles.values()];
}
function readStyleProperties(style: XmlElement): Record<string, string> {
const properties: Record<string, string> = {};
for (const child of childElements(style)) {
if (!child.localName?.endsWith("properties")) continue;
for (let index = 0; index < child.attributes.length; index += 1) {
const item = child.attributes.item(index);
if (!item || item.namespaceURI === "http://www.w3.org/2000/xmlns/")
continue;
properties[`${child.localName}.${item.nodeName}`] = item.value;
}
}
return properties;
}
+372
View File
@@ -0,0 +1,372 @@
import { normalizeResourcePath, OdfReadContext } from "./context";
import type {
OdfHeading,
OdfImage,
OdfList,
OdfNote,
OdfParagraph,
OdfTextBlock,
OdfTextRun,
OdfTextTable,
OdfTextTableCell,
} from "./types";
import { OdfParseError } from "./types";
import {
NS,
attribute,
boundedRepeat,
childElements,
childrenNamed,
directText,
firstChildNamed,
parseLength,
positiveSpan,
type XmlElement,
type XmlNode,
} from "./xml";
interface InlineResult {
runs: OdfTextRun[];
images: OdfImage[];
notes: OdfNote[];
}
export function parseTextBlocks(
parent: XmlNode,
context: OdfReadContext,
): OdfTextBlock[] {
const blocks: OdfTextBlock[] = [];
for (const element of childElements(parent)) {
if (element.namespaceURI === NS.text && element.localName === "p") {
blocks.push(parseParagraph(element, context));
} else if (element.namespaceURI === NS.text && element.localName === "h") {
blocks.push(parseHeading(element, context));
} else if (
element.namespaceURI === NS.text &&
element.localName === "list"
) {
blocks.push(parseList(element, context));
} else if (
element.namespaceURI === NS.table &&
element.localName === "table"
) {
blocks.push(parseTextTable(element, context));
} else if (
element.namespaceURI === NS.draw &&
element.localName === "frame"
) {
const image = parseFrameImage(element, context);
if (image) blocks.push(image);
const textBox = firstChildNamed(element, NS.draw, "text-box");
if (textBox) blocks.push(...parseTextBlocks(textBox, context));
} else if (
(element.namespaceURI === NS.text &&
(element.localName === "section" ||
element.localName === "list-header")) ||
(element.namespaceURI === NS.table &&
(element.localName === "table-header-rows" ||
element.localName === "table-row-group" ||
element.localName === "table-rows"))
) {
blocks.push(...parseTextBlocks(element, context));
}
}
return blocks;
}
export function parseParagraph(
element: XmlElement,
context: OdfReadContext,
): OdfParagraph {
const inline = parseInline(element, context);
return {
kind: "paragraph",
styleName: attribute(element, NS.text, "style-name"),
text: inline.runs.map((run) => run.text).join(""),
...inline,
};
}
function parseHeading(
element: XmlElement,
context: OdfReadContext,
): OdfHeading {
const inline = parseInline(element, context);
const levelValue = attribute(element, NS.text, "outline-level");
const level =
levelValue && /^\d+$/u.test(levelValue) ? Number(levelValue) : 1;
return {
kind: "heading",
level: Number.isSafeInteger(level) && level >= 1 ? Math.min(level, 10) : 1,
styleName: attribute(element, NS.text, "style-name"),
text: inline.runs.map((run) => run.text).join(""),
...inline,
};
}
function parseInline(
parent: XmlNode,
context: OdfReadContext,
inheritedStyle?: string,
inheritedLink?: string,
): InlineResult {
const result: InlineResult = { runs: [], images: [], notes: [] };
const append = (
text: string,
styleName = inheritedStyle,
link = inheritedLink,
) => {
if (!text) return;
context.consumeText(text);
const previous = result.runs[result.runs.length - 1];
if (
previous &&
previous.styleName === styleName &&
previous.link === link
) {
previous.text += text;
} else {
result.runs.push({ text, styleName, link });
}
};
for (let node = parent.firstChild; node; node = node.nextSibling) {
if (node.nodeType === 3 || node.nodeType === 4) {
append(node.nodeValue ?? "");
continue;
}
if (node.nodeType !== 1) continue;
const element = node as XmlElement;
if (element.namespaceURI === NS.text && element.localName === "s") {
const count = boundedRepeat(
attribute(element, NS.text, "c"),
context.limits,
"Repeated space count",
);
append(" ".repeat(count));
continue;
}
if (element.namespaceURI === NS.text && element.localName === "tab") {
append("\t");
continue;
}
if (
element.namespaceURI === NS.text &&
element.localName === "line-break"
) {
append("\n");
continue;
}
if (element.namespaceURI === NS.text && element.localName === "note") {
const note = parseNote(element, context);
result.notes.push(note);
if (note.citation) append(note.citation);
continue;
}
if (element.namespaceURI === NS.draw && element.localName === "frame") {
const image = parseFrameImage(element, context);
if (image) result.images.push(image);
continue;
}
let childStyle = inheritedStyle;
let childLink = inheritedLink;
if (element.namespaceURI === NS.text && element.localName === "span") {
childStyle = attribute(element, NS.text, "style-name") ?? inheritedStyle;
}
if (element.namespaceURI === NS.text && element.localName === "a") {
childLink = safeHyperlink(attribute(element, NS.xlink, "href"));
}
const nested = parseInline(element, context, childStyle, childLink);
for (const run of nested.runs) {
const previous = result.runs[result.runs.length - 1];
if (
previous &&
previous.styleName === run.styleName &&
previous.link === run.link
) {
previous.text += run.text;
} else {
result.runs.push(run);
}
}
result.images.push(...nested.images);
result.notes.push(...nested.notes);
}
return result;
}
function safeHyperlink(href: string | undefined): string | undefined {
if (!href) return undefined;
const value = href.trim();
if (value.startsWith("#") || /^(?:https?:|mailto:)/iu.test(value))
return value;
if (/^[a-z][a-z0-9+.-]*:/iu.test(value) || value.startsWith("//")) {
throw new OdfParseError(
"external-resource",
`Dangerous hyperlink scheme is prohibited: ${href}`,
);
}
return normalizeResourcePath(value);
}
function parseNote(element: XmlElement, context: OdfReadContext): OdfNote {
const noteClassValue = attribute(element, NS.text, "note-class");
const citationElement = firstChildNamed(element, NS.text, "note-citation");
const bodyElement = firstChildNamed(element, NS.text, "note-body");
const citation = citationElement
? (citationElement.textContent ?? "")
: undefined;
return {
id: attribute(element, NS.text, "id"),
noteClass: noteClassValue === "endnote" ? "endnote" : "footnote",
citation,
blocks: bodyElement ? parseTextBlocks(bodyElement, context) : [],
};
}
function parseList(element: XmlElement, context: OdfReadContext): OdfList {
const items = childrenNamed(element, NS.text, "list-item").map((item) => ({
blocks: parseTextBlocks(item, context),
}));
return {
kind: "list",
styleName: attribute(element, NS.text, "style-name"),
items,
};
}
export function parseTextTable(
element: XmlElement,
context: OdfReadContext,
): OdfTextTable {
const rowElements: XmlElement[] = [];
collectRows(element, rowElements);
const rows = rowElements.map((row) => {
const rowRepeat = boundedRepeat(
attribute(row, NS.table, "number-rows-repeated"),
context.limits,
"Repeated table row count",
);
const cells: OdfTextTableCell[] = [];
let expandedColumns = 0;
for (const cell of childElements(row)) {
if (
cell.namespaceURI !== NS.table ||
(cell.localName !== "table-cell" &&
cell.localName !== "covered-table-cell")
) {
continue;
}
const columnRepeat = boundedRepeat(
attribute(cell, NS.table, "number-columns-repeated"),
context.limits,
"Repeated table column count",
);
expandedColumns += columnRepeat;
if (!Number.isSafeInteger(expandedColumns)) {
throw new OdfParseError(
"limit-exceeded",
"Expanded table width is too large",
);
}
cells.push({
covered: cell.localName === "covered-table-cell",
styleName: attribute(cell, NS.table, "style-name"),
columnRepeat,
columnSpan: positiveSpan(
attribute(cell, NS.table, "number-columns-spanned"),
"Table column span",
),
rowSpan: positiveSpan(
attribute(cell, NS.table, "number-rows-spanned"),
"Table row span",
),
blocks: parseTextBlocks(cell, context),
});
}
context.consumeCells(expandedColumns * rowRepeat);
return {
styleName: attribute(row, NS.table, "style-name"),
rowRepeat,
cells,
};
});
return {
kind: "table",
name: attribute(element, NS.table, "name"),
styleName: attribute(element, NS.table, "style-name"),
rows,
};
}
function collectRows(parent: XmlElement, result: XmlElement[]): void {
for (const child of childElements(parent)) {
if (child.namespaceURI !== NS.table) continue;
if (child.localName === "table-row") {
result.push(child);
} else if (
child.localName === "table-header-rows" ||
child.localName === "table-row-group" ||
child.localName === "table-rows"
) {
collectRows(child, result);
}
}
}
export function parseFrameImage(
frame: XmlElement,
context: OdfReadContext,
): OdfImage | undefined {
const image =
frame.namespaceURI === NS.draw && frame.localName === "image"
? frame
: firstChildNamed(frame, NS.draw, "image");
if (!image) return undefined;
const asset = context.resolveAsset(attribute(image, NS.xlink, "href"));
const title =
firstChildNamed(frame, NS.svg, "title") ??
firstChildNamed(image, NS.svg, "title");
const description =
firstChildNamed(frame, NS.svg, "desc") ??
firstChildNamed(image, NS.svg, "desc");
const titleText = title ? directText(title).trim() || undefined : undefined;
const altText = description
? directText(description).trim() || undefined
: undefined;
if (titleText) context.consumeText(titleText);
if (altText) context.consumeText(altText);
return {
kind: "image",
assetId: asset.id,
path: asset.path,
mediaType: asset.mediaType,
title: titleText,
alt: altText,
width: parseLength(attribute(frame, NS.svg, "width")),
height: parseLength(attribute(frame, NS.svg, "height")),
x: parseLength(attribute(frame, NS.svg, "x")),
y: parseLength(attribute(frame, NS.svg, "y")),
styleName: attribute(frame, NS.draw, "style-name"),
};
}
export function collectNotes(blocks: readonly OdfTextBlock[]): OdfNote[] {
const result: OdfNote[] = [];
const visit = (block: OdfTextBlock) => {
if (block.kind === "paragraph" || block.kind === "heading") {
result.push(...block.notes);
for (const note of block.notes)
for (const child of note.blocks) visit(child);
} else if (block.kind === "list") {
for (const item of block.items)
for (const child of item.blocks) visit(child);
} else if (block.kind === "table") {
for (const row of block.rows) {
for (const cell of row.cells)
for (const child of cell.blocks) visit(child);
}
}
};
for (const block of blocks) visit(block);
return result;
}
+261
View File
@@ -0,0 +1,261 @@
export type OdfFormat = "odt" | "ods" | "odp";
export type OdfErrorCode =
| "invalid-zip"
| "unsupported-zip"
| "limit-exceeded"
| "encrypted"
| "invalid-path"
| "invalid-xml"
| "type-mismatch"
| "missing-part"
| "external-resource"
| "invalid-document";
export class OdfParseError extends Error {
readonly code: OdfErrorCode;
constructor(code: OdfErrorCode, message: string) {
super(message);
this.name = "OdfParseError";
this.code = code;
}
}
export interface OdfParseLimits {
maxEntryBytes: number;
maxTotalBytes: number;
maxEntries: number;
maxCells: number;
maxRepeat: number;
maxXmlDepth: number;
maxXmlNodes: number;
maxTextChars: number;
maxAssets: number;
maxAssetBytes: number;
maxTotalAssetBytes: number;
maxCompressionRatio: number;
}
export interface OdfParseOptions {
expectedFormat?: OdfFormat;
fileName?: string;
limits?: Partial<OdfParseLimits>;
}
export interface OdfMetadata {
title?: string;
subject?: string;
description?: string;
creator?: string;
initialCreator?: string;
language?: string;
createdAt?: string;
modifiedAt?: string;
generator?: string;
keywords: string[];
statistics: Record<string, number>;
}
export interface OdfStyle {
name: string;
family?: string;
displayName?: string;
parentName?: string;
nextName?: string;
listStyleName?: string;
masterPageName?: string;
properties: Record<string, string>;
}
export interface OdfAsset {
id: string;
path: string;
mediaType: string;
byteLength: number;
data: ArrayBuffer;
}
export interface OdfLength {
value: number;
unit: "cm" | "mm" | "in" | "pt" | "pc" | "px" | "%" | "unitless";
raw: string;
}
export interface OdfImage {
kind: "image";
assetId: string;
path: string;
mediaType: string;
alt?: string;
title?: string;
width?: OdfLength;
height?: OdfLength;
x?: OdfLength;
y?: OdfLength;
styleName?: string;
}
export interface OdfTextRun {
text: string;
styleName?: string;
link?: string;
}
export interface OdfParagraph {
kind: "paragraph";
styleName?: string;
text: string;
runs: OdfTextRun[];
images: OdfImage[];
notes: OdfNote[];
}
export interface OdfHeading {
kind: "heading";
level: number;
styleName?: string;
text: string;
runs: OdfTextRun[];
images: OdfImage[];
notes: OdfNote[];
}
export interface OdfNote {
id?: string;
noteClass: "footnote" | "endnote";
citation?: string;
blocks: OdfTextBlock[];
}
export interface OdfListItem {
blocks: OdfTextBlock[];
}
export interface OdfList {
kind: "list";
styleName?: string;
items: OdfListItem[];
}
export interface OdfTextTableCell {
covered: boolean;
styleName?: string;
columnRepeat: number;
columnSpan: number;
rowSpan: number;
blocks: OdfTextBlock[];
}
export interface OdfTextTableRow {
styleName?: string;
rowRepeat: number;
cells: OdfTextTableCell[];
}
export interface OdfTextTable {
kind: "table";
name?: string;
styleName?: string;
rows: OdfTextTableRow[];
}
export type OdfTextBlock =
OdfParagraph | OdfHeading | OdfList | OdfTextTable | OdfImage;
export interface OdfSpreadsheetCell {
kind: "cell" | "covered";
column: number;
columnRepeat: number;
columnSpan: number;
rowSpan: number;
styleName?: string;
formula?: string;
valueType?: string;
value?: string | number | boolean;
display: string;
paragraphs: OdfParagraph[];
annotation?: OdfTextBlock[];
}
export interface OdfSpreadsheetRow {
index: number;
rowRepeat: number;
styleName?: string;
cells: OdfSpreadsheetCell[];
}
export interface OdfSpreadsheetSheet {
name: string;
styleName?: string;
protected: boolean;
rows: OdfSpreadsheetRow[];
expandedRowCount: number;
expandedCellCount: number;
}
export type OdfPresentationShapeType =
| "text-box"
| "image"
| "table"
| "rectangle"
| "ellipse"
| "line"
| "custom"
| "group"
| "unknown";
export interface OdfPresentationShape {
id: string;
type: OdfPresentationShapeType;
name?: string;
styleName?: string;
textStyleName?: string;
layer?: string;
x?: OdfLength;
y?: OdfLength;
width?: OdfLength;
height?: OdfLength;
transform?: string;
blocks: OdfTextBlock[];
image?: OdfImage;
table?: OdfTextTable;
children: OdfPresentationShape[];
}
export interface OdfPresentationSlide {
name: string;
styleName?: string;
masterPageName?: string;
layoutName?: string;
shapes: OdfPresentationShape[];
notes: OdfTextBlock[];
}
interface OdfBaseModel {
format: OdfFormat;
mediaType: string;
metadata: OdfMetadata;
styles: OdfStyle[];
assets: OdfAsset[];
warnings: string[];
}
export interface OdfTextDocument extends OdfBaseModel {
format: "odt";
blocks: OdfTextBlock[];
notes: OdfNote[];
}
export interface OdfSpreadsheetDocument extends OdfBaseModel {
format: "ods";
sheets: OdfSpreadsheetSheet[];
}
export interface OdfPresentationDocument extends OdfBaseModel {
format: "odp";
slides: OdfPresentationSlide[];
}
export type OdfDocument =
OdfTextDocument | OdfSpreadsheetDocument | OdfPresentationDocument;
+255
View File
@@ -0,0 +1,255 @@
import {
DOMParser,
type Document as XmlDocument,
type Element as XmlElement,
type Node as XmlNode,
} from "@xmldom/xmldom";
import type { OdfLength, OdfParseLimits } from "./types";
import { OdfParseError } from "./types";
export type { XmlDocument, XmlElement, XmlNode };
export const NS = Object.freeze({
office: "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
text: "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
table: "urn:oasis:names:tc:opendocument:xmlns:table:1.0",
draw: "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
presentation: "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0",
style: "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
fo: "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
xlink: "http://www.w3.org/1999/xlink",
dc: "http://purl.org/dc/elements/1.1/",
meta: "urn:oasis:names:tc:opendocument:xmlns:meta:1.0",
manifest: "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0",
svg: "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0",
} as const);
const XML_DECLARATION_ATTACK = /<!\s*(?:DOCTYPE|ENTITY)|<\?xml-stylesheet/iu;
export function parseXmlPart(
bytes: Uint8Array,
path: string,
limits: OdfParseLimits,
): XmlDocument {
let source: string;
try {
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw new OdfParseError("invalid-xml", `${path} is not valid UTF-8 XML`);
}
if (XML_DECLARATION_ATTACK.test(source)) {
throw new OdfParseError(
"invalid-xml",
`${path} contains a prohibited DTD, entity, or XML stylesheet`,
);
}
let document: XmlDocument;
try {
document = new DOMParser({
locator: false,
onError(level, message) {
throw new Error(`${level}: ${message}`);
},
}).parseFromString(source, "application/xml");
} catch (error) {
const detail = error instanceof Error ? `: ${error.message}` : "";
throw new OdfParseError("invalid-xml", `Malformed XML in ${path}${detail}`);
}
if (!document.documentElement) {
throw new OdfParseError("invalid-xml", `${path} has no document element`);
}
validateStructure(
document.documentElement,
limits.maxXmlDepth,
limits.maxXmlNodes,
path,
);
rejectActiveXml(document.documentElement, path);
return document;
}
function validateStructure(
root: XmlElement,
maximumDepth: number,
maximumNodes: number,
path: string,
): void {
const stack: Array<{ node: XmlNode; depth: number }> = [
{ node: root, depth: 1 },
];
let nodes = 0;
while (stack.length > 0) {
const current = stack.pop();
if (!current) break;
nodes += 1;
if (nodes > maximumNodes) {
throw new OdfParseError(
"limit-exceeded",
`${path} exceeds the XML node limit of ${maximumNodes}`,
);
}
if (current.depth > maximumDepth) {
throw new OdfParseError(
"limit-exceeded",
`${path} exceeds the XML depth limit of ${maximumDepth}`,
);
}
for (
let child = current.node.firstChild;
child;
child = child.nextSibling
) {
stack.push({
node: child,
depth: current.depth + (child.nodeType === 1 ? 1 : 0),
});
}
}
}
function rejectActiveXml(root: XmlElement, path: string): void {
const prohibited = new Set(["script", "applet", "plugin", "floating-frame"]);
const stack = [root];
while (stack.length > 0) {
const element = stack.pop();
if (!element) break;
if (prohibited.has(element.localName ?? element.nodeName)) {
throw new OdfParseError(
"invalid-document",
`${path} contains prohibited active content: ${element.nodeName}`,
);
}
for (const child of childElements(element)) stack.push(child);
}
}
export function childElements(parent: XmlNode): XmlElement[] {
const result: XmlElement[] = [];
for (let node = parent.firstChild; node; node = node.nextSibling) {
if (node.nodeType === 1) result.push(node as XmlElement);
}
return result;
}
export function childrenNamed(
parent: XmlNode,
namespace: string,
localName: string,
): XmlElement[] {
return childElements(parent).filter(
(element) =>
element.namespaceURI === namespace && element.localName === localName,
);
}
export function firstChildNamed(
parent: XmlNode,
namespace: string,
localName: string,
): XmlElement | undefined {
return childElements(parent).find(
(element) =>
element.namespaceURI === namespace && element.localName === localName,
);
}
export function descendantsNamed(
parent: XmlNode,
namespace: string,
localName: string,
): XmlElement[] {
const result: XmlElement[] = [];
const stack = childElements(parent).reverse();
while (stack.length > 0) {
const element = stack.pop();
if (!element) break;
if (element.namespaceURI === namespace && element.localName === localName)
result.push(element);
const children = childElements(element);
for (let index = children.length - 1; index >= 0; index -= 1) {
const child = children[index];
if (child) stack.push(child);
}
}
return result;
}
export function attribute(
element: XmlElement,
namespace: string,
localName: string,
): string | undefined {
const value = element.getAttributeNS(namespace, localName);
return value === null || value === "" ? undefined : value;
}
export function attributeAnyNamespace(
element: XmlElement,
localName: string,
): string | undefined {
for (let index = 0; index < element.attributes.length; index += 1) {
const item = element.attributes.item(index);
if (item?.localName === localName && item.value !== "") return item.value;
}
return undefined;
}
export function boundedRepeat(
value: string | undefined,
limits: OdfParseLimits,
label: string,
): number {
if (value === undefined) return 1;
if (!/^[1-9]\d*$/u.test(value)) {
throw new OdfParseError(
"invalid-document",
`${label} must be a positive integer`,
);
}
const repeat = Number(value);
if (!Number.isSafeInteger(repeat) || repeat > limits.maxRepeat) {
throw new OdfParseError(
"limit-exceeded",
`${label} exceeds the repeat limit of ${limits.maxRepeat}`,
);
}
return repeat;
}
export function positiveSpan(value: string | undefined, label: string): number {
if (value === undefined) return 1;
if (!/^[1-9]\d*$/u.test(value)) {
throw new OdfParseError(
"invalid-document",
`${label} must be a positive integer`,
);
}
const span = Number(value);
if (!Number.isSafeInteger(span) || span > 1_000_000) {
throw new OdfParseError("limit-exceeded", `${label} is unreasonably large`);
}
return span;
}
export function parseLength(value: string | undefined): OdfLength | undefined {
if (!value) return undefined;
const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+))(cm|mm|in|pt|pc|px|%)?$/iu.exec(
value.trim(),
);
if (!match?.[1]) return undefined;
const numeric = Number(match[1]);
if (!Number.isFinite(numeric)) return undefined;
const rawUnit = match[2]?.toLowerCase();
const unit = (rawUnit ?? "unitless") as OdfLength["unit"];
return { value: numeric, unit, raw: value };
}
export function directText(element: XmlElement): string {
let result = "";
for (let node = element.firstChild; node; node = node.nextSibling) {
if (node.nodeType === 3 || node.nodeType === 4)
result += node.nodeValue ?? "";
}
return result;
}
+431
View File
@@ -0,0 +1,431 @@
import { unzipSync } from "fflate";
import type { OdfParseLimits } from "./types";
import { OdfParseError } from "./types";
const LOCAL_FILE_SIGNATURE = 0x04034b50;
const CENTRAL_FILE_SIGNATURE = 0x02014b50;
const END_SIGNATURE = 0x06054b50;
const ZIP64_END_SIGNATURE = 0x06064b50;
interface ZipEntryDescriptor {
path: string;
canonicalPath: string;
compressedSize: number;
uncompressedSize: number;
crc32: number;
method: number;
flags: number;
localOffset: number;
directory: boolean;
}
export interface SafeZipArchive {
entries: ReadonlyMap<string, Uint8Array<ArrayBuffer>>;
pathLookup: ReadonlyMap<string, string>;
descriptors: readonly ZipEntryDescriptor[];
}
function fail(
code:
| "invalid-zip"
| "unsupported-zip"
| "limit-exceeded"
| "encrypted"
| "invalid-path",
message: string,
): never {
throw new OdfParseError(code, message);
}
function ensureRange(
bytes: Uint8Array,
offset: number,
length: number,
label: string,
) {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset + length > bytes.length
) {
fail("invalid-zip", `${label} lies outside the ZIP file`);
}
}
function readU16(view: DataView, offset: number): number {
if (offset + 2 > view.byteLength)
fail("invalid-zip", "Truncated ZIP structure");
return view.getUint16(offset, true);
}
function readU32(view: DataView, offset: number): number {
if (offset + 4 > view.byteLength)
fail("invalid-zip", "Truncated ZIP structure");
return view.getUint32(offset, true);
}
function decodePath(bytes: Uint8Array): string {
if (bytes.length === 0 || bytes.length > 4096) {
fail("invalid-path", "A ZIP entry has an empty or excessively long path");
}
let path: string;
try {
path = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
fail("invalid-path", "A ZIP entry path is not valid UTF-8");
}
validatePackagePath(path);
return path;
}
export function validatePackagePath(path: string): void {
const directoryPath = path.endsWith("/") ? path.slice(0, -1) : path;
if (
!directoryPath ||
path.startsWith("/") ||
path.startsWith("\\") ||
path.includes("\\") ||
hasControlCharacter(path) ||
/^[a-z][a-z0-9+.-]*:/iu.test(path)
) {
fail("invalid-path", `Unsafe package path: ${JSON.stringify(path)}`);
}
const segments = directoryPath.split("/");
if (
segments.some(
(segment) => segment === "" || segment === "." || segment === "..",
)
) {
fail("invalid-path", `Unsafe package path: ${JSON.stringify(path)}`);
}
}
export function canonicalPackagePath(path: string): string {
let decoded: string;
try {
decoded = decodeURIComponent(path);
} catch {
fail(
"invalid-path",
`Malformed percent-encoding in package path: ${JSON.stringify(path)}`,
);
}
decoded = decoded.normalize("NFC");
validatePackagePath(decoded);
return decoded;
}
function hasControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0);
return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f);
});
}
function locateEnd(bytes: Uint8Array, view: DataView): number {
if (bytes.length < 22)
fail("invalid-zip", "The file is too short to be a ZIP package");
const earliest = Math.max(0, bytes.length - 65_557);
for (let offset = bytes.length - 22; offset >= earliest; offset -= 1) {
if (readU32(view, offset) !== END_SIGNATURE) continue;
const commentLength = readU16(view, offset + 20);
if (offset + 22 + commentLength === bytes.length) return offset;
}
fail("invalid-zip", "ZIP end-of-central-directory record was not found");
}
function inspectCentralDirectory(
bytes: Uint8Array,
limits: OdfParseLimits,
): ZipEntryDescriptor[] {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (readU32(view, 0) !== LOCAL_FILE_SIGNATURE) {
fail(
"invalid-zip",
"OpenDocument input must begin with a ZIP local-file header",
);
}
const endOffset = locateEnd(bytes, view);
if (
endOffset >= 20 &&
readU32(view, endOffset - 20) === ZIP64_END_SIGNATURE
) {
fail("unsupported-zip", "ZIP64 OpenDocument packages are not supported");
}
const disk = readU16(view, endOffset + 4);
const centralDisk = readU16(view, endOffset + 6);
const entriesOnDisk = readU16(view, endOffset + 8);
const entryCount = readU16(view, endOffset + 10);
const centralSize = readU32(view, endOffset + 12);
const centralOffset = readU32(view, endOffset + 16);
if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) {
fail("unsupported-zip", "Multi-disk ZIP packages are not supported");
}
if (
entryCount === 0xffff ||
centralSize === 0xffffffff ||
centralOffset === 0xffffffff
) {
fail("unsupported-zip", "ZIP64 OpenDocument packages are not supported");
}
if (entryCount === 0) fail("invalid-zip", "The ZIP package is empty");
if (entryCount > limits.maxEntries) {
fail(
"limit-exceeded",
`ZIP package contains ${entryCount} entries; the limit is ${limits.maxEntries}`,
);
}
ensureRange(bytes, centralOffset, centralSize, "ZIP central directory");
if (centralOffset + centralSize !== endOffset) {
fail(
"invalid-zip",
"ZIP central directory has an inconsistent size or offset",
);
}
const descriptors: ZipEntryDescriptor[] = [];
const paths = new Set<string>();
const canonicalPaths = new Set<string>();
const intervals: Array<[number, number]> = [];
let offset = centralOffset;
let totalSize = 0;
for (let index = 0; index < entryCount; index += 1) {
ensureRange(bytes, offset, 46, "ZIP central-directory entry");
if (readU32(view, offset) !== CENTRAL_FILE_SIGNATURE) {
fail("invalid-zip", `Invalid central-directory entry ${index + 1}`);
}
const madeBy = readU16(view, offset + 4);
const flags = readU16(view, offset + 8);
const method = readU16(view, offset + 10);
const crc32 = readU32(view, offset + 16);
const compressedSize = readU32(view, offset + 20);
const uncompressedSize = readU32(view, offset + 24);
const nameLength = readU16(view, offset + 28);
const extraLength = readU16(view, offset + 30);
const commentLength = readU16(view, offset + 32);
const startDisk = readU16(view, offset + 34);
const externalAttributes = readU32(view, offset + 38);
const localOffset = readU32(view, offset + 42);
const recordLength = 46 + nameLength + extraLength + commentLength;
ensureRange(bytes, offset, recordLength, "ZIP central-directory entry");
const path = decodePath(
bytes.subarray(offset + 46, offset + 46 + nameLength),
);
const canonicalPath = canonicalPackagePath(path);
if (paths.has(path))
fail("invalid-path", `Duplicate ZIP entry path: ${path}`);
if (canonicalPaths.has(canonicalPath)) {
fail(
"invalid-path",
`ZIP entry path collides after URI/Unicode normalization: ${path}`,
);
}
paths.add(path);
canonicalPaths.add(canonicalPath);
if (flags & 0x0001 || flags & 0x0040 || flags & 0x2000) {
fail("encrypted", `Encrypted ZIP entry is not supported: ${path}`);
}
if (flags & 0x0020)
fail("unsupported-zip", `Patched ZIP data is not supported: ${path}`);
if (method !== 0 && method !== 8) {
fail(
"unsupported-zip",
`ZIP compression method ${method} is not supported: ${path}`,
);
}
if (startDisk !== 0)
fail("unsupported-zip", "Multi-disk ZIP entries are not supported");
const directory = path.endsWith("/");
if (directory && (compressedSize !== 0 || uncompressedSize !== 0)) {
fail("invalid-zip", `Directory entry contains data: ${path}`);
}
const host = madeBy >>> 8;
if (host === 3) {
const fileType = (externalAttributes >>> 16) & 0xf000;
if (fileType !== 0 && fileType !== 0x4000 && fileType !== 0x8000) {
fail(
"unsupported-zip",
`Special or symbolic-link ZIP entry is not supported: ${path}`,
);
}
}
if (uncompressedSize > limits.maxEntryBytes) {
fail(
"limit-exceeded",
`ZIP entry exceeds the ${limits.maxEntryBytes}-byte limit: ${path}`,
);
}
totalSize += uncompressedSize;
if (!Number.isSafeInteger(totalSize) || totalSize > limits.maxTotalBytes) {
fail(
"limit-exceeded",
`ZIP expanded size exceeds the ${limits.maxTotalBytes}-byte limit`,
);
}
if (
uncompressedSize > 1024 &&
uncompressedSize / Math.max(1, compressedSize) >
limits.maxCompressionRatio
) {
fail(
"limit-exceeded",
`ZIP entry has an unsafe compression ratio: ${path}`,
);
}
ensureRange(bytes, localOffset, 30, `Local header for ${path}`);
if (readU32(view, localOffset) !== LOCAL_FILE_SIGNATURE) {
fail("invalid-zip", `Missing local-file header for ${path}`);
}
const localFlags = readU16(view, localOffset + 6);
const localMethod = readU16(view, localOffset + 8);
const localCrc32 = readU32(view, localOffset + 14);
const localCompressedSize = readU32(view, localOffset + 18);
const localUncompressedSize = readU32(view, localOffset + 22);
const localNameLength = readU16(view, localOffset + 26);
const localExtraLength = readU16(view, localOffset + 28);
const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
ensureRange(
bytes,
localOffset + 30,
localNameLength + localExtraLength,
`Local metadata for ${path}`,
);
const localPath = decodePath(
bytes.subarray(localOffset + 30, localOffset + 30 + localNameLength),
);
if (localPath !== path || localMethod !== method || localFlags !== flags) {
fail("invalid-zip", `Local and central metadata disagree for ${path}`);
}
if (
!(flags & 0x0008) &&
(localCrc32 !== crc32 ||
localCompressedSize !== compressedSize ||
localUncompressedSize !== uncompressedSize)
) {
fail(
"invalid-zip",
`Local and central size/CRC declarations disagree for ${path}`,
);
}
ensureRange(
bytes,
dataOffset,
compressedSize,
`Compressed data for ${path}`,
);
if (dataOffset + compressedSize > centralOffset) {
fail(
"invalid-zip",
`Compressed data overlaps the ZIP central directory: ${path}`,
);
}
intervals.push([localOffset, dataOffset + compressedSize]);
descriptors.push({
path,
canonicalPath,
compressedSize,
uncompressedSize,
crc32,
method,
flags,
localOffset,
directory,
});
offset += recordLength;
}
if (offset !== endOffset)
fail("invalid-zip", "ZIP central directory entry count is inconsistent");
intervals.sort((left, right) => left[0] - right[0]);
for (let index = 1; index < intervals.length; index += 1) {
const previous = intervals[index - 1];
const current = intervals[index];
if (previous && current && current[0] < previous[1]) {
fail("invalid-zip", "ZIP entries have overlapping local data ranges");
}
}
const first = descriptors[0];
if (
!first ||
first.path !== "mimetype" ||
first.localOffset !== 0 ||
first.method !== 0
) {
fail(
"invalid-zip",
"ODF requires an uncompressed mimetype entry first in the package",
);
}
return descriptors;
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
})();
function calculateCrc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) {
const lookup = CRC_TABLE[(crc ^ byte) & 0xff];
if (lookup === undefined) fail("invalid-zip", "CRC lookup failed");
crc = lookup ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
export function openSafeZip(
input: ArrayBuffer,
limits: OdfParseLimits,
): SafeZipArchive {
const bytes = new Uint8Array(input);
const descriptors = inspectCentralDirectory(bytes, limits);
let inflated: Record<string, Uint8Array<ArrayBufferLike>>;
try {
inflated = unzipSync(bytes);
} catch (error) {
const detail = error instanceof Error ? `: ${error.message}` : "";
fail("invalid-zip", `ZIP decompression failed${detail}`);
}
const entries = new Map<string, Uint8Array<ArrayBuffer>>();
const pathLookup = new Map<string, string>();
for (const descriptor of descriptors) {
const value = inflated[descriptor.path];
if (!value)
fail("invalid-zip", `ZIP entry could not be read: ${descriptor.path}`);
if (value.byteLength !== descriptor.uncompressedSize) {
fail(
"invalid-zip",
`Expanded size does not match the ZIP declaration: ${descriptor.path}`,
);
}
if (calculateCrc32(value) !== descriptor.crc32) {
fail("invalid-zip", `CRC check failed for ZIP entry: ${descriptor.path}`);
}
let owned: Uint8Array<ArrayBuffer>;
if (
value.buffer instanceof ArrayBuffer &&
value.byteOffset === 0 &&
value.byteLength === value.buffer.byteLength
) {
owned = value as Uint8Array<ArrayBuffer>;
} else {
owned = new Uint8Array(value.byteLength);
owned.set(value);
}
entries.set(descriptor.path, owned);
pathLookup.set(descriptor.canonicalPath, descriptor.path);
}
return { entries, pathLookup, descriptors };
}
+78
View File
@@ -0,0 +1,78 @@
type ErrorWithCode = Error & {
code?: string;
officeIssue?: { code?: string };
};
export interface ViewerErrorDescription {
code: string;
message: string;
passwordRequired: boolean;
}
export function describeOoxmlError(value: unknown): ViewerErrorDescription {
const error = value instanceof Error ? (value as ErrorWithCode) : undefined;
const code = error?.code ?? error?.officeIssue?.code ?? "open-failed";
switch (code) {
case "encrypted":
return {
code,
message:
"This file is password protected. Enter its password to open it locally.",
passwordRequired: true,
};
case "invalid-password":
return {
code,
message: "That password did not unlock the file. Please try again.",
passwordRequired: true,
};
case "unsupported-encryption":
return {
code,
message:
"This file uses an older encryption scheme that the local viewer cannot safely open.",
passwordRequired: false,
};
case "legacy-binary-format":
return {
code,
message: "This is a legacy binary Office file, not an OOXML document.",
passwordRequired: false,
};
case "not-ooxml":
return {
code,
message:
"The file extension says OOXML, but the package contents do not match that format.",
passwordRequired: false,
};
case "ooxml-resource-limit":
return {
code,
message:
"Opening stopped because the package exceeds the viewers decompression safety limits.",
passwordRequired: false,
};
case "ooxml-decoded-image-limit":
return {
code,
message:
"Opening stopped because an embedded image exceeds the safe decoded-image limit.",
passwordRequired: false,
};
case "parser-crashed":
return {
code,
message:
"The isolated document parser stopped while reading this file.",
passwordRequired: false,
};
default:
return {
code,
message: error?.message?.trim() || "The document could not be opened.",
passwordRequired: false,
};
}
}