import type { TipTapJSON } from "@/lib/drive/richtext-import" import type { DocsTableBorder } from "@/lib/drive/docs-table-types" function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value) } function parseHtmlBorder(style: string, side: "top" | "right" | "bottom" | "left"): DocsTableBorder | null { const match = style.match(new RegExp(`border-${side}:\\s*([^;]+)`, "i")) if (!match) return null const value = match[1]?.trim() if (!value || value === "none" || value === "0") return null const parts = value.split(/\s+/) if (parts.length < 2) return null const size = parseFloat(parts[0] ?? "") const styleName = parts[1] ?? "solid" const color = parts.slice(2).join(" ") || "#000000" return { size: Number.isFinite(size) ? Math.round(size * 8) : 6, style: styleName === "solid" ? "single" : styleName, color, } } function parseHtmlCellAttrs(cell: HTMLTableCellElement): Record { const attrs: Record = {} const colspan = Number(cell.getAttribute("colspan") || "1") const rowspan = Number(cell.getAttribute("rowspan") || "1") if (colspan > 1) attrs.colspan = colspan if (rowspan > 1) attrs.rowspan = rowspan const width = cell.getAttribute("width") || cell.style.width if (width) { const px = width.endsWith("px") ? parseFloat(width) : parseFloat(width) if (Number.isFinite(px) && px > 0) attrs.colwidth = [Math.round(px)] } const bg = cell.getAttribute("bgcolor") || cell.getAttribute("data-background-color") || cell.style.backgroundColor if (bg) attrs.backgroundColor = bg const align = cell.getAttribute("align") || cell.style.textAlign if (align === "left" || align === "center" || align === "right" || align === "justify") { attrs.align = align } const valign = cell.getAttribute("valign") || cell.style.verticalAlign if (valign === "top" || valign === "middle" || valign === "bottom") { attrs.verticalAlign = valign } const inlineStyle = cell.getAttribute("style") ?? "" const borderTop = parseHtmlBorder(inlineStyle, "top") const borderRight = parseHtmlBorder(inlineStyle, "right") const borderBottom = parseHtmlBorder(inlineStyle, "bottom") const borderLeft = parseHtmlBorder(inlineStyle, "left") if (borderTop) attrs.borderTop = borderTop if (borderRight) attrs.borderRight = borderRight if (borderBottom) attrs.borderBottom = borderBottom if (borderLeft) attrs.borderLeft = borderLeft return attrs } function inlineTextFromElement(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() const marks: TipTapJSON[] = [] if (tag === "strong" || tag === "b") marks.push({ type: "bold" }) if (tag === "em" || tag === "i") marks.push({ type: "italic" }) if (tag === "u") marks.push({ type: "underline" }) const text = child.textContent ?? "" if (text) content.push({ type: "text", text, ...(marks.length ? { marks } : {}) }) } return content } export function htmlTableToTipTap(table: HTMLTableElement): TipTapJSON { const rows = Array.from(table.querySelectorAll(":scope > tbody > tr, :scope > tr")) const tableAttrs: Record = {} const align = table.getAttribute("align") || table.style.margin if (table.getAttribute("align") === "center") tableAttrs.alignment = "center" else if (table.getAttribute("align") === "right") tableAttrs.alignment = "right" const width = table.getAttribute("width") || table.style.width if (width && width !== "auto") tableAttrs.layout = "fixed" const rowNodes = rows.map((row, rowIndex) => { const htmlRow = row as HTMLTableRowElement const cells = Array.from(htmlRow.querySelectorAll(":scope > th, :scope > td")) const rowAttrs: Record = {} const rowHeight = htmlRow.style.height || htmlRow.getAttribute("height") if (rowHeight) rowAttrs.rowHeight = rowHeight.endsWith("px") ? rowHeight : `${rowHeight}px` if (rowIndex === 0 && cells.every((cell) => cell.tagName.toLowerCase() === "th")) { rowAttrs.header = true } return { type: "tableRow", ...(Object.keys(rowAttrs).length ? { attrs: rowAttrs } : {}), content: cells.map((cell) => { const htmlCell = cell as HTMLTableCellElement const isHeader = htmlCell.tagName.toLowerCase() === "th" const attrs = parseHtmlCellAttrs(htmlCell) const paragraphContent = inlineTextFromElement(htmlCell) return { type: isHeader ? "tableHeader" : "tableCell", ...(Object.keys(attrs).length ? { attrs } : {}), content: [ { type: "paragraph", content: paragraphContent.length ? paragraphContent : [], }, ], } }), } }) return { type: "table", ...(Object.keys(tableAttrs).length ? { attrs: tableAttrs } : {}), content: rowNodes.length ? rowNodes : [{ type: "tableRow", content: [{ type: "tableCell", content: [{ type: "paragraph" }] }] }], } } function normalizeCellNode(node: TipTapJSON): TipTapJSON | null { if (!isRecord(node)) return null if (node.type !== "tableCell" && node.type !== "tableHeader") return node const attrs = isRecord(node.attrs) ? { ...node.attrs } : {} if (attrs.rowspan === 0) return null if (typeof attrs.colspan !== "number" || attrs.colspan < 1) attrs.colspan = 1 if (typeof attrs.rowspan !== "number" || attrs.rowspan < 1) attrs.rowspan = 1 if (Array.isArray(attrs.colwidth)) { const colwidth = (attrs.colwidth as unknown[]) .map((value) => (typeof value === "number" && Number.isFinite(value) ? value : null)) .filter((value): value is number => value != null) if (colwidth.length) attrs.colwidth = colwidth else delete attrs.colwidth } const content = Array.isArray(node.content) ? node.content.map((child) => normalizeTableTree(child)).filter(Boolean) : [{ type: "paragraph" }] return { ...node, attrs, content: content.length ? content : [{ type: "paragraph" }], } } function normalizeRowNode(node: TipTapJSON): TipTapJSON | null { if (!isRecord(node) || node.type !== "tableRow") return node const content = Array.isArray(node.content) ? node.content .map((child) => normalizeCellNode(child)) .filter((child): child is TipTapJSON => Boolean(child)) : [] if (!content.length) return null return { ...node, content } } function normalizeTableNode(node: TipTapJSON): TipTapJSON | null { if (!isRecord(node) || node.type !== "table") return node const content = Array.isArray(node.content) ? node.content .map((child) => normalizeRowNode(child)) .filter((child): child is TipTapJSON => Boolean(child)) : [] if (!content.length) { return { type: "table", content: [ { type: "tableRow", content: [{ type: "tableCell", content: [{ type: "paragraph" }] }], }, ], } } return { ...node, content } } function normalizeTableTree(node: unknown): TipTapJSON | null { if (!isRecord(node)) return null if (node.type === "table") return normalizeTableNode(node) if (node.type === "tableRow") return normalizeRowNode(node) if (node.type === "tableCell" || node.type === "tableHeader") return normalizeCellNode(node) if (Array.isArray(node.content)) { return { ...node, content: node.content.map((child) => normalizeTableTree(child)).filter(Boolean), } } return node } /** Preserve DOCX/HTML table attrs and drop invalid merged-cell placeholders. */ export function normalizeImportedTables(content: TipTapJSON): TipTapJSON { const normalized = normalizeTableTree(content) return (normalized ?? content) as TipTapJSON }