ultisuite-client/lib/drive/docx-position-export.ts
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

154 lines
5.3 KiB
TypeScript

import type { DocsGraphicAttrs } from "@/lib/drive/docs-graphic-types"
import type { PreparedGraphicMeta } from "@/lib/drive/docs-export-prepare"
type DocxArchive = Record<string, Uint8Array>
const EMU_PER_PX = 914400 / 96
function pxToEmu(px: number): number {
return Math.round(px * EMU_PER_PX)
}
function decodeXml(bytes: Uint8Array | undefined): string {
if (!bytes) return ""
return new TextDecoder().decode(bytes)
}
function encodeXml(text: string): Uint8Array {
return new TextEncoder().encode(text)
}
function nextRelId(relsXml: string): string {
const ids = [...relsXml.matchAll(/Id="rId(\d+)"/gi)].map((m) =>
Number.parseInt(m[1] ?? "0", 10)
)
const max = ids.length > 0 ? Math.max(...ids) : 0
return `rId${max + 1}`
}
function nextImageName(archive: DocxArchive): string {
const existing = Object.keys(archive).filter((k) => /^word\/media\/image\d+\./i.test(k))
const nums = existing.map((k) => Number.parseInt(k.match(/image(\d+)/i)?.[1] ?? "0", 10))
const next = nums.length > 0 ? Math.max(...nums) + 1 : 1
return `image${next}.png`
}
function dataUrlToBytes(dataUrl: string): Uint8Array | null {
const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/)
if (!match?.[1]) return null
const binary = atob(match[1])
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return bytes
}
function wrapTypeForGraphic(wrap: DocsGraphicAttrs["wrap"]): string {
switch (wrap) {
case "tight":
return "wrapTight"
case "through":
return "wrapThrough"
case "top-bottom":
return "wrapTopAndBottom"
case "behind":
case "in-front":
return "wrapNone"
default:
return "wrapSquare"
}
}
function buildAnchorDrawingXml(
relId: string,
attrs: DocsGraphicAttrs,
imageName: string
): string {
const cx = pxToEmu(attrs.width)
const cy = pxToEmu(attrs.height)
const x = pxToEmu(attrs.pageX || attrs.x)
const y = pxToEmu(attrs.pageY || attrs.y)
const rot = attrs.rotationDeg * 60000
const behind = attrs.wrap === "behind" ? ' behindDoc="1"' : ""
const wrapTag = wrapTypeForGraphic(attrs.wrap)
return `<w:p><w:r><w:drawing>
<wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="${251658240 + attrs.zIndex}"${behind} locked="0" layoutInCell="1" allowOverlap="1">
<wp:simplePos x="0" y="0"/>
<wp:positionH relativeFrom="page"><wp:posOffset>${x}</wp:posOffset></wp:positionH>
<wp:positionV relativeFrom="page"><wp:posOffset>${y}</wp:posOffset></wp:positionV>
<wp:extent cx="${cx}" cy="${cy}"/>
<wp:effectExtent l="0" t="0" r="0" b="0"/>
<wp:${wrapTag} wrapText="bothSides"/>
<wp:docPr id="${Math.floor(Math.random() * 100000)}" name="${imageName}"/>
<wp:cNvGraphicFramePr><a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/></wp:cNvGraphicFramePr>
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:nvPicPr>
<pic:cNvPr id="0" name="${imageName}"/>
<pic:cNvPicPr/>
</pic:nvPicPr>
<pic:blipFill>
<a:blip r:embed="${relId}"/>
<a:stretch><a:fillRect/></a:stretch>
</pic:blipFill>
<pic:spPr>
<a:xfrm rot="${rot}"><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</pic:spPr>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:anchor>
</w:drawing></w:r></w:p>`
}
/** Inject page-layer anchored graphics into document.xml. */
export async function patchDocxAnchoredGraphics(
buffer: Uint8Array,
graphics: PreparedGraphicMeta[]
): Promise<Uint8Array> {
if (graphics.length === 0) return buffer
const { unzipSync, zipSync } = await import("fflate")
const archive = { ...(unzipSync(buffer) as DocxArchive) }
let relsXml =
decodeXml(archive["word/_rels/document.xml.rels"]) ||
`<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
let documentXml = decodeXml(archive["word/document.xml"])
if (!documentXml) return buffer
const drawingBlocks: string[] = []
for (const { attrs } of graphics) {
if (!attrs.src) continue
const bytes = attrs.src.startsWith("data:") ? dataUrlToBytes(attrs.src) : null
if (!bytes) continue
const imageName = nextImageName(archive)
const mediaPath = `word/media/${imageName}`
archive[mediaPath] = bytes
const relId = nextRelId(relsXml)
relsXml = relsXml.replace(
"</Relationships>",
`<Relationship Id="${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${imageName}"/></Relationships>`
)
drawingBlocks.push(buildAnchorDrawingXml(relId, attrs, imageName))
}
if (drawingBlocks.length === 0) return buffer
archive["word/_rels/document.xml.rels"] = encodeXml(relsXml)
documentXml = documentXml.replace(
/<\/w:body>/i,
`${drawingBlocks.join("")}</w:body>`
)
archive["word/document.xml"] = encodeXml(documentXml)
return zipSync(archive)
}