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 { return Boolean(value) && typeof value === "object" && !Array.isArray(value) } function blobToDataUrl(blob: Blob): Promise { 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 { 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 = {} 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 = { ...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, asInline: boolean ): Promise { 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 = { 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 => { 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) }