feat: support Office documents in chat attachments
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.
This commit is contained in:
@@ -182,6 +182,7 @@
|
||||
"cron-parser": "^5.5.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"express": "^5.1.0",
|
||||
"fflate": "^0.8.3",
|
||||
"fuse.js": "^7.1.0",
|
||||
"ghostty-web": "^0.4.0",
|
||||
"heic2any": "^0.0.4",
|
||||
@@ -2000,6 +2001,8 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"cron-parser": "^5.5.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"express": "^5.1.0",
|
||||
"fflate": "^0.8.3",
|
||||
"fuse.js": "^7.1.0",
|
||||
"ghostty-web": "^0.4.0",
|
||||
"heic2any": "^0.0.4",
|
||||
|
||||
@@ -48,12 +48,15 @@ So:
|
||||
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
|
||||
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
|
||||
| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes |
|
||||
| `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments |
|
||||
| `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state |
|
||||
| `selection-store.ts` | Model/agent/variant selections | App UI state |
|
||||
| `voice-store.ts` | Voice state | App UI state |
|
||||
|
||||
Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection.
|
||||
|
||||
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 2,000,000 characters. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.
|
||||
|
||||
## Session list rules
|
||||
|
||||
### Directory bootstrap scheduling
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("attachment file preparation", () => {
|
||||
for (const extension of [
|
||||
"diff", "patch", "ipynb", "jsonl", "ndjson", "har", "svg", "drawio",
|
||||
"vue", "svelte", "php", "cs", "kt", "swift", "lua", "dart", "tf", "hcl", "proto",
|
||||
"docx", "pptx", "xlsx", "odt", "odp", "ods",
|
||||
]) {
|
||||
expect(ACCEPTED_ATTACHMENT_EXTENSIONS.includes(extension)).toBe(true)
|
||||
expect(ATTACHMENT_ACCEPT.includes(`.${extension}`)).toBe(true)
|
||||
|
||||
@@ -6,6 +6,12 @@ const ACCEPTED_ATTACHMENT_TYPES = [
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.oasis.opendocument.presentation",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"text/*",
|
||||
"application/json",
|
||||
"application/ld+json",
|
||||
@@ -27,6 +33,7 @@ const ACCEPTED_ATTACHMENT_TYPES = [
|
||||
".cts",
|
||||
".dart",
|
||||
".diff",
|
||||
".docx",
|
||||
".drawio",
|
||||
".env",
|
||||
".erl",
|
||||
@@ -64,9 +71,13 @@ const ACCEPTED_ATTACHMENT_TYPES = [
|
||||
".mjs",
|
||||
".mts",
|
||||
".ndjson",
|
||||
".odp",
|
||||
".ods",
|
||||
".odt",
|
||||
".patch",
|
||||
".php",
|
||||
".proto",
|
||||
".pptx",
|
||||
".ps1",
|
||||
".py",
|
||||
".r",
|
||||
@@ -88,6 +99,7 @@ const ACCEPTED_ATTACHMENT_TYPES = [
|
||||
".txt",
|
||||
".vue",
|
||||
".xml",
|
||||
".xlsx",
|
||||
".yaml",
|
||||
".yml",
|
||||
".zig",
|
||||
@@ -104,6 +116,12 @@ const PICKER_MIME_EXTENSIONS = new Map<string, string>([
|
||||
["image/heic", "heic"],
|
||||
["image/heif", "heif"],
|
||||
["application/pdf", "pdf"],
|
||||
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"],
|
||||
["application/vnd.openxmlformats-officedocument.presentationml.presentation", "pptx"],
|
||||
["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"],
|
||||
["application/vnd.oasis.opendocument.text", "odt"],
|
||||
["application/vnd.oasis.opendocument.presentation", "odp"],
|
||||
["application/vnd.oasis.opendocument.spreadsheet", "ods"],
|
||||
["application/json", "json"],
|
||||
["application/ld+json", "jsonld"],
|
||||
["application/toml", "toml"],
|
||||
@@ -157,11 +175,12 @@ const TEXT_MIMES = new Set([
|
||||
"image/svg+xml",
|
||||
])
|
||||
const ATTACHMENT_SAMPLE_BYTES = 4096
|
||||
const DOCUMENT_EXTENSIONS = new Set(["docx", "pptx", "xlsx", "odt", "odp", "ods"])
|
||||
const REDACTED = "[REDACTED]"
|
||||
const OMITTED = "[OMITTED BY OPENCHAMBER]"
|
||||
const SENSITIVE_NAMES = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[-_]?key|client[-_]?secret|password|secret|access[-_]?token|refresh[-_]?token|id[-_]?token|token)$/i
|
||||
|
||||
export type PreparedAttachmentFile = {
|
||||
type PreparedAttachmentFile = {
|
||||
file: File
|
||||
mimeType: string
|
||||
}
|
||||
@@ -341,3 +360,26 @@ export const prepareAttachmentFile = (
|
||||
if (typeof mime === "string") return { file, mimeType: mime }
|
||||
return mime?.then((mimeType) => mimeType ? { file, mimeType } : undefined)
|
||||
}
|
||||
|
||||
export const prepareAttachmentFiles = (
|
||||
file: File,
|
||||
reservedFilenames: Iterable<string> = [],
|
||||
): PreparedAttachmentFile[] | Promise<PreparedAttachmentFile[] | undefined> | undefined => {
|
||||
if (!DOCUMENT_EXTENSIONS.has(extensionOf(file.name))) {
|
||||
const prepared = prepareAttachmentFile(file)
|
||||
if (prepared instanceof Promise) return prepared.then((output) => output ? [output] : undefined)
|
||||
return prepared ? [prepared] : undefined
|
||||
}
|
||||
|
||||
return import("./document-attachments").then(async ({ extractDocumentAttachments }) => {
|
||||
const extracted = await extractDocumentAttachments(file, reservedFilenames)
|
||||
if (!extracted) return
|
||||
const prepared: PreparedAttachmentFile[] = [{ file: extracted.textFile, mimeType: "text/plain" }]
|
||||
for (const image of extracted.images) {
|
||||
const output = await prepareAttachmentFile(image)
|
||||
if (!output || !output.mimeType.startsWith("image/")) return
|
||||
prepared.push(output)
|
||||
}
|
||||
return prepared
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { strToU8, zipSync } from "fflate"
|
||||
import { extractDocumentAttachments } from "./document-attachments"
|
||||
|
||||
const zippedFile = (name: string, entries: Record<string, string | Uint8Array>) => new File([
|
||||
zipSync(Object.fromEntries(Object.entries(entries).map(([path, value]) => [
|
||||
path,
|
||||
typeof value === "string" ? strToU8(value) : value,
|
||||
]))),
|
||||
], name)
|
||||
|
||||
const relationships = (items: Array<{ id: string; target: string; type?: string }>) => `
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
${items.map((item) => `<Relationship Id="${item.id}" Target="${item.target}" Type="${item.type ?? "image"}"/>`).join("")}
|
||||
</Relationships>
|
||||
`
|
||||
|
||||
const pngBytes = (suffix = 0) => new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, suffix])
|
||||
const jpegBytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0])
|
||||
const webpBytes = new Uint8Array([
|
||||
0x52, 0x49, 0x46, 0x46, 0x04, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
|
||||
])
|
||||
|
||||
describe("document attachment extraction", () => {
|
||||
test("extracts DOCX text and preserves inline image citations", async () => {
|
||||
const file = zippedFile("report.docx", {
|
||||
"word/document.xml": `
|
||||
<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>Before image</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:drawing><a:blip r:embed="rId1"/></w:drawing></w:r></w:p>
|
||||
<w:p><w:r><w:t>After image</w:t></w:r></w:p>
|
||||
</w:body>
|
||||
</w:document>`,
|
||||
"word/_rels/document.xml.rels": relationships([{ id: "rId1", target: "media/image1.png" }]),
|
||||
"word/media/image1.png": pngBytes(),
|
||||
})
|
||||
|
||||
const result = await extractDocumentAttachments(file)
|
||||
const text = await result?.textFile.text() ?? ""
|
||||
|
||||
expect(text.includes("Before image\n\n[report-image-1.png]\n\nAfter image")).toBe(true)
|
||||
expect(result?.textFile.name).toBe("report.docx")
|
||||
expect(result?.textFile.type.startsWith("text/plain")).toBe(true)
|
||||
expect(result?.images).toHaveLength(1)
|
||||
expect(result?.images[0]?.name).toBe("report-image-1.png")
|
||||
expect(result?.images[0]?.type).toBe("image/png")
|
||||
|
||||
const deduplicated = await extractDocumentAttachments(file, ["report-image-1.png"])
|
||||
expect((await deduplicated?.textFile.text())?.includes("[report-image-2.png]")).toBe(true)
|
||||
expect(deduplicated?.images[0]?.name).toBe("report-image-2.png")
|
||||
})
|
||||
|
||||
test("extracts PPTX slide text, notes, and pictures", async () => {
|
||||
const file = zippedFile("deck.pptx", {
|
||||
"ppt/slides/slide1.xml": `
|
||||
<p:sld xmlns:p="p" xmlns:a="a" xmlns:r="r">
|
||||
<a:p><a:r><a:t>Slide title</a:t></a:r></a:p>
|
||||
<p:pic><p:blipFill><a:blip r:embed="rIdImage"/></p:blipFill></p:pic>
|
||||
</p:sld>`,
|
||||
"ppt/slides/_rels/slide1.xml.rels": relationships([
|
||||
{ id: "rIdImage", target: "../media/image1.jpeg" },
|
||||
{ id: "rIdNotes", target: "../notesSlides/notesSlide1.xml", type: "http://example/notesSlide" },
|
||||
]),
|
||||
"ppt/notesSlides/notesSlide1.xml": `<p:notes xmlns:p="p" xmlns:a="a"><a:p><a:r><a:t>Speaker note</a:t></a:r></a:p></p:notes>`,
|
||||
"ppt/media/image1.jpeg": jpegBytes,
|
||||
})
|
||||
|
||||
const result = await extractDocumentAttachments(file)
|
||||
const text = await result?.textFile.text() ?? ""
|
||||
|
||||
expect(text.includes("## Slide 1")).toBe(true)
|
||||
expect(text.includes("Slide title")).toBe(true)
|
||||
expect(text.includes("[deck-image-1.jpg]")).toBe(true)
|
||||
expect(text.includes("### Slide 1 notes\n\nSpeaker note")).toBe(true)
|
||||
expect(result?.images[0]?.name).toBe("deck-image-1.jpg")
|
||||
})
|
||||
|
||||
test("extracts XLSX cell values and anchors pictures to cells", async () => {
|
||||
const file = zippedFile("budget.xlsx", {
|
||||
"xl/workbook.xml": `<workbook xmlns:r="r"><sheets><sheet name="Summary" r:id="rIdSheet"/></sheets></workbook>`,
|
||||
"xl/_rels/workbook.xml.rels": relationships([{ id: "rIdSheet", target: "worksheets/sheet1.xml" }]),
|
||||
"xl/sharedStrings.xml": `<sst><si><t>Revenue</t></si></sst>`,
|
||||
"xl/worksheets/sheet1.xml": `
|
||||
<worksheet xmlns:r="r"><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>42</v></c></row></sheetData><drawing r:id="rIdDrawing"/></worksheet>`,
|
||||
"xl/worksheets/_rels/sheet1.xml.rels": relationships([{ id: "rIdDrawing", target: "../drawings/drawing1.xml" }]),
|
||||
"xl/drawings/drawing1.xml": `
|
||||
<xdr:wsDr xmlns:xdr="xdr" xmlns:a="a" xmlns:r="r"><xdr:oneCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:row>2</xdr:row></xdr:from><a:blip r:embed="rIdImage"/></xdr:oneCellAnchor></xdr:wsDr>`,
|
||||
"xl/drawings/_rels/drawing1.xml.rels": relationships([{ id: "rIdImage", target: "../media/image1.webp" }]),
|
||||
"xl/media/image1.webp": webpBytes,
|
||||
})
|
||||
|
||||
const result = await extractDocumentAttachments(file)
|
||||
const text = await result?.textFile.text() ?? ""
|
||||
|
||||
expect(text.includes("## Sheet: Summary")).toBe(true)
|
||||
expect(text.includes("A1: Revenue | B1: 42")).toBe(true)
|
||||
expect(text.includes("Image at B3: [budget-image-1.webp]")).toBe(true)
|
||||
expect(result?.images[0]?.name).toBe("budget-image-1.webp")
|
||||
})
|
||||
|
||||
test("extracts OpenDocument text, presentations, spreadsheets, and image positions", async () => {
|
||||
const image = pngBytes()
|
||||
const odt = zippedFile("notes.odt", {
|
||||
"content.xml": `<office:document xmlns:office="office" xmlns:text="text" xmlns:draw="draw" xmlns:xlink="xlink"><text:h>Heading</text:h><text:p>Hello <text:span>world</text:span></text:p><draw:frame><draw:image xlink:href="Pictures/photo.png"/></draw:frame><text:p>After image</text:p></office:document>`,
|
||||
"Pictures/photo.png": image,
|
||||
})
|
||||
const odp = zippedFile("slides.odp", {
|
||||
"content.xml": `<office:document xmlns:office="office" xmlns:text="text" xmlns:draw="draw"><draw:page draw:name="Intro"><text:p>Welcome</text:p></draw:page></office:document>`,
|
||||
})
|
||||
const ods = zippedFile("table.ods", {
|
||||
"content.xml": `<office:document xmlns:office="office" xmlns:text="text" xmlns:table="table" xmlns:draw="draw" xmlns:xlink="xlink"><table:table table:name="Data"><table:shapes><draw:frame><draw:image xlink:href="Pictures/chart.png"/></draw:frame></table:shapes><table:table-row><table:table-cell><text:p>Name</text:p></table:table-cell><table:table-cell><text:p>Value</text:p></table:table-cell></table:table-row></table:table></office:document>`,
|
||||
"Pictures/chart.png": image,
|
||||
})
|
||||
|
||||
const odtResult = await extractDocumentAttachments(odt)
|
||||
const odpResult = await extractDocumentAttachments(odp)
|
||||
const odsResult = await extractDocumentAttachments(ods)
|
||||
|
||||
expect((await odtResult?.textFile.text())?.includes("Heading\n\nHello world\n\n[notes-image-1.png]\n\nAfter image")).toBe(true)
|
||||
expect(odtResult?.images).toHaveLength(1)
|
||||
expect((await odpResult?.textFile.text())?.includes("## Slide: Intro\n\nWelcome")).toBe(true)
|
||||
expect((await odsResult?.textFile.text())?.includes("## Sheet: Data\n\n[table-image-1.png]\n\nName | Value")).toBe(true)
|
||||
expect(odsResult?.images).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("rejects unsafe archive paths", async () => {
|
||||
const file = zippedFile("unsafe.docx", {
|
||||
"../word/document.xml": `<w:document xmlns:w="w"><w:p><w:t>Unsafe</w:t></w:p></w:document>`,
|
||||
})
|
||||
await expect(extractDocumentAttachments(file)).rejects.toThrow("unsafe file path")
|
||||
})
|
||||
|
||||
test("rejects archives over the entry-count limit", async () => {
|
||||
const entries = Object.fromEntries(Array.from({ length: 5_001 }, (_, index) => [
|
||||
`metadata/entry-${index}.xml`,
|
||||
"<metadata/>",
|
||||
]))
|
||||
|
||||
await expect(extractDocumentAttachments(zippedFile("too-many.docx", entries))).rejects.toThrow("too many files")
|
||||
})
|
||||
|
||||
test("bounds embedded image count and marks omitted images in document text", async () => {
|
||||
const imageTags = Array.from({ length: 51 }, (_, index) => `<w:p><a:blip r:embed="rId${index}"/></w:p>`).join("")
|
||||
const relationshipItems = Array.from({ length: 51 }, (_, index) => ({
|
||||
id: `rId${index}`,
|
||||
target: `media/image${index}.png`,
|
||||
}))
|
||||
const entries: Record<string, string | Uint8Array> = {
|
||||
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body>${imageTags}</w:body></w:document>`,
|
||||
"word/_rels/document.xml.rels": relationships(relationshipItems),
|
||||
}
|
||||
for (let index = 0; index < 51; index += 1) entries[`word/media/image${index}.png`] = pngBytes(index)
|
||||
|
||||
const result = await extractDocumentAttachments(zippedFile("gallery.docx", entries))
|
||||
const text = await result?.textFile.text() ?? ""
|
||||
|
||||
expect(result?.images).toHaveLength(50)
|
||||
expect(text.includes("[Embedded image omitted by attachment limits: image50.png]")).toBe(true)
|
||||
})
|
||||
|
||||
test("omits unsupported and spoofed embedded image content", async () => {
|
||||
const file = zippedFile("unsafe-images.docx", {
|
||||
"word/document.xml": `
|
||||
<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body>
|
||||
<w:p><a:blip r:embed="svg"/></w:p>
|
||||
<w:p><a:blip r:embed="fakePng"/></w:p>
|
||||
</w:body></w:document>`,
|
||||
"word/_rels/document.xml.rels": relationships([
|
||||
{ id: "svg", target: "media/image.svg" },
|
||||
{ id: "fakePng", target: "media/fake.png" },
|
||||
]),
|
||||
"word/media/image.svg": `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`,
|
||||
"word/media/fake.png": new Uint8Array([1, 2, 3]),
|
||||
})
|
||||
|
||||
const result = await extractDocumentAttachments(file)
|
||||
const text = await result?.textFile.text() ?? ""
|
||||
|
||||
expect(result?.images).toEqual([])
|
||||
expect(text.includes("[Unsupported embedded image omitted: image.svg]")).toBe(true)
|
||||
expect(text.includes("[Invalid embedded image omitted: fake.png]")).toBe(true)
|
||||
})
|
||||
|
||||
test("bounds expanded ODF spaces", async () => {
|
||||
const file = zippedFile("spaces.odt", {
|
||||
"content.xml": `<office:document xmlns:office="office" xmlns:text="text"><text:p>Before<text:s text:c="999999999999999999999"/>After</text:p></office:document>`,
|
||||
})
|
||||
|
||||
const result = await extractDocumentAttachments(file)
|
||||
const text = await result?.textFile.text() ?? ""
|
||||
|
||||
expect(text.includes("[Additional spaces omitted]After")).toBe(true)
|
||||
expect(text.length).toBeLessThan(1_000)
|
||||
})
|
||||
|
||||
test("does not retain images whose citations fall beyond the text limit", async () => {
|
||||
const file = zippedFile("long.docx", {
|
||||
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>${"x".repeat(2_000_100)}</w:t></w:p><w:p><a:blip r:embed="image"/></w:p></w:body></w:document>`,
|
||||
"word/_rels/document.xml.rels": relationships([{ id: "image", target: "media/image.png" }]),
|
||||
"word/media/image.png": pngBytes(),
|
||||
})
|
||||
|
||||
const result = await extractDocumentAttachments(file)
|
||||
const text = await result?.textFile.text() ?? ""
|
||||
|
||||
expect(text.length <= 2_000_000).toBe(true)
|
||||
expect(text.endsWith("[Document text truncated by OpenChamber]\n")).toBe(true)
|
||||
expect(text.includes("[long-image-1.png]")).toBe(false)
|
||||
expect(result?.images).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,596 @@
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { strToU8, zipSync } from "fflate"
|
||||
import { useInputStore } from "./input-store"
|
||||
|
||||
class MockFileReader {
|
||||
@@ -41,6 +42,14 @@ const rejectReader = (reader: MockFileReader) => {
|
||||
reader.onerror?.call(reader as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
||||
}
|
||||
|
||||
const waitForReaderCount = async (count: number) => {
|
||||
for (let attempt = 0; attempt < 100 && pendingReaders.length < count; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
}
|
||||
|
||||
const pngBytes = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
|
||||
describe("input-store attachments", () => {
|
||||
beforeEach(() => {
|
||||
pendingReaders.length = 0
|
||||
@@ -212,4 +221,59 @@ describe("input-store attachments", () => {
|
||||
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("image/webp")
|
||||
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe("data:image/webp;base64,AQID")
|
||||
})
|
||||
|
||||
testWithMockFileReader("adds extracted document text and referenced images atomically", async () => {
|
||||
const archive = zipSync({
|
||||
"word/document.xml": strToU8(`<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>Diagram</w:t><a:blip r:embed="rId1"/></w:p></w:body></w:document>`),
|
||||
"word/_rels/document.xml.rels": strToU8(`<Relationships><Relationship Id="rId1" Target="media/image.png" Type="image"/></Relationships>`),
|
||||
"word/media/image.png": pngBytes,
|
||||
})
|
||||
const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx"))
|
||||
|
||||
await waitForReaderCount(1)
|
||||
expect(pendingReaders).toHaveLength(1)
|
||||
resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=")
|
||||
await waitForReaderCount(2)
|
||||
expect(pendingReaders).toHaveLength(2)
|
||||
expect(useInputStore.getState().attachedFiles).toEqual([])
|
||||
resolveReader(pendingReaders[1], "data:image/png;base64,AQID")
|
||||
|
||||
expect(await addPromise).toBe(true)
|
||||
expect(useInputStore.getState().attachedFiles.map((attachment) => ({
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
}))).toEqual([
|
||||
{ filename: "design.docx", mimeType: "text/plain" },
|
||||
{ filename: "design-image-1.png", mimeType: "image/png" },
|
||||
])
|
||||
})
|
||||
|
||||
testWithMockFileReader("regenerates document image names when the composer changes during preparation", async () => {
|
||||
const archive = zipSync({
|
||||
"word/document.xml": strToU8(`<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><a:blip r:embed="rId1"/></w:p></w:body></w:document>`),
|
||||
"word/_rels/document.xml.rels": strToU8(`<Relationships><Relationship Id="rId1" Target="media/image.png" Type="image"/></Relationships>`),
|
||||
"word/media/image.png": pngBytes,
|
||||
})
|
||||
const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx"))
|
||||
|
||||
await waitForReaderCount(1)
|
||||
resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=")
|
||||
await waitForReaderCount(2)
|
||||
useInputStore.getState().addVSCodeFileAttachment("/workspace/design-image-1.png", "design-image-1.png", 1)
|
||||
resolveReader(pendingReaders[1], "data:image/png;base64,AQID")
|
||||
|
||||
await waitForReaderCount(3)
|
||||
resolveReader(pendingReaders[2], "data:text/plain;base64,RG9jdW1lbnQ=")
|
||||
await waitForReaderCount(4)
|
||||
resolveReader(pendingReaders[3], "data:image/png;base64,AQID")
|
||||
|
||||
expect(await addPromise).toBe(true)
|
||||
expect(useInputStore.getState().attachedFiles.map((attachment) => attachment.filename)).toEqual([
|
||||
"design-image-1.png",
|
||||
"design.docx",
|
||||
"design-image-2.png",
|
||||
])
|
||||
const textAttachment = useInputStore.getState().attachedFiles[1]
|
||||
expect((await textAttachment?.file.text())?.includes("[design-image-2.png]")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
import { create } from "zustand"
|
||||
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
||||
import { prepareAttachmentFile } from "./attachment-files"
|
||||
import { prepareAttachmentFiles } from "./attachment-files"
|
||||
|
||||
const FILE_URI_PREFIX = "file://"
|
||||
const MAX_ATTACHMENT_PREPARATION_ATTEMPTS = 3
|
||||
const pendingVSCodeSelectionKeys = new Set<string>()
|
||||
let attachmentReadGeneration = 0
|
||||
|
||||
@@ -35,6 +36,12 @@ const toFileUrl = (filepath: string): string => {
|
||||
|
||||
const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}`
|
||||
|
||||
const hasGeneratedFilenameCollision = (filenames: string[], attachedFiles: AttachedFile[]): boolean => {
|
||||
if (filenames.length === 0) return false
|
||||
const attachedFilenames = new Set(attachedFiles.map((attachment) => attachment.filename.toLowerCase()))
|
||||
return filenames.some((filename) => attachedFilenames.has(filename.toLowerCase()))
|
||||
}
|
||||
|
||||
const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
@@ -157,30 +164,41 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
||||
},
|
||||
|
||||
addAttachedFile: async (file: File) => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const generation = attachmentReadGeneration
|
||||
const preparedOrPending = prepareAttachmentFile(file)
|
||||
// Keep the synchronous preparation path synchronous so FileReader starts before this action yields.
|
||||
const prepared = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
|
||||
if (!prepared) return false
|
||||
let dataUrl: string
|
||||
try {
|
||||
dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
|
||||
} catch {
|
||||
return false
|
||||
for (let attempt = 0; attempt < MAX_ATTACHMENT_PREPARATION_ATTEMPTS; attempt += 1) {
|
||||
const reservedFilenames = get().attachedFiles.map((attachment) => attachment.filename)
|
||||
const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames)
|
||||
const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
|
||||
if (!preparedFiles || preparedFiles.length === 0 || generation !== attachmentReadGeneration) return false
|
||||
|
||||
const generatedFilenames = preparedFiles.slice(1).map((prepared) => prepared.file.name)
|
||||
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
|
||||
|
||||
const attachedFiles: AttachedFile[] = []
|
||||
for (const prepared of preparedFiles) {
|
||||
let dataUrl: string
|
||||
try {
|
||||
dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!dataUrl || generation !== attachmentReadGeneration) return false
|
||||
attachedFiles.push({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
file: prepared.file,
|
||||
dataUrl,
|
||||
mimeType: prepared.mimeType,
|
||||
filename: prepared.file.name,
|
||||
size: prepared.file.size,
|
||||
source: "local",
|
||||
})
|
||||
}
|
||||
|
||||
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
|
||||
set((state) => ({ attachedFiles: [...state.attachedFiles, ...attachedFiles] }))
|
||||
return true
|
||||
}
|
||||
if (!dataUrl || generation !== attachmentReadGeneration) return false
|
||||
const attached: AttachedFile = {
|
||||
id,
|
||||
file: prepared.file,
|
||||
dataUrl,
|
||||
mimeType: prepared.mimeType,
|
||||
filename: prepared.file.name,
|
||||
size: prepared.file.size,
|
||||
source: "local",
|
||||
}
|
||||
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
|
||||
return true
|
||||
return false
|
||||
},
|
||||
|
||||
removeAttachedFile: (id) =>
|
||||
|
||||
@@ -39,6 +39,8 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
||||
- dropped-file parsing and attachment reading
|
||||
- models metadata fetch helper
|
||||
|
||||
The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`.
|
||||
|
||||
- `bridge-localfs-proxy-runtime.ts`
|
||||
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, test } from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./webviewHtml.ts', import.meta.url), 'utf8');
|
||||
|
||||
describe('VS Code webview content security policy', () => {
|
||||
test('allows blob URLs for workers without allowing blob scripts', () => {
|
||||
const workerSource = source.match(/const workerSrc = ([^\n]+);/)?.[1] ?? '';
|
||||
const scriptSource = source.match(/const scriptSrc = ([^\n]+);/)?.[1] ?? '';
|
||||
|
||||
assert.match(workerSource, /'blob:'/);
|
||||
assert.doesNotMatch(scriptSource, /'blob:'/);
|
||||
assert.match(source, /worker-src \$\{workerSrc\}/);
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
const connectSrc = uniqueTokens(['*', 'ws:', 'wss:', 'http:', 'https:', devServerOrigin]);
|
||||
const imgSrc = uniqueTokens([webview.cspSource, 'data:', 'https:', devServerOrigin]);
|
||||
const fontSrc = uniqueTokens([webview.cspSource, 'data:', devServerOrigin]);
|
||||
const workerSrc = uniqueTokens([webview.cspSource, devServerOrigin]);
|
||||
// fflate's async browser inflater creates blob-backed workers. Keep blob:
|
||||
// scoped to worker-src so document decompression works without allowing blob scripts.
|
||||
const workerSrc = uniqueTokens([webview.cspSource, 'blob:', devServerOrigin]);
|
||||
|
||||
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user