import type { DocsGraphicAttrs } from "@/lib/drive/docs-graphic-types" import type { PreparedGraphicMeta } from "@/lib/drive/docs-export-prepare" type DocxArchive = Record const EMU_PER_PX = 914400 / 96 function pxToEmu(px: number): number { return Math.round(px * EMU_PER_PX) } function decodeXml(bytes: Uint8Array | undefined): string { if (!bytes) return "" return new TextDecoder().decode(bytes) } function encodeXml(text: string): Uint8Array { return new TextEncoder().encode(text) } function nextRelId(relsXml: string): string { const ids = [...relsXml.matchAll(/Id="rId(\d+)"/gi)].map((m) => Number.parseInt(m[1] ?? "0", 10) ) const max = ids.length > 0 ? Math.max(...ids) : 0 return `rId${max + 1}` } function nextImageName(archive: DocxArchive): string { const existing = Object.keys(archive).filter((k) => /^word\/media\/image\d+\./i.test(k)) const nums = existing.map((k) => Number.parseInt(k.match(/image(\d+)/i)?.[1] ?? "0", 10)) const next = nums.length > 0 ? Math.max(...nums) + 1 : 1 return `image${next}.png` } function dataUrlToBytes(dataUrl: string): Uint8Array | null { const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/) if (!match?.[1]) return null const binary = atob(match[1]) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) return bytes } function wrapTypeForGraphic(wrap: DocsGraphicAttrs["wrap"]): string { switch (wrap) { case "tight": return "wrapTight" case "through": return "wrapThrough" case "top-bottom": return "wrapTopAndBottom" case "behind": case "in-front": return "wrapNone" default: return "wrapSquare" } } function buildAnchorDrawingXml( relId: string, attrs: DocsGraphicAttrs, imageName: string ): string { const cx = pxToEmu(attrs.width) const cy = pxToEmu(attrs.height) const x = pxToEmu(attrs.pageX || attrs.x) const y = pxToEmu(attrs.pageY || attrs.y) const rot = attrs.rotationDeg * 60000 const behind = attrs.wrap === "behind" ? ' behindDoc="1"' : "" const wrapTag = wrapTypeForGraphic(attrs.wrap) return ` ${x} ${y} ` } /** Inject page-layer anchored graphics into document.xml. */ export async function patchDocxAnchoredGraphics( buffer: Uint8Array, graphics: PreparedGraphicMeta[] ): Promise { if (graphics.length === 0) return buffer const { unzipSync, zipSync } = await import("fflate") const archive = { ...(unzipSync(buffer) as DocxArchive) } let relsXml = decodeXml(archive["word/_rels/document.xml.rels"]) || `` let documentXml = decodeXml(archive["word/document.xml"]) if (!documentXml) return buffer const drawingBlocks: string[] = [] for (const { attrs } of graphics) { if (!attrs.src) continue const bytes = attrs.src.startsWith("data:") ? dataUrlToBytes(attrs.src) : null if (!bytes) continue const imageName = nextImageName(archive) const mediaPath = `word/media/${imageName}` archive[mediaPath] = bytes const relId = nextRelId(relsXml) relsXml = relsXml.replace( "", `` ) drawingBlocks.push(buildAnchorDrawingXml(relId, attrs, imageName)) } if (drawingBlocks.length === 0) return buffer archive["word/_rels/document.xml.rels"] = encodeXml(relsXml) documentXml = documentXml.replace( /<\/w:body>/i, `${drawingBlocks.join("")}` ) archive["word/document.xml"] = encodeXml(documentXml) return zipSync(archive) }