267 lines
8.6 KiB
TypeScript
267 lines
8.6 KiB
TypeScript
import type { TipTapJSON } from "@/lib/drive/richtext-import"
|
|
import type { DocsGraphicFloatSide, DocsGraphicPlacement, DocsGraphicPositionMode, DocsGraphicWrap } from "./docs-graphic-types.ts"
|
|
|
|
type DocxArchive = Record<string, Uint8Array>
|
|
|
|
const EMU_PER_PX = 914400 / 96
|
|
|
|
export type DocxGraphicPosition = {
|
|
width: number
|
|
height: number
|
|
x: number
|
|
y: number
|
|
pageX: number
|
|
pageY: number
|
|
pageIndex: number
|
|
placement: DocsGraphicPlacement
|
|
wrap: DocsGraphicWrap
|
|
floatSide: DocsGraphicFloatSide
|
|
rotationDeg: number
|
|
zIndex: number
|
|
behindDoc: boolean
|
|
positionMode: DocsGraphicPositionMode
|
|
wrapMarginMm: number
|
|
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 (/<wp:wrapNone\b/i.test(anchorXml)) return "top-bottom"
|
|
if (/<wp:wrapTopAndBottom\b/i.test(anchorXml)) return "top-bottom"
|
|
if (/<wp:wrapTight\b/i.test(anchorXml)) return "tight"
|
|
if (/<wp:wrapThrough\b/i.test(anchorXml)) return "through"
|
|
if (/<wp:wrapSquare\b/i.test(anchorXml)) return "square"
|
|
if (/<wp:wrapNone\b/i.test(anchorXml)) return "square"
|
|
return "square"
|
|
}
|
|
|
|
function parseFloatSide(anchorXml: string): DocsGraphicFloatSide {
|
|
const align = anchorXml.match(/<wp:positionH\b[^>]*>[\s\S]*?<wp:align>(\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(/<a:srcRect\b[^>]*\/?>/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 parseWrapMarginMm(block: string): number {
|
|
const wrapSquare = block.match(/<wp:wrapSquare\b[^>]*\/?>/i)?.[0]
|
|
if (!wrapSquare) return 3
|
|
const distL = readIntAttr(wrapSquare, "distL") ?? readIntAttr(wrapSquare, "distT")
|
|
if (distL == null) return 3
|
|
const px = emuToPx(distL)
|
|
return Math.max(0, Math.round((px / 96) * 25.4))
|
|
}
|
|
|
|
function parsePositionMode(block: string, inline: boolean): DocsGraphicPositionMode {
|
|
if (inline) return "move-with-text"
|
|
const posH = block.match(/<wp:positionH\b[^>]*relativeFrom="(\w+)"/i)?.[1]
|
|
const posV = block.match(/<wp:positionV\b[^>]*relativeFrom="(\w+)"/i)?.[1]
|
|
if (
|
|
posH === "page" ||
|
|
posH === "margin" ||
|
|
posH === "column" ||
|
|
posV === "page" ||
|
|
posV === "margin" ||
|
|
posV === "paragraph"
|
|
) {
|
|
if (posH === "page" || posH === "margin" || posV === "page" || posV === "margin") {
|
|
return "fixed-on-page"
|
|
}
|
|
}
|
|
return "move-with-text"
|
|
}
|
|
|
|
function parseDrawingBlock(block: string, inline: boolean): DocxGraphicPosition | null {
|
|
const extent = block.match(/<wp:extent\b[^>]*\/?>/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 = /<wp:anchor\b[^>]*\bbehindDoc="1"/i.test(block)
|
|
const inFront = /<wp:anchor\b[^>]*\blayoutInCell="0"/i.test(block) && !behindDoc
|
|
|
|
let x = 0
|
|
let y = 0
|
|
if (!inline) {
|
|
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 wrap = inline ? "inline" : behindDoc ? "behind" : inFront ? "in-front" : parseWrapType(block)
|
|
const placement: DocsGraphicPlacement = inline ? "inline" : "absolute"
|
|
|
|
const blip = block.match(/<a:blip\b[^>]*\/?>/i)?.[0] ?? ""
|
|
const crop = parseCropFromBlip(blip)
|
|
|
|
return {
|
|
width: Math.max(24, emuToPx(cx)),
|
|
height: Math.max(24, emuToPx(cy)),
|
|
x,
|
|
y,
|
|
pageX: x,
|
|
pageY: y,
|
|
pageIndex: 0,
|
|
placement,
|
|
wrap,
|
|
floatSide: inline ? "left" : parseFloatSide(block),
|
|
rotationDeg: parseRotation(block),
|
|
zIndex: behindDoc ? 0 : inFront ? 20 : 1,
|
|
behindDoc,
|
|
positionMode: parsePositionMode(block, inline),
|
|
wrapMarginMm: parseWrapMarginMm(block),
|
|
...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 = /<w:drawing\b[^>]*>[\s\S]*?<\/w:drawing>/gi
|
|
for (const match of documentXml.matchAll(drawingPattern)) {
|
|
const block = match[0]
|
|
const inline = /<wp:inline\b/i.test(block)
|
|
const anchor = /<wp:anchor\b/i.test(block)
|
|
if (!inline && !anchor) continue
|
|
const parsed = parseDrawingBlock(block, inline)
|
|
if (parsed) positions.push(parsed)
|
|
}
|
|
return positions
|
|
}
|
|
|
|
function isGraphicNode(node: TipTapJSON): boolean {
|
|
return (
|
|
node.type === "image" ||
|
|
node.type === "docsGraphic" ||
|
|
node.type === "docsInlineGraphic"
|
|
)
|
|
}
|
|
|
|
function applyPositionToNode(node: TipTapJSON, pos: DocxGraphicPosition): TipTapJSON {
|
|
const attrs = (node.attrs as Record<string, unknown>) ?? {}
|
|
return {
|
|
...node,
|
|
attrs: {
|
|
...attrs,
|
|
width: pos.width,
|
|
height: pos.height,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
pageX: pos.pageX,
|
|
pageY: pos.pageY,
|
|
pageIndex: pos.pageIndex,
|
|
placement: pos.placement,
|
|
wrap: pos.wrap,
|
|
floatSide: pos.floatSide,
|
|
rotationDeg: pos.rotationDeg,
|
|
zIndex: pos.zIndex,
|
|
positionMode: pos.positionMode,
|
|
wrapMarginMm: pos.wrapMarginMm,
|
|
...(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<TipTapJSON> {
|
|
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
|
|
}
|
|
}
|