import type { CustomDiagramRelationship, DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram"; import type { DiagramLayer } from "@/types/diagram"; export type DiagramExportFormat = "svg" | "png" | "json" | "dbml" | "mermaid"; export interface DiagramJsonSnapshot { meta: { connectionName: string; database: string; schema: string; mode: "table" | "engineering"; exportedAt: string; }; tables: Array<{ name: string; schema?: string; columns: Array<{ name: string; dataType: string; nullable: boolean; primaryKey: boolean; }>; foreignKeys: Array<{ name: string; column: string; refTable: string; refColumn: string; }>; }>; relationships: DiagramRelationship[]; positions: Record; layers: DiagramLayer[]; customRelationships: CustomDiagramRelationship[]; matchConfirms: string[]; matchIgnores: string[]; } function fileToken(value: string): string { return value .trim() .replace(/[^\p{L}\p{N}._-]+/gu, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function quoteIdent(name: string): string { if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return name; return `"${name.replace(/"/g, '\\"')}"`; } function mermaidIdent(name: string): string { const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_"); return cleaned || "entity"; } function dbmlType(dataType: string): string { const t = dataType.trim() || "varchar"; // Keep parentheses content; quote if spaces / special chars if (/^[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?$/.test(t)) return t; return `"${t.replace(/"/g, '\\"')}"`; } function dbmlRefOp(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string { if (sourceCardinality === "1" && targetCardinality === "1") return "-"; if (sourceCardinality === "N" && targetCardinality === "N") return "<>"; if (sourceCardinality === "N" && targetCardinality === "1") return ">"; return "<"; } function mermaidCardinality(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string { // Mermaid: ENTITY1 ||--o{ ENTITY2 : "label" if (sourceCardinality === "1" && targetCardinality === "1") return "||--||"; if (sourceCardinality === "1" && targetCardinality === "N") return "||--o{"; if (sourceCardinality === "N" && targetCardinality === "1") return "}o--||"; return "}o--o{"; } export function diagramExportFileName(connectionName: string, databaseName: string, mode: "table" | "engineering", format: DiagramExportFormat): string { const context = [connectionName, databaseName].map(fileToken).filter(Boolean); const modeSuffix = mode === "engineering" ? "engineering-er" : "table-structure"; const ext = format === "svg" ? "svg" : format === "png" ? "png" : format === "json" ? "json" : format === "dbml" ? "dbml" : "mmd"; const kind = format === "svg" || format === "png" ? modeSuffix : format === "json" ? "diagram" : format === "dbml" ? "schema" : "er"; return ["dbx", ...(context.length > 0 ? context : ["diagram"]), kind].join("-") + `.${ext}`; } export function buildDiagramJson(snapshot: DiagramJsonSnapshot): string { return `${JSON.stringify(snapshot, null, 2)}\n`; } export function buildDiagramDbml(tables: DiagramTable[], relationships: DiagramRelationship[]): string { const lines: string[] = ["// Generated by DBX", ""]; for (const table of tables) { lines.push(`Table ${quoteIdent(table.name)} {`); for (const column of table.columns) { const attrs: string[] = []; if (column.is_primary_key) attrs.push("pk"); if (!column.is_nullable) attrs.push("not null"); const attrSuffix = attrs.length > 0 ? ` [${attrs.join(", ")}]` : ""; lines.push(` ${quoteIdent(column.name)} ${dbmlType(column.data_type)}${attrSuffix}`); } lines.push("}", ""); } for (const rel of relationships) { const op = dbmlRefOp(rel.sourceCardinality, rel.targetCardinality); lines.push(`Ref: ${quoteIdent(rel.sourceTable)}.${quoteIdent(rel.sourceColumn)} ${op} ${quoteIdent(rel.targetTable)}.${quoteIdent(rel.targetColumn)}`); } if (relationships.length > 0) lines.push(""); return lines.join("\n"); } export function buildDiagramMermaid(tables: DiagramTable[], relationships: DiagramRelationship[]): string { const lines: string[] = ["erDiagram"]; for (const table of tables) { const entity = mermaidIdent(table.name); lines.push(` ${entity} {`); for (const column of table.columns) { const typeToken = (column.data_type || "unknown").replace(/\s+/g, "_").replace(/[^A-Za-z0-9_]/g, "") || "unknown"; const key = column.is_primary_key ? " PK" : ""; lines.push(` ${typeToken} ${mermaidIdent(column.name)}${key}`); } lines.push(" }"); } for (const rel of relationships) { const left = mermaidIdent(rel.sourceTable); const right = mermaidIdent(rel.targetTable); const card = mermaidCardinality(rel.sourceCardinality, rel.targetCardinality); const label = `${rel.sourceColumn} -> ${rel.targetColumn}`.replace(/"/g, "'"); lines.push(` ${left} ${card} ${right} : "${label}"`); } lines.push(""); return lines.join("\n"); } // Browsers cap canvas backing stores: Chromium/WebView2 and WebKit both // reject surfaces beyond 16384 px per side (and beyond ~16384² total pixels), // in which case rasterization silently fails and `toBlob` yields nothing. // Large diagrams at the default 2x scale exceed these limits, so the export // scale is clamped to the largest value that still fits (issue #7248). export const MAX_PNG_CANVAS_SIDE = 16384; export const MAX_PNG_CANVAS_AREA = MAX_PNG_CANVAS_SIDE * MAX_PNG_CANVAS_SIDE; // Never downscale below this when retrying failed rasterizations: a heavily // reduced PNG is still more useful than a failed export, but beyond this the // diagram becomes unreadable. export const MIN_PNG_EXPORT_SCALE = 0.05; export function resolvePngExportScale(width: number, height: number, requestedScale: number): number { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 || !Number.isFinite(requestedScale) || requestedScale <= 0) { return requestedScale; } const sideScale = Math.min(MAX_PNG_CANVAS_SIDE / width, MAX_PNG_CANVAS_SIDE / height); const areaScale = Math.sqrt(MAX_PNG_CANVAS_AREA / (width * height)); // Allow sub-1 scales for oversized diagrams: a lower-resolution PNG beats // a failed export. return Math.min(requestedScale, sideScale, areaScale); } export function svgToPngBlob(svg: string, scale = 2): Promise { return new Promise((resolve, reject) => { const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { // Device-dependent caps (GPU texture limits, available memory) can be // far below the documented canvas limits, and a failed rasterization // only surfaces as a null blob. Try the clamped scale first, then keep // halving it instead of failing the export outright (issue #7248). const initialScale = Math.max(resolvePngExportScale(img.naturalWidth, img.naturalHeight, scale), MIN_PNG_EXPORT_SCALE); const rasterize = (attemptScale: number): void => { try { const width = Math.max(1, Math.ceil(img.naturalWidth * attemptScale)); const height = Math.max(1, Math.ceil(img.naturalHeight * attemptScale)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) { URL.revokeObjectURL(url); reject(new Error("Canvas unsupported")); return; } ctx.fillStyle = "#fafafa"; ctx.fillRect(0, 0, width, height); ctx.drawImage(img, 0, 0, width, height); canvas.toBlob((png) => { if (png) { URL.revokeObjectURL(url); resolve(png); return; } const nextScale = attemptScale / 2; if (nextScale < MIN_PNG_EXPORT_SCALE) { URL.revokeObjectURL(url); reject(new Error(`PNG encode failed for ${width}x${height} canvas`)); return; } rasterize(nextScale); }, "image/png"); } catch (err) { URL.revokeObjectURL(url); reject(err); } }; rasterize(initialScale); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Failed to load SVG for PNG export")); }; img.src = url; }); } export function diagramExportDialogFilter(format: DiagramExportFormat): { name: string; extensions: string[] } { switch (format) { case "svg": return { name: "SVG", extensions: ["svg"] }; case "png": return { name: "PNG", extensions: ["png"] }; case "json": return { name: "JSON", extensions: ["json"] }; case "dbml": return { name: "DBML", extensions: ["dbml"] }; case "mermaid": return { name: "Mermaid", extensions: ["mmd", "md"] }; } }