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

188 lines
5.5 KiB
TypeScript

import type { TipTapJSON } from "@/lib/drive/richtext-import"
import type { DocsTableBorder } from "@/lib/drive/docs-table-types"
const BORDER_STYLES = new Set([
"single",
"dashed",
"dotted",
"double",
"dotDash",
"dotDotDash",
"none",
])
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
function parseCssBorderValue(value: string): DocsTableBorder | null {
const trimmed = value.trim()
if (!trimmed || trimmed === "none" || trimmed === "0") return null
const parts = trimmed.split(/\s+/)
if (parts.length < 2) return null
const size = parseFloat(parts[0] ?? "")
const styleToken = parts[1] ?? "solid"
const color = parts.slice(2).join(" ") || "#000000"
const style = styleToken === "solid" ? "single" : styleToken
return {
size: Number.isFinite(size) ? Math.max(1, Math.round(size * 8)) : 8,
style,
color,
}
}
/** Normalize border attrs for @docen/export-docx (DOCX Border objects). */
export function normalizeTableBorderForExport(
border: unknown
): DocsTableBorder | null {
if (!border) return null
if (typeof border === "string") return parseCssBorderValue(border)
if (!isRecord(border)) return null
const styleRaw = typeof border.style === "string" ? border.style : "single"
const style = styleRaw === "solid" ? "single" : styleRaw
if (style === "none" || style === "nil") return null
const color =
typeof border.color === "string" && border.color
? border.color
: "#000000"
const size =
typeof border.size === "number" && Number.isFinite(border.size)
? border.size
: 8
const normalized: DocsTableBorder = {
size,
style: BORDER_STYLES.has(style) ? style : "single",
color,
}
if (typeof border.space === "number" && Number.isFinite(border.space)) {
normalized.space = border.space
}
return normalized
}
function normalizeCellAttrsForExport(attrs: Record<string, unknown>): Record<string, unknown> {
const next = { ...attrs }
if (typeof next.backgroundColor === "string") {
next.backgroundColor = next.backgroundColor.trim() || null
}
if (Array.isArray(next.colwidth)) {
const colwidth = next.colwidth
.map((value) => (typeof value === "number" && Number.isFinite(value) ? value : null))
.filter((value): value is number => value != null && value > 0)
if (colwidth.length) next.colwidth = colwidth
else delete next.colwidth
}
for (const side of ["borderTop", "borderRight", "borderBottom", "borderLeft"] as const) {
const normalized = normalizeTableBorderForExport(next[side])
if (normalized) next[side] = normalized
else delete next[side]
}
return next
}
function normalizeTableNodeForExport(node: TipTapJSON): TipTapJSON {
if (!isRecord(node) || node.type !== "table") return node
const rows = (Array.isArray(node.content) ? node.content : []) as TipTapJSON[]
const firstRow = rows.find((row) => isRecord(row) && row.type === "tableRow")
const firstRowCells = (Array.isArray(firstRow?.content) ? firstRow.content : []) as TipTapJSON[]
const colwidths = firstRowCells
.map((cell) => {
if (!isRecord(cell) || !isRecord(cell.attrs)) return null
const raw = cell.attrs.colwidth
if (Array.isArray(raw) && typeof raw[0] === "number") return raw[0]
if (typeof raw === "number") return raw
return null
})
.filter((value): value is number => value != null && value > 0)
const tableAttrs = isRecord(node.attrs) ? { ...node.attrs } : {}
if (tableAttrs.layout === "fixed" && !colwidths.length) {
delete tableAttrs.layout
}
const content = rows.map((row) => {
if (!isRecord(row) || row.type !== "tableRow") return row
const rowAttrs = isRecord(row.attrs) ? { ...row.attrs } : {}
if (typeof rowAttrs.rowHeight === "string" && !rowAttrs.rowHeight.trim()) {
delete rowAttrs.rowHeight
}
if (
rowAttrs.rowHeightRule !== "exact" &&
rowAttrs.rowHeightRule !== "atLeast"
) {
delete rowAttrs.rowHeightRule
}
const cells = Array.isArray(row.content) ? row.content : []
const normalizedCells = cells.map((cell, cellIndex) => {
if (
!isRecord(cell) ||
(cell.type !== "tableCell" && cell.type !== "tableHeader")
) {
return cell
}
const attrs = normalizeCellAttrsForExport(
isRecord(cell.attrs) ? { ...cell.attrs } : {}
)
if (!attrs.colwidth && colwidths[cellIndex]) {
attrs.colwidth = [colwidths[cellIndex]]
}
const cellContent = Array.isArray(cell.content)
? cell.content.map((child) => prepareTablesForDocxExport(child))
: [{ type: "paragraph" }]
return {
...cell,
attrs: Object.keys(attrs).length ? attrs : undefined,
content: cellContent,
}
})
return {
...row,
attrs: Object.keys(rowAttrs).length ? rowAttrs : undefined,
content: normalizedCells,
}
})
return {
...node,
attrs: Object.keys(tableAttrs).length ? tableAttrs : undefined,
content,
}
}
/** Walk TipTap JSON and normalize table attrs before DOCX export. */
export function prepareTablesForDocxExport(content: TipTapJSON): TipTapJSON {
if (!isRecord(content)) return content
if (content.type === "table") {
return normalizeTableNodeForExport(content)
}
if (Array.isArray(content.content)) {
return {
...content,
content: content.content.map((child) => prepareTablesForDocxExport(child)),
}
}
return content
}