@@ -0,0 +1,203 @@
|
||||
import {
|
||||
stableStringify,
|
||||
stringifyCsv,
|
||||
triggerBlobDownload,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
import type {
|
||||
OdfDocument,
|
||||
OdfPresentationShape,
|
||||
OdfSpreadsheetSheet,
|
||||
OdfTextBlock,
|
||||
} from "./odf";
|
||||
|
||||
const MAX_EXPORT_ROWS = 50_000;
|
||||
const MAX_EXPORT_CELLS = 250_000;
|
||||
const MAX_EXPORT_TEXT = 5_000_000;
|
||||
|
||||
export function downloadExactCopy(file: File): void {
|
||||
triggerBlobDownload(file, file.name || "office-source.bin");
|
||||
}
|
||||
|
||||
export function downloadOdfSemanticExport(
|
||||
document: OdfDocument,
|
||||
sourceName: string,
|
||||
activeSheetIndex = 0,
|
||||
): void {
|
||||
const base = withoutExtension(sourceName) || "office-export";
|
||||
if (document.format === "ods") {
|
||||
const sheet = document.sheets[activeSheetIndex];
|
||||
if (!sheet) throw new Error("The selected workbook has no active sheet.");
|
||||
const csv = sheetToCsv(sheet);
|
||||
triggerBlobDownload(
|
||||
new Blob(["\uFEFF", csv], { type: "text/csv;charset=utf-8" }),
|
||||
`${base}-${safePart(sheet.name)}.csv`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const text =
|
||||
document.format === "odt"
|
||||
? [
|
||||
blocksToText(document.blocks),
|
||||
...document.notes.map((note) => blocksToText(note.blocks)),
|
||||
...document.annotations.map((comment) =>
|
||||
blocksToText(comment.blocks),
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: presentationToText(document.slides);
|
||||
if (text.length > MAX_EXPORT_TEXT)
|
||||
throw new Error(
|
||||
"The semantic text export exceeds the 5,000,000-character limit.",
|
||||
);
|
||||
triggerBlobDownload(
|
||||
new Blob([text], { type: "text/plain;charset=utf-8" }),
|
||||
`${base}-${document.format === "odt" ? "text" : "outline"}.txt`,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadOdfModel(
|
||||
document: OdfDocument,
|
||||
sourceName: string,
|
||||
): void {
|
||||
const { assets, ...model } = document;
|
||||
const source = stableStringify(
|
||||
{
|
||||
schema: "de.add-ideas.office-tools.odf-inspection.v1",
|
||||
sourceName,
|
||||
...model,
|
||||
assets: assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
path: asset.path,
|
||||
mediaType: asset.mediaType,
|
||||
byteLength: asset.byteLength,
|
||||
})),
|
||||
},
|
||||
2,
|
||||
{ maxDepth: 160, maxNodes: 500_000, maxTextChars: MAX_EXPORT_TEXT },
|
||||
);
|
||||
triggerBlobDownload(
|
||||
new Blob([source, "\n"], { type: "application/json;charset=utf-8" }),
|
||||
`${withoutExtension(sourceName) || "office"}-inspection.json`,
|
||||
);
|
||||
}
|
||||
|
||||
export function sheetToCsv(sheet: OdfSpreadsheetSheet): string {
|
||||
if (sheet.expandedRowCount > MAX_EXPORT_ROWS)
|
||||
throw new Error(
|
||||
`This sheet has ${sheet.expandedRowCount.toLocaleString()} rows; CSV export is limited to ${MAX_EXPORT_ROWS.toLocaleString()} rows.`,
|
||||
);
|
||||
const rows: string[][] = [];
|
||||
let cellCount = 0;
|
||||
for (const sourceRow of sheet.rows) {
|
||||
for (let repeat = 0; repeat < sourceRow.rowRepeat; repeat += 1) {
|
||||
const row: string[] = [];
|
||||
for (const cell of sourceRow.cells) {
|
||||
for (let offset = 0; offset < cell.columnRepeat; offset += 1) {
|
||||
const column = cell.column + offset;
|
||||
if (column >= 256) break;
|
||||
row[column] = cell.kind === "covered" ? "" : cellDisplay(cell);
|
||||
}
|
||||
}
|
||||
while (row.at(-1) === "") row.pop();
|
||||
cellCount += row.length;
|
||||
if (cellCount > MAX_EXPORT_CELLS)
|
||||
throw new Error(
|
||||
`CSV export is limited to ${MAX_EXPORT_CELLS.toLocaleString()} populated-range cells.`,
|
||||
);
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
return stringifyCsv(rows, {
|
||||
maxRows: MAX_EXPORT_ROWS,
|
||||
maxColumns: 256,
|
||||
maxFieldChars: 1_000_000,
|
||||
});
|
||||
}
|
||||
|
||||
function cellDisplay(
|
||||
cell: OdfSpreadsheetSheet["rows"][number]["cells"][number],
|
||||
): string {
|
||||
if (cell.display) return cell.display;
|
||||
return cell.value === undefined ? "" : String(cell.value);
|
||||
}
|
||||
|
||||
function presentationToText(
|
||||
slides: Extract<OdfDocument, { format: "odp" }>["slides"],
|
||||
): string {
|
||||
return slides
|
||||
.map((slide, index) => {
|
||||
const content = slide.shapes
|
||||
.flatMap((shape) => shapeText(shape))
|
||||
.filter(Boolean);
|
||||
const notes = blocksToText(slide.notes).trim();
|
||||
return [
|
||||
`Slide ${index + 1}: ${slide.name}`,
|
||||
...content,
|
||||
...(notes ? ["Speaker notes:", notes] : []),
|
||||
].join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function shapeText(shape: OdfPresentationShape): string[] {
|
||||
const content = blocksToText(shape.blocks).trim();
|
||||
const table = shape.table ? blocksToText([shape.table]).trim() : "";
|
||||
return [
|
||||
content,
|
||||
table,
|
||||
...shape.children.flatMap((child) => shapeText(child)),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function blocksToText(blocks: readonly OdfTextBlock[], depth = 0): string {
|
||||
if (depth > 128)
|
||||
throw new Error("Document nesting exceeds the export limit.");
|
||||
return blocks
|
||||
.map((block) => {
|
||||
if (block.kind === "paragraph" || block.kind === "heading")
|
||||
return block.text;
|
||||
if (block.kind === "image")
|
||||
return `[Image: ${block.alt ?? block.title ?? block.path}]`;
|
||||
if (block.kind === "list")
|
||||
return block.items
|
||||
.map(
|
||||
(item) =>
|
||||
`${" ".repeat(depth)}- ${blocksToText(item.blocks, depth + 1).trim()}`,
|
||||
)
|
||||
.join("\n");
|
||||
return block.rows
|
||||
.flatMap((row) =>
|
||||
Array.from({ length: row.rowRepeat }, () =>
|
||||
row.cells
|
||||
.flatMap((cell) =>
|
||||
Array.from({ length: cell.columnRepeat }, () =>
|
||||
cell.covered
|
||||
? ""
|
||||
: blocksToText(cell.blocks, depth + 1).replaceAll(
|
||||
"\n",
|
||||
" ",
|
||||
),
|
||||
),
|
||||
)
|
||||
.join("\t"),
|
||||
),
|
||||
)
|
||||
.join("\n");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function withoutExtension(name: string): string {
|
||||
return name.replace(/\.[^.]+$/u, "");
|
||||
}
|
||||
|
||||
function safePart(value: string): string {
|
||||
return (
|
||||
value
|
||||
.trim()
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
||||
.slice(0, 80) || "sheet"
|
||||
);
|
||||
}
|
||||
+152
-20
@@ -2,36 +2,97 @@ export const MAX_SOURCE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
export const OFFICE_ACCEPT = [
|
||||
".docx",
|
||||
".docm",
|
||||
".dotx",
|
||||
".dotm",
|
||||
".odt",
|
||||
".xlsx",
|
||||
".xlsm",
|
||||
".xltx",
|
||||
".xltm",
|
||||
".ods",
|
||||
".pptx",
|
||||
".pptm",
|
||||
".potx",
|
||||
".potm",
|
||||
".ppsx",
|
||||
".ppsm",
|
||||
".odp",
|
||||
// Accepted by the picker so users get a precise compatibility diagnostic.
|
||||
".doc",
|
||||
".xls",
|
||||
".ppt",
|
||||
".rtf",
|
||||
].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 FORMAT_BY_EXTENSION: Readonly<Record<string, OfficeFormat>> =
|
||||
Object.freeze({
|
||||
docx: "docx",
|
||||
docm: "docx",
|
||||
dotx: "docx",
|
||||
dotm: "docx",
|
||||
odt: "odt",
|
||||
xlsx: "xlsx",
|
||||
xlsm: "xlsx",
|
||||
xltx: "xlsx",
|
||||
xltm: "xlsx",
|
||||
ods: "ods",
|
||||
pptx: "pptx",
|
||||
pptm: "pptx",
|
||||
potx: "pptx",
|
||||
potm: "pptx",
|
||||
ppsx: "pptx",
|
||||
ppsm: "pptx",
|
||||
odp: "odp",
|
||||
});
|
||||
|
||||
const LEGACY_EXTENSIONS = new Set(["doc", "dot", "xls", "xlt", "ppt", "pps"]);
|
||||
const LEGACY_EXTENSIONS = new Set([
|
||||
"doc",
|
||||
"dot",
|
||||
"wps",
|
||||
"xls",
|
||||
"xlt",
|
||||
"xla",
|
||||
"ppt",
|
||||
"pps",
|
||||
"pot",
|
||||
"rtf",
|
||||
"sxw",
|
||||
"sxc",
|
||||
"sxi",
|
||||
]);
|
||||
|
||||
const FLAT_ODF_EXTENSIONS = new Set(["fodt", "fods", "fodp"]);
|
||||
|
||||
const VARIANT_NOTICE: Readonly<Record<string, string>> = Object.freeze({
|
||||
docm: "VBA content is kept inert; the document view uses the OOXML content only.",
|
||||
dotm: "VBA content is kept inert; the template is opened as a read-only document.",
|
||||
dotx: "This OOXML template is opened as a read-only document.",
|
||||
xlsm: "VBA content is kept inert; cached workbook content is shown without recalculation.",
|
||||
xltm: "VBA content is kept inert; the template is opened as a read-only workbook.",
|
||||
xltx: "This OOXML template is opened as a read-only workbook.",
|
||||
pptm: "VBA content is kept inert; media and active content remain disabled.",
|
||||
potm: "VBA content is kept inert; the template is opened as a read-only presentation.",
|
||||
potx: "This OOXML template is opened as a read-only presentation.",
|
||||
ppsm: "VBA content is kept inert; the slide show is opened as a read-only presentation.",
|
||||
ppsx: "This OOXML slide show is opened as a read-only presentation.",
|
||||
});
|
||||
|
||||
export type OfficeFileErrorCode =
|
||||
| "empty-file"
|
||||
| "file-too-large"
|
||||
| "legacy-format"
|
||||
| "flat-odf"
|
||||
| "invalid-container"
|
||||
| "unsupported-format";
|
||||
|
||||
export class OfficeFileError extends Error {
|
||||
readonly code:
|
||||
"empty-file" | "file-too-large" | "legacy-format" | "unsupported-format";
|
||||
readonly code: OfficeFileErrorCode;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code:
|
||||
"empty-file" | "file-too-large" | "legacy-format" | "unsupported-format",
|
||||
) {
|
||||
constructor(message: string, code: OfficeFileErrorCode) {
|
||||
super(message);
|
||||
this.name = "OfficeFileError";
|
||||
this.code = code;
|
||||
@@ -51,29 +112,100 @@ export function detectOfficeFormat(
|
||||
}
|
||||
if (file.size > MAX_SOURCE_BYTES) {
|
||||
throw new OfficeFileError(
|
||||
`This first viewer slice accepts files up to ${formatBytes(MAX_SOURCE_BYTES)}.`,
|
||||
`This viewer accepts files up to ${formatBytes(MAX_SOURCE_BYTES)}.`,
|
||||
"file-too-large",
|
||||
);
|
||||
}
|
||||
|
||||
const extension = extensionOf(file.name);
|
||||
if (LEGACY_EXTENSIONS.has(extension)) {
|
||||
throw new OfficeFileError(legacyMessage(extension), "legacy-format");
|
||||
}
|
||||
if (FLAT_ODF_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",
|
||||
`.${extension} is a flat XML OpenDocument file. This package reader currently supports zipped ODT, ODS and ODP; save it in the corresponding packaged format first.`,
|
||||
"flat-odf",
|
||||
);
|
||||
}
|
||||
|
||||
const format = FORMAT_BY_EXTENSION[extension];
|
||||
if (!format) {
|
||||
throw new OfficeFileError(
|
||||
"Choose a DOCX, ODT, XLSX, ODS, PPTX, or ODP file.",
|
||||
"Choose a supported OOXML or OpenDocument package. Legacy DOC/XLS/PPT/RTF and flat OpenDocument XML are diagnosed but not rendered.",
|
||||
"unsupported-format",
|
||||
);
|
||||
}
|
||||
return format;
|
||||
}
|
||||
|
||||
export interface OfficeFileInspection {
|
||||
format: OfficeFormat;
|
||||
extension: string;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a small bounded content sniff before a package parser receives the
|
||||
* file. It catches renamed OLE/RTF files and ordinary non-package data while
|
||||
* reading no more than eight bytes.
|
||||
*/
|
||||
export async function inspectOfficeFile(
|
||||
file: File,
|
||||
): Promise<OfficeFileInspection> {
|
||||
const format = detectOfficeFormat(file),
|
||||
extension = extensionOf(file.name),
|
||||
prefix = new Uint8Array(await file.slice(0, 8).arrayBuffer());
|
||||
validateOfficePackagePrefix(prefix, extension);
|
||||
return { format, extension, notice: VARIANT_NOTICE[extension] };
|
||||
}
|
||||
|
||||
export function validateOfficePackagePrefix(
|
||||
prefix: Uint8Array,
|
||||
extension: string,
|
||||
): void {
|
||||
if (hasPrefix(prefix, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])) {
|
||||
throw new OfficeFileError(
|
||||
`The bytes use the legacy OLE Compound File container, even though the name ends in .${extension}. Binary DOC, XLS and PPT parsing is not available; convert the original file to OOXML or OpenDocument first.`,
|
||||
"legacy-format",
|
||||
);
|
||||
}
|
||||
const ascii = new TextDecoder("ascii").decode(prefix).toLowerCase();
|
||||
if (ascii.startsWith("{\\rtf")) {
|
||||
throw new OfficeFileError(
|
||||
"The bytes are Rich Text Format (RTF). RTF is identified explicitly but is not yet rendered; save or convert it as DOCX or ODT first.",
|
||||
"legacy-format",
|
||||
);
|
||||
}
|
||||
if (!isZipPrefix(prefix)) {
|
||||
throw new OfficeFileError(
|
||||
`.${extension} must be a ZIP-based office package, but its file signature is different. The file may be renamed, truncated or use a legacy format.`,
|
||||
"invalid-container",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isZipPrefix(bytes: Uint8Array): boolean {
|
||||
return (
|
||||
bytes[0] === 0x50 &&
|
||||
bytes[1] === 0x4b &&
|
||||
((bytes[2] === 0x03 && bytes[3] === 0x04) ||
|
||||
(bytes[2] === 0x05 && bytes[3] === 0x06) ||
|
||||
(bytes[2] === 0x07 && bytes[3] === 0x08))
|
||||
);
|
||||
}
|
||||
|
||||
function hasPrefix(bytes: Uint8Array, signature: readonly number[]): boolean {
|
||||
return signature.every((value, index) => bytes[index] === value);
|
||||
}
|
||||
|
||||
function legacyMessage(extension: string): string {
|
||||
if (extension === "rtf")
|
||||
return "RTF is a legacy text interchange format, not an OOXML package. Save or convert it as DOCX or ODT first.";
|
||||
if (["sxw", "sxc", "sxi"].includes(extension))
|
||||
return `.${extension} is an older OpenOffice XML package. Save it as ODT, ODS or ODP before opening it here.`;
|
||||
return `.${extension} uses a legacy compound-binary Office format. Its macros and embedded objects require a distinct bounded parser; convert it to a modern OOXML or OpenDocument file first.`;
|
||||
}
|
||||
|
||||
export function familyForFormat(format: OfficeFormat): OfficeFamily {
|
||||
if (format === "docx" || format === "odt") return "document";
|
||||
if (format === "xlsx" || format === "ods") return "spreadsheet";
|
||||
|
||||
@@ -60,6 +60,8 @@ export function parsePreparedOdp(
|
||||
styles: prepared.styles,
|
||||
assets: prepared.context.assets,
|
||||
warnings: prepared.context.warnings,
|
||||
pageWidth: prepared.pageWidth,
|
||||
pageHeight: prepared.pageHeight,
|
||||
slides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parseParagraph, parseTextBlocks } from "./text";
|
||||
import type {
|
||||
OdfParseOptions,
|
||||
OdfSpreadsheetCell,
|
||||
OdfSpreadsheetColumn,
|
||||
OdfSpreadsheetDocument,
|
||||
OdfSpreadsheetRow,
|
||||
OdfSpreadsheetSheet,
|
||||
@@ -61,6 +62,8 @@ export function parsePreparedOds(
|
||||
styles: prepared.styles,
|
||||
assets: prepared.context.assets,
|
||||
warnings: prepared.context.warnings,
|
||||
pageWidth: prepared.pageWidth,
|
||||
pageHeight: prepared.pageHeight,
|
||||
sheets,
|
||||
};
|
||||
}
|
||||
@@ -70,6 +73,34 @@ function parseSheet(
|
||||
sheetIndex: number,
|
||||
context: ReturnType<typeof prepareOdfPackage>["context"],
|
||||
): OdfSpreadsheetSheet {
|
||||
const columnElements: XmlElement[] = [];
|
||||
collectSheetColumns(table, columnElements);
|
||||
const columns: OdfSpreadsheetColumn[] = [];
|
||||
let columnIndex = 0;
|
||||
for (const column of columnElements) {
|
||||
const repeat = boundedRepeat(
|
||||
attribute(column, NS.table, "number-columns-repeated"),
|
||||
context.limits,
|
||||
"Repeated spreadsheet column count",
|
||||
);
|
||||
columns.push({
|
||||
index: columnIndex,
|
||||
repeat,
|
||||
styleName: attribute(column, NS.table, "style-name"),
|
||||
defaultCellStyleName: attribute(
|
||||
column,
|
||||
NS.table,
|
||||
"default-cell-style-name",
|
||||
),
|
||||
visibility: visibility(attribute(column, NS.table, "visibility")),
|
||||
});
|
||||
columnIndex += repeat;
|
||||
if (!Number.isSafeInteger(columnIndex))
|
||||
throw new OdfParseError(
|
||||
"limit-exceeded",
|
||||
"Expanded spreadsheet column count is too large",
|
||||
);
|
||||
}
|
||||
const rowElements: XmlElement[] = [];
|
||||
collectSheetRows(table, rowElements);
|
||||
let rows: OdfSpreadsheetRow[] = [];
|
||||
@@ -112,6 +143,11 @@ function parseSheet(
|
||||
index: rowIndex,
|
||||
rowRepeat,
|
||||
styleName: attribute(rowElement, NS.table, "style-name"),
|
||||
defaultCellStyleName: attribute(
|
||||
rowElement,
|
||||
NS.table,
|
||||
"default-cell-style-name",
|
||||
),
|
||||
cells,
|
||||
});
|
||||
rowIndex += rowRepeat;
|
||||
@@ -146,12 +182,20 @@ function parseSheet(
|
||||
name: attribute(table, NS.table, "name") ?? `Sheet ${sheetIndex + 1}`,
|
||||
styleName: attribute(table, NS.table, "style-name"),
|
||||
protected: attribute(table, NS.table, "protected") === "true",
|
||||
visibility: visibility(attribute(table, NS.table, "visibility")),
|
||||
columns,
|
||||
rows,
|
||||
expandedRowCount,
|
||||
expandedCellCount,
|
||||
};
|
||||
}
|
||||
|
||||
function visibility(
|
||||
value: string | undefined,
|
||||
): "visible" | "collapse" | "filter" {
|
||||
return value === "collapse" || value === "filter" ? value : "visible";
|
||||
}
|
||||
|
||||
function isMeaningfulCell(cell: OdfSpreadsheetCell): boolean {
|
||||
return (
|
||||
cell.kind === "covered" ||
|
||||
@@ -247,3 +291,16 @@ function collectSheetRows(parent: XmlElement, result: XmlElement[]): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectSheetColumns(parent: XmlElement, result: XmlElement[]): void {
|
||||
for (const child of childElements(parent)) {
|
||||
if (child.namespaceURI !== NS.table) continue;
|
||||
if (child.localName === "table-column") result.push(child);
|
||||
else if (
|
||||
child.localName === "table-header-columns" ||
|
||||
child.localName === "table-column-group" ||
|
||||
child.localName === "table-columns"
|
||||
)
|
||||
collectSheetColumns(child, result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prepareOdfPackage, type PreparedOdfPackage } from "./package";
|
||||
import { collectNotes, parseTextBlocks } from "./text";
|
||||
import { collectAnnotations, collectNotes, parseTextBlocks } from "./text";
|
||||
import type { OdfParseOptions, OdfTextDocument } from "./types";
|
||||
import { OdfParseError } from "./types";
|
||||
import { NS, firstChildNamed } from "./xml";
|
||||
@@ -44,7 +44,10 @@ export function parsePreparedOdt(
|
||||
styles: prepared.styles,
|
||||
assets: prepared.context.assets,
|
||||
warnings: prepared.context.warnings,
|
||||
pageWidth: prepared.pageWidth,
|
||||
pageHeight: prepared.pageHeight,
|
||||
blocks,
|
||||
notes: collectNotes(blocks),
|
||||
annotations: collectAnnotations(blocks),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { OdfReadContext } from "./context";
|
||||
import { resolveLimits } from "./limits";
|
||||
import type {
|
||||
OdfFormat,
|
||||
OdfLength,
|
||||
OdfMetadata,
|
||||
OdfParseOptions,
|
||||
OdfStyle,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
childElements,
|
||||
descendantsNamed,
|
||||
parseXmlPart,
|
||||
parseLength,
|
||||
type XmlDocument,
|
||||
type XmlElement,
|
||||
} from "./xml";
|
||||
@@ -32,6 +34,8 @@ export interface PreparedOdfPackage {
|
||||
context: OdfReadContext;
|
||||
metadata: OdfMetadata;
|
||||
styles: OdfStyle[];
|
||||
pageWidth?: OdfLength;
|
||||
pageHeight?: OdfLength;
|
||||
}
|
||||
|
||||
export function prepareOdfPackage(
|
||||
@@ -88,6 +92,24 @@ export function prepareOdfPackage(
|
||||
`${omittedObjects} embedded ${omittedObjects === 1 ? "object was" : "objects were"} kept inert and omitted`,
|
||||
);
|
||||
}
|
||||
const trackedChanges = descendantsNamed(
|
||||
content.documentElement!,
|
||||
NS.text,
|
||||
"tracked-changes",
|
||||
).length;
|
||||
if (trackedChanges > 0)
|
||||
context.warnings.push(
|
||||
"Tracked-change metadata is kept inert; the semantic view shows the stored document text without an accept/reject workflow",
|
||||
);
|
||||
const forms = descendantsNamed(
|
||||
content.documentElement!,
|
||||
NS.office,
|
||||
"forms",
|
||||
).length;
|
||||
if (forms > 0)
|
||||
context.warnings.push(
|
||||
"Interactive form controls are kept inert and omitted from the semantic view",
|
||||
);
|
||||
const metaBytes = archive.entries.get("meta.xml");
|
||||
const metadata = metaBytes
|
||||
? parseMetadata(parseXmlPart(metaBytes, "meta.xml", limits), context)
|
||||
@@ -97,7 +119,8 @@ export function prepareOdfPackage(
|
||||
if (stylesBytes)
|
||||
styleDocuments.unshift(parseXmlPart(stylesBytes, "styles.xml", limits));
|
||||
const styles = parseStyles(styleDocuments);
|
||||
return { format, mediaType, content, context, metadata, styles };
|
||||
const pageSize = parsePageSize(styleDocuments);
|
||||
return { format, mediaType, content, context, metadata, styles, ...pageSize };
|
||||
}
|
||||
|
||||
function requireEntry(
|
||||
@@ -241,6 +264,21 @@ function parseMetadata(
|
||||
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,
|
||||
"default-style",
|
||||
)) {
|
||||
const family = attribute(element, NS.style, "family");
|
||||
if (!family) continue;
|
||||
const style: OdfStyle = {
|
||||
name: `__default__:${family}`,
|
||||
isDefault: true,
|
||||
family,
|
||||
properties: readStyleProperties(element),
|
||||
};
|
||||
styles.set(`${family}\0${style.name}`, style);
|
||||
}
|
||||
for (const element of descendantsNamed(
|
||||
document.documentElement!,
|
||||
NS.style,
|
||||
@@ -265,6 +303,24 @@ function parseStyles(documents: readonly XmlDocument[]): OdfStyle[] {
|
||||
return [...styles.values()];
|
||||
}
|
||||
|
||||
function parsePageSize(documents: readonly XmlDocument[]): {
|
||||
pageWidth?: OdfLength;
|
||||
pageHeight?: OdfLength;
|
||||
} {
|
||||
for (const document of documents) {
|
||||
const properties = descendantsNamed(
|
||||
document.documentElement!,
|
||||
NS.style,
|
||||
"page-layout-properties",
|
||||
)[0];
|
||||
if (!properties) continue;
|
||||
const pageWidth = parseLength(attribute(properties, NS.fo, "page-width"));
|
||||
const pageHeight = parseLength(attribute(properties, NS.fo, "page-height"));
|
||||
if (pageWidth || pageHeight) return { pageWidth, pageHeight };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function readStyleProperties(style: XmlElement): Record<string, string> {
|
||||
const properties: Record<string, string> = {};
|
||||
for (const child of childElements(style)) {
|
||||
|
||||
+62
-1
@@ -2,6 +2,7 @@ import { normalizeResourcePath, OdfReadContext } from "./context";
|
||||
import type {
|
||||
OdfHeading,
|
||||
OdfImage,
|
||||
OdfAnnotation,
|
||||
OdfList,
|
||||
OdfNote,
|
||||
OdfParagraph,
|
||||
@@ -29,6 +30,7 @@ interface InlineResult {
|
||||
runs: OdfTextRun[];
|
||||
images: OdfImage[];
|
||||
notes: OdfNote[];
|
||||
annotations: OdfAnnotation[];
|
||||
}
|
||||
|
||||
export function parseTextBlocks(
|
||||
@@ -110,7 +112,12 @@ function parseInline(
|
||||
inheritedStyle?: string,
|
||||
inheritedLink?: string,
|
||||
): InlineResult {
|
||||
const result: InlineResult = { runs: [], images: [], notes: [] };
|
||||
const result: InlineResult = {
|
||||
runs: [],
|
||||
images: [],
|
||||
notes: [],
|
||||
annotations: [],
|
||||
};
|
||||
const append = (
|
||||
text: string,
|
||||
styleName = inheritedStyle,
|
||||
@@ -162,6 +169,13 @@ function parseInline(
|
||||
if (note.citation) append(note.citation);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
element.namespaceURI === NS.office &&
|
||||
element.localName === "annotation"
|
||||
) {
|
||||
result.annotations.push(parseAnnotation(element, context));
|
||||
continue;
|
||||
}
|
||||
if (element.namespaceURI === NS.draw && element.localName === "frame") {
|
||||
const image = parseFrameImage(element, context);
|
||||
if (image) result.images.push(image);
|
||||
@@ -190,10 +204,35 @@ function parseInline(
|
||||
}
|
||||
result.images.push(...nested.images);
|
||||
result.notes.push(...nested.notes);
|
||||
result.annotations.push(...nested.annotations);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseAnnotation(
|
||||
element: XmlElement,
|
||||
context: OdfReadContext,
|
||||
): OdfAnnotation {
|
||||
const creator = firstChildNamed(
|
||||
element,
|
||||
NS.dc,
|
||||
"creator",
|
||||
)?.textContent?.trim();
|
||||
const createdAt = firstChildNamed(
|
||||
element,
|
||||
NS.dc,
|
||||
"date",
|
||||
)?.textContent?.trim();
|
||||
if (creator) context.consumeText(creator);
|
||||
if (createdAt) context.consumeText(createdAt);
|
||||
return {
|
||||
id: attribute(element, NS.office, "name"),
|
||||
creator: creator || undefined,
|
||||
createdAt: createdAt || undefined,
|
||||
blocks: parseTextBlocks(element, context),
|
||||
};
|
||||
}
|
||||
|
||||
function safeHyperlink(href: string | undefined): string | undefined {
|
||||
if (!href) return undefined;
|
||||
const value = href.trim();
|
||||
@@ -370,3 +409,25 @@ export function collectNotes(blocks: readonly OdfTextBlock[]): OdfNote[] {
|
||||
for (const block of blocks) visit(block);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function collectAnnotations(
|
||||
blocks: readonly OdfTextBlock[],
|
||||
): OdfAnnotation[] {
|
||||
const result: OdfAnnotation[] = [];
|
||||
const visit = (block: OdfTextBlock) => {
|
||||
if (block.kind === "paragraph" || block.kind === "heading") {
|
||||
result.push(...block.annotations);
|
||||
for (const annotation of block.annotations)
|
||||
for (const child of annotation.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;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface OdfMetadata {
|
||||
|
||||
export interface OdfStyle {
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
family?: string;
|
||||
displayName?: string;
|
||||
parentName?: string;
|
||||
@@ -102,6 +103,13 @@ export interface OdfTextRun {
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface OdfAnnotation {
|
||||
id?: string;
|
||||
creator?: string;
|
||||
createdAt?: string;
|
||||
blocks: OdfTextBlock[];
|
||||
}
|
||||
|
||||
export interface OdfParagraph {
|
||||
kind: "paragraph";
|
||||
styleName?: string;
|
||||
@@ -109,6 +117,7 @@ export interface OdfParagraph {
|
||||
runs: OdfTextRun[];
|
||||
images: OdfImage[];
|
||||
notes: OdfNote[];
|
||||
annotations: OdfAnnotation[];
|
||||
}
|
||||
|
||||
export interface OdfHeading {
|
||||
@@ -119,6 +128,7 @@ export interface OdfHeading {
|
||||
runs: OdfTextRun[];
|
||||
images: OdfImage[];
|
||||
notes: OdfNote[];
|
||||
annotations: OdfAnnotation[];
|
||||
}
|
||||
|
||||
export interface OdfNote {
|
||||
@@ -182,13 +192,24 @@ export interface OdfSpreadsheetRow {
|
||||
index: number;
|
||||
rowRepeat: number;
|
||||
styleName?: string;
|
||||
defaultCellStyleName?: string;
|
||||
cells: OdfSpreadsheetCell[];
|
||||
}
|
||||
|
||||
export interface OdfSpreadsheetColumn {
|
||||
index: number;
|
||||
repeat: number;
|
||||
styleName?: string;
|
||||
defaultCellStyleName?: string;
|
||||
visibility: "visible" | "collapse" | "filter";
|
||||
}
|
||||
|
||||
export interface OdfSpreadsheetSheet {
|
||||
name: string;
|
||||
styleName?: string;
|
||||
protected: boolean;
|
||||
visibility: "visible" | "collapse" | "filter";
|
||||
columns: OdfSpreadsheetColumn[];
|
||||
rows: OdfSpreadsheetRow[];
|
||||
expandedRowCount: number;
|
||||
expandedCellCount: number;
|
||||
@@ -239,12 +260,15 @@ interface OdfBaseModel {
|
||||
styles: OdfStyle[];
|
||||
assets: OdfAsset[];
|
||||
warnings: string[];
|
||||
pageWidth?: OdfLength;
|
||||
pageHeight?: OdfLength;
|
||||
}
|
||||
|
||||
export interface OdfTextDocument extends OdfBaseModel {
|
||||
format: "odt";
|
||||
blocks: OdfTextBlock[];
|
||||
notes: OdfNote[];
|
||||
annotations: OdfAnnotation[];
|
||||
}
|
||||
|
||||
export interface OdfSpreadsheetDocument extends OdfBaseModel {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const NS = Object.freeze({
|
||||
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",
|
||||
form: "urn:oasis:names:tc:opendocument:xmlns:form:1.0",
|
||||
} as const);
|
||||
|
||||
const XML_DECLARATION_ATTACK = /<!\s*(?:DOCTYPE|ENTITY)|<\?xml-stylesheet/iu;
|
||||
|
||||
Reference in New Issue
Block a user