232 lines
9.0 KiB
TypeScript
232 lines
9.0 KiB
TypeScript
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;
|
|
columns: Array<{
|
|
name: string;
|
|
dataType: string;
|
|
nullable: boolean;
|
|
primaryKey: boolean;
|
|
}>;
|
|
foreignKeys: Array<{
|
|
name: string;
|
|
column: string;
|
|
refTable: string;
|
|
refColumn: string;
|
|
}>;
|
|
}>;
|
|
relationships: DiagramRelationship[];
|
|
positions: Record<string, DiagramPosition>;
|
|
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<Blob> {
|
|
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"] };
|
|
}
|
|
}
|