import mammoth from "mammoth" import { normalizeImportedGraphics, normalizeLegacyImageAttrs, } from "@/lib/drive/docs-graphic-import" import { enrichContentFromDocxDrawings } from "@/lib/drive/docx-drawing-import" import { enrichGraphicsFromDocxPositions } from "@/lib/drive/docx-position-import" import { extractDocxHeaderFooter } from "@/lib/drive/docx-header-footer-import" import { extractDocxPageSetup, type DocPageSetup, } from "@/lib/drive/doc-page-setup" export type TipTapJSON = Record export type RichTextImportResult = { content: TipTapJSON pageSetup?: DocPageSetup | null } function imageNodeFromElement(el: HTMLElement): TipTapJSON | null { const src = el.getAttribute("src") if (!src) return null const attrs: Record = { src, alt: el.getAttribute("alt") ?? "", } const title = el.getAttribute("title") if (title) attrs.title = title const width = el.getAttribute("width") const height = el.getAttribute("height") if (width) attrs.width = Number(width) || width if (height) attrs.height = Number(height) || height return { type: "image", attrs } } function inlineContentFromElement(el: HTMLElement): TipTapJSON[] { const content: TipTapJSON[] = [] for (const node of el.childNodes) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent ?? "" if (text) content.push({ type: "text", text }) continue } if (node.nodeType !== Node.ELEMENT_NODE) continue const child = node as HTMLElement const tag = child.tagName.toLowerCase() if (tag === "img") { const image = imageNodeFromElement(child) if (image) content.push(image) continue } content.push(...inlineContentFromElement(child)) } return content } /** Preserve graphic positioning attrs, then upgrade to docsGraphic nodes. */ export function normalizeImportedTipTap(content: TipTapJSON): TipTapJSON { const withLegacy = normalizeLegacyImageAttrs(content) return normalizeImportedGraphics(withLegacy) } function htmlToTipTapDoc(html: string): TipTapJSON { const parser = typeof DOMParser !== "undefined" ? new DOMParser() : null if (!parser) { return { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: html.replace(/<[^>]+>/g, " ") }] }], } } const doc = parser.parseFromString(html, "text/html") const blocks: TipTapJSON[] = [] const walk = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent ?? "" if (text.trim()) { blocks.push({ type: "paragraph", content: [{ type: "text", text }] }) } return } if (node.nodeType !== Node.ELEMENT_NODE) return const el = node as HTMLElement const tag = el.tagName.toLowerCase() if (tag === "img") { const image = imageNodeFromElement(el) if (image) { blocks.push({ type: "paragraph", content: [image] }) } return } if (tag === "p" || tag === "div") { const content = inlineContentFromElement(el) blocks.push({ type: "paragraph", content: content.length ? content : [], }) return } if (/^h[1-6]$/.test(tag)) { const level = Number(tag[1]) blocks.push({ type: "heading", attrs: { level }, content: [{ type: "text", text: el.textContent ?? "" }], }) return } if (tag === "ul" || tag === "ol") { const listType = tag === "ul" ? "bulletList" : "orderedList" const items = Array.from(el.querySelectorAll(":scope > li")).map((li) => ({ type: "listItem", content: [{ type: "paragraph", content: inlineContentFromElement(li) }], })) blocks.push({ type: listType, content: items }) return } Array.from(el.childNodes).forEach(walk) } Array.from(doc.body.childNodes).forEach(walk) if (blocks.length === 0) { blocks.push({ type: "paragraph" }) } return normalizeImportedTipTap({ type: "doc", content: blocks }) } export async function importDocxToTipTap(buffer: ArrayBuffer): Promise { const pageSetup = await extractDocxPageSetup(buffer) const headerFooter = await extractDocxHeaderFooter(buffer) const normalizeRegion = (region: typeof headerFooter.header) => { if (!region?.content) return region return { ...region, content: normalizeImportedTipTap(region.content as TipTapJSON) } } const mergedPageSetup: DocPageSetup | null = pageSetup ? { ...pageSetup, header: normalizeRegion(headerFooter.header ?? pageSetup.header ?? null), footer: normalizeRegion(headerFooter.footer ?? pageSetup.footer ?? null), headerFooterDifferentFirstPage: headerFooter.headerFooterDifferentFirstPage ?? pageSetup.headerFooterDifferentFirstPage ?? false, } : headerFooter.header || headerFooter.footer ? { widthMm: 210, heightMm: 297, marginsMm: { top: 25.4, right: 25.4, bottom: 25.4, left: 25.4 }, header: normalizeRegion(headerFooter.header ?? null), footer: normalizeRegion(headerFooter.footer ?? null), headerFooterDifferentFirstPage: headerFooter.headerFooterDifferentFirstPage ?? false, } : null try { const { parseDOCX } = await import("@docen/import-docx") const content = await parseDOCX(buffer, { image: { crop: true } }) if (content && typeof content === "object") { let normalized = normalizeImportedTipTap(content as TipTapJSON) normalized = await enrichGraphicsFromDocxPositions(buffer, normalized) normalized = await enrichContentFromDocxDrawings(buffer, normalized) return { content: normalized, pageSetup: mergedPageSetup } } } catch (error) { if (process.env.NODE_ENV !== "production") { console.warn("[richtext-import] parseDOCX failed, falling back to mammoth", error) } } const result = await mammoth.convertToHtml( { arrayBuffer: buffer }, { convertImage: mammoth.images.imgElement((image) => image.read("base64").then((imageBuffer) => ({ src: `data:${image.contentType};base64,${imageBuffer}`, })) ), } ) let content = htmlToTipTapDoc(result.value) content = await enrichGraphicsFromDocxPositions(buffer, content) content = await enrichContentFromDocxDrawings(buffer, content) return { content, pageSetup: mergedPageSetup } } export async function exportTipTapToDocx(content: TipTapJSON): Promise { try { const { generateDOCX } = await import("@docen/export-docx") const buf = await generateDOCX(content, { outputType: "blob" }) if (buf instanceof Blob) return buf } catch { /* fallback unavailable */ } throw new Error("Export DOCX indisponible") } export async function importFileToTipTap( fileName: string, buffer: ArrayBuffer ): Promise { const ext = fileName.split(".").pop()?.toLowerCase() ?? "" if (ext === "docx" || ext === "docm") { return importDocxToTipTap(buffer) } const text = new TextDecoder().decode(buffer) if (ext === "html" || ext === "htm") { return { content: htmlToTipTapDoc(text) } } const lines = text.split(/\r?\n/) return { content: { type: "doc", content: lines.map((line) => ({ type: "paragraph", content: line ? [{ type: "text", text: line }] : [], })), }, } }