import type { TipTapJSON } from "@/lib/drive/richtext-import" import { buildGradientCss, DOCS_GRAPHIC_DEFAULTS, type DocsShapeType, } from "./docs-graphic-types.ts" import { parseVmlColorForDrawing, parseRotationDegFromStyle, } from "./docx-drawing-vml.ts" import { resolveDocxMediaDataUrl } from "./doc-page-background.ts" type DocxArchive = Record export type DocxBodyDrawing = { graphicType: "image" | "shape" | "gradient" src?: string shapeType?: DocsShapeType fill?: string stroke?: string strokeWidth?: number gradientCss?: string gradientAngle?: number gradientColor1?: string gradientColor2?: string width: number height: number x: number y: number rotationDeg: number wrap: "square" | "behind" | "in-front" | "inline" placement: "block" | "absolute" | "inline" } const EMU_PER_PX = 914400 / 96 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 parsePresetShape(prst: string | undefined): DocsShapeType { switch (prst) { case "ellipse": case "oval": return "ellipse" case "line": case "straightConnector1": return "line" case "rightArrow": case "leftArrow": case "bentArrow": return "arrow" default: return "rect" } } function parseDrawingXml( block: string, archive: DocxArchive, relsPath: string ): DocxBodyDrawing | null { const extent = block.match(/]*\/?>/i)?.[0] const cx = extent ? readIntAttr(extent, "cx") : null const cy = extent ? readIntAttr(extent, "cy") : null const width = cx != null ? Math.max(24, emuToPx(cx)) : 240 const height = cy != null ? Math.max(24, emuToPx(cy)) : 160 let x = 0 let y = 0 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 rot = block.match(/\brot="(-?\d+)"/i)?.[1] const rotationDeg = rot ? Math.round(Number(rot) / 60000) : 0 const behindDoc = /]*\bbehindDoc="1"/i.test(block) const inline = /]*\bembed="([^"]+)"/i)?.[1] if (blipEmbed) { const src = resolveDocxMediaDataUrl(archive, relsPath, blipEmbed) if (src) { return { graphicType: "image", src, width, height, x, y, rotationDeg, wrap, placement, } } } const prstGeom = block.match(/]*\bprst="([^"]+)"/i)?.[1] const solidFill = block.match(/]*>[\s\S]*?<\/a:solidFill>/i)?.[0] const gradFill = block.match(/]*>[\s\S]*?<\/a:gradFill>/i)?.[0] const ln = block.match(/]*>[\s\S]*?<\/a:ln>/i)?.[0] ?? block.match(/]*\/?>/i)?.[0] if (gradFill) { const stop1 = gradFill.match(/]*\bval="([^"]+)"/i)?.[1] const stop2 = [...gradFill.matchAll(/]*\bval="([^"]+)"/gi)][1]?.[1] const color1 = stop1 ? `#${stop1}` : "#4285f4" const color2 = stop2 ? `#${stop2}` : "#34a853" const angle = readIntAttr(gradFill, "ang") ?? 1800000 const gradientAngle = Math.round(angle / 60000) return { graphicType: "gradient", gradientCss: buildGradientCss(gradientAngle, color1, color2), gradientAngle, gradientColor1: color1, gradientColor2: color2, width, height, x, y, rotationDeg, wrap, placement, } } if (prstGeom || solidFill) { const srgb = solidFill?.match(/]*\bval="([^"]+)"/i)?.[1] const fill = srgb ? `#${srgb}` : "#4285f4" const strokeClr = ln?.match(/]*\bval="([^"]+)"/i)?.[1] const stroke = strokeClr ? `#${strokeClr}` : "#1a73e8" const strokeWidth = ln ? Math.max(1, emuToPx(readIntAttr(ln, "w") ?? 12700)) : 2 return { graphicType: "shape", shapeType: parsePresetShape(prstGeom), fill, stroke, strokeWidth, width, height, x, y, rotationDeg, wrap, placement, } } return null } function parseVmlShape( shapeXml: string, archive: DocxArchive, relsPath: string ): DocxBodyDrawing | null { const style = shapeXml.match(/\bstyle="([^"]+)"/i)?.[1] ?? "" const widthMatch = style.match(/\bwidth:\s*([\d.]+)pt/i) const heightMatch = style.match(/\bheight:\s*([\d.]+)pt/i) const width = widthMatch ? Math.round(Number(widthMatch[1]) * 96 / 72) : 240 const height = heightMatch ? Math.round(Number(heightMatch[1]) * 96 / 72) : 160 const leftMatch = style.match(/\bleft:\s*([\d.]+)pt/i) const topMatch = style.match(/\btop:\s*([\d.]+)pt/i) const x = leftMatch ? Math.round(Number(leftMatch[1]) * 96 / 72) : 0 const y = topMatch ? Math.round(Number(topMatch[1]) * 96 / 72) : 0 const fill = parseVmlColorForDrawing(shapeXml.match(/\bfillcolor="([^"]+)"/i)?.[1]) const stroke = parseVmlColorForDrawing(shapeXml.match(/\bstrokecolor="([^"]+)"/i)?.[1]) const rotationDeg = parseRotationDegFromStyle(style) const imagedata = shapeXml.match(/]*\br:id="([^"]+)"/i)?.[1] if (imagedata) { const src = resolveDocxMediaDataUrl(archive, relsPath, imagedata) if (src) { return { graphicType: "image", src, width, height, x, y, rotationDeg, wrap: "square", placement: "absolute", } } } const type = shapeXml.match(/\btype="[^"]*#([^"]+)"/i)?.[1]?.toLowerCase() let shapeType: DocsShapeType = "rect" if (type?.includes("oval") || type?.includes("ellipse")) shapeType = "ellipse" else if (type?.includes("line")) shapeType = "line" if (fill || stroke) { return { graphicType: "shape", shapeType, fill: fill ?? "#4285f4", stroke: stroke ?? "#1a73e8", strokeWidth: 2, width, height, x, y, rotationDeg, wrap: "square", placement: "absolute", } } return null } export function extractBodyDrawingsFromDocx(archive: DocxArchive): DocxBodyDrawing[] { const documentXml = decodeXml(archive["word/document.xml"]) if (!documentXml) return [] const relsPath = "word/_rels/document.xml.rels" const drawings: DocxBodyDrawing[] = [] for (const match of documentXml.matchAll(/]*>[\s\S]*?<\/w:drawing>/gi)) { const parsed = parseDrawingXml(match[0], archive, relsPath) if (parsed?.graphicType !== "image") { if (parsed) drawings.push(parsed) } } for (const match of documentXml.matchAll(/]*>[\s\S]*?<\/v:shape>/gi)) { const parsed = parseVmlShape(match[0], archive, relsPath) if (parsed) drawings.push(parsed) } return drawings } function drawingToGraphicNode(drawing: DocxBodyDrawing): TipTapJSON { return { type: "docsGraphic", attrs: { ...DOCS_GRAPHIC_DEFAULTS, graphicType: drawing.graphicType, src: drawing.src ?? null, shapeType: drawing.shapeType ?? "rect", fill: drawing.fill ?? DOCS_GRAPHIC_DEFAULTS.fill, stroke: drawing.stroke ?? DOCS_GRAPHIC_DEFAULTS.stroke, strokeWidth: drawing.strokeWidth ?? 2, gradientCss: drawing.gradientCss ?? "", gradientAngle: drawing.gradientAngle ?? 180, gradientColor1: drawing.gradientColor1 ?? DOCS_GRAPHIC_DEFAULTS.gradientColor1, gradientColor2: drawing.gradientColor2 ?? DOCS_GRAPHIC_DEFAULTS.gradientColor2, width: drawing.width, height: drawing.height, x: drawing.x, y: drawing.y, rotationDeg: drawing.rotationDeg, wrap: drawing.wrap, placement: drawing.placement, floatSide: "left", }, } } function countGraphicImages(content: TipTapJSON): number { let count = 0 const walk = (node: TipTapJSON) => { if ( node.type === "image" || (node.type === "docsGraphic" && (node.attrs as { graphicType?: string })?.graphicType === "image") ) { count++ } if (Array.isArray(node.content)) { node.content.forEach((child) => { if (child && typeof child === "object") walk(child as TipTapJSON) }) } } walk(content) return count } /** Insert shape/gradient drawings not already represented as image nodes. */ export async function enrichContentFromDocxDrawings( buffer: ArrayBuffer, content: TipTapJSON ): Promise { try { const { unzipSync } = await import("fflate") const archive = unzipSync(new Uint8Array(buffer)) as DocxArchive const drawings = extractBodyDrawingsFromDocx(archive) const nonImageDrawings = drawings.filter((d) => d.graphicType !== "image") if (nonImageDrawings.length === 0) return content const existingImages = countGraphicImages(content) const toInsert = nonImageDrawings.slice(Math.max(0, existingImages)) if (toInsert.length === 0) return content const contentArray = Array.isArray(content.content) ? [...content.content] : [] for (const drawing of toInsert) { contentArray.push(drawingToGraphicNode(drawing)) } return { ...content, content: contentArray } } catch { return content } }