256 lines
8.1 KiB
TypeScript
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)
|
|
}
|