ultisuite-client/lib/drive/docs-table-import.ts
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

223 lines
8.0 KiB
TypeScript

import type { TipTapJSON } from "@/lib/drive/richtext-import"
import type { DocsTableBorder } from "@/lib/drive/docs-table-types"
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> {
const attrs: Record<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown> = {}
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
}