ultisuite-client/lib/drive/richtext-import.ts
R3D347HR4Y 2a7c153748
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wrap page
2026-06-10 12:48:27 +02:00

221 lines
7.3 KiB
TypeScript

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<string, unknown>
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<string, unknown> = {
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<RichTextImportResult> {
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<Blob> {
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<RichTextImportResult> {
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 }] : [],
})),
},
}
}