Files
openchamber/packages/ui/src/sync/document-attachments.test.ts
T

213 lines
11 KiB
TypeScript
Raw Normal View History

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([])
})
})