Files
dbx/apps/desktop/src/lib/export/diagramFormats.ts
T

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"] };
}
}