feat(docs): export data dictionary as PDF
This commit is contained in:
1 parent
2d4ee24660
commit
b140f84ac4
28 files changed
+3108
-3
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { supportsDataDictionary, supportsSchemaDiagram } from "@/lib/database/databaseFeatureSupport";
|
||||
|
||||
describe("supportsDataDictionary", () => {
|
||||
it("is available for relational engines, including ones without a diagram", () => {
|
||||
expect(supportsDataDictionary("mysql")).toBe(true);
|
||||
expect(supportsDataDictionary("oracle")).toBe(true);
|
||||
expect(supportsDataDictionary("postgres")).toBe(true);
|
||||
expect(supportsDataDictionary("doris")).toBe(true);
|
||||
expect(supportsSchemaDiagram("doris")).toBe(false);
|
||||
});
|
||||
|
||||
it("is hidden for engines that cannot list table metadata", () => {
|
||||
expect(supportsDataDictionary("redis")).toBe(false);
|
||||
expect(supportsDataDictionary("plugin")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -198,6 +198,11 @@ export function supportsSchemaDiagram(dbType?: DatabaseType): boolean {
|
||||
return supportsDatabaseFeature(dbType, "diagram");
|
||||
}
|
||||
|
||||
/** Relational engines that can list tables and columns. Independent of diagram support. */
|
||||
export function supportsDataDictionary(dbType?: DatabaseType): boolean {
|
||||
return supportsDatabaseFeature(dbType, "metadataBrowse");
|
||||
}
|
||||
|
||||
export function supportsDatabaseSearch(dbType?: DatabaseType): boolean {
|
||||
return supportsDatabaseFeature(dbType, "schemaSearch");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ColumnInfo } from "@/docs/types";
|
||||
import { applyTemplate, dataDictionaryFileName, dictionaryCatalogNames, exportBlockedBySkippedTables, moveOrderedKey, objectKey, orderDictionaryTables, resolveDictionaryExportPath, toggleOrderedKey, warningsForSelection, type DictionaryLayout, type DictionaryTable } from "@/lib/docs/dataDictionary";
|
||||
import { buildDataDictionaryPdf, pdfUtf16Hex } from "@/lib/docs/dataDictionaryPdf";
|
||||
|
||||
const labels = {
|
||||
column: "Column",
|
||||
type: "Type",
|
||||
length: "Length",
|
||||
precision: "Precision",
|
||||
scale: "Scale",
|
||||
primaryKey: "Primary key",
|
||||
nullable: "Nullable",
|
||||
unique: "Unique",
|
||||
defaultValue: "Default",
|
||||
extra: "Extra",
|
||||
comment: "Comment",
|
||||
indexName: "Index",
|
||||
indexColumns: "Index columns",
|
||||
indexType: "Index type",
|
||||
constraintName: "Constraint",
|
||||
refSchema: "Referenced schema",
|
||||
refTable: "Referenced table",
|
||||
refColumn: "Referenced column",
|
||||
onUpdate: "ON UPDATE",
|
||||
onDelete: "ON DELETE",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
indexesHeading: "Indexes",
|
||||
foreignKeysHeading: "Foreign keys",
|
||||
contentsHeading: "Contents",
|
||||
kindTable: "Table",
|
||||
kindView: "View",
|
||||
kindMaterializedView: "Materialized view",
|
||||
};
|
||||
|
||||
function column(partial: Partial<ColumnInfo> & Pick<ColumnInfo, "name" | "data_type">): ColumnInfo {
|
||||
return {
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: null,
|
||||
numeric_precision: null,
|
||||
numeric_scale: null,
|
||||
character_maximum_length: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function table(partial: Partial<DictionaryTable> & Pick<DictionaryTable, "name">): DictionaryTable {
|
||||
return {
|
||||
database: "shop",
|
||||
schema: "public",
|
||||
kind: "TABLE",
|
||||
columns: [column({ name: "id", data_type: "integer", is_primary_key: true, is_nullable: false })],
|
||||
indexes: [],
|
||||
foreignKeys: [],
|
||||
groupId: null,
|
||||
note: null,
|
||||
noteSource: "NONE",
|
||||
shadowedNote: null,
|
||||
columnNotes: {},
|
||||
estimatedRows: null,
|
||||
viewDefinition: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("dictionaryCatalogNames", () => {
|
||||
it("hides internal schemas unless that schema was explicitly opened", () => {
|
||||
const listed = ["shop", "information_schema", "performance_schema", "mysql", "sys", "public"];
|
||||
expect(dictionaryCatalogNames(listed, "mysql")).toEqual(["shop", "public"]);
|
||||
expect(dictionaryCatalogNames(listed, "mysql", "information_schema")).toEqual(["shop", "information_schema", "public"]);
|
||||
expect(dictionaryCatalogNames(["public", "pg_catalog", "information_schema"], "postgres")).toEqual(["public"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dictionary selection", () => {
|
||||
it("checks, unchecks, and reorders objects", () => {
|
||||
const orders = objectKey({ database: "shop", schema: "public", name: "orders" });
|
||||
const users = objectKey({ database: "shop", schema: "public", name: "users" });
|
||||
expect(toggleOrderedKey([], orders, true)).toEqual([orders]);
|
||||
expect(toggleOrderedKey([orders, users], orders, false)).toEqual([users]);
|
||||
expect(moveOrderedKey([orders, users], users, -1)).toEqual([users, orders]);
|
||||
});
|
||||
|
||||
it("exports tables in the selected order", () => {
|
||||
const orders = table({ name: "orders" });
|
||||
const users = table({ name: "users" });
|
||||
const ordered = orderDictionaryTables([orders, users], [objectKey(users), objectKey(orders)]);
|
||||
expect(ordered.map((item) => item.name)).toEqual(["users", "orders"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dataDictionaryFileName", () => {
|
||||
it("appends a timestamp when requested, including on a path that was already chosen", () => {
|
||||
const now = new Date("2026-09-24T14:05:06");
|
||||
expect(dataDictionaryFileName("shop", false, now)).toBe("shop-data-dictionary.pdf");
|
||||
expect(dataDictionaryFileName("a/b", true, now)).toBe("a_b-data-dictionary_20260924140506.pdf");
|
||||
expect(resolveDictionaryExportPath("/tmp/shop.pdf", "shop-data-dictionary.pdf", true, now)).toBe("/tmp/shop_20260924140506.pdf");
|
||||
expect(resolveDictionaryExportPath("", "shop-data-dictionary.pdf", true, now)).toBe("shop-data-dictionary_20260924140506.pdf");
|
||||
expect(resolveDictionaryExportPath("/tmp/shop_20260924140506.pdf", "ignored.pdf", true, now)).toBe("/tmp/shop_20260924140506.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportBlockedBySkippedTables", () => {
|
||||
it("stops export only when continue-on-error is off and a selected table was skipped", () => {
|
||||
const warnings = [{ kind: "tableSkipped", table: "secret", reason: "denied" }, { kind: "tableSkipped", table: "public.secret", reason: "denied" }, { kind: "commentsUnsupported" }];
|
||||
const selected = [{ schema: "public", name: "users" }];
|
||||
const relevant = warningsForSelection(warnings, selected);
|
||||
expect(exportBlockedBySkippedTables(relevant, false)).toBe(false);
|
||||
expect(exportBlockedBySkippedTables(relevant, true)).toBe(false);
|
||||
expect(relevant.some((warning) => warning.table?.includes("secret"))).toBe(false);
|
||||
expect(exportBlockedBySkippedTables(warningsForSelection([{ kind: "tableSkipped", table: "public.users", reason: "denied" }], selected), false)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the collector's empty-schema skip and drops schema-wide skips for other schemas", () => {
|
||||
const selected = [{ schema: "", name: "orders" }];
|
||||
const kept = warningsForSelection([{ kind: "tableSkipped", table: ".orders", reason: "denied" }], selected);
|
||||
const otherSchema = warningsForSelection([{ kind: "tableSkipped", table: "analytics.*", reason: "denied" }], selected);
|
||||
const ownSchema = warningsForSelection([{ kind: "tableSkipped", table: ".*", reason: "denied" }], selected);
|
||||
|
||||
expect(exportBlockedBySkippedTables(kept, false)).toBe(true);
|
||||
expect(kept.map((warning) => warning.table)).toEqual([".orders"]);
|
||||
expect(exportBlockedBySkippedTables(otherSchema, false)).toBe(false);
|
||||
expect(otherSchema).toEqual([]);
|
||||
expect(ownSchema.map((warning) => warning.table)).toEqual([".*"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTemplate", () => {
|
||||
it("changes paper and sections with the template", () => {
|
||||
const standard = applyTemplate("standard", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" });
|
||||
const compact = applyTemplate("compact", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" });
|
||||
expect(standard.includeCover).toBe(true);
|
||||
expect(standard.includeIndexesAndForeignKeys).toBe(true);
|
||||
expect(compact.includeCover).toBe(false);
|
||||
expect(compact.includeIndexesAndForeignKeys).toBe(false);
|
||||
expect(compact.orientation).toBe("landscape");
|
||||
});
|
||||
});
|
||||
|
||||
function pdfText(bytes: Uint8Array): string {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function textAt(pdf: string, text: string): number {
|
||||
const cjk = [...text].filter((char) => (char.codePointAt(0) ?? 0) >= 128).join("");
|
||||
if (cjk && [...text].every((char) => (char.codePointAt(0) ?? 0) >= 128)) return pdf.indexOf(pdfUtf16Hex(cjk));
|
||||
const literal = pdf.indexOf(`(${text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)")})`);
|
||||
if (literal >= 0) return literal;
|
||||
const raw = pdf.indexOf(text);
|
||||
return raw >= 0 && pdf[raw - 1] !== "/" ? raw : -1;
|
||||
}
|
||||
|
||||
describe("buildDataDictionaryPdf", () => {
|
||||
it("writes a PDF whose text includes only the chosen tables in order and omits indexes when that choice is off", () => {
|
||||
const users = table({ name: "users" });
|
||||
const orders = table({
|
||||
name: "orders",
|
||||
note: "订单",
|
||||
indexes: [{ name: "orders_pkey", columns: ["id"], is_unique: true, is_primary: true, filter: null, index_type: "btree", included_columns: null, comment: null }],
|
||||
});
|
||||
const guests = table({ name: "guests" });
|
||||
const chosen = orderDictionaryTables([users, orders, guests], [objectKey(orders), objectKey(users)]);
|
||||
const layout = applyTemplate("compact", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" });
|
||||
const pdf = pdfText(buildDataDictionaryPdf(chosen, labels, layout));
|
||||
|
||||
expect(pdf.startsWith("%PDF-1.4")).toBe(true);
|
||||
const ordersAt = textAt(pdf, "orders");
|
||||
const usersAt = textAt(pdf, "users");
|
||||
expect(ordersAt).toBeGreaterThan(-1);
|
||||
expect(usersAt).toBeGreaterThan(ordersAt);
|
||||
expect(textAt(pdf, "订单")).toBeGreaterThan(-1);
|
||||
expect(textAt(pdf, "orders_pkey")).toBe(-1);
|
||||
expect(textAt(pdf, "guests")).toBe(-1);
|
||||
});
|
||||
|
||||
it("changes the file when cover, contents, introduction, indexes, or paper changes", () => {
|
||||
const orders = table({
|
||||
name: "orders",
|
||||
indexes: [{ name: "orders_pkey", columns: ["id"], is_unique: true, is_primary: true, filter: null, index_type: "btree", included_columns: null, comment: null }],
|
||||
});
|
||||
const base: DictionaryLayout = {
|
||||
...applyTemplate("standard", { title: "shop", introduction: "INTRO-ONLY-TEXT", detailedIntroduction: "detail", leftFooter: "shop" }),
|
||||
subtitle: "SUBTITLE-ONLY",
|
||||
includeIndexesAndForeignKeys: true,
|
||||
};
|
||||
const withChoices = pdfText(buildDataDictionaryPdf([orders], labels, base));
|
||||
const withoutCover = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, includeCover: false }));
|
||||
const withoutContents = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, includeToc: false }));
|
||||
const withoutIntro = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, includeIntroduction: false }));
|
||||
const withoutIndexes = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, includeIndexesAndForeignKeys: false }));
|
||||
const landscape = pdfText(buildDataDictionaryPdf([orders], labels, { ...base, orientation: "landscape" }));
|
||||
|
||||
expect(textAt(withChoices, "SUBTITLE-ONLY")).toBeGreaterThan(-1);
|
||||
expect(textAt(withoutCover, "SUBTITLE-ONLY")).toBe(-1);
|
||||
expect(textAt(withChoices, labels.contentsHeading)).toBeGreaterThan(-1);
|
||||
expect(textAt(withoutContents, labels.contentsHeading)).toBe(-1);
|
||||
expect(textAt(withChoices, "INTRO-ONLY-TEXT")).toBeGreaterThan(-1);
|
||||
expect(textAt(withoutIntro, "INTRO-ONLY-TEXT")).toBe(-1);
|
||||
expect(textAt(withChoices, "orders_pkey")).toBeGreaterThan(-1);
|
||||
expect(textAt(withoutIndexes, "orders_pkey")).toBe(-1);
|
||||
expect(withChoices).toContain("/MediaBox [0 0 595.28 841.89]");
|
||||
expect(landscape).toContain("/MediaBox [0 0 841.89 595.28]");
|
||||
});
|
||||
|
||||
it("numbers pages from the introduction so TOC entries match the printed footers", () => {
|
||||
const orders = table({ name: "orders" });
|
||||
const layout = applyTemplate("standard", {
|
||||
title: "shop",
|
||||
introduction: Array.from({ length: 160 }, (_, index) => `INTRO-LINE-${index}`).join("\n"),
|
||||
detailedIntroduction: "detail",
|
||||
leftFooter: "shop",
|
||||
});
|
||||
const streams = pdfText(buildDataDictionaryPdf([orders], labels, layout))
|
||||
.split("endstream")
|
||||
.filter((chunk) => chunk.includes("BT"));
|
||||
const introStreams = streams.filter((chunk) => chunk.includes("INTRO-LINE-"));
|
||||
expect(introStreams.length).toBeGreaterThanOrEqual(2);
|
||||
const lastIntroIndex = streams.lastIndexOf(introStreams[introStreams.length - 1]!);
|
||||
const bodyStream = streams.slice(lastIntroIndex + 1).find((chunk) => chunk.includes("orders"));
|
||||
expect(bodyStream).toBeDefined();
|
||||
const tocStream = streams.find((chunk) => chunk.includes("Contents"));
|
||||
expect(tocStream).toBeDefined();
|
||||
const introPages = introStreams.length;
|
||||
expect(introStreams[0]).toContain("(1) Tj");
|
||||
expect(introStreams[introPages - 1]).toContain(`(${introPages}) Tj`);
|
||||
expect(bodyStream).toContain(`(${introPages + 1}) Tj`);
|
||||
expect(tocStream).toContain(`(${introPages + 1}) Tj`);
|
||||
});
|
||||
|
||||
it("does not abort or name an unselected skipped table when the snapshot warned about it", () => {
|
||||
const users = table({ name: "users", schema: "public" });
|
||||
const orders = table({ name: "orders", schema: "public" });
|
||||
const selected = orderDictionaryTables([users, orders], [objectKey(users)]);
|
||||
const warnings = warningsForSelection(
|
||||
[
|
||||
{ kind: "tableSkipped", table: "secret", reason: "denied" },
|
||||
{ kind: "tableSkipped", table: "public.secret", reason: "denied" },
|
||||
],
|
||||
selected,
|
||||
);
|
||||
expect(exportBlockedBySkippedTables(warnings, false)).toBe(false);
|
||||
const pdf = pdfText(
|
||||
buildDataDictionaryPdf(
|
||||
selected,
|
||||
labels,
|
||||
applyTemplate("compact", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" }),
|
||||
warnings.map((warning) => `${warning.table}: ${warning.reason}`),
|
||||
),
|
||||
);
|
||||
expect(textAt(pdf, "users")).toBeGreaterThan(-1);
|
||||
expect(textAt(pdf, "secret")).toBe(-1);
|
||||
expect(textAt(pdf, "orders")).toBe(-1);
|
||||
});
|
||||
|
||||
it("names a selected empty-schema skip in the PDF and omits another schema's skip", () => {
|
||||
const orders = table({ name: "orders", schema: "" });
|
||||
const selected = [{ schema: "", name: "orders" }];
|
||||
const warnings = warningsForSelection(
|
||||
[
|
||||
{ kind: "tableSkipped", table: ".orders", reason: "denied" },
|
||||
{ kind: "tableSkipped", table: "analytics.*", reason: "denied" },
|
||||
],
|
||||
selected,
|
||||
);
|
||||
const pdf = pdfText(
|
||||
buildDataDictionaryPdf(
|
||||
[orders],
|
||||
labels,
|
||||
applyTemplate("compact", { title: "shop", introduction: "intro", detailedIntroduction: "detail", leftFooter: "shop" }),
|
||||
warnings.map((warning) => `${warning.table}: ${warning.reason}`),
|
||||
),
|
||||
);
|
||||
expect(textAt(pdf, ".orders")).toBeGreaterThan(-1);
|
||||
expect(textAt(pdf, "analytics.*")).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveDictionaryExportPath } from "@/lib/docs/dataDictionary";
|
||||
|
||||
describe("saveDataDictionaryFile", () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@/lib/backend/tauriRuntime");
|
||||
vi.doUnmock("@tauri-apps/plugin-dialog");
|
||||
vi.doUnmock("@tauri-apps/plugin-fs");
|
||||
});
|
||||
|
||||
async function loadSaver(save: ReturnType<typeof vi.fn>, writeFile: ReturnType<typeof vi.fn>, exists: ReturnType<typeof vi.fn>) {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.doMock("@tauri-apps/plugin-dialog", () => ({ save }));
|
||||
vi.doMock("@tauri-apps/plugin-fs", () => ({ writeFile, exists }));
|
||||
return import("../saveDataDictionaryFile");
|
||||
}
|
||||
|
||||
it("writes only the path the save dialog just returned, after stamping the suggested name", async () => {
|
||||
const save = vi.fn().mockResolvedValue("/granted/shop.pdf");
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined);
|
||||
const exists = vi.fn().mockResolvedValue(false);
|
||||
const { saveDataDictionaryFile } = await loadSaver(save, writeFile, exists);
|
||||
const suggested = resolveDictionaryExportPath("/typed/shop.pdf", "shop-data-dictionary.pdf", true, new Date("2026-09-24T14:05:06"));
|
||||
const bytes = new Uint8Array([37, 80, 68, 70]);
|
||||
|
||||
await saveDataDictionaryFile(suggested, bytes, { overwrite: true });
|
||||
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
defaultPath: "/typed/shop_20260924140506.pdf",
|
||||
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||||
});
|
||||
expect(writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(writeFile.mock.calls[0]?.[0]).toBe("/granted/shop.pdf");
|
||||
expect(writeFile.mock.calls[0]?.[0]).not.toBe(suggested);
|
||||
expect(exists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to write a granted path that already exists when overwrite is off", async () => {
|
||||
const save = vi.fn().mockResolvedValue("/granted/shop.pdf");
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined);
|
||||
const exists = vi.fn().mockResolvedValue(true);
|
||||
const { saveDataDictionaryFile, DictionaryFileExistsError } = await loadSaver(save, writeFile, exists);
|
||||
|
||||
await expect(saveDataDictionaryFile("shop-data-dictionary.pdf", new Uint8Array([1]), { overwrite: false })).rejects.toBeInstanceOf(DictionaryFileExistsError);
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { ColumnInfo, DocTable, TableKind } from "@/docs/types";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { isSystemDatabaseName, isSystemSchemaName } from "@/lib/database/visibleDatabases";
|
||||
|
||||
export type DictionaryPaper = "A4" | "A3" | "Letter" | "Legal";
|
||||
export type DictionaryOrientation = "portrait" | "landscape";
|
||||
export type DictionaryTemplateId = "standard" | "detailed" | "compact" | "catalog" | "reference";
|
||||
|
||||
export interface DataDictionaryLabels {
|
||||
column: string;
|
||||
type: string;
|
||||
length: string;
|
||||
precision: string;
|
||||
scale: string;
|
||||
primaryKey: string;
|
||||
nullable: string;
|
||||
unique: string;
|
||||
defaultValue: string;
|
||||
extra: string;
|
||||
comment: string;
|
||||
indexName: string;
|
||||
indexColumns: string;
|
||||
indexType: string;
|
||||
constraintName: string;
|
||||
refSchema: string;
|
||||
refTable: string;
|
||||
refColumn: string;
|
||||
onUpdate: string;
|
||||
onDelete: string;
|
||||
yes: string;
|
||||
no: string;
|
||||
indexesHeading: string;
|
||||
foreignKeysHeading: string;
|
||||
contentsHeading: string;
|
||||
kindTable: string;
|
||||
kindView: string;
|
||||
kindMaterializedView: string;
|
||||
}
|
||||
|
||||
export interface DictionaryLayout {
|
||||
includeCover: boolean;
|
||||
header: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
remarks: string;
|
||||
coverFooter: string;
|
||||
includeToc: boolean;
|
||||
includeBreadcrumbs: boolean;
|
||||
includeLeftFooter: boolean;
|
||||
leftFooter: string;
|
||||
includePageNumber: boolean;
|
||||
includeIntroduction: boolean;
|
||||
introduction: string;
|
||||
paper: DictionaryPaper;
|
||||
orientation: DictionaryOrientation;
|
||||
marginCm: number;
|
||||
headingSize: number;
|
||||
bodySize: number;
|
||||
includeIndexesAndForeignKeys: boolean;
|
||||
}
|
||||
|
||||
export interface DictionaryObjectRef {
|
||||
database: string;
|
||||
schema: string;
|
||||
name: string;
|
||||
kind: TableKind;
|
||||
}
|
||||
|
||||
export interface DictionaryProfile {
|
||||
version: 1;
|
||||
databases: string[];
|
||||
schemas: Array<{ database: string; name: string }>;
|
||||
objects: Array<{ database: string; schema: string; name: string }>;
|
||||
layout: DictionaryLayout;
|
||||
appendTimestamp: boolean;
|
||||
overwrite: boolean;
|
||||
continueOnError: boolean;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface DictionaryTable extends DocTable {
|
||||
database: string;
|
||||
}
|
||||
|
||||
const TEMPLATES: Record<DictionaryTemplateId, Omit<DictionaryLayout, "header" | "title" | "subtitle" | "remarks" | "coverFooter" | "leftFooter" | "introduction">> = {
|
||||
standard: { includeCover: true, includeToc: true, includeBreadcrumbs: true, includeLeftFooter: true, includePageNumber: true, includeIntroduction: true, paper: "A4", orientation: "portrait", marginCm: 1.5, headingSize: 16, bodySize: 9, includeIndexesAndForeignKeys: true },
|
||||
detailed: { includeCover: true, includeToc: true, includeBreadcrumbs: true, includeLeftFooter: true, includePageNumber: true, includeIntroduction: true, paper: "A4", orientation: "landscape", marginCm: 1.5, headingSize: 18, bodySize: 9, includeIndexesAndForeignKeys: true },
|
||||
compact: { includeCover: false, includeToc: false, includeBreadcrumbs: true, includeLeftFooter: false, includePageNumber: true, includeIntroduction: false, paper: "A4", orientation: "landscape", marginCm: 1, headingSize: 14, bodySize: 8, includeIndexesAndForeignKeys: false },
|
||||
catalog: { includeCover: true, includeToc: true, includeBreadcrumbs: false, includeLeftFooter: true, includePageNumber: true, includeIntroduction: false, paper: "A4", orientation: "portrait", marginCm: 1.5, headingSize: 16, bodySize: 9, includeIndexesAndForeignKeys: false },
|
||||
reference: { includeCover: false, includeToc: true, includeBreadcrumbs: true, includeLeftFooter: true, includePageNumber: true, includeIntroduction: false, paper: "A3", orientation: "landscape", marginCm: 1.2, headingSize: 14, bodySize: 8, includeIndexesAndForeignKeys: true },
|
||||
};
|
||||
|
||||
const INTERNAL_DICTIONARY_NAMES = new Set(["information_schema", "performance_schema", "mysql", "sys", "pg_catalog", "pg_toast"]);
|
||||
|
||||
export function isInternalDictionaryName(databaseType: DatabaseType | undefined, name: string): boolean {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (INTERNAL_DICTIONARY_NAMES.has(normalized) || normalized.startsWith("pg_toast") || normalized.startsWith("pg_temp_")) return true;
|
||||
return isSystemDatabaseName(databaseType, name) || isSystemSchemaName(databaseType, name);
|
||||
}
|
||||
|
||||
export function dictionaryCatalogNames(names: string[], databaseType: DatabaseType | undefined, pinned?: string): string[] {
|
||||
return names.filter((name) => name === pinned || !isInternalDictionaryName(databaseType, name));
|
||||
}
|
||||
|
||||
export function objectKey(ref: { database: string; schema: string; name: string }): string {
|
||||
return `${ref.database}\u0000${ref.schema}\u0000${ref.name}`;
|
||||
}
|
||||
|
||||
export function tableKindFrom(tableType: string): TableKind {
|
||||
const normalized = tableType.trim().toUpperCase().replaceAll("_", " ");
|
||||
if (normalized.includes("MATERIALIZED")) return "MATERIALIZED_VIEW";
|
||||
if (normalized.includes("VIEW")) return "VIEW";
|
||||
return "TABLE";
|
||||
}
|
||||
|
||||
export function applyTemplate(id: DictionaryTemplateId, context: { title: string; introduction: string; detailedIntroduction: string; leftFooter: string }): DictionaryLayout {
|
||||
const preset = TEMPLATES[id];
|
||||
return {
|
||||
...preset,
|
||||
header: context.title,
|
||||
title: context.title,
|
||||
subtitle: "",
|
||||
remarks: "",
|
||||
coverFooter: "",
|
||||
leftFooter: context.leftFooter,
|
||||
introduction: id === "detailed" ? context.detailedIntroduction : context.introduction,
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleOrderedKey(order: string[], key: string, checked: boolean): string[] {
|
||||
const without = order.filter((item) => item !== key);
|
||||
return checked ? [...without, key] : without;
|
||||
}
|
||||
|
||||
export function moveOrderedKey(order: string[], key: string, direction: -1 | 1): string[] {
|
||||
const index = order.indexOf(key);
|
||||
const next = index + direction;
|
||||
if (index < 0 || next < 0 || next >= order.length) return order;
|
||||
const copy = order.slice();
|
||||
const [item] = copy.splice(index, 1);
|
||||
copy.splice(next, 0, item!);
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function orderDictionaryTables(tables: DictionaryTable[], keys: string[]): DictionaryTable[] {
|
||||
const byKey = new Map(tables.map((table) => [objectKey({ database: table.database, schema: table.schema ?? "", name: table.name }), table]));
|
||||
return keys.flatMap((key) => {
|
||||
const table = byKey.get(key);
|
||||
return table ? [table] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function dictionaryTimestamp(now = new Date()): string {
|
||||
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function appendTimestampToFileName(path: string, now = new Date()): string {
|
||||
const stamp = dictionaryTimestamp(now);
|
||||
const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
||||
const dir = slash >= 0 ? path.slice(0, slash + 1) : "";
|
||||
const base = slash >= 0 ? path.slice(slash + 1) : path;
|
||||
const extIndex = base.toLowerCase().endsWith(".pdf") ? base.length - 4 : -1;
|
||||
const stem = extIndex >= 0 ? base.slice(0, extIndex) : base;
|
||||
const ext = extIndex >= 0 ? base.slice(extIndex) : ".pdf";
|
||||
if (/_\d{14}$/.test(stem)) return `${dir}${stem}${ext}`;
|
||||
return `${dir}${stem}_${stamp}${ext}`;
|
||||
}
|
||||
|
||||
export function dataDictionaryFileName(database: string, appendTimestamp: boolean, now = new Date()): string {
|
||||
const safe = database.replace(/[\\/:*?"<>|]/g, "_").trim() || "DataDictionary";
|
||||
const name = `${safe}-data-dictionary.pdf`;
|
||||
return appendTimestamp ? appendTimestampToFileName(name, now) : name;
|
||||
}
|
||||
|
||||
/** Apply the timestamp choice to either a typed/browsed path or the fallback name. */
|
||||
export function resolveDictionaryExportPath(existingPath: string, fallbackName: string, appendTimestamp: boolean, now = new Date()): string {
|
||||
const chosen = existingPath.trim();
|
||||
const name = chosen || fallbackName;
|
||||
return appendTimestamp ? appendTimestampToFileName(name, now) : name;
|
||||
}
|
||||
|
||||
export function exportBlockedBySkippedTables(warnings: Array<{ kind: string }>, continueOnError: boolean): boolean {
|
||||
return !continueOnError && warnings.some((warning) => warning.kind === "tableSkipped");
|
||||
}
|
||||
|
||||
function warningNamesSelectedTable(table: string, schema: string, name: string): boolean {
|
||||
if (table === name) return true;
|
||||
// Collector formats skips as `{schema}.{table}`, so an empty schema is `.orders`.
|
||||
return table === `${schema}.${name}`;
|
||||
}
|
||||
|
||||
function schemaWideSkipMatches(table: string, selected: ReadonlyArray<{ schema?: string | null }>): boolean {
|
||||
if (table === "*") return true;
|
||||
if (!table.endsWith(".*")) return false;
|
||||
const schema = table.slice(0, -2);
|
||||
return selected.some((item) => (item.schema ?? "") === schema);
|
||||
}
|
||||
|
||||
/** Drop skip warnings that do not name a selected object or that object's schema. */
|
||||
export function warningsForSelection<T extends { kind: string; table?: string }>(warnings: T[], selected: ReadonlyArray<{ schema?: string | null; name: string }>): T[] {
|
||||
return warnings.filter((warning) => {
|
||||
const table = warning.table;
|
||||
if (!table) return true;
|
||||
if (table === "*" || table.endsWith(".*")) return schemaWideSkipMatches(table, selected);
|
||||
return selected.some((item) => warningNamesSelectedTable(table, item.schema ?? "", item.name));
|
||||
});
|
||||
}
|
||||
|
||||
export function isDictionaryProfile(value: unknown): value is DictionaryProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const profile = value as Partial<DictionaryProfile>;
|
||||
return profile.version === 1 && Array.isArray(profile.databases) && Array.isArray(profile.objects) && !!profile.layout && typeof profile.fileName === "string";
|
||||
}
|
||||
|
||||
export function columnComment(table: DocTable, column: ColumnInfo): string {
|
||||
const note = table.columnNotes[column.name]?.note;
|
||||
if (note && note.trim() !== "") return note;
|
||||
return column.comment ?? "";
|
||||
}
|
||||
|
||||
export function typeLabel(column: ColumnInfo): string {
|
||||
const base = column.data_type.trim();
|
||||
if (base.includes("(")) return base;
|
||||
if (column.character_maximum_length !== null) return `${base}(${column.character_maximum_length})`;
|
||||
if (column.numeric_precision !== null) {
|
||||
const scale = column.numeric_scale === null ? "" : `,${column.numeric_scale}`;
|
||||
return `${base}(${column.numeric_precision}${scale})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function qualifiedObjectName(table: Pick<DictionaryTable, "database" | "schema" | "name">): string {
|
||||
const schema = table.schema ? `${table.schema}.` : "";
|
||||
return table.database ? `${table.database}.${schema}${table.name}` : `${schema}${table.name}`;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import type { DataDictionaryLabels, DictionaryLayout, DictionaryTable } from "@/lib/docs/dataDictionary";
|
||||
import { columnComment, qualifiedObjectName, typeLabel } from "@/lib/docs/dataDictionary";
|
||||
|
||||
const CM = 28.3464567;
|
||||
const INK = "0.16 0.16 0.16";
|
||||
const MUTED = "0.38 0.38 0.38";
|
||||
const ORANGE = "0.91 0.44 0.08";
|
||||
const BROWN = "0.35 0.18 0.06";
|
||||
const PEACH = "0.99 0.88 0.74";
|
||||
const GRID = "0.90 0.50 0.18";
|
||||
const CHECK = "\u0001";
|
||||
|
||||
const PAPER: Record<DictionaryLayout["paper"], { width: number; height: number }> = {
|
||||
A4: { width: 595.28, height: 841.89 },
|
||||
A3: { width: 841.89, height: 1190.55 },
|
||||
Letter: { width: 612, height: 792 },
|
||||
Legal: { width: 612, height: 1008 },
|
||||
};
|
||||
|
||||
interface Page {
|
||||
commands: string[];
|
||||
bookmark?: string;
|
||||
}
|
||||
|
||||
interface PlacedObject {
|
||||
table: DictionaryTable;
|
||||
page: number;
|
||||
}
|
||||
|
||||
interface TocEntry {
|
||||
label: string;
|
||||
page: number;
|
||||
indent: number;
|
||||
}
|
||||
|
||||
export function pdfUtf16Hex(text: string): string {
|
||||
let hex = "";
|
||||
for (const char of text) {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
if (code > 0xffff) {
|
||||
const adjusted = code - 0x10000;
|
||||
hex += (0xd800 + (adjusted >> 10)).toString(16).padStart(4, "0");
|
||||
hex += (0xdc00 + (adjusted & 0x3ff)).toString(16).padStart(4, "0");
|
||||
} else {
|
||||
hex += code.toString(16).padStart(4, "0");
|
||||
}
|
||||
}
|
||||
return hex.toUpperCase();
|
||||
}
|
||||
|
||||
export function buildDataDictionaryPdf(tables: DictionaryTable[], labels: DataDictionaryLabels, layout: DictionaryLayout, warnings: string[] = []): Uint8Array {
|
||||
const page = pageSize(layout);
|
||||
const margin = clamp(layout.marginCm, 0.5, 4) * CM;
|
||||
const includeIntro = layout.includeIntroduction && layout.introduction.trim() !== "";
|
||||
const intro = includeIntro ? flowLines(layout.introduction.split(/\n+/), page, margin, layout.bodySize, introHeading(labels)) : [];
|
||||
const body = flowObjects(tables, labels, layout, page, margin);
|
||||
const notes = warnings.length ? flowLines(warnings, page, margin, layout.bodySize) : [];
|
||||
const toc = layout.includeToc ? renderToc(tocFor(labels, tables, body.placed, intro.length), labels, page, margin, layout.bodySize) : [];
|
||||
const cover = layout.includeCover ? [coverPage(layout, page, margin)] : [];
|
||||
// Page numbers start at the introduction so TOC entries (numbered from the
|
||||
// introduction) match the printed footers; cover and contents stay unnumbered.
|
||||
const frontCount = cover.length + toc.length;
|
||||
const pages = [...cover, ...toc, ...intro, ...body.pages, ...notes];
|
||||
if (pages.length === 0) pages.push({ commands: [] });
|
||||
stampChrome(pages, layout, page, margin, frontCount);
|
||||
return encodePdf(pages, page, layout.title || "Data Dictionary");
|
||||
}
|
||||
|
||||
function pageSize(layout: DictionaryLayout): { width: number; height: number } {
|
||||
const paper = PAPER[layout.paper];
|
||||
return layout.orientation === "landscape" ? { width: paper.height, height: paper.width } : paper;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function charWidth(char: string, size: number): number {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
if (code >= 128) return size;
|
||||
if ("ilj.,:;!'|".includes(char)) return size * 0.28;
|
||||
if (char === " ") return size * 0.28;
|
||||
if ("mwMW@%".includes(char)) return size * 0.78;
|
||||
return size * 0.52;
|
||||
}
|
||||
|
||||
function textWidth(text: string, size: number): number {
|
||||
let width = 0;
|
||||
for (const char of text) width += charWidth(char, size);
|
||||
return width;
|
||||
}
|
||||
|
||||
function wrap(text: string, size: number, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
let line = "";
|
||||
for (const char of text) {
|
||||
const next = line + char;
|
||||
if (line && textWidth(next, size) > width) {
|
||||
lines.push(line);
|
||||
line = char;
|
||||
} else {
|
||||
line = next;
|
||||
}
|
||||
}
|
||||
if (line || lines.length === 0) lines.push(line);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function fill(x: number, y: number, width: number, height: number, color: string): string {
|
||||
return `${color} rg ${num(x)} ${num(y)} ${num(width)} ${num(height)} re f`;
|
||||
}
|
||||
|
||||
function coverPage(layout: DictionaryLayout, page: { width: number; height: number }, margin: number): Page {
|
||||
const commands: string[] = [];
|
||||
let y = page.height * 0.62;
|
||||
const titleSize = Math.max(28, layout.headingSize + 16);
|
||||
for (const line of wrap(layout.title || "Data Dictionary", titleSize, page.width - margin * 2)) {
|
||||
commands.push(textOp(margin, y, titleSize, line, ORANGE));
|
||||
y -= titleSize + 6;
|
||||
}
|
||||
y -= 4;
|
||||
const second = layout.subtitle.trim() || "数据字典";
|
||||
commands.push(textOp(margin, y, layout.headingSize + 6, second, BROWN));
|
||||
y -= layout.headingSize + 28;
|
||||
const meta = [layout.header, layout.remarks, layout.coverFooter].filter((line) => line && line !== layout.title && line !== second);
|
||||
for (const line of meta) {
|
||||
commands.push(textOp(margin, y, layout.bodySize + 1, line, INK));
|
||||
y -= layout.bodySize + 8;
|
||||
}
|
||||
coverShapes(commands, page.width, 0);
|
||||
return { commands };
|
||||
}
|
||||
|
||||
function coverShapes(commands: string[], pageWidth: number, bottom: number): void {
|
||||
const shades = [ORANGE, "0.96 0.62 0.22", "0.98 0.78 0.45", "0.93 0.55 0.16"];
|
||||
const tiles = [
|
||||
[pageWidth * 0.08, bottom + 36, 92, 64, -18],
|
||||
[pageWidth * 0.28, bottom + 18, 110, 72, -12],
|
||||
[pageWidth * 0.48, bottom + 48, 120, 70, -8],
|
||||
[pageWidth * 0.66, bottom + 12, 100, 68, -16],
|
||||
[pageWidth * 0.82, bottom + 40, 90, 60, -10],
|
||||
] as const;
|
||||
tiles.forEach(([x, y, width, height, degrees], index) => {
|
||||
commands.push(rotatedFill(x, y, width, height, degrees, shades[index % shades.length]!));
|
||||
});
|
||||
}
|
||||
|
||||
function rotatedFill(x: number, y: number, width: number, height: number, degrees: number, color: string): string {
|
||||
const rad = (degrees * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
return `q ${color} rg ${num(cos)} ${num(sin)} ${num(-sin)} ${num(cos)} ${num(x)} ${num(y)} cm 0 0 ${num(width)} ${num(height)} re f Q`;
|
||||
}
|
||||
|
||||
function introHeading(labels: DataDictionaryLabels): string {
|
||||
return labels.contentsHeading === "目录" ? "1. 引言" : "1. Introduction";
|
||||
}
|
||||
|
||||
function tocFor(labels: DataDictionaryLabels, tables: DictionaryTable[], placed: PlacedObject[], introPages: number): TocEntry[] {
|
||||
const entries: TocEntry[] = [];
|
||||
let section = 1;
|
||||
if (introPages > 0) {
|
||||
entries.push({ label: introHeading(labels), page: 1, indent: 0 });
|
||||
section = 2;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const item of placed) {
|
||||
const database = item.table.database || labels.kindTable;
|
||||
if (!seen.has(database)) {
|
||||
seen.add(database);
|
||||
entries.push({ label: `${section}. ${database}`, page: introPages + item.page + 1, indent: 0 });
|
||||
section += 1;
|
||||
}
|
||||
entries.push({ label: `${labels.kindTable}: ${item.table.name}`, page: introPages + item.page + 1, indent: 2 });
|
||||
}
|
||||
if (entries.length === 0 && tables.length === 0) entries.push({ label: labels.contentsHeading, page: 1, indent: 0 });
|
||||
return entries;
|
||||
}
|
||||
|
||||
function tocLineHeight(body: number): number {
|
||||
return body + 10;
|
||||
}
|
||||
|
||||
function renderToc(entries: TocEntry[], labels: DataDictionaryLabels, page: { width: number; height: number }, margin: number, body: number): Page[] {
|
||||
const pages: Page[] = [];
|
||||
const line = tocLineHeight(body);
|
||||
const capacity = Math.max(1, Math.floor((page.height - margin * 2 - 52) / line));
|
||||
const rows = entries.length ? entries : [{ label: labels.contentsHeading, page: 1, indent: 0 }];
|
||||
for (let offset = 0; offset < rows.length; offset += capacity) {
|
||||
const slice = rows.slice(offset, offset + capacity);
|
||||
const commands: string[] = [];
|
||||
let y = page.height - margin - 8;
|
||||
y = orangeHeading(commands, margin, y, page.width - margin * 2, labels.contentsHeading, 16);
|
||||
y -= 8;
|
||||
for (const item of slice) {
|
||||
const x = margin + item.indent * 16;
|
||||
const pageLabel = String(item.page);
|
||||
const pageWidth = textWidth(pageLabel, body);
|
||||
commands.push(textOp(x, y, body, item.label, INK));
|
||||
commands.push(textOp(page.width - margin - pageWidth, y, body, pageLabel, INK));
|
||||
const dotsFrom = x + textWidth(item.label, body) + 4;
|
||||
const dotsTo = page.width - margin - pageWidth - 4;
|
||||
for (let dot = dotsFrom; dot < dotsTo; dot += 3.2) commands.push(fill(dot, y + 1, 0.7, 0.7, "0.55 0.55 0.55"));
|
||||
y -= line;
|
||||
}
|
||||
pages.push({ commands });
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
function orangeHeading(commands: string[], x: number, y: number, width: number, text: string, size: number): number {
|
||||
commands.push(textOp(x, y, size, text, ORANGE));
|
||||
const textW = Math.min(textWidth(text, size), width - 24);
|
||||
const lineY = y + size * 0.35;
|
||||
commands.push(`${ORANGE} RG 1.4 w ${num(x + textW + 8)} ${num(lineY)} m ${num(x + width - 6)} ${num(lineY)} l S`);
|
||||
commands.push(`${ORANGE} rg ${num(x + width - 3.5)} ${num(lineY)} 3.2 0 360 arc f`);
|
||||
return y - size - 8;
|
||||
}
|
||||
|
||||
function flowLines(lines: string[], page: { width: number; height: number }, margin: number, body: number, heading = ""): Page[] {
|
||||
const pages: Page[] = [];
|
||||
let commands: string[] = [];
|
||||
let y = page.height - margin - body - 8;
|
||||
const start = () => {
|
||||
commands = [];
|
||||
y = page.height - margin - 8;
|
||||
if (heading) y = orangeHeading(commands, margin, y, page.width - margin * 2, heading, body + 5) - 6;
|
||||
};
|
||||
const push = () => {
|
||||
if (commands.length) pages.push({ commands });
|
||||
start();
|
||||
};
|
||||
start();
|
||||
for (const paragraph of lines) {
|
||||
for (const line of wrap(paragraph, body, page.width - margin * 2)) {
|
||||
if (y < margin + body) push();
|
||||
commands.push(textOp(margin, y, body, line, INK));
|
||||
y -= body + 5;
|
||||
}
|
||||
y -= 8;
|
||||
}
|
||||
if (commands.length) pages.push({ commands });
|
||||
return pages;
|
||||
}
|
||||
|
||||
function flowObjects(tables: DictionaryTable[], labels: DataDictionaryLabels, layout: DictionaryLayout, page: { width: number; height: number }, margin: number): { pages: Page[]; placed: PlacedObject[] } {
|
||||
const pages: Page[] = [];
|
||||
const placed: PlacedObject[] = [];
|
||||
const cursor = { commands: [] as string[], y: 0, bookmark: undefined as string | undefined };
|
||||
const bottom = contentBottom(margin);
|
||||
const top = () => page.height - margin - (layout.includeBreadcrumbs ? 22 : 6);
|
||||
const flush = () => {
|
||||
if (cursor.commands.length === 0 && !cursor.bookmark) return;
|
||||
pages.push({ commands: cursor.commands, bookmark: cursor.bookmark });
|
||||
cursor.commands = [];
|
||||
cursor.bookmark = undefined;
|
||||
cursor.y = top();
|
||||
};
|
||||
const nextPage = () => flush();
|
||||
const ensure = (height: number) => {
|
||||
if (cursor.y - height < bottom) nextPage();
|
||||
};
|
||||
cursor.y = top();
|
||||
if (tables.length === 0) return { pages: [{ commands: [] }], placed };
|
||||
for (const table of tables) {
|
||||
const title = `${kindLabel(table, labels)}: ${table.name}`;
|
||||
ensure(layout.headingSize + 20);
|
||||
if (!cursor.bookmark) cursor.bookmark = qualifiedObjectName(table);
|
||||
placed.push({ table, page: pages.length });
|
||||
cursor.y = orangeHeading(cursor.commands, margin, cursor.y, page.width - margin * 2, title, layout.headingSize);
|
||||
cursor.y -= 6;
|
||||
if (table.note) {
|
||||
for (const line of wrap(table.note, layout.bodySize, page.width - margin * 2)) {
|
||||
cursor.commands.push(textOp(margin, cursor.y, layout.bodySize, line, MUTED));
|
||||
cursor.y -= layout.bodySize + 4;
|
||||
}
|
||||
cursor.y -= 8;
|
||||
}
|
||||
cursor.commands.push(textOp(margin, cursor.y, layout.bodySize, labels.column, INK));
|
||||
cursor.y -= layout.bodySize + 6;
|
||||
cursor.y = drawTable(cursor, columnRows(table, labels), margin, page.width - margin * 2, layout.bodySize, bottom, nextPage, ensure, [0.55, 1.6, 1.3, 0.9, 1.8]);
|
||||
if (layout.includeIndexesAndForeignKeys && table.indexes.length > 0) {
|
||||
cursor.y = quietLabel(cursor, labels.indexesHeading, margin, layout.bodySize, ensure);
|
||||
cursor.y = drawTable(cursor, indexRows(table, labels), margin, page.width - margin * 2, layout.bodySize, bottom, nextPage, ensure, [1.8, 1.3, 0.7, 0.7, 0.9, 1.6]);
|
||||
}
|
||||
if (layout.includeIndexesAndForeignKeys && table.foreignKeys.length > 0) {
|
||||
cursor.y = quietLabel(cursor, labels.foreignKeysHeading, margin, layout.bodySize, ensure);
|
||||
cursor.y = drawTable(cursor, foreignKeyRows(table, labels), margin, page.width - margin * 2, layout.bodySize, bottom, nextPage, ensure, [1.7, 1.2, 1.1, 1.2, 1, 0.9, 1]);
|
||||
}
|
||||
cursor.y -= 16;
|
||||
}
|
||||
flush();
|
||||
return { pages, placed };
|
||||
}
|
||||
|
||||
function quietLabel(cursor: { commands: string[]; y: number }, title: string, x: number, size: number, ensure: (height: number) => void): number {
|
||||
ensure(size + 28);
|
||||
const y = cursor.y - 20;
|
||||
cursor.commands.push(fill(x, y - 1, 18, 2, ORANGE));
|
||||
cursor.commands.push(textOp(x + 24, y - size * 0.2, size, title, ORANGE));
|
||||
return y - size - 8;
|
||||
}
|
||||
|
||||
function kindLabel(table: DictionaryTable, labels: DataDictionaryLabels): string {
|
||||
if (table.kind === "VIEW") return labels.kindView;
|
||||
if (table.kind === "MATERIALIZED_VIEW") return labels.kindMaterializedView;
|
||||
return labels.kindTable;
|
||||
}
|
||||
|
||||
function columnRows(table: DictionaryTable, labels: DataDictionaryLabels): string[][] {
|
||||
return [
|
||||
["#", labels.column, labels.type, labels.nullable, labels.extra],
|
||||
...table.columns.map((column, index) => [String(index + 1), column.name, typeLabel(column), column.is_nullable ? "" : CHECK, [column.extra, columnComment(table, column)].filter((part) => part && part.trim() !== "").join(" ")]),
|
||||
];
|
||||
}
|
||||
|
||||
function indexRows(table: DictionaryTable, labels: DataDictionaryLabels): string[][] {
|
||||
return [
|
||||
[labels.indexName, labels.indexColumns, labels.unique, labels.primaryKey, labels.indexType, labels.comment],
|
||||
...table.indexes.map((index) => [index.name, index.columns.join(", "), yesNo(index.is_unique, labels), yesNo(index.is_primary, labels), index.index_type ?? "", index.comment ?? ""]),
|
||||
];
|
||||
}
|
||||
|
||||
function foreignKeyRows(table: DictionaryTable, labels: DataDictionaryLabels): string[][] {
|
||||
return [[labels.constraintName, labels.column, labels.refSchema, labels.refTable, labels.refColumn, labels.onUpdate, labels.onDelete], ...table.foreignKeys.map((key) => [key.name, key.column, key.ref_schema ?? "", key.ref_table, key.ref_column, key.on_update ?? "", key.on_delete ?? ""])];
|
||||
}
|
||||
|
||||
function yesNo(value: boolean, labels: DataDictionaryLabels): string {
|
||||
return value ? labels.yes : labels.no;
|
||||
}
|
||||
|
||||
function drawTable(cursor: { commands: string[]; y: number }, rows: string[][], x: number, width: number, size: number, bottom: number, nextPage: () => void, ensure: (height: number) => void, weights?: number[]): number {
|
||||
if (rows.length === 0 || !rows[0]) return cursor.y;
|
||||
const header = rows[0];
|
||||
const used = weights && weights.length === header.length ? weights : header.map((_, index) => (index === header.length - 1 ? 2.2 : 1));
|
||||
const weightSum = used.reduce((sum, weight) => sum + weight, 0);
|
||||
const widths = used.map((weight) => (width * weight) / weightSum);
|
||||
const rowHeight = (row: string[]) => {
|
||||
const wrapped = row.map((cell, index) => wrap(cell, size, Math.max(8, widths[index]! - 8)));
|
||||
return { wrapped, height: Math.max(1, ...wrapped.map((item) => item.length)) * (size + 3) + 8 };
|
||||
};
|
||||
const paint = (row: string[], headerRow: boolean, zebra: boolean) => {
|
||||
const painted = rowHeight(row);
|
||||
ensure(painted.height);
|
||||
const top = cursor.y;
|
||||
const boxY = top - painted.height;
|
||||
if (headerRow) cursor.commands.push(fill(x, boxY, width, painted.height, PEACH));
|
||||
else if (zebra) cursor.commands.push(fill(x, boxY, width, painted.height, "0.995 0.98 0.96"));
|
||||
cursor.commands.push(`${GRID} RG 0.7 w ${num(x)} ${num(boxY)} ${num(width)} ${num(painted.height)} re S`);
|
||||
let columnX = x;
|
||||
const linePitch = size + 3;
|
||||
const block = painted.wrapped.reduce((max, cell) => Math.max(max, cell.length), 1) * linePitch - 3;
|
||||
const firstBaseline = boxY + (painted.height - block) / 2 + size * 0.78;
|
||||
painted.wrapped.forEach((cell, index) => {
|
||||
if (index > 0) cursor.commands.push(`${GRID} RG 0.5 w ${num(columnX)} ${num(boxY)} m ${num(columnX)} ${num(boxY + painted.height)} l S`);
|
||||
if (cell.length === 1 && cell[0] === CHECK) {
|
||||
const markX = columnX + widths[index]! / 2;
|
||||
const markY = boxY + painted.height / 2;
|
||||
cursor.commands.push(`${INK} RG 1.15 w ${num(markX - 3.4)} ${num(markY - 0.4)} m ${num(markX - 0.8)} ${num(markY - 3.2)} l ${num(markX + 4.2)} ${num(markY + 3.2)} l S`);
|
||||
} else {
|
||||
cell.forEach((line, lineIndex) => {
|
||||
const lineWidth = textWidth(line, size);
|
||||
const textX = columnX + Math.max(3, (widths[index]! - lineWidth) / 2);
|
||||
cursor.commands.push(textOp(textX, firstBaseline - lineIndex * linePitch, size, line, INK));
|
||||
});
|
||||
}
|
||||
columnX += widths[index]!;
|
||||
});
|
||||
cursor.y -= painted.height;
|
||||
};
|
||||
paint(header, true, false);
|
||||
rows.slice(1).forEach((row, index) => {
|
||||
if (cursor.y - rowHeight(row).height < bottom) {
|
||||
nextPage();
|
||||
paint(header, true, false);
|
||||
}
|
||||
paint(row, false, index % 2 === 1);
|
||||
});
|
||||
return cursor.y;
|
||||
}
|
||||
|
||||
function contentBottom(margin: number): number {
|
||||
return Math.max(margin, 36);
|
||||
}
|
||||
|
||||
function stampChrome(pages: Page[], layout: DictionaryLayout, page: { width: number; height: number }, margin: number, frontCount: number): void {
|
||||
pages.forEach((item, index) => {
|
||||
if (index < frontCount) return;
|
||||
if (layout.includeBreadcrumbs && layout.header) item.commands.unshift(textOp(margin, page.height - margin + 6, Math.max(8, layout.bodySize - 1), layout.header, MUTED));
|
||||
const footerY = 22;
|
||||
if (layout.includeLeftFooter && layout.leftFooter) item.commands.push(textOp(margin, footerY, layout.bodySize, layout.leftFooter, MUTED));
|
||||
else if (layout.title) item.commands.push(textOp(margin, footerY, layout.bodySize, layout.title, MUTED));
|
||||
if (layout.includePageNumber) {
|
||||
const label = String(index - frontCount + 1);
|
||||
item.commands.push(textOp(page.width - margin - textWidth(label, layout.bodySize), footerY, layout.bodySize, label, MUTED));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function textOp(x: number, y: number, size: number, text: string, color = INK): string {
|
||||
const parts = ["BT"];
|
||||
let cursor = x;
|
||||
let run = "";
|
||||
let runAscii = false;
|
||||
const flush = () => {
|
||||
if (!run) return;
|
||||
if (runAscii) parts.push(`${color} rg /F2 ${num(size)} Tf 1 0 0 1 ${num(cursor - textWidth(run, size))} ${num(y)} Tm (${escapePdf(run)}) Tj`);
|
||||
else parts.push(`${color} rg /F1 ${num(size)} Tf 1 0 0 1 ${num(cursor - textWidth(run, size))} ${num(y)} Tm <${pdfUtf16Hex(run)}> Tj`);
|
||||
run = "";
|
||||
};
|
||||
for (const char of text) {
|
||||
const ascii = (char.codePointAt(0) ?? 0) < 128;
|
||||
if (run && ascii !== runAscii) flush();
|
||||
runAscii = ascii;
|
||||
run += char;
|
||||
cursor += charWidth(char, size);
|
||||
}
|
||||
flush();
|
||||
parts.push("ET");
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function escapePdf(text: string): string {
|
||||
return text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
||||
}
|
||||
|
||||
function num(value: number): string {
|
||||
return Number(value.toFixed(2)).toString();
|
||||
}
|
||||
|
||||
function encodePdf(pages: Page[], size: { width: number; height: number }, title: string): Uint8Array {
|
||||
const objects: string[] = [];
|
||||
const pageIds: number[] = [];
|
||||
objects[1] = "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>";
|
||||
objects[3] = "<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UTF16-H /DescendantFonts [5 0 R] >>";
|
||||
objects[5] = "<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 /FontDescriptor 6 0 R >>";
|
||||
objects[6] = "<< /Type /FontDescriptor /FontName /STSong-Light /Flags 6 /FontBBox [-25 -254 1000 880] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 620 /StemV 80 >>";
|
||||
objects[7] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
|
||||
let nextId = 8;
|
||||
for (const page of pages) {
|
||||
const contentId = nextId++;
|
||||
const pageId = nextId++;
|
||||
pageIds.push(pageId);
|
||||
const stream = page.commands.join("\n");
|
||||
objects[contentId] = `<< /Length ${byteLength(stream)} >>\nstream\n${stream}\nendstream`;
|
||||
objects[pageId] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${num(size.width)} ${num(size.height)}] /Resources << /Font << /F1 3 0 R /F2 7 0 R >> >> /Contents ${contentId} 0 R >>`;
|
||||
}
|
||||
objects[2] = `<< /Type /Pages /Count ${pageIds.length} /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] >>`;
|
||||
const bookmarks = pages.flatMap((page, index) => (page.bookmark ? [{ pageId: pageIds[index]!, title: page.bookmark }] : []));
|
||||
if (bookmarks.length === 0) {
|
||||
objects[4] = "<< /Type /Outlines /Count 0 >>";
|
||||
} else {
|
||||
const first = nextId;
|
||||
bookmarks.forEach((item, index) => {
|
||||
const id = nextId++;
|
||||
const prev = index === 0 ? "" : ` /Prev ${id - 1} 0 R`;
|
||||
const next = index === bookmarks.length - 1 ? "" : ` /Next ${id + 1} 0 R`;
|
||||
objects[id] = `<< /Title <FEFF${pdfUtf16Hex(item.title)}> /Parent 4 0 R${prev}${next} /Dest [${item.pageId} 0 R /Fit] >>`;
|
||||
});
|
||||
objects[4] = `<< /Type /Outlines /First ${first} 0 R /Last ${nextId - 1} 0 R /Count ${bookmarks.length} >>`;
|
||||
}
|
||||
const infoId = nextId;
|
||||
objects[infoId] = `<< /Title <FEFF${pdfUtf16Hex(title)}> >>`;
|
||||
objects[1] = `<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R /Info ${infoId} 0 R >>`;
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets = [0];
|
||||
for (let id = 1; id <= infoId; id++) {
|
||||
offsets[id] = pdf.length;
|
||||
pdf += `${id} 0 obj\n${objects[id] ?? "<<>>"}\nendobj\n`;
|
||||
}
|
||||
const xref = pdf.length;
|
||||
pdf += `xref\n0 ${infoId + 1}\n0000000000 65535 f \n`;
|
||||
for (let id = 1; id <= infoId; id++) pdf += `${String(offsets[id]).padStart(10, "0")} 00000 n \n`;
|
||||
pdf += `trailer\n<< /Size ${infoId + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
|
||||
return new TextEncoder().encode(pdf);
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).length;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
export class DictionaryFileExistsError extends Error {
|
||||
constructor() {
|
||||
super("exists");
|
||||
this.name = "DictionaryFileExistsError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggested name is only the save dialog's defaultPath. The write uses the path
|
||||
* `save()` just returned. Tauri's dialog grant covers that exact path, not a
|
||||
* typed path or a timestamp appended after the dialog closes.
|
||||
*/
|
||||
export async function saveDataDictionaryFile(suggestedName: string, content: Uint8Array, options: { overwrite: boolean }): Promise<string | null> {
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const granted = await save({
|
||||
defaultPath: suggestedName,
|
||||
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||||
});
|
||||
if (!granted) return null;
|
||||
if (!options.overwrite && (await fs.exists(granted))) throw new DictionaryFileExistsError();
|
||||
await fs.writeFile(granted, content);
|
||||
return granted;
|
||||
}
|
||||
|
||||
const downloadName = suggestedName.split(/[/\\]/).pop() || suggestedName;
|
||||
const blob = new Blob([content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength) as ArrayBuffer], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = downloadName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return downloadName;
|
||||
}
|
||||
Reference in new issue
Block a user