import { shortHash } from "../core/hash.ts"; import { mergeLimits } from "../core/limits.ts"; import { fail } from "../errors.ts"; import type { DocxReadRequest, StoryKind, Warning } from "../contracts.ts"; import { OoxmlPackage } from "./package.ts"; import { NS, attr, directChildren, elements, firstDirectChild, parseXml, textOf } from "./xml.ts"; export type SemanticRun = { text: string; bold?: boolean; italic?: boolean; underline?: string; fontFamily?: string; fontSizePoints?: number; color?: string }; export type SemanticParagraph = { kind: "paragraph"; story: StoryKind; path: string; selector: Record; hash: string; text: string; style?: string; outlineLevel?: number; alignment?: string; runs: SemanticRun[]; bookmarks: string[]; fields: string[]; revisionIds: string[] }; export type SemanticTable = { kind: "table"; story: StoryKind; path: string; hash: string; rows: Array<{ path: string; hash: string; cells: Array<{ text: string; hash: string; path: string }> }> }; export type SemanticContentControl = { kind: "contentControl"; story: StoryKind; path: string; tag?: string; title?: string; text: string; hash: string; selector: Record }; export type SemanticSnapshot = { properties: Record; stories: Array<{ kind: StoryKind; part: string; paragraphs: SemanticParagraph[]; tables: SemanticTable[] }>; contentControls: SemanticContentControl[]; outline: Array<{ level: number; text: string; selector: Record }>; inventory: Record; warnings: Warning[] }; function truthyProperty(parent: Element | undefined, name: string): boolean | undefined { const node = parent ? firstDirectChild(parent, NS.word, name) : undefined; if (!node) return undefined; const value = attr(node, NS.word, "val"); return value === undefined || !/^(?:0|false|off|none)$/i.test(value); } function paragraphText(paragraph: Element, includeHidden: boolean): string { const texts = elements(paragraph, NS.word, "t").map(textOf); if (includeHidden) texts.push(...elements(paragraph, NS.word, "delText").map(textOf)); return texts.join(""); } function runModel(run: Element): SemanticRun { const props = firstDirectChild(run, NS.word, "rPr"), fonts = firstDirectChild(props as Element, NS.word, "rFonts"), size = firstDirectChild(props as Element, NS.word, "sz"), color = firstDirectChild(props as Element, NS.word, "color"), underline = firstDirectChild(props as Element, NS.word, "u"); return { text: elements(run, NS.word, "t").map(textOf).join(""), bold: truthyProperty(props, "b"), italic: truthyProperty(props, "i"), underline: attr(underline, NS.word, "val"), fontFamily: attr(fonts, NS.word, "ascii") ?? attr(fonts, NS.word, "hAnsi"), fontSizePoints: size ? Number(attr(size, NS.word, "val")) / 2 : undefined, color: attr(color, NS.word, "val") }; } function paragraphModel(paragraph: Element, story: StoryKind, structuralPath: string, includeHidden: boolean): SemanticParagraph { const text = paragraphText(paragraph, includeHidden), hash = shortHash(`${story}\n${structuralPath}\n${text}`), paraId = attr(paragraph, NS.word14, "paraId"); const pPr = firstDirectChild(paragraph, NS.word, "pPr"), style = attr(firstDirectChild(pPr as Element, NS.word, "pStyle"), NS.word, "val"), outline = attr(firstDirectChild(pPr as Element, NS.word, "outlineLvl"), NS.word, "val"), alignment = attr(firstDirectChild(pPr as Element, NS.word, "jc"), NS.word, "val"); return { kind: "paragraph", story, path: structuralPath, selector: paraId ? { kind: "paragraphId", story, paragraphId: paraId, expectedHash: hash } : { kind: "path", story, path: structuralPath, expectedHash: hash }, hash, text, style, outlineLevel: outline === undefined ? headingLevel(style) : Number(outline), alignment, runs: directChildren(paragraph, NS.word, "r").map(runModel), bookmarks: elements(paragraph, NS.word, "bookmarkStart").map((node) => attr(node, NS.word, "name") ?? "").filter(Boolean), fields: elements(paragraph, NS.word, "instrText").map(textOf), revisionIds: [...elements(paragraph, NS.word, "ins"), ...elements(paragraph, NS.word, "del")].map((node) => attr(node, NS.word, "id") ?? "").filter(Boolean) }; } function headingLevel(style?: string): number | undefined { const match = style?.match(/^Heading\s*([1-9])$/i); return match ? Number(match[1]) - 1 : undefined; } function storyParts(pkg: OoxmlPackage): Array<{ kind: StoryKind; part: string }> { const parts: Array<{ kind: StoryKind; part: string }> = [{ kind: "main", part: pkg.mainDocumentPart }]; for (const rel of pkg.relationships) { if (!rel.resolvedTarget) continue; const map: Array<[RegExp, StoryKind]> = [[/\/header$/i, "header"], [/\/footer$/i, "footer"], [/\/footnotes$/i, "footnote"], [/\/endnotes$/i, "endnote"], [/\/comments$/i, "comment"]]; const found = map.find(([re]) => re.test(rel.type)); if (found && !parts.some((item) => item.part === rel.resolvedTarget)) parts.push({ kind: found[1], part: rel.resolvedTarget }); } return parts; } function partElementCount(pkg: OoxmlPackage, part: string, localName: string): number { const bytes = pkg.archive.get(part); return bytes ? elements(parseXml(bytes, part, pkg.archive.limits.maxXmlBytes), NS.word, localName).length : 0; } function properties(pkg: OoxmlPackage): Record { const result: Record = {}, bytes = pkg.archive.get("docProps/core.xml"); if (!bytes) return result; const doc = parseXml(bytes, "docProps/core.xml", pkg.archive.limits.maxXmlBytes); for (const name of ["title", "subject", "creator", "description", "language"]) { const nodes = Array.from(doc.getElementsByTagNameNS(name === "creator" || name === "title" || name === "subject" || name === "description" || name === "language" ? NS.dc : NS.core, name)) as Element[]; if (nodes[0]?.textContent) result[name] = nodes[0].textContent; } for (const name of ["keywords", "category", "lastModifiedBy", "revision"]) { const node = doc.getElementsByTagNameNS(NS.core, name).item(0); if (node?.textContent) result[name] = node.textContent; } return result; } export function semanticSnapshot(pkg: OoxmlPackage, includeHiddenData = false): SemanticSnapshot { let paragraphCount = 0, runCount = 0, tableCount = 0; const stories = storyParts(pkg).map(({ kind, part }) => { const doc = parseXml(pkg.archive.require(part), part, pkg.archive.limits.maxXmlBytes); const paragraphs: SemanticParagraph[] = [], tables: SemanticTable[] = []; let p = 0, t = 0; const walk = (node: Element, base: string) => { for (let child = node.firstChild; child; child = child.nextSibling) { if (child.nodeType !== 1) continue; const element = child as Element; if (element.namespaceURI === NS.word && element.localName === "txbxContent") continue; if (element.namespaceURI === NS.word && element.localName === "p") { p++; paragraphCount++; if (paragraphCount > pkg.archive.limits.maxParagraphs) fail("LIMIT_EXCEEDED", "Paragraph limit exceeded."); const model = paragraphModel(element, kind, `${base}/p[${p}]`, includeHiddenData); runCount += model.runs.length; if (runCount > pkg.archive.limits.maxRuns) fail("LIMIT_EXCEEDED", "Run limit exceeded."); paragraphs.push(model); } else if (element.namespaceURI === NS.word && element.localName === "tbl") { t++; tableCount++; if (tableCount > pkg.archive.limits.maxTables) fail("LIMIT_EXCEEDED", "Table limit exceeded."); const tablePath = `${base}/tbl[${t}]`, rows = directChildren(element, NS.word, "tr").map((row, rowIndex) => { const rowPath = `${tablePath}/tr[${rowIndex + 1}]`, cells = directChildren(row, NS.word, "tc").map((cell, cellIndex) => { const value = paragraphText(cell, includeHiddenData); const cellPath = `${rowPath}/tc[${cellIndex + 1}]`; return { text: value, path: cellPath, hash: shortHash(`${cellPath}\n${value}`) }; }); return { path: rowPath, hash: shortHash(`${rowPath}\n${cells.map((cell) => `${cell.text.length}:${cell.text}`).join("|")}`), cells }; }); tables.push({ kind: "table", story: kind, path: tablePath, hash: shortHash(JSON.stringify(rows)), rows }); walk(element, tablePath); } else walk(element, base); } }; walk(doc.documentElement, `/${kind}`); return { kind, part, paragraphs, tables }; }); const textboxParagraphs: SemanticParagraph[] = [], textboxTables: SemanticTable[] = []; let textboxIndex = 0, textboxParagraphIndex = 0, textboxTableIndex = 0; for (const source of storyParts(pkg)) { const doc = parseXml(pkg.archive.require(source.part), source.part, pkg.archive.limits.maxXmlBytes); for (const box of elements(doc, NS.word, "txbxContent")) { textboxIndex++; const walkTextbox = (node: Element, base: string) => { for (let child = node.firstChild; child; child = child.nextSibling) { if (child.nodeType !== 1) continue; const element = child as Element; if (element.namespaceURI === NS.word && element.localName === "p") { textboxParagraphIndex++; paragraphCount++; if (paragraphCount > pkg.archive.limits.maxParagraphs) fail("LIMIT_EXCEEDED", "Paragraph limit exceeded."); const model = paragraphModel(element, "textbox", `${base}/p[${textboxParagraphIndex}]`, includeHiddenData); runCount += model.runs.length; if (runCount > pkg.archive.limits.maxRuns) fail("LIMIT_EXCEEDED", "Run limit exceeded."); textboxParagraphs.push(model); } else if (element.namespaceURI === NS.word && element.localName === "tbl") { textboxTableIndex++; tableCount++; if (tableCount > pkg.archive.limits.maxTables) fail("LIMIT_EXCEEDED", "Table limit exceeded."); const tablePath = `${base}/tbl[${textboxTableIndex}]`, rows = directChildren(element, NS.word, "tr").map((row, rowIndex) => { const rowPath = `${tablePath}/tr[${rowIndex + 1}]`, cells = directChildren(row, NS.word, "tc").map((cell, cellIndex) => { const value = paragraphText(cell, includeHiddenData), cellPath = `${rowPath}/tc[${cellIndex + 1}]`; return { text: value, path: cellPath, hash: shortHash(`${cellPath}\n${value}`) }; }); return { path: rowPath, hash: shortHash(`${rowPath}\n${cells.map((cell) => `${cell.text.length}:${cell.text}`).join("|")}`), cells }; }); textboxTables.push({ kind: "table", story: "textbox", path: tablePath, hash: shortHash(JSON.stringify(rows)), rows }); walkTextbox(element, tablePath); } else walkTextbox(element, base); } }; walkTextbox(box, `/textbox/box[${textboxIndex}]`); } } if (textboxParagraphs.length || textboxTables.length) stories.push({ kind: "textbox", part: "virtual:textboxes", paragraphs: textboxParagraphs, tables: textboxTables }); const outline = stories.flatMap((story) => story.paragraphs.filter((p) => p.outlineLevel !== undefined || /^Heading/i.test(p.style ?? "")).map((p) => ({ level: p.outlineLevel ?? 0, text: p.text, selector: p.selector }))); const allParagraphs = stories.flatMap((s) => s.paragraphs); const contentControls: SemanticContentControl[] = stories.filter((story) => story.kind !== "textbox").flatMap((story) => { const doc = parseXml(pkg.archive.require(story.part), story.part, pkg.archive.limits.maxXmlBytes); return elements(doc, NS.word, "sdt").map((control, index) => { const props = firstDirectChild(control, NS.word, "sdtPr"), tag = attr(firstDirectChild(props, NS.word, "tag"), NS.word, "val"), title = attr(firstDirectChild(props, NS.word, "alias"), NS.word, "val"), text = elements(control, NS.word, "t").map(textOf).join(""), controlPath = `/${story.kind}/sdt[${index + 1}]`, hash = shortHash(`${controlPath}\n${text}`); return { kind: "contentControl" as const, story: story.kind, path: controlPath, tag, title, text, hash, selector: { kind: "contentControl", tag, title } }; }); }); return { properties: properties(pkg), stories, contentControls, outline, inventory: { stories: stories.length, paragraphs: allParagraphs.length, runs: allParagraphs.reduce((sum, p) => sum + p.runs.length, 0), tables: stories.reduce((sum, s) => sum + s.tables.length, 0), tableCells: stories.reduce((sum, s) => sum + s.tables.reduce((subtotal, t) => subtotal + t.rows.reduce((r, row) => r + row.cells.length, 0), 0), 0), bookmarks: allParagraphs.reduce((sum, p) => sum + p.bookmarks.length, 0), fields: allParagraphs.reduce((sum, p) => sum + p.fields.length, 0), revisions: allParagraphs.reduce((sum, p) => sum + p.revisionIds.length, 0), comments: stories.find((s) => s.kind === "comment")?.paragraphs.length ?? 0, contentControls: contentControls.length, styles: partElementCount(pkg, "word/styles.xml", "style"), lists: partElementCount(pkg, "word/numbering.xml", "num"), sections: partElementCount(pkg, pkg.mainDocumentPart, "sectPr"), headers: stories.filter((story) => story.kind === "header").length, footers: stories.filter((story) => story.kind === "footer").length, images: pkg.relationships.filter((rel) => /\/image$/i.test(rel.type)).length, charts: pkg.relationships.filter((rel) => /\/chart$/i.test(rel.type)).length, smartArt: pkg.relationships.filter((rel) => /\/(?:diagramData|diagramLayout|diagramQuickStyle|diagramColors)$/i.test(rel.type)).length, textBoxes: textboxIndex, hyperlinks: pkg.relationships.filter((rel) => /\/hyperlink$/i.test(rel.type)).length, externalRelationships: pkg.relationships.filter((rel) => rel.targetMode?.toLowerCase() === "external").length, attachedTemplates: pkg.relationships.filter((rel) => /\/attachedTemplate$/i.test(rel.type)).length, customXmlParts: [...pkg.archive.entries.keys()].filter((part) => /^customXml\//i.test(part)).length, altChunks: stories.filter((story) => story.kind !== "textbox").reduce((sum, story) => sum + partElementCount(pkg, story.part, "altChunk"), 0), equations: [...pkg.archive.entries.keys()].filter((part) => /equation|math/i.test(pkg.contentTypes.get(part) ?? "")).length, embeddedObjects: [...pkg.classifications.values()].filter((value) => value === "active-content" || value === "protected").length, activeX: [...pkg.archive.entries.keys()].filter((part) => /(?:^|\/)activeX\//i.test(part)).length, macros: [...pkg.classifications.entries()].filter(([part, value]) => value === "active-content" && /vba/i.test(part)).length, signatures: [...pkg.classifications.values()].filter((value) => value === "signed").length }, warnings: includeHiddenData ? [{ code: "HIDDEN_DATA_INCLUDED", message: "Hidden/deleted text was explicitly included.", severity: "warning" }] : [{ code: "HIDDEN_DATA_OMITTED", message: "Deleted revision text and hidden metadata are omitted by default.", severity: "info" }] }; } function selectorMatches(block: SemanticParagraph, selector: NonNullable): boolean { const expectedHash = "expectedHash" in selector ? selector.expectedHash : undefined; if (expectedHash && block.hash !== expectedHash) return false; if (selector.kind === "paragraphId") return block.selector.kind === "paragraphId" && block.selector.paragraphId === selector.paragraphId && (!selector.story || block.story === selector.story); if (selector.kind === "path") return block.story === selector.story && block.path === selector.path; if (selector.kind === "text") return (!selector.story || block.story === selector.story) && block.text.includes(selector.text) && (!selector.before || block.text.includes(`${selector.before}${selector.text}`)) && (!selector.after || block.text.includes(`${selector.text}${selector.after}`)); if (selector.kind === "bookmark") return block.bookmarks.includes(selector.name); return false; } export function readSemantic(snapshot: SemanticSnapshot, request: DocxReadRequest): Record { const wantedStories = new Set(request.stories ?? ["main"]), max = Math.min(request.maxBlocks ?? 200, mergeLimits(request.limits).maxSearchMatches), selector = request.selector; if (selector?.kind === "tableCell" || selector?.kind === "tableRow") { const story = snapshot.stories.find((item) => item.kind === (selector.story ?? "main")), table = story?.tables[selector.table - 1], row = table?.rows[selector.row - 1]; if (!row) fail("SELECTOR_NOT_FOUND", "Table or row selector was not found."); if (selector.kind === "tableRow") { if (selector.expectedHash && selector.expectedHash !== row.hash) fail("SOURCE_CHANGED", "Table row expectedHash precondition failed.", { expectedHash: selector.expectedHash, actualHash: row.hash }); return { blocks: [{ kind: "tableRow", story: story!.kind, table: selector.table, row: selector.row, ...row }], totalMatches: 1, truncated: false, stories: [story!.kind], warnings: snapshot.warnings }; } const cell = row.cells[selector.cell - 1]; if (!cell) fail("SELECTOR_NOT_FOUND", "Table cell selector was not found."); if (selector.expectedHash && selector.expectedHash !== cell.hash) fail("SOURCE_CHANGED", "Table cell expectedHash precondition failed.", { expectedHash: selector.expectedHash, actualHash: cell.hash }); return { blocks: [{ kind: "tableCell", story: story!.kind, table: selector.table, row: selector.row, cell: selector.cell, ...cell }], totalMatches: 1, truncated: false, stories: [story!.kind], warnings: snapshot.warnings }; } if (selector?.kind === "contentControl") { const matches = snapshot.contentControls.filter((control) => (!selector.tag || control.tag === selector.tag) && (!selector.title || control.title === selector.title)); if (!matches.length) fail("SELECTOR_NOT_FOUND", "Content-control selector was not found."); if (matches.length > 1) fail("AMBIGUOUS_SELECTOR", `Content-control selector matched ${matches.length} controls.`); return { blocks: matches, totalMatches: 1, truncated: false, stories: [...new Set(matches.map((item) => item.story))], warnings: snapshot.warnings }; } let paragraphs = snapshot.stories.filter((story) => wantedStories.has(story.kind)).flatMap((story) => story.paragraphs); if (selector) { paragraphs = paragraphs.filter((paragraph) => selectorMatches(paragraph, selector)); if (selector.kind === "text") { const totalSelectorMatches = paragraphs.length; if (selector.expectedCount !== undefined && selector.expectedCount !== totalSelectorMatches) fail("AMBIGUOUS_SELECTOR", "Text selector expectedCount precondition failed.", { expected: selector.expectedCount, actual: totalSelectorMatches }); if (selector.occurrence !== undefined) paragraphs = paragraphs.slice(selector.occurrence - 1, selector.occurrence); } if (!paragraphs.length) fail("SELECTOR_NOT_FOUND", "Semantic selector matched no block."); } if (request.query) { const query = request.exact === false ? request.query.toLocaleLowerCase() : request.query; paragraphs = paragraphs.filter((paragraph) => (request.exact === false ? paragraph.text.toLocaleLowerCase() : paragraph.text).includes(query)); } const total = paragraphs.length; return { blocks: paragraphs.slice(0, max), totalMatches: total, truncated: total > max, stories: [...wantedStories], warnings: snapshot.warnings }; }