154 lines
5.3 KiB
TypeScript
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)
|
|
}
|