327 lines
9.7 KiB
TypeScript
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
|
|
}
|
|
}
|