quickdbd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,337 @@
1
+ /* qdbd interactive viewer: pan/zoom, search, minimap, focus, collapse, FK hover, export.
2
+ * In serve mode (data.serve) tables can also be dragged; positions are POSTed to the server. */
3
+ (function () {
4
+ "use strict";
5
+ var Q = window.qdbd;
6
+ var data = JSON.parse(document.getElementById("qdbd-data").textContent);
7
+ var S = {
8
+ model: data.model,
9
+ pos: data.positions,
10
+ mode: "full",
11
+ focus: null,
12
+ view: { x: 0, y: 0, k: 1 },
13
+ serve: !!data.serve,
14
+ };
15
+ var svg = document.getElementById("canvas");
16
+ var vp = document.getElementById("vp");
17
+ var zoomval = document.getElementById("zoomval");
18
+ var byId = {};
19
+
20
+ function index() {
21
+ byId = {};
22
+ S.model.tables.forEach(function (t) { byId[t.id] = t; });
23
+ }
24
+
25
+ /* ------------------------------------------------------------- render */
26
+ function render() {
27
+ vp.innerHTML = Q.renderInner(S.model, S.pos, S.mode);
28
+ applyFocus();
29
+ drawMinimap();
30
+ }
31
+
32
+ function renderEdges() {
33
+ var g = vp.querySelector(".edges");
34
+ if (g) g.innerHTML = Q.renderEdges(S.model, S.pos, S.mode);
35
+ applyFocus();
36
+ }
37
+
38
+ function applyView() {
39
+ var v = S.view;
40
+ vp.setAttribute("transform", "translate(" + v.x + "," + v.y + ") scale(" + v.k + ")");
41
+ zoomval.textContent = Math.round(v.k * 100) + "%";
42
+ drawViewportRect();
43
+ }
44
+
45
+ function stageSize() {
46
+ var r = svg.getBoundingClientRect();
47
+ return [r.width || 800, r.height || 600];
48
+ }
49
+
50
+ function fit() {
51
+ var b = Q.bounds(S.model, S.pos, S.mode), sz = stageSize(), pad = 40;
52
+ var k = Math.min((sz[0] - 2 * pad) / (b[2] - b[0]), (sz[1] - 2 * pad) / (b[3] - b[1]), 1.5);
53
+ k = Math.max(k, 0.05);
54
+ S.view = { k: k, x: (sz[0] - (b[2] - b[0]) * k) / 2 - b[0] * k, y: (sz[1] - (b[3] - b[1]) * k) / 2 - b[1] * k };
55
+ applyView();
56
+ }
57
+
58
+ function zoomAt(factor, cx, cy) {
59
+ var v = S.view, k = Math.min(4, Math.max(0.03, v.k * factor));
60
+ S.view = { k: k, x: cx - (cx - v.x) * (k / v.k), y: cy - (cy - v.y) * (k / v.k) };
61
+ applyView();
62
+ }
63
+
64
+ function centerOn(id) {
65
+ var t = byId[id], p = S.pos[id];
66
+ if (!t || !p) return;
67
+ var g = Q.geometry(S.model, S.mode)[id], sz = stageSize();
68
+ var k = Math.max(S.view.k, 0.9);
69
+ S.view = { k: k, x: sz[0] / 2 - (p[0] + g.w / 2) * k, y: sz[1] / 2 - (p[1] + g.h / 2) * k };
70
+ applyView();
71
+ var el = vp.querySelector('.t[data-id="' + cssEsc(id) + '"]');
72
+ if (el) {
73
+ el.classList.add("flash");
74
+ setTimeout(function () { el.classList.remove("flash"); }, 1200);
75
+ }
76
+ }
77
+
78
+ function cssEsc(s) { return window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/"/g, '\\"'); }
79
+
80
+ /* ------------------------------------------------------------- focus + hover */
81
+ function neighbours(id) {
82
+ var set = {};
83
+ set[id] = true;
84
+ S.model.rels.forEach(function (r) {
85
+ if (r.a.t === id) set[r.b.t] = true;
86
+ if (r.b.t === id) set[r.a.t] = true;
87
+ });
88
+ return set;
89
+ }
90
+
91
+ function applyFocus() {
92
+ if (!S.focus || !byId[S.focus]) {
93
+ S.focus = null;
94
+ svg.classList.remove("focus");
95
+ return;
96
+ }
97
+ var set = neighbours(S.focus);
98
+ svg.classList.add("focus");
99
+ vp.querySelectorAll(".t").forEach(function (el) { el.classList.toggle("dim", !set[el.getAttribute("data-id")]); });
100
+ vp.querySelectorAll(".e").forEach(function (el) {
101
+ var a = el.getAttribute("data-a"), b = el.getAttribute("data-b");
102
+ el.classList.toggle("dim", !(a === S.focus || b === S.focus));
103
+ });
104
+ }
105
+
106
+ function clearHover() {
107
+ vp.querySelectorAll(".hl").forEach(function (el) { el.classList.remove("hl"); });
108
+ }
109
+
110
+ vp.addEventListener("mouseover", function (ev) {
111
+ var row = ev.target.closest && ev.target.closest(".r");
112
+ clearHover();
113
+ if (!row) return;
114
+ var tid = row.closest(".t").getAttribute("data-id"), cid = row.getAttribute("data-c");
115
+ vp.querySelectorAll(".e").forEach(function (e) {
116
+ var a = e.getAttribute("data-a"), ac = e.getAttribute("data-ac"), b = e.getAttribute("data-b"), bc = e.getAttribute("data-bc");
117
+ var other = null;
118
+ if (a === tid && ac === cid) other = [b, bc];
119
+ else if (b === tid && bc === cid) other = [a, ac];
120
+ if (!other) return;
121
+ e.classList.add("hl");
122
+ row.classList.add("hl");
123
+ var t = vp.querySelector('.t[data-id="' + cssEsc(other[0]) + '"] .r[data-c="' + cssEsc(other[1]) + '"]');
124
+ if (t) t.classList.add("hl");
125
+ });
126
+ });
127
+ vp.addEventListener("mouseleave", clearHover);
128
+
129
+ /* ------------------------------------------------------------- pointer: pan, click, drag */
130
+ var drag = null;
131
+ svg.addEventListener("pointerdown", function (ev) {
132
+ if (ev.button !== 0) return;
133
+ var tEl = ev.target.closest && ev.target.closest(".t");
134
+ drag = { sx: ev.clientX, sy: ev.clientY, moved: false, table: tEl ? tEl.getAttribute("data-id") : null, el: tEl };
135
+ if (drag.table && S.serve) {
136
+ drag.start = S.pos[drag.table].slice();
137
+ } else {
138
+ drag.view = { x: S.view.x, y: S.view.y };
139
+ svg.classList.add("panning");
140
+ }
141
+ svg.setPointerCapture(ev.pointerId);
142
+ });
143
+ var raf = null;
144
+ svg.addEventListener("pointermove", function (ev) {
145
+ if (!drag) return;
146
+ var dx = ev.clientX - drag.sx, dy = ev.clientY - drag.sy;
147
+ if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
148
+ if (!drag.moved) return;
149
+ if (drag.start) {
150
+ var p = [Math.round(drag.start[0] + dx / S.view.k), Math.round(drag.start[1] + dy / S.view.k)];
151
+ S.pos[drag.table] = p;
152
+ drag.el.setAttribute("transform", "translate(" + p[0] + "," + p[1] + ")");
153
+ if (!raf) raf = requestAnimationFrame(function () { raf = null; renderEdges(); });
154
+ } else if (drag.view) {
155
+ S.view.x = drag.view.x + dx;
156
+ S.view.y = drag.view.y + dy;
157
+ applyView();
158
+ }
159
+ });
160
+ svg.addEventListener("pointerup", function () {
161
+ if (!drag) return;
162
+ svg.classList.remove("panning");
163
+ var d = drag;
164
+ drag = null;
165
+ if (!d.moved) {
166
+ S.focus = d.table && S.focus !== d.table ? d.table : null;
167
+ applyFocus();
168
+ return;
169
+ }
170
+ if (d.start) {
171
+ drawMinimap();
172
+ var name = byId[d.table].name, body = {};
173
+ body[name] = S.pos[d.table];
174
+ if (window.qdbdServe) window.qdbdServe.saveLayout(body);
175
+ }
176
+ });
177
+ svg.addEventListener("wheel", function (ev) {
178
+ ev.preventDefault();
179
+ var r = svg.getBoundingClientRect();
180
+ var factor = Math.exp(-ev.deltaY * (ev.ctrlKey ? 0.01 : 0.0015));
181
+ zoomAt(factor, ev.clientX - r.left, ev.clientY - r.top);
182
+ }, { passive: false });
183
+ document.addEventListener("keydown", function (ev) {
184
+ if (ev.key === "Escape") { S.focus = null; applyFocus(); }
185
+ });
186
+
187
+ /* ------------------------------------------------------------- toolbar */
188
+ function center() { var s = stageSize(); return [s[0] / 2, s[1] / 2]; }
189
+ document.getElementById("zin").onclick = function () { var c = center(); zoomAt(1.25, c[0], c[1]); };
190
+ document.getElementById("zout").onclick = function () { var c = center(); zoomAt(0.8, c[0], c[1]); };
191
+ document.getElementById("zfit").onclick = fit;
192
+ document.querySelectorAll("#modes button").forEach(function (b) {
193
+ b.onclick = function () {
194
+ S.mode = b.getAttribute("data-mode");
195
+ document.querySelectorAll("#modes button").forEach(function (o) { o.classList.toggle("on", o === b); });
196
+ render();
197
+ };
198
+ });
199
+
200
+ /* ------------------------------------------------------------- search */
201
+ var input = document.getElementById("q"), results = document.getElementById("results"), sel = 0, hits = [];
202
+ function search() {
203
+ var q = input.value.trim().toLowerCase();
204
+ hits = !q ? [] : S.model.tables.filter(function (t) {
205
+ return t.name.toLowerCase().indexOf(q) >= 0 || t.aliases.some(function (a) { return a.toLowerCase().indexOf(q) >= 0; });
206
+ }).slice(0, 50);
207
+ sel = 0;
208
+ results.innerHTML = hits.map(function (t, i) {
209
+ return '<div data-i="' + i + '"' + (i === 0 ? ' class="sel"' : "") + ">" + Q.esc(t.name) +
210
+ (t.aliases.length ? "<small>as " + Q.esc(t.aliases.join(", ")) + "</small>" : "") + "</div>";
211
+ }).join("");
212
+ results.classList.toggle("open", hits.length > 0);
213
+ }
214
+ function pick(i) {
215
+ var t = hits[i];
216
+ if (!t) return;
217
+ results.classList.remove("open");
218
+ input.blur();
219
+ centerOn(t.id);
220
+ }
221
+ input.addEventListener("input", search);
222
+ input.addEventListener("keydown", function (ev) {
223
+ if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
224
+ sel = Math.max(0, Math.min(hits.length - 1, sel + (ev.key === "ArrowDown" ? 1 : -1)));
225
+ results.querySelectorAll("div").forEach(function (d, i) { d.classList.toggle("sel", i === sel); });
226
+ ev.preventDefault();
227
+ } else if (ev.key === "Enter") pick(sel);
228
+ else if (ev.key === "Escape") { input.value = ""; search(); input.blur(); }
229
+ });
230
+ results.addEventListener("mousedown", function (ev) {
231
+ var d = ev.target.closest("div[data-i]");
232
+ if (d) pick(+d.getAttribute("data-i"));
233
+ });
234
+ document.addEventListener("keydown", function (ev) {
235
+ if ((ev.key === "/" || (ev.key === "f" && (ev.metaKey || ev.ctrlKey))) && document.activeElement.tagName !== "TEXTAREA" &&
236
+ !(document.activeElement.closest && document.activeElement.closest(".CodeMirror"))) {
237
+ ev.preventDefault();
238
+ input.focus();
239
+ }
240
+ });
241
+
242
+ /* ------------------------------------------------------------- minimap */
243
+ var mini = document.getElementById("mini"), miniB = null;
244
+ function drawMinimap() {
245
+ miniB = Q.bounds(S.model, S.pos, S.mode);
246
+ var pad = 40, b = miniB;
247
+ mini.setAttribute("viewBox", (b[0] - pad) + " " + (b[1] - pad) + " " + (b[2] - b[0] + 2 * pad) + " " + (b[3] - b[1] + 2 * pad));
248
+ var geo = Q.geometry(S.model, S.mode), parts = [];
249
+ S.model.tables.forEach(function (t) {
250
+ var p = S.pos[t.id];
251
+ if (p) parts.push('<rect x="' + p[0] + '" y="' + p[1] + '" width="' + geo[t.id].w + '" height="' + geo[t.id].h + '" fill="#2b5797" opacity=".55"/>');
252
+ });
253
+ parts.push('<rect class="vpr" id="vpr"/>');
254
+ mini.innerHTML = parts.join("");
255
+ drawViewportRect();
256
+ }
257
+ function drawViewportRect() {
258
+ var r = document.getElementById("vpr");
259
+ if (!r) return;
260
+ var sz = stageSize(), v = S.view;
261
+ r.setAttribute("x", -v.x / v.k);
262
+ r.setAttribute("y", -v.y / v.k);
263
+ r.setAttribute("width", sz[0] / v.k);
264
+ r.setAttribute("height", sz[1] / v.k);
265
+ }
266
+ function miniPan(ev) {
267
+ var pt = mini.createSVGPoint();
268
+ pt.x = ev.clientX;
269
+ pt.y = ev.clientY;
270
+ var m = pt.matrixTransform(mini.getScreenCTM().inverse()), sz = stageSize();
271
+ S.view.x = sz[0] / 2 - m.x * S.view.k;
272
+ S.view.y = sz[1] / 2 - m.y * S.view.k;
273
+ applyView();
274
+ }
275
+ var miniDrag = false;
276
+ mini.addEventListener("pointerdown", function (ev) { miniDrag = true; mini.setPointerCapture(ev.pointerId); miniPan(ev); });
277
+ mini.addEventListener("pointermove", function (ev) { if (miniDrag) miniPan(ev); });
278
+ mini.addEventListener("pointerup", function () { miniDrag = false; });
279
+
280
+ /* ------------------------------------------------------------- export */
281
+ function download(name, blob) {
282
+ var a = document.createElement("a");
283
+ a.href = URL.createObjectURL(blob);
284
+ a.download = name;
285
+ document.body.appendChild(a);
286
+ a.click();
287
+ setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
288
+ }
289
+ function exportSvg() {
290
+ var only = S.focus ? Object.keys(neighbours(S.focus)) : null;
291
+ return Q.renderSvg(S.model, S.pos, { mode: S.mode, only: only, fontCss: data.fontCss });
292
+ }
293
+ document.getElementById("xsvg").onclick = function () {
294
+ download((data.title || "diagram") + ".svg", new Blob([exportSvg()], { type: "image/svg+xml" }));
295
+ };
296
+ document.getElementById("xpng").onclick = function () {
297
+ var text = exportSvg();
298
+ var m = /width="([\d.]+)" height="([\d.]+)"/.exec(text);
299
+ var w = +m[1], h = +m[2], scale = Math.min(2, 16000 / Math.max(w, h));
300
+ var img = new Image();
301
+ img.onload = function () {
302
+ var c = document.createElement("canvas");
303
+ c.width = Math.round(w * scale);
304
+ c.height = Math.round(h * scale);
305
+ var ctx = c.getContext("2d");
306
+ ctx.scale(scale, scale);
307
+ ctx.drawImage(img, 0, 0, w, h);
308
+ c.toBlob(function (b) { download((data.title || "diagram") + ".png", b); }, "image/png");
309
+ };
310
+ img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(text);
311
+ };
312
+
313
+ /* ------------------------------------------------------------- public API (serve mode) */
314
+ window.qdbdViewer = {
315
+ setData: function (model, positions) {
316
+ S.model = model;
317
+ S.pos = positions;
318
+ index();
319
+ render();
320
+ applyView();
321
+ },
322
+ state: S,
323
+ };
324
+
325
+ index();
326
+ if (S.serve) document.body.classList.add("serve");
327
+ render();
328
+ if (S.model.zoom) {
329
+ var k = S.model.zoom / 100, p = S.model.position || [0, 0];
330
+ S.view = { k: k, x: -p[0] * k, y: -p[1] * k };
331
+ applyView();
332
+ if (!S.model.position) fit();
333
+ } else {
334
+ fit();
335
+ }
336
+ window.addEventListener("resize", drawViewportRect);
337
+ })();
quickdbd_cli/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """`qdbd` command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .model import Schema
11
+ from .project import Project
12
+
13
+ OK = "No errors found. Good job!"
14
+
15
+
16
+ def _print_diagnostics(schema: Schema, strict: bool) -> bool:
17
+ """Print diagnostics; returns True when the schema is usable."""
18
+ for d in schema.diagnostics:
19
+ print(d.format(), file=sys.stderr)
20
+ failed = bool(schema.errors) or (strict and bool(schema.warnings))
21
+ if failed:
22
+ n_e, n_w = len(schema.errors), len(schema.warnings)
23
+ print(f"{n_e} error(s), {n_w} warning(s)", file=sys.stderr)
24
+ return not failed
25
+
26
+
27
+ def _open(args) -> Project:
28
+ return Project.open([Path(p) for p in args.paths], Path(args.layout) if getattr(args, "layout", None) else None)
29
+
30
+
31
+ def _title(project: Project) -> str:
32
+ if len(project.inputs) == 1:
33
+ p = project.inputs[0]
34
+ return p.stem if p.is_file() else (p.resolve().name or "diagram")
35
+ return "diagram"
36
+
37
+
38
+ def cmd_check(args) -> int:
39
+ project = _open(args)
40
+ schema = project.compile()
41
+ ok = _print_diagnostics(schema, args.strict)
42
+ if ok and not schema.diagnostics:
43
+ print(OK)
44
+ elif ok:
45
+ print(f"No errors found ({len(schema.warnings)} warning(s)).")
46
+ return 0 if ok else 1
47
+
48
+
49
+ def cmd_build(args) -> int:
50
+ from .html import render_page
51
+
52
+ project = _open(args)
53
+ schema = project.compile()
54
+ if not _print_diagnostics(schema, args.strict):
55
+ return 1
56
+ model, pos = project.build(schema)
57
+ out = Path(args.output)
58
+ out.parent.mkdir(parents=True, exist_ok=True)
59
+ out.write_text(render_page(model, pos, _title(project)), encoding="utf-8")
60
+ print(f"Wrote {out} ({len(model['tables'])} tables, {len(model['rels'])} relationships)")
61
+ return 0
62
+
63
+
64
+ def cmd_export(args) -> int:
65
+ from . import export
66
+
67
+ project = _open(args)
68
+ schema = project.compile()
69
+ if not _print_diagnostics(schema, args.strict):
70
+ return 1
71
+ model, pos = project.build(schema)
72
+ only = None
73
+ if args.focus:
74
+ t = export.find_table(model, args.focus)
75
+ if t is None:
76
+ print(f"error: table '{args.focus}' not found", file=sys.stderr)
77
+ return 1
78
+ only = export.focus_ids(model, t["id"], args.depth)
79
+ pos = export.focus_positions(model, pos, only)
80
+ out = Path(args.output)
81
+ fmt = out.suffix.lower().lstrip(".")
82
+ if fmt not in ("svg", "png"):
83
+ print("error: output must end in .svg or .png", file=sys.stderr)
84
+ return 2
85
+ svg = export.render_svg(model, pos, only=only, mode=args.mode, embed_font=(fmt == "svg"))
86
+ out.parent.mkdir(parents=True, exist_ok=True)
87
+ if fmt == "svg":
88
+ out.write_text(svg, encoding="utf-8")
89
+ else:
90
+ out.write_bytes(export.svg_to_png(svg, args.scale))
91
+ print(f"Wrote {out} ({len(only) if only else len(model['tables'])} tables)")
92
+ return 0
93
+
94
+
95
+ def cmd_layout(args) -> int:
96
+ project = _open(args)
97
+ schema = project.compile()
98
+ if not _print_diagnostics(schema, False):
99
+ return 1
100
+ model, pos = project.build(schema, reset=args.reset)
101
+ project.save_positions(model, pos)
102
+ print(f"Wrote {project.sidecar} ({len(pos)} tables)")
103
+ return 0
104
+
105
+
106
+ def cmd_serve(args) -> int:
107
+ from .serve import serve
108
+
109
+ project = _open(args)
110
+ return serve(project, port=args.port, open_browser=not args.no_open)
111
+
112
+
113
+ def build_parser() -> argparse.ArgumentParser:
114
+ p = argparse.ArgumentParser(prog="qdbd", description="Compile QuickDBD schema files into interactive ER diagrams.")
115
+ p.add_argument("--version", action="version", version=f"qdbd {__version__}")
116
+ sub = p.add_subparsers(dest="cmd", required=True)
117
+
118
+ def paths(sp):
119
+ sp.add_argument("paths", nargs="+", metavar="PATH", help="schema .txt file(s) or folder(s) (recursive)")
120
+
121
+ sp = sub.add_parser("check", help="validate schema files")
122
+ paths(sp)
123
+ sp.add_argument("--strict", action="store_true", help="treat warnings (QuickDBD incompatibilities) as errors")
124
+ sp.set_defaults(func=cmd_check)
125
+
126
+ sp = sub.add_parser("build", help="write an interactive HTML diagram")
127
+ paths(sp)
128
+ sp.add_argument("-o", "--output", default="diagram.html")
129
+ sp.add_argument("--layout", help="layout sidecar file (default: next to the schema)")
130
+ sp.add_argument("--strict", action="store_true")
131
+ sp.set_defaults(func=cmd_build)
132
+
133
+ sp = sub.add_parser("export", help="write a PNG or SVG image")
134
+ paths(sp)
135
+ sp.add_argument("-o", "--output", required=True, help="out.png or out.svg")
136
+ sp.add_argument("--focus", metavar="TABLE", help="only this table and its neighbours")
137
+ sp.add_argument("--depth", type=int, default=1, help="relationship hops around --focus (default 1)")
138
+ sp.add_argument("--mode", choices=["full", "keys", "header"], default="full", help="collapse mode")
139
+ sp.add_argument("--scale", type=float, default=2.0, help="PNG pixel scale (default 2)")
140
+ sp.add_argument("--layout")
141
+ sp.add_argument("--strict", action="store_true")
142
+ sp.set_defaults(func=cmd_export)
143
+
144
+ sp = sub.add_parser("layout", help="write table positions to the layout sidecar")
145
+ paths(sp)
146
+ sp.add_argument("--reset", action="store_true", help="discard saved positions and auto-layout everything")
147
+ sp.add_argument("--layout")
148
+ sp.set_defaults(func=cmd_layout)
149
+
150
+ sp = sub.add_parser("serve", help="live editor: reload on save, drag tables to arrange")
151
+ paths(sp)
152
+ sp.add_argument("--port", type=int, default=8765)
153
+ sp.add_argument("--layout")
154
+ sp.add_argument("--no-open", action="store_true", help="don't open a browser")
155
+ sp.set_defaults(func=cmd_serve)
156
+ return p
157
+
158
+
159
+ def main(argv: list[str] | None = None) -> int:
160
+ args = build_parser().parse_args(argv)
161
+ try:
162
+ return args.func(args)
163
+ except FileNotFoundError as e:
164
+ print(f"error: {e}", file=sys.stderr)
165
+ return 2
166
+
167
+
168
+ if __name__ == "__main__":
169
+ sys.exit(main())
quickdbd_cli/export.py ADDED
@@ -0,0 +1,79 @@
1
+ """Static SVG / PNG export via render.js (in V8) and resvg."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from functools import cache
7
+ from importlib import resources
8
+
9
+ from . import layout
10
+ from .jsrt import runtime
11
+
12
+
13
+ def _asset_bytes(name: str) -> bytes:
14
+ return resources.files("quickdbd_cli").joinpath(f"assets/{name}").read_bytes()
15
+
16
+
17
+ @cache
18
+ def font_css() -> str:
19
+ out = []
20
+ for weight, name in (("normal", "Inter-Regular.woff2"), ("bold", "Inter-Bold.woff2")):
21
+ b64 = base64.b64encode(_asset_bytes(name)).decode("ascii")
22
+ out.append(
23
+ f"@font-face{{font-family:Inter;font-weight:{weight};"
24
+ f"src:url(data:font/woff2;base64,{b64}) format('woff2');}}"
25
+ )
26
+ return "".join(out)
27
+
28
+
29
+ def find_table(model: dict, name: str) -> dict | None:
30
+ for key in ("name", "id"):
31
+ for t in model["tables"]:
32
+ if t[key] == name:
33
+ return t
34
+ low = name.lower()
35
+ for t in model["tables"]:
36
+ if t["name"].lower() == low or low in (a.lower() for a in t["aliases"]):
37
+ return t
38
+ return None
39
+
40
+
41
+ def focus_ids(model: dict, table_id: str, depth: int) -> list[str]:
42
+ rt = runtime()
43
+ rt.load("render.js")
44
+ return rt.call("qdbdNeighbourhood", model, table_id, depth)
45
+
46
+
47
+ def render_svg(
48
+ model: dict, positions: dict, only: list[str] | None = None, mode: str = "full", embed_font: bool = True
49
+ ) -> str:
50
+ rt = runtime()
51
+ rt.load("render.js")
52
+ opts = {"mode": mode, "only": only}
53
+ if embed_font:
54
+ opts["fontCss"] = font_css()
55
+ return rt.call("qdbdRenderSvg", model, positions, opts)
56
+
57
+
58
+ def focus_positions(model: dict, positions: dict, ids: list[str]) -> dict:
59
+ """Fresh compact ELK layout for a focus subset, so the image isn't mostly empty space."""
60
+ keep = set(ids)
61
+ tables = [t for t in model["tables"] if t["id"] in keep]
62
+ rels = [r for r in model["rels"] if r["a"]["t"] in keep and r["b"]["t"] in keep]
63
+ pos = layout.elk_layout(tables, rels)
64
+ return {k: [v[0], v[1]] for k, v in pos.items()}
65
+
66
+
67
+ def svg_to_png(svg: str, scale: float = 2.0) -> bytes:
68
+ import resvg_py
69
+
70
+ fonts_dir = resources.files("quickdbd_cli").joinpath("assets")
71
+ font_files = [str(fonts_dir.joinpath(n)) for n in ("Inter-Regular.ttf", "Inter-Bold.ttf")]
72
+ data = resvg_py.svg_to_bytes(
73
+ svg_string=svg,
74
+ font_files=font_files,
75
+ skip_system_fonts=True,
76
+ sans_serif_family="Inter",
77
+ zoom=scale,
78
+ )
79
+ return bytes(data)
quickdbd_cli/html.py ADDED
@@ -0,0 +1,97 @@
1
+ """Self-contained HTML page: model + positions + renderer + viewer, all inline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from importlib import resources
7
+
8
+ from .export import font_css
9
+
10
+ CODEMIRROR = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16"
11
+
12
+
13
+ def _asset(name: str) -> str:
14
+ return resources.files("quickdbd_cli").joinpath(f"assets/{name}").read_text(encoding="utf-8")
15
+
16
+
17
+ def _json_for_script(obj) -> str:
18
+ # Safe inside <script type="application/json">: no "</" sequences.
19
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).replace("</", "<\\/")
20
+
21
+
22
+ def _escape(text: str) -> str:
23
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
24
+
25
+
26
+ BODY = """
27
+ <div id="app">
28
+ {side}
29
+ <div id="stage">
30
+ <svg id="canvas" xmlns="http://www.w3.org/2000/svg"><g id="vp"></g></svg>
31
+ <div class="search">
32
+ <input id="q" type="search" placeholder="Search {count} tables ( / )" autocomplete="off" spellcheck="false">
33
+ <div id="results" class="results"></div>
34
+ </div>
35
+ <div class="bar">
36
+ <div class="seg" id="modes" title="Collapse tables">
37
+ <button data-mode="full" class="on">Full</button>
38
+ <button data-mode="keys">Keys</button>
39
+ <button data-mode="header">Names</button>
40
+ </div>
41
+ <div class="sep"></div>
42
+ <button id="xsvg" title="Export SVG">SVG</button>
43
+ <button id="xpng" title="Export PNG">PNG</button>
44
+ <div class="sep"></div>
45
+ <button id="zout" title="Zoom out">&#8722;</button>
46
+ <span class="zoomval" id="zoomval">100%</span>
47
+ <button id="zin" title="Zoom in">+</button>
48
+ <button id="zfit" title="Fit to screen">&#9678;</button>
49
+ </div>
50
+ <div class="minimap">
51
+ <svg id="mini" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"></svg>
52
+ </div>
53
+ <div class="hint">{hint}</div>
54
+ </div>
55
+ </div>
56
+ """
57
+
58
+ SIDE = """
59
+ <div id="side">
60
+ <div class="tabs" id="tabs"></div>
61
+ <div class="banner" id="banner">File changed on disk while you have unsaved edits.
62
+ <button id="keepmine">Keep mine</button><button id="reload">Reload from disk</button></div>
63
+ <div id="editor"></div>
64
+ <div id="problems"></div>
65
+ </div>
66
+ """
67
+
68
+
69
+ def render_page(model: dict, positions: dict, title: str, serve: bool = False) -> str:
70
+ data = {"model": model, "positions": positions, "serve": serve, "title": title, "fontCss": font_css()}
71
+ hint = (
72
+ "Drag tables to move them (saved). Click a table to focus. Esc clears."
73
+ if serve
74
+ else "Click a table to focus its relationships. Esc clears. Scroll to zoom, drag to pan."
75
+ )
76
+ body = BODY.format(side=SIDE if serve else "", count=len(model["tables"]), hint=hint)
77
+ head_extra = ""
78
+ scripts = ""
79
+ if serve:
80
+ head_extra = f'<link rel="stylesheet" href="{CODEMIRROR}/codemirror.min.css">'
81
+ scripts = (
82
+ f'<script src="{CODEMIRROR}/codemirror.min.js"></script>'
83
+ f'<script src="{CODEMIRROR}/addon/mode/simple.min.js"></script>'
84
+ f"<script>{_asset('editor.js')}</script>"
85
+ )
86
+ return (
87
+ '<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
88
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
89
+ '<meta name="generator" content="qdbd">'
90
+ f"<title>{_escape(title)}</title>"
91
+ f"<style>{font_css()}{_asset('style.css')}</style>{head_extra}</head><body>"
92
+ f"{body}"
93
+ f'<script type="application/json" id="qdbd-data">{_json_for_script(data)}</script>'
94
+ f"<script>{_asset('render.js')}</script>"
95
+ f"<script>{_asset('viewer.js')}</script>"
96
+ f"{scripts}</body></html>\n"
97
+ )