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

256 lines
8.1 KiB
TypeScript

import type { DocParagraphStyleDefinition, DocParagraphStylesCatalog } from "@/lib/drive/docs-paragraph-styles"
import { resolveParagraphStyleDefinition } from "@/lib/drive/docs-paragraph-styles"
import { prepareTablesForDocxExport } from "@/lib/drive/docs-table-export"
import { parseGraphicAttrs, type DocsGraphicAttrs } from "@/lib/drive/docs-graphic-types"
import type { TipTapJSON } from "@/lib/drive/richtext-import"
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
async function resolveGraphicSrc(src: string | null): Promise<string | null> {
if (!src) return null
if (src.startsWith("data:")) return src
try {
const res = await fetch(src, { credentials: "include" })
if (!res.ok) return null
return blobToDataUrl(await res.blob())
} catch {
return null
}
}
function textMarksFromStyle(def: DocParagraphStyleDefinition): TipTapJSON[] {
const marks: TipTapJSON[] = []
if (def.bold) marks.push({ type: "bold" })
if (def.italic) marks.push({ type: "italic" })
if (def.underline) marks.push({ type: "underline" })
const textStyleAttrs: Record<string, unknown> = {}
if (def.fontFamily) textStyleAttrs.fontFamily = def.fontFamily
if (def.fontSizePx != null) textStyleAttrs.fontSize = `${def.fontSizePx}px`
if (def.color) textStyleAttrs.color = def.color
if (Object.keys(textStyleAttrs).length > 0) {
marks.push({ type: "textStyle", attrs: textStyleAttrs })
}
return marks
}
function applyMarksToTextNodes(node: TipTapJSON, marks: TipTapJSON[]): TipTapJSON {
if (node.type === "text") {
const existing = Array.isArray(node.marks) ? (node.marks as TipTapJSON[]) : []
return { ...node, marks: [...existing, ...marks] }
}
if (!Array.isArray(node.content)) return node
return {
...node,
content: node.content.map((child) =>
applyMarksToTextNodes(child as TipTapJSON, marks)
),
}
}
function nodeChildren(node: TipTapJSON): TipTapJSON[] {
return Array.isArray(node.content) ? (node.content as TipTapJSON[]) : []
}
export function applyParagraphStylesForDocxExport(
content: TipTapJSON,
catalog: DocParagraphStylesCatalog
): TipTapJSON {
const walk = (node: TipTapJSON): TipTapJSON => {
const type = node.type as string | undefined
const attrs = isRecord(node.attrs) ? { ...node.attrs } : undefined
if ((type === "paragraph" || type === "heading") && attrs) {
const styleId = typeof attrs.styleId === "string" ? attrs.styleId : null
if (styleId) {
const def = resolveParagraphStyleDefinition(catalog, styleId)
if (def) {
if (def.blockType === "heading" && def.level) {
return walk({
type: "heading",
attrs: {
level: def.level,
textAlign: def.textAlign ?? attrs.textAlign,
},
content: nodeChildren(node).map((child) =>
applyMarksToTextNodes(child, textMarksFromStyle(def))
),
})
}
const nextAttrs: Record<string, unknown> = { ...attrs }
delete nextAttrs.styleId
if (def.textAlign) nextAttrs.textAlign = def.textAlign
let next: TipTapJSON = {
...node,
attrs: nextAttrs,
content: nodeChildren(node).map((child) =>
applyMarksToTextNodes(child, textMarksFromStyle(def))
),
}
if (Array.isArray(next.content)) {
next = {
...next,
content: next.content.map((child) => walk(child as TipTapJSON)),
}
}
return next
}
}
}
if (Array.isArray(node.content)) {
return {
...node,
content: node.content.map((child) => walk(child as TipTapJSON)),
}
}
return node
}
return walk(content)
}
export type PreparedGraphicMeta = {
nodeIndex: number
attrs: DocsGraphicAttrs
}
async function graphicToImageNode(
attrs: Record<string, unknown>,
asInline: boolean
): Promise<TipTapJSON | null> {
const graphic = parseGraphicAttrs(attrs)
if (graphic.graphicType !== "image" || !graphic.src) {
if (graphic.graphicType === "shape" || graphic.graphicType === "gradient") {
return null
}
return null
}
const src = await resolveGraphicSrc(graphic.src)
if (!src) return null
const imageAttrs: Record<string, unknown> = {
src,
alt: graphic.alt || graphic.altTitle || "",
width: graphic.width,
height: graphic.height,
}
if (graphic.placement === "absolute" || graphic.positionMode === "fixed-on-page") {
imageAttrs.placement = "absolute"
imageAttrs.x = graphic.pageX || graphic.x
imageAttrs.y = graphic.pageY || graphic.y
imageAttrs.wrap = graphic.wrap
imageAttrs.floatSide = graphic.floatSide
imageAttrs.rotationDeg = graphic.rotationDeg
}
return { type: "image", attrs: imageAttrs }
}
export async function prepareGraphicsForDocxExport(
content: TipTapJSON
): Promise<{ content: TipTapJSON; anchoredGraphics: PreparedGraphicMeta[] }> {
const anchoredGraphics: PreparedGraphicMeta[] = []
let nodeIndex = 0
const walk = async (node: TipTapJSON): Promise<TipTapJSON | null> => {
const type = node.type as string | undefined
if (type === "docsGraphic" || type === "docsInlineGraphic") {
const attrs = isRecord(node.attrs) ? node.attrs : {}
const graphic = parseGraphicAttrs(attrs)
const usesPageLayer =
graphic.placement === "absolute" ||
graphic.positionMode === "fixed-on-page" ||
graphic.wrap === "behind" ||
graphic.wrap === "in-front"
if (usesPageLayer && graphic.src) {
const resolvedSrc = await resolveGraphicSrc(graphic.src)
anchoredGraphics.push({
nodeIndex,
attrs: resolvedSrc ? { ...graphic, src: resolvedSrc } : graphic,
})
nodeIndex += 1
return null
}
const imageNode = await graphicToImageNode(attrs, type === "docsInlineGraphic")
nodeIndex += 1
return imageNode
}
if (type === "image") {
const attrs = isRecord(node.attrs) ? { ...node.attrs } : {}
if (typeof attrs.src === "string") {
const resolved = await resolveGraphicSrc(attrs.src)
if (resolved) attrs.src = resolved
}
nodeIndex += 1
return { ...node, attrs }
}
if (Array.isArray(node.content)) {
const nextContent: TipTapJSON[] = []
for (const child of node.content) {
const next = await walk(child as TipTapJSON)
if (next) nextContent.push(next)
}
return { ...node, content: nextContent }
}
nodeIndex += 1
return node
}
const prepared = await walk(content)
return {
content: prepared ?? { type: "doc", content: [] },
anchoredGraphics,
}
}
export function stripEditorDecorations(content: TipTapJSON): TipTapJSON {
const walk = (node: TipTapJSON): TipTapJSON | null => {
if (node.type === "docsPageFlowSpacer") return null
if (!Array.isArray(node.content)) return node
const nextContent = node.content
.map((child) => walk(child as TipTapJSON))
.filter((child): child is TipTapJSON => child != null)
return { ...node, content: nextContent }
}
const result = walk(content)
return result ?? { type: "doc", content: [] }
}
export function prepareListsForDocxExport(content: TipTapJSON): TipTapJSON {
return content
}
export async function prepareContentForDocxExport(
content: TipTapJSON,
catalog: DocParagraphStylesCatalog
): Promise<{ content: TipTapJSON; anchoredGraphics: PreparedGraphicMeta[] }> {
let prepared = stripEditorDecorations(content)
prepared = applyParagraphStylesForDocxExport(prepared, catalog)
prepared = prepareListsForDocxExport(prepared)
prepared = prepareTablesForDocxExport(prepared)
return prepareGraphicsForDocxExport(prepared)
}