Users can now attach Microsoft Office documents (.docx, .pptx, and .xlsx) and OpenDocument files (.odt, .odp, and .ods) from the shared web, desktop, mobile, and VS Code chat surfaces. Document text is extracted locally and sent as a text/plain file part with the original filename, keeping the visible user message clean. Supported embedded PNG, JPEG, GIF, and WebP images are sent as separate image parts, with matching [filename] citations preserved near their source paragraph, slide object, spreadsheet cell anchor, or OpenDocument position. Presentation notes, spreadsheet values, headers, and footers are included where available. Document expansion is metadata-validated and bounded against oversized entries, excessive uncompressed data, unsafe paths, invalid image signatures, attachment-name races, and dangling citations after truncation. Generated document parts are published to the composer atomically. Add fflate for worker-backed ZIP extraction and narrowly allow blob workers in the VS Code webview CSP without permitting blob scripts. Include focused fixtures for every supported format, extraction limits, positional citations, collision recovery, atomic attachment state, and CSP behavior.
597 lines
24 KiB
TypeScript
597 lines
24 KiB
TypeScript
import { unzip, unzipSync, type UnzipFileInfo, type Unzipped } from "fflate"
|
|
|
|
const MAX_ARCHIVE_BYTES = 20 * 1024 * 1024
|
|
const MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024
|
|
const MAX_ENTRY_BYTES = 25 * 1024 * 1024
|
|
const MAX_XML_ENTRY_BYTES = 8 * 1024 * 1024
|
|
const MAX_ARCHIVE_ENTRIES = 5_000
|
|
const MAX_EMBEDDED_IMAGES = 50
|
|
const MAX_EMBEDDED_IMAGE_BYTES = 20 * 1024 * 1024
|
|
const MAX_EMBEDDED_IMAGES_BYTES = 40 * 1024 * 1024
|
|
const MAX_EXTRACTED_TEXT_CHARS = 2_000_000
|
|
const MAX_ODF_SPACES_PER_ELEMENT = 100
|
|
const TEXT_TRUNCATION_NOTICE = "\n\n[Document text truncated by OpenChamber]\n"
|
|
|
|
const OFFICE_EXTENSIONS = new Set(["docx", "pptx", "xlsx", "odt", "odp", "ods"])
|
|
const IMAGE_MIMES = new Map([
|
|
["png", "image/png"],
|
|
["jpg", "image/jpeg"],
|
|
["jpeg", "image/jpeg"],
|
|
["gif", "image/gif"],
|
|
["webp", "image/webp"],
|
|
])
|
|
|
|
type Relationship = { target: string; type: string }
|
|
type Relationships = Map<string, Relationship>
|
|
|
|
type ExtractedDocumentAttachments = {
|
|
textFile: File
|
|
images: File[]
|
|
}
|
|
|
|
const extensionOf = (name: string): string => {
|
|
const index = name.lastIndexOf(".")
|
|
return index === -1 ? "" : name.slice(index + 1).toLowerCase()
|
|
}
|
|
|
|
const basenameWithoutExtension = (name: string): string => {
|
|
const basename = name.replace(/\\/g, "/").split("/").pop() || "document"
|
|
const index = basename.lastIndexOf(".")
|
|
return (index > 0 ? basename.slice(0, index) : basename).replace(/[^a-zA-Z0-9._-]+/g, "-") || "document"
|
|
}
|
|
|
|
const normalizeArchivePath = (path: string): string | undefined => {
|
|
const segments: string[] = []
|
|
for (const segment of path.replace(/\\/g, "/").split("/")) {
|
|
if (!segment || segment === ".") continue
|
|
if (segment === "..") {
|
|
if (segments.length === 0) return
|
|
segments.pop()
|
|
continue
|
|
}
|
|
segments.push(segment)
|
|
}
|
|
return segments.join("/")
|
|
}
|
|
|
|
const resolveArchivePath = (sourcePath: string, target: string): string | undefined => {
|
|
if (target.startsWith("/")) return normalizeArchivePath(target.slice(1))
|
|
const sourceDirectory = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/") + 1) : ""
|
|
return normalizeArchivePath(`${sourceDirectory}${target}`)
|
|
}
|
|
|
|
const relationshipsPath = (sourcePath: string): string => {
|
|
const index = sourcePath.lastIndexOf("/")
|
|
const directory = index === -1 ? "" : sourcePath.slice(0, index + 1)
|
|
const filename = sourcePath.slice(index + 1)
|
|
return `${directory}_rels/${filename}.rels`
|
|
}
|
|
|
|
const decodeXmlCodePoint = (code: string, radix: number): string => {
|
|
const value = Number.parseInt(code, radix)
|
|
if (value < 0 || value > 0x10FFFF || (value >= 0xD800 && value <= 0xDFFF)) return "�"
|
|
return String.fromCodePoint(value)
|
|
}
|
|
|
|
const decodeXml = (value: string): string => value
|
|
.replace(/&#x([0-9a-f]+);/gi, (_, code: string) => decodeXmlCodePoint(code, 16))
|
|
.replace(/&#([0-9]+);/g, (_, code: string) => decodeXmlCodePoint(code, 10))
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, "&")
|
|
|
|
const attribute = (tag: string, name: string): string | undefined => {
|
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
const match = tag.match(new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"))
|
|
return decodeXml(match?.[1] ?? match?.[2] ?? "") || undefined
|
|
}
|
|
|
|
const tagBlocks = (xml: string, tag: string): string[] => {
|
|
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
return Array.from(xml.matchAll(new RegExp(`<${escaped}\\b[^>]*>[\\s\\S]*?<\\/${escaped}>`, "gi")), (match) => match[0])
|
|
}
|
|
|
|
const textDecoder = new TextDecoder()
|
|
const xml = (archive: Unzipped, path: string): string => {
|
|
const bytes = archive[path]
|
|
return bytes ? textDecoder.decode(bytes) : ""
|
|
}
|
|
|
|
const parseRelationships = (archive: Unzipped, sourcePath: string): Relationships => {
|
|
const result: Relationships = new Map()
|
|
const source = xml(archive, relationshipsPath(sourcePath))
|
|
for (const match of source.matchAll(/<Relationship\b[^>]*\/?\s*>/gi)) {
|
|
const id = attribute(match[0], "Id")
|
|
const target = attribute(match[0], "Target")
|
|
if (!id || !target) continue
|
|
result.set(id, { target, type: attribute(match[0], "Type") ?? "" })
|
|
}
|
|
return result
|
|
}
|
|
|
|
const isControlCharacter = (character: string): boolean => {
|
|
const code = character.charCodeAt(0)
|
|
return code <= 0x1F || code === 0x7F
|
|
}
|
|
|
|
const hasControlCharacters = (value: string): boolean => Array.from(value).some(isControlCharacter)
|
|
|
|
const embeddedImageLabel = (path: string): string => {
|
|
const basename = path.replace(/\\/g, "/").split("/").pop() || "embedded image"
|
|
return Array.from(basename.slice(0, 200), (character) => {
|
|
if (character === "[" || character === "]") return "_"
|
|
return isControlCharacter(character) ? "_" : character
|
|
}).join("")
|
|
}
|
|
|
|
const hasBytes = (bytes: Uint8Array, expected: number[]): boolean => expected.every((value, index) => bytes[index] === value)
|
|
|
|
const hasAscii = (bytes: Uint8Array, offset: number, expected: string): boolean => {
|
|
if (bytes.byteLength < offset + expected.length) return false
|
|
for (let index = 0; index < expected.length; index += 1) {
|
|
if (bytes[offset + index] !== expected.charCodeAt(index)) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const hasValidImageSignature = (bytes: Uint8Array, extension: string): boolean => {
|
|
switch (extension) {
|
|
case "png":
|
|
return hasBytes(bytes, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
|
case "jpg":
|
|
case "jpeg":
|
|
return hasBytes(bytes, [0xFF, 0xD8, 0xFF])
|
|
case "gif":
|
|
return hasAscii(bytes, 0, "GIF87a") || hasAscii(bytes, 0, "GIF89a")
|
|
case "webp":
|
|
return hasAscii(bytes, 0, "RIFF") && hasAscii(bytes, 8, "WEBP")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
class EmbeddedImages {
|
|
private readonly files: File[] = []
|
|
private readonly filenames = new Map<string, string>()
|
|
private count = 0
|
|
private imageBytes = 0
|
|
private readonly reservedFilenames: Set<string>
|
|
|
|
constructor(
|
|
private readonly archive: Unzipped,
|
|
private readonly documentName: string,
|
|
reservedFilenames: Iterable<string>,
|
|
) {
|
|
this.reservedFilenames = new Set(Array.from(reservedFilenames, (filename) => filename.toLowerCase()))
|
|
}
|
|
|
|
citation(path: string | undefined): string {
|
|
if (!path) return "[Embedded image reference could not be resolved]"
|
|
const normalized = normalizeArchivePath(path)
|
|
if (!normalized) return "[Unsafe embedded image path omitted]"
|
|
const existing = this.filenames.get(normalized)
|
|
if (existing) return `[${existing}]`
|
|
|
|
const bytes = this.archive[normalized]
|
|
const extension = extensionOf(normalized)
|
|
const mime = IMAGE_MIMES.get(extension)
|
|
const label = embeddedImageLabel(normalized)
|
|
if (!bytes || !mime) return `[Unsupported embedded image omitted: ${label}]`
|
|
if (!hasValidImageSignature(bytes, extension)) return `[Invalid embedded image omitted: ${label}]`
|
|
if (
|
|
this.files.length >= MAX_EMBEDDED_IMAGES
|
|
|| bytes.byteLength > MAX_EMBEDDED_IMAGE_BYTES
|
|
|| this.imageBytes + bytes.byteLength > MAX_EMBEDDED_IMAGES_BYTES
|
|
) {
|
|
return `[Embedded image omitted by attachment limits: ${label}]`
|
|
}
|
|
|
|
const outputExtension = extension === "jpeg" ? "jpg" : extension
|
|
let filename: string
|
|
do {
|
|
this.count += 1
|
|
filename = `${basenameWithoutExtension(this.documentName)}-image-${this.count}.${outputExtension}`
|
|
} while (this.reservedFilenames.has(filename.toLowerCase()))
|
|
this.reservedFilenames.add(filename.toLowerCase())
|
|
this.filenames.set(normalized, filename)
|
|
this.files.push(new File([bytes], filename, { type: mime }))
|
|
this.imageBytes += bytes.byteLength
|
|
return `[${filename}]`
|
|
}
|
|
|
|
all(): File[] {
|
|
return this.files
|
|
}
|
|
}
|
|
|
|
const relationshipTarget = (sourcePath: string, relationships: Relationships, id: string | undefined): string | undefined => {
|
|
if (!id) return
|
|
const relationship = relationships.get(id)
|
|
return relationship ? resolveArchivePath(sourcePath, relationship.target) : undefined
|
|
}
|
|
|
|
const inlineText = (
|
|
block: string,
|
|
sourcePath: string,
|
|
relationships: Relationships,
|
|
images: EmbeddedImages,
|
|
): string => {
|
|
const pieces: string[] = []
|
|
const tokenPattern = /<(?:w:t|a:t|text:span)\b[^>]*>([\s\S]*?)<\/(?:w:t|a:t|text:span)>|<(?:w:tab|text:tab)\b[^>]*\/?>|<(?:w:br|a:br|text:line-break)\b[^>]*\/?>|<(?:a:blip|v:imagedata)\b[^>]*>|<draw:image\b[^>]*>/gi
|
|
for (const match of block.matchAll(tokenPattern)) {
|
|
if (match[1] !== undefined) {
|
|
pieces.push(decodeXml(match[1]).replace(/<[^>]+>/g, ""))
|
|
continue
|
|
}
|
|
if (/tab/i.test(match[0])) {
|
|
pieces.push("\t")
|
|
continue
|
|
}
|
|
if (/br|line-break/i.test(match[0])) {
|
|
pieces.push("\n")
|
|
continue
|
|
}
|
|
const relationshipId = attribute(match[0], "r:embed") ?? attribute(match[0], "r:id")
|
|
const directPath = attribute(match[0], "xlink:href")
|
|
const target = directPath
|
|
? resolveArchivePath(sourcePath, directPath)
|
|
: relationshipTarget(sourcePath, relationships, relationshipId)
|
|
pieces.push(`\n${images.citation(target)}\n`)
|
|
}
|
|
return pieces.join("").replace(/[ \t]+\n/g, "\n").trim()
|
|
}
|
|
|
|
const paragraphs = (
|
|
source: string,
|
|
paragraphTag: string,
|
|
sourcePath: string,
|
|
relationships: Relationships,
|
|
images: EmbeddedImages,
|
|
): string[] => tagBlocks(source, paragraphTag)
|
|
.map((block) => inlineText(block, sourcePath, relationships, images))
|
|
.filter(Boolean)
|
|
|
|
const extractDocx = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
|
|
const documentPath = "word/document.xml"
|
|
const documentXml = xml(archive, documentPath)
|
|
if (!documentXml) return
|
|
const sections = ["# Document", ...paragraphs(documentXml, "w:p", documentPath, parseRelationships(archive, documentPath), images)]
|
|
|
|
const extras = Object.keys(archive)
|
|
.filter((path) => /^word\/(?:header|footer)\d+\.xml$/i.test(path))
|
|
.sort()
|
|
for (const path of extras) {
|
|
const content = paragraphs(xml(archive, path), "w:p", path, parseRelationships(archive, path), images)
|
|
if (content.length > 0) sections.push(`## ${path.includes("header") ? "Header" : "Footer"}`, ...content)
|
|
}
|
|
return `${sections.join("\n\n")}\n`
|
|
}
|
|
|
|
const numberedPaths = (archive: Unzipped, pattern: RegExp): string[] => Object.keys(archive)
|
|
.filter((path) => pattern.test(path))
|
|
.sort((left, right) => {
|
|
const leftNumber = Number(left.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0)
|
|
const rightNumber = Number(right.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0)
|
|
return leftNumber - rightNumber
|
|
})
|
|
|
|
const extractPptx = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
|
|
const slidePaths = numberedPaths(archive, /^ppt\/slides\/slide\d+\.xml$/i)
|
|
if (slidePaths.length === 0) return
|
|
const sections: string[] = ["# Presentation"]
|
|
|
|
slidePaths.forEach((slidePath, index) => {
|
|
const relationships = parseRelationships(archive, slidePath)
|
|
const content = Array.from(
|
|
xml(archive, slidePath).matchAll(/<a:p\b[^>]*>[\s\S]*?<\/a:p>|<p:pic\b[^>]*>[\s\S]*?<\/p:pic>/gi),
|
|
(match) => inlineText(match[0], slidePath, relationships, images),
|
|
).filter(Boolean)
|
|
sections.push(`## Slide ${index + 1}`, ...(content.length > 0 ? content : ["[Empty slide]"]))
|
|
|
|
const notesRelationship = Array.from(relationships.values()).find((relationship) => relationship.type.endsWith("/notesSlide"))
|
|
const notesPath = notesRelationship ? resolveArchivePath(slidePath, notesRelationship.target) : undefined
|
|
if (!notesPath) return
|
|
const notes = paragraphs(xml(archive, notesPath), "a:p", notesPath, parseRelationships(archive, notesPath), images)
|
|
if (notes.length > 0) sections.push(`### Slide ${index + 1} notes`, ...notes)
|
|
})
|
|
return `${sections.join("\n\n")}\n`
|
|
}
|
|
|
|
const columnName = (index: number): string => {
|
|
let value = index + 1
|
|
let result = ""
|
|
while (value > 0) {
|
|
value -= 1
|
|
result = String.fromCharCode(65 + (value % 26)) + result
|
|
value = Math.floor(value / 26)
|
|
}
|
|
return result
|
|
}
|
|
|
|
const cellValue = (cell: string, sharedStrings: string[]): string => {
|
|
const type = attribute(cell.match(/^<c\b[^>]*>/i)?.[0] ?? "", "t")
|
|
if (type === "inlineStr") {
|
|
return Array.from(cell.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/gi), (match) => decodeXml(match[1]).replace(/<[^>]+>/g, "")).join("")
|
|
}
|
|
const value = cell.match(/<v\b[^>]*>([\s\S]*?)<\/v>/i)?.[1] ?? ""
|
|
if (type === "s") return sharedStrings[Number(value)] ?? ""
|
|
if (type === "b") return value === "1" ? "TRUE" : "FALSE"
|
|
return decodeXml(value)
|
|
}
|
|
|
|
const drawingCitations = (
|
|
archive: Unzipped,
|
|
worksheetPath: string,
|
|
images: EmbeddedImages,
|
|
): string[] => {
|
|
const worksheetXml = xml(archive, worksheetPath)
|
|
const worksheetRelationships = parseRelationships(archive, worksheetPath)
|
|
const output: string[] = []
|
|
for (const drawing of worksheetXml.matchAll(/<drawing\b[^>]*r:id=(?:"([^"]+)"|'([^']+)')[^>]*\/?\s*>/gi)) {
|
|
const drawingPath = relationshipTarget(worksheetPath, worksheetRelationships, drawing[1] ?? drawing[2])
|
|
if (!drawingPath) continue
|
|
const drawingXml = xml(archive, drawingPath)
|
|
const drawingRelationships = parseRelationships(archive, drawingPath)
|
|
for (const anchor of drawingXml.matchAll(/<xdr:(?:oneCellAnchor|twoCellAnchor)\b[^>]*>([\s\S]*?)<\/xdr:(?:oneCellAnchor|twoCellAnchor)>/gi)) {
|
|
const content = anchor[1]
|
|
const column = Number(content.match(/<xdr:col>(\d+)<\/xdr:col>/i)?.[1] ?? 0)
|
|
const row = Number(content.match(/<xdr:row>(\d+)<\/xdr:row>/i)?.[1] ?? 0)
|
|
const imageId = content.match(/<a:blip\b[^>]*r:embed=(?:"([^"]+)"|'([^']+)')[^>]*>/i)
|
|
const target = relationshipTarget(drawingPath, drawingRelationships, imageId?.[1] ?? imageId?.[2])
|
|
output.push(`Image at ${columnName(column)}${row + 1}: ${images.citation(target)}`)
|
|
}
|
|
}
|
|
return output
|
|
}
|
|
|
|
const extractXlsx = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
|
|
const workbookPath = "xl/workbook.xml"
|
|
const workbookXml = xml(archive, workbookPath)
|
|
if (!workbookXml) return
|
|
const workbookRelationships = parseRelationships(archive, workbookPath)
|
|
const sharedStrings = tagBlocks(xml(archive, "xl/sharedStrings.xml"), "si")
|
|
.map((item) => Array.from(item.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/gi), (match) => decodeXml(match[1]).replace(/<[^>]+>/g, "")).join(""))
|
|
const sections: string[] = ["# Workbook"]
|
|
|
|
for (const sheet of workbookXml.matchAll(/<sheet\b[^>]*\/?\s*>/gi)) {
|
|
const name = attribute(sheet[0], "name") ?? "Sheet"
|
|
const relationshipId = attribute(sheet[0], "r:id")
|
|
const worksheetPath = relationshipTarget(workbookPath, workbookRelationships, relationshipId)
|
|
if (!worksheetPath) continue
|
|
sections.push(`## Sheet: ${name}`)
|
|
|
|
const rows: string[] = []
|
|
for (const row of tagBlocks(xml(archive, worksheetPath), "row")) {
|
|
const cells = Array.from(row.matchAll(/<c\b[^>]*>[\s\S]*?<\/c>/gi), (match) => {
|
|
const tag = match[0].match(/^<c\b[^>]*>/i)?.[0] ?? ""
|
|
const reference = attribute(tag, "r") ?? "?"
|
|
return `${reference}: ${cellValue(match[0], sharedStrings)}`
|
|
}).filter((value) => !value.endsWith(": "))
|
|
if (cells.length > 0) rows.push(cells.join(" | "))
|
|
}
|
|
sections.push(...(rows.length > 0 ? rows : ["[Empty sheet]"]), ...drawingCitations(archive, worksheetPath, images))
|
|
}
|
|
return `${sections.join("\n\n")}\n`
|
|
}
|
|
|
|
const expandOdfSpaces = (tag: string): string => {
|
|
const rawCount = attribute(tag, "text:c")
|
|
if (!rawCount) return " "
|
|
|
|
const count = Number(rawCount)
|
|
if (Number.isSafeInteger(count) && count > 0 && count <= MAX_ODF_SPACES_PER_ELEMENT) return " ".repeat(count)
|
|
|
|
const omitted = Number.isSafeInteger(count) && count > MAX_ODF_SPACES_PER_ELEMENT
|
|
? `${count - MAX_ODF_SPACES_PER_ELEMENT} additional spaces omitted`
|
|
: "Additional spaces omitted"
|
|
return `${" ".repeat(MAX_ODF_SPACES_PER_ELEMENT)}[${omitted}]`
|
|
}
|
|
|
|
const odfInlineText = (source: string, sourcePath: string, images: EmbeddedImages): string => source
|
|
.replace(/<draw:image\b[^>]*>/gi, (tag) => {
|
|
const target = resolveArchivePath(sourcePath, attribute(tag, "xlink:href") ?? "")
|
|
return `\n${images.citation(target)}\n`
|
|
})
|
|
.replace(/<text:tab\b[^>]*\/?\s*>/gi, "\t")
|
|
.replace(/<text:line-break\b[^>]*\/?\s*>/gi, "\n")
|
|
.replace(/<text:s\b[^>]*\/?\s*>/gi, expandOdfSpaces)
|
|
.replace(/<[^>]+>/g, "")
|
|
|
|
const odfContent = (source: string, sourcePath: string, images: EmbeddedImages): string[] => {
|
|
const output: string[] = []
|
|
const contentPattern = /<text:(p|h)\b[^>]*>[\s\S]*?<\/text:\1>|<draw:image\b[^>]*>/gi
|
|
for (const match of source.matchAll(contentPattern)) {
|
|
const content = /^<draw:image\b/i.test(match[0])
|
|
? images.citation(resolveArchivePath(sourcePath, attribute(match[0], "xlink:href") ?? ""))
|
|
: decodeXml(odfInlineText(match[0], sourcePath, images)).replace(/[ \t]+\n/g, "\n").trim()
|
|
if (content) output.push(content)
|
|
}
|
|
return output
|
|
}
|
|
|
|
const extractOdt = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
|
|
const contentPath = "content.xml"
|
|
const content = xml(archive, contentPath)
|
|
if (!content) return
|
|
return `${["# Document", ...odfContent(content, contentPath, images)].join("\n\n")}\n`
|
|
}
|
|
|
|
const extractOdp = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
|
|
const contentPath = "content.xml"
|
|
const content = xml(archive, contentPath)
|
|
if (!content) return
|
|
const sections = ["# Presentation"]
|
|
const pages = tagBlocks(content, "draw:page")
|
|
pages.forEach((page, index) => {
|
|
const openTag = page.match(/^<draw:page\b[^>]*>/i)?.[0] ?? ""
|
|
const name = attribute(openTag, "draw:name") ?? String(index + 1)
|
|
sections.push(`## Slide: ${name}`, ...odfContent(page, contentPath, images))
|
|
})
|
|
return `${sections.join("\n\n")}\n`
|
|
}
|
|
|
|
const extractOds = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
|
|
const contentPath = "content.xml"
|
|
const content = xml(archive, contentPath)
|
|
if (!content) return
|
|
const sections = ["# Workbook"]
|
|
for (const table of tagBlocks(content, "table:table")) {
|
|
const openTag = table.match(/^<table:table\b[^>]*>/i)?.[0] ?? ""
|
|
sections.push(`## Sheet: ${attribute(openTag, "table:name") ?? "Sheet"}`)
|
|
for (const shapes of tagBlocks(table, "table:shapes")) {
|
|
sections.push(...odfContent(shapes, contentPath, images))
|
|
}
|
|
for (const row of tagBlocks(table, "table:table-row")) {
|
|
const cells = tagBlocks(row, "table:table-cell")
|
|
.map((cell) => odfContent(cell, contentPath, images).join(" "))
|
|
if (cells.some(Boolean)) sections.push(cells.join(" | "))
|
|
}
|
|
}
|
|
return `${sections.join("\n\n")}\n`
|
|
}
|
|
|
|
const createArchiveEntryValidator = () => {
|
|
let entries = 0
|
|
let uncompressedBytes = 0
|
|
|
|
return (info: UnzipFileInfo): void => {
|
|
entries += 1
|
|
uncompressedBytes += info.originalSize
|
|
if (entries > MAX_ARCHIVE_ENTRIES) throw new Error("Document contains too many files")
|
|
if (info.originalSize > MAX_ENTRY_BYTES) throw new Error("Document contains an oversized file")
|
|
if (/\.(?:xml|rels)$/i.test(info.name) && info.originalSize > MAX_XML_ENTRY_BYTES) {
|
|
throw new Error("Document contains XML that is too large to process safely")
|
|
}
|
|
if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) throw new Error("Document expands beyond the 100 MB safety limit")
|
|
const normalized = normalizeArchivePath(info.name)
|
|
const canonicalName = info.name.endsWith("/") ? info.name.slice(0, -1) : info.name
|
|
if (
|
|
!normalized
|
|
|| normalized !== canonicalName
|
|
|| info.name.includes("\\")
|
|
|| /^[a-z]:/i.test(info.name)
|
|
|| hasControlCharacters(info.name)
|
|
) {
|
|
throw new Error("Document contains an unsafe file path")
|
|
}
|
|
}
|
|
}
|
|
|
|
const shouldExtractArchiveEntry = (info: UnzipFileInfo): boolean => {
|
|
const extension = extensionOf(info.name)
|
|
return extension === "xml" || extension === "rels" || IMAGE_MIMES.has(extension)
|
|
}
|
|
|
|
const validateArchiveMetadata = async (data: Uint8Array): Promise<void> => {
|
|
const validateArchiveEntry = createArchiveEntryValidator()
|
|
if ("Bun" in globalThis) {
|
|
unzipSync(data, { filter: (info) => {
|
|
validateArchiveEntry(info)
|
|
return false
|
|
} })
|
|
return
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
unzip(data, { filter: (info) => {
|
|
validateArchiveEntry(info)
|
|
return false
|
|
} }, (error) => {
|
|
if (error) reject(error)
|
|
else resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
const validateExtractedArchive = (archive: Unzipped): void => {
|
|
let uncompressedBytes = 0
|
|
for (const [path, bytes] of Object.entries(archive)) {
|
|
uncompressedBytes += bytes.byteLength
|
|
if (bytes.byteLength > MAX_ENTRY_BYTES) throw new Error("Document contains an oversized file")
|
|
if (/\.(?:xml|rels)$/i.test(path) && bytes.byteLength > MAX_XML_ENTRY_BYTES) {
|
|
throw new Error("Document contains XML that is too large to process safely")
|
|
}
|
|
if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) throw new Error("Document expands beyond the 100 MB safety limit")
|
|
}
|
|
}
|
|
|
|
const unzipDocument = async (file: File): Promise<Unzipped> => {
|
|
if (file.size > MAX_ARCHIVE_BYTES) throw new Error("Document exceeds the 20 MB attachment limit")
|
|
const data = new Uint8Array(await file.arrayBuffer())
|
|
await validateArchiveMetadata(data)
|
|
|
|
// Bun's browser-style Blob workers do not reliably execute fflate's async decoder.
|
|
// Production browser runtimes use the worker-backed path below.
|
|
const archive = "Bun" in globalThis
|
|
? unzipSync(data, { filter: shouldExtractArchiveEntry })
|
|
: await new Promise<Unzipped>((resolve, reject) => {
|
|
unzip(data, { filter: shouldExtractArchiveEntry }, (error, result) => {
|
|
if (error) reject(error)
|
|
else resolve(result)
|
|
})
|
|
})
|
|
validateExtractedArchive(archive)
|
|
return archive
|
|
}
|
|
|
|
const extractDocumentText = (extension: string, archive: Unzipped, images: EmbeddedImages): string | undefined => {
|
|
switch (extension) {
|
|
case "docx":
|
|
return extractDocx(archive, images)
|
|
case "pptx":
|
|
return extractPptx(archive, images)
|
|
case "xlsx":
|
|
return extractXlsx(archive, images)
|
|
case "odt":
|
|
return extractOdt(archive, images)
|
|
case "odp":
|
|
return extractOdp(archive, images)
|
|
case "ods":
|
|
return extractOds(archive, images)
|
|
default:
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
const boundExtractedText = (text: string, imageFilenames: Set<string>): string => {
|
|
if (text.length <= MAX_EXTRACTED_TEXT_CHARS) return text
|
|
|
|
let end = MAX_EXTRACTED_TEXT_CHARS - TEXT_TRUNCATION_NOTICE.length
|
|
const lastOpenBracket = text.lastIndexOf("[", end - 1)
|
|
const lastCloseBracket = text.lastIndexOf("]", end - 1)
|
|
if (lastOpenBracket > lastCloseBracket) {
|
|
const nextCloseBracket = text.indexOf("]", lastOpenBracket)
|
|
const candidate = nextCloseBracket === -1 ? "" : text.slice(lastOpenBracket + 1, nextCloseBracket)
|
|
if (imageFilenames.has(candidate)) end = lastOpenBracket
|
|
}
|
|
return `${text.slice(0, end)}${TEXT_TRUNCATION_NOTICE}`
|
|
}
|
|
|
|
const citedImageFilenames = (text: string): Set<string> => {
|
|
const filenames = new Set<string>()
|
|
for (const match of text.matchAll(/\[([^\]\r\n]+)\]/g)) filenames.add(match[1])
|
|
return filenames
|
|
}
|
|
|
|
export const extractDocumentAttachments = async (
|
|
file: File,
|
|
reservedFilenames: Iterable<string> = [],
|
|
): Promise<ExtractedDocumentAttachments | undefined> => {
|
|
const extension = extensionOf(file.name)
|
|
if (!OFFICE_EXTENSIONS.has(extension)) return
|
|
const archive = await unzipDocument(file)
|
|
const images = new EmbeddedImages(archive, file.name, reservedFilenames)
|
|
const text = extractDocumentText(extension, archive, images)
|
|
if (!text) return
|
|
const extractedImages = images.all()
|
|
const imageFilenames = new Set(extractedImages.map((image) => image.name))
|
|
const boundedText = boundExtractedText(text, imageFilenames)
|
|
const citations = citedImageFilenames(boundedText)
|
|
return {
|
|
textFile: new File([boundedText], file.name, { type: "text/plain" }),
|
|
images: extractedImages.filter((image) => citations.has(image.name)),
|
|
}
|
|
}
|