feat: release Office Tools 0.1.0
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 viewer’s 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user