ultisuite-client/lib/drive/docx-drawing-import.ts
R3D347HR4Y 2a7c153748
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wrap page
2026-06-10 12:48:27 +02:00

327 lines
9.7 KiB
TypeScript

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<string, Uint8Array>
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(/<wp:extent\b[^>]*\/?>/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(/<wp:positionH\b[^>]*>[\s\S]*?<\/wp:positionH>/i)?.[0]
const posV = block.match(/<wp:positionV\b[^>]*>[\s\S]*?<\/wp:positionV>/i)?.[0]
const posOffsetH = posH?.match(/<wp:posOffset>(-?\d+)<\/wp:posOffset>/i)?.[1]
const posOffsetV = posV?.match(/<wp:posOffset>(-?\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 = /<wp:anchor\b[^>]*\bbehindDoc="1"/i.test(block)
const inline = /<wp:inline\b/i.test(block)
const wrap = inline ? "inline" : behindDoc ? "behind" : "square"
const placement = inline ? "inline" : "absolute"
const blipEmbed = block.match(/<a:blip\b[^>]*\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(/<a:prstGeom\b[^>]*\bprst="([^"]+)"/i)?.[1]
const solidFill = block.match(/<a:solidFill\b[^>]*>[\s\S]*?<\/a:solidFill>/i)?.[0]
const gradFill = block.match(/<a:gradFill\b[^>]*>[\s\S]*?<\/a:gradFill>/i)?.[0]
const ln = block.match(/<a:ln\b[^>]*>[\s\S]*?<\/a:ln>/i)?.[0] ?? block.match(/<a:ln\b[^>]*\/?>/i)?.[0]
if (gradFill) {
const stop1 = gradFill.match(/<a:srgbClr\b[^>]*\bval="([^"]+)"/i)?.[1]
const stop2 = [...gradFill.matchAll(/<a:srgbClr\b[^>]*\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(/<a:srgbClr\b[^>]*\bval="([^"]+)"/i)?.[1]
const fill = srgb ? `#${srgb}` : "#4285f4"
const strokeClr = ln?.match(/<a:srgbClr\b[^>]*\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(/<v:imagedata\b[^>]*\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(/<w:drawing\b[^>]*>[\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(/<v:shape\b[^>]*>[\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<TipTapJSON> {
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
}
}