import type { TipTapJSON } from "@/lib/drive/richtext-import" import type { DocsGraphicFloatSide, DocsGraphicPlacement, DocsGraphicWrap } from "./docs-graphic-types.ts" type DocxArchive = Record const EMU_PER_PX = 914400 / 96 export type DocxGraphicPosition = { width: number height: number x: number y: number placement: DocsGraphicPlacement wrap: DocsGraphicWrap floatSide: DocsGraphicFloatSide rotationDeg: number zIndex: number behindDoc: boolean cropX?: number cropY?: number cropWidth?: number cropHeight?: number } function decodeXml(bytes: Uint8Array | undefined): string { if (!bytes) return "" return new TextDecoder().decode(bytes) } function emuToPx(emu: number): number { return Math.round(emu / EMU_PER_PX) } function readIntAttr(fragment: string, name: string): number | null { const match = fragment.match(new RegExp(`\\b${name}="(-?\\d+)"`, "i")) if (!match) return null const value = Number.parseInt(match[1] ?? "", 10) return Number.isFinite(value) ? value : null } function parseWrapType(anchorXml: string): DocsGraphicWrap { if (/]*>[\s\S]*?(\w+)<\/wp:align>/i)?.[1] if (align === "right") return "right" if (align === "center") return "center" return "left" } function parseRotation(drawingXml: string): number { const rot = drawingXml.match(/\brot="(-?\d+)"/i)?.[1] if (!rot) return 0 // OOXML rotation is in 60000ths of a degree return Math.round(Number(rot) / 60000) } function parseCropFromBlip(blipXml: string): Pick< DocxGraphicPosition, "cropX" | "cropY" | "cropWidth" | "cropHeight" > | null { const srcRect = blipXml.match(/]*\/?>/i)?.[0] if (!srcRect) return null const l = readIntAttr(srcRect, "l") ?? 0 const t = readIntAttr(srcRect, "t") ?? 0 const r = readIntAttr(srcRect, "r") ?? 0 const b = readIntAttr(srcRect, "b") ?? 0 if (l === 0 && t === 0 && r === 0 && b === 0) return null const cropX = l / 100000 const cropY = t / 100000 const cropWidth = 1 - (l + r) / 100000 const cropHeight = 1 - (t + b) / 100000 return { cropX, cropY, cropWidth, cropHeight } } function parseDrawingBlock(block: string, inline: boolean): DocxGraphicPosition | null { const extent = block.match(/]*\/?>/i)?.[0] if (!extent) return null const cx = readIntAttr(extent, "cx") const cy = readIntAttr(extent, "cy") if (cx == null || cy == null) return null const behindDoc = /]*\bbehindDoc="1"/i.test(block) const inFront = /]*\blayoutInCell="0"/i.test(block) && !behindDoc let x = 0 let y = 0 if (!inline) { const posH = block.match(/]*>[\s\S]*?<\/wp:positionH>/i)?.[0] const posV = block.match(/]*>[\s\S]*?<\/wp:positionV>/i)?.[0] const posOffsetH = posH?.match(/(-?\d+)<\/wp:posOffset>/i)?.[1] const posOffsetV = posV?.match(/(-?\d+)<\/wp:posOffset>/i)?.[1] if (posOffsetH) x = emuToPx(Number(posOffsetH)) if (posOffsetV) y = emuToPx(Number(posOffsetV)) } const wrap = inline ? "inline" : behindDoc ? "behind" : inFront ? "in-front" : parseWrapType(block) const placement: DocsGraphicPlacement = inline ? "inline" : "absolute" const blip = block.match(/]*\/?>/i)?.[0] ?? "" const crop = parseCropFromBlip(blip) return { width: Math.max(24, emuToPx(cx)), height: Math.max(24, emuToPx(cy)), x, y, placement, wrap, floatSide: inline ? "left" : parseFloatSide(block), rotationDeg: parseRotation(block), zIndex: behindDoc ? 0 : inFront ? 20 : 1, behindDoc, ...crop, } } /** Extract ordered graphic positions from word/document.xml. */ export function extractGraphicPositionsFromDocx(archive: DocxArchive): DocxGraphicPosition[] { const documentXml = decodeXml(archive["word/document.xml"]) if (!documentXml) return [] const positions: DocxGraphicPosition[] = [] const drawingPattern = /]*>[\s\S]*?<\/w:drawing>/gi for (const match of documentXml.matchAll(drawingPattern)) { const block = match[0] const inline = /) ?? {} return { ...node, attrs: { ...attrs, width: pos.width, height: pos.height, x: pos.x, y: pos.y, placement: pos.placement, wrap: pos.wrap, floatSide: pos.floatSide, rotationDeg: pos.rotationDeg, zIndex: pos.zIndex, ...(pos.cropX != null ? { cropX: pos.cropX } : {}), ...(pos.cropY != null ? { cropY: pos.cropY } : {}), ...(pos.cropWidth != null ? { cropWidth: pos.cropWidth } : {}), ...(pos.cropHeight != null ? { cropHeight: pos.cropHeight } : {}), }, } } function collectGraphicNodes(content: TipTapJSON): { path: number[]; node: TipTapJSON }[] { const results: { path: number[]; node: TipTapJSON }[] = [] const walk = (node: TipTapJSON, path: number[]) => { if (isGraphicNode(node)) { results.push({ path, node }) } if (Array.isArray(node.content)) { node.content.forEach((child, index) => { if (child && typeof child === "object") walk(child as TipTapJSON, [...path, index]) }) } } walk(content, []) return results } function setNodeAtPath(root: TipTapJSON, path: number[], node: TipTapJSON): TipTapJSON { if (path.length === 0) return node const [head, ...rest] = path const content = Array.isArray(root.content) ? [...root.content] : [] content[head!] = setNodeAtPath(content[head!] as TipTapJSON, rest, node) return { ...root, content } } /** Enrich imported TipTap content with OOXML anchor/inline positioning. */ export async function enrichGraphicsFromDocxPositions( buffer: ArrayBuffer, content: TipTapJSON ): Promise { try { const { unzipSync } = await import("fflate") const archive = unzipSync(new Uint8Array(buffer)) as DocxArchive const positions = extractGraphicPositionsFromDocx(archive) if (positions.length === 0) return content const graphics = collectGraphicNodes(content) let result = content const count = Math.min(graphics.length, positions.length) for (let i = 0; i < count; i++) { const { path, node } = graphics[i]! const pos = positions[i]! result = setNodeAtPath(result, path, applyPositionToNode(node, pos)) } return result } catch { return content } }