diff --git a/lib/features/erd/erd_export.dart b/lib/features/erd/erd_export.dart index 5d10a4de..5e087cf9 100644 --- a/lib/features/erd/erd_export.dart +++ b/lib/features/erd/erd_export.dart @@ -6,6 +6,41 @@ import 'package:querya_desktop/features/erd/erd_layout.dart'; import 'package:querya_desktop/features/erd/erd_model.dart'; import 'package:querya_desktop/features/erd/erd_router.dart'; +/// Colours of the SVG export as `#rrggbb`. The defaults are a light theme; +/// the diagram view passes the colours of the current theme, so the file looks +/// like the screen. +class ErdSvgColors { + const ErdSvgColors({ + this.background = '#ffffff', + this.card = '#ffffff', + this.border = '#cbd5e1', + this.header = '#e8f1fd', + this.text = '#0f172a', + this.muted = '#64748b', + this.edge = '#64748b', + this.primaryKey = '#2563eb', + this.foreignKey = '#0d9488', + }); + + final String background; + final String card; + final String border; + + /// Header band of a card (the accent over the card colour). + final String header; + final String text; + + /// Column types, the column count and the optional-end circle outline. + final String muted; + final String edge; + final String primaryKey; + final String foreignKey; + + /// `#rrggbb` for a 0xAARRGGBB colour value (alpha is dropped). + static String hex(int argb) => + '#${(argb & 0xFFFFFF).toRadixString(16).padLeft(6, '0')}'; +} + /// Text exports of an [ErdSchema]. class ErdExport { ErdExport._(); @@ -13,10 +48,17 @@ class ErdExport { /// Longest side of a PNG export, in pixels. static const int pngMaxSide = 8192; - // Rough glyph widths for the SVG text fitting: sans-serif at 12 px and 10 px. + // Rough glyph widths for the SVG text fitting: sans-serif at 12 px for + // names, monospace at 10 px for types and the column count. static const double _nameCharPx = 7.5; static const double _typeCharPx = 6.0; + /// Card corner radius, as on screen. + static const double _cardRadius = 8; + + /// Width of one PK / FK pill and the gap after it. + static const double _pillStep = 21; + static String _id(String s) { final r = s.replaceAll(RegExp(r'[^A-Za-z0-9_]'), '_'); return r.isEmpty ? '_' : r; @@ -91,16 +133,24 @@ class ErdExport { /// Standalone SVG rendering of the diagram, with the same positions and /// edge routes as the screen ([routes] defaults to a fresh routing). /// - /// Like the screen, it draws rounded cards with a tinted header, a crow's - /// foot at the FK end and a bar at the referenced end, PK and FK pills, and - /// clips text to each card. - static String toSvg(ErdSchema schema, ErdLayout layout, {List? routes}) { + /// Like the screen, it draws cards with an 8 px radius and a tinted header + /// with the column count, a crow's foot at the FK end and a bar at the + /// referenced end, PK and FK pills in a slot of the same width for every + /// row of a card, monospace types, and clips text to each card. Every edge + /// is an `erd-edge` path with a ``, followed by its `erd-ends` path. + static String toSvg( + ErdSchema schema, + ErdLayout layout, { + List<ErdRoute>? routes, + ErdSvgColors colors = const ErdSvgColors(), + }) { final size = layout.size; final w = _n(size.width), h = _n(size.height); final b = StringBuffer() ..writeln('<svg xmlns="http://www.w3.org/2000/svg" width="$w" height="$h" ' - 'viewBox="0 0 $w $h" font-family="sans-serif" font-size="12">') - ..writeln('<rect width="100%" height="100%" fill="#ffffff"/>'); + 'viewBox="0 0 $w $h" font-family="sans-serif" font-size="12" ' + 'fill="${colors.text}">') + ..writeln('<rect width="100%" height="100%" fill="${colors.background}"/>'); for (final r in routes ?? ErdRouter.route(schema, layout)) { if (r.points.length < 2) continue; final title = '${_esc(r.relation.fromTable)}.' @@ -116,7 +166,8 @@ class ErdExport { final (centre, radius) = ErdGeometry.optionalCircle(r.points[0], r.points[1]); circle = '<circle cx="${_n(centre.dx)}" cy="${_n(centre.dy)}" ' - 'r="${_n(radius)}" fill="#ffffff" stroke="#64748b" stroke-width="1.5"/>'; + 'r="${_n(radius)}" fill="${colors.background}" ' + 'stroke="${colors.edge}" stroke-width="1.5"/>'; } else { final (fa, fb) = ErdGeometry.oneBar(r.points[0], r.points[1], distance: 17); @@ -126,11 +177,14 @@ class ErdExport { ErdGeometry.oneBar(r.points.last, r.points[r.points.length - 2]); ends.write('M ${_p(barA)} L ${_p(barB)}'); b - ..writeln('<path d="${_routePath(r.points)}" fill="none" stroke="#64748b" ' - 'stroke-width="1.5" stroke-linejoin="round"><title>$title') - ..writeln(''); + ..writeln('$title') + ..writeln(''); if (circle != null) b.writeln(circle); } + final rx = _n(_cardRadius); for (var ti = 0; ti < schema.tables.length; ti++) { final t = schema.tables[ti]; final rect = layout.rectOf(t); @@ -138,37 +192,54 @@ class ErdExport { final clip = 'card$ti'; final inner = cw - 20; final typeAvail = inner * 0.4; + // One slot width for every row, so the names of a card line up. + var pills = 0; + for (final c in t.columns) { + final n = (c.isPrimaryKey ? 1 : 0) + (c.isForeignKey ? 1 : 0); + if (n > pills) pills = n; + } + final slot = pills * _pillStep; + final count = '${t.columns.length}'; + final countWidth = count.length * _typeCharPx + 8; b ..writeln('') + 'width="${_n(cw)}" height="${_n(ch)}" rx="$rx"/>') ..writeln('') + 'height="${_n(ch)}" rx="$rx" fill="${colors.card}" ' + 'stroke="${colors.border}"/>') ..writeln('') ..writeln('') + 'height="${_n(ErdLayout.headerHeight)}" fill="${colors.header}"/>') ..writeln('${_esc(_fit(t.name, inner, _nameCharPx))}'); + 'font-weight="bold">' + '${_esc(_fit(t.name, inner - countWidth, _nameCharPx))}') + ..writeln('' + '$count'); for (var i = 0; i < t.columns.length; i++) { final c = t.columns[i]; final rowTop = top + ErdLayout.headerHeight + ErdLayout.rowHeight * i; final baseline = rowTop + 15; var x = left + 10; if (c.isPrimaryKey) { - b.writeln(_pill(x, rowTop, 'PK', '#2563eb')); - x += 21; + b.writeln(_pill(x, rowTop, 'PK', colors.primaryKey)); + x += _pillStep; } if (c.isForeignKey) { - b.writeln(_pill(x, rowTop, 'FK', '#0d9488')); - x += 21; + b.writeln(_pill(x, rowTop, 'FK', colors.foreignKey)); } - final nameAvail = inner - (x - left - 10) - typeAvail - 6; + final nameX = left + 10 + slot; + final type = c.isNullable ? '${c.type}?' : c.type; + final typeWidth = min(type.length * _typeCharPx, typeAvail); + final nameAvail = inner - slot - typeWidth - 6; b - ..writeln('' '${_esc(_fit(c.name, nameAvail, _nameCharPx))}') ..writeln('' - '${_esc(_fit(c.isNullable ? '${c.type}?' : c.type, typeAvail, _typeCharPx))}'); + 'text-anchor="end" font-size="10" font-family="monospace" ' + 'fill="${colors.muted}">' + '${_esc(_fit(type, typeAvail, _typeCharPx))}'); } b.writeln(''); } diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index b94263a6..f46938f7 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math' show min; import 'dart:typed_data'; @@ -403,23 +404,71 @@ class _ErdViewState extends material.State { return focus == table || (neighbours[focus]?.contains(table) ?? false); } + /// Renders the canvas to a PNG. The picture is the diagram, not the + /// current interaction: a picked table, the hover highlight and the edge + /// label are cleared for the capture, and the pick comes back afterwards. Future _exportPng() async { final boundary = _boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return; - final ratio = ErdExport.pngPixelRatio(boundary.size); - if (ratio < 1) { - showAppToast( - context: context, - message: 'The diagram is larger than ' - '${ErdExport.pngMaxSide} px per side, so the PNG is at ' - '${(ratio * 100).round()}% resolution. The SVG export keeps full detail.', - ); + final picked = _selected; + final needsFrame = picked != null || + _hovered.value != null || + _edgeTipNotifier.value != null; + _hovered.value = null; + _edgeTipNotifier.value = null; + if (picked != null) setState(() => _selected = null); + try { + if (needsFrame) await material.WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final ratio = ErdExport.pngPixelRatio(boundary.size); + if (ratio < 1) { + showAppToast( + context: context, + message: 'The diagram is larger than ' + '${ErdExport.pngMaxSide} px per side, so the PNG is at ' + '${(ratio * 100).round()}% resolution. The SVG export keeps full detail.', + ); + } + final image = await boundary.toImage(pixelRatio: ratio); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (data == null) throw StateError('no image data'); + await _save('$_fileStem.png', data.buffer.asUint8List()); + } catch (e) { + if (mounted) { + showAppToast( + context: context, + variant: AppToastVariant.error, + message: 'Could not render the PNG ($e). ' + 'Export SVG instead: it has no size limit.', + ); + } + } finally { + if (mounted && picked != null && _selected == null) { + setState(() => _selected = picked); + } } - final image = await boundary.toImage(pixelRatio: ratio); - final data = await image.toByteData(format: ui.ImageByteFormat.png); - if (data == null) return; - await _save('$_fileStem.png', data.buffer.asUint8List()); + } + + /// Colours of the current theme for the SVG export, as the screen draws the + /// cards and edges. + ErdSvgColors _svgColors() { + final wb = context.workbench; + final palette = context.semanticPalette; + String hex(material.Color c) => ErdSvgColors.hex(c.toARGB32()); + return ErdSvgColors( + background: hex(wb.surface), + card: hex(wb.surface), + border: hex(wb.borderSubtle), + header: hex(material.Color.alphaBlend( + wb.accent.withValues(alpha: 0.10), wb.surface)), + text: hex(Theme.of(context).colorScheme.foreground), + muted: hex(wb.mutedForeground), + edge: hex(wb.mutedForeground), + primaryKey: hex(palette.type1), + foreignKey: hex(palette.type2), + ); } /// `-erd`, or `erd` when the database name is unknown. @@ -436,9 +485,10 @@ class _ErdViewState extends material.State { _save( '$_fileStem.svg', Uint8List.fromList(utf8.encode( - ErdExport.toSvg(schema, layout, routes: _routes)))); + ErdExport.toSvg(schema, layout, + routes: _routes, colors: _svgColors())))); case _ExportAction.png: - _exportPng(); + unawaited(_exportPng()); case _ExportAction.copyMermaid: Clipboard.setData(ClipboardData(text: ErdExport.toMermaid(schema))); showAppToast( diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index edf18bba..3ae3cf74 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -459,6 +459,60 @@ void main() { expect(svg, contains('clip-path="url(#card0)"')); }); + test('svg draws an edge and its end markers for every relation', () { + final s = ErdSchema.fromCatalog(columnRows: [ + ['users', 'id', 'int', '1'], + ['orders', 'id', 'int', '1'], + ['orders', 'user_id', 'int', '0'], + ['payments', 'id', 'int', '1'], + ['payments', 'order_id', 'int', '0'], + ['payments', 'user_id', 'int', '0'], + ], fkRows: [ + ['orders', 'user_id', 'users', 'id'], + ['payments', 'order_id', 'orders', 'id'], + ['payments', 'user_id', 'users', 'id'], + ]); + final layout = ErdLayout.compute(s); + final routes = ErdRouter.route(s, layout); + final drawn = routes.where((r) => r.points.length >= 2).length; + expect(drawn, s.relations.length); + final svg = ErdExport.toSvg(s, layout, routes: routes); + expect('class="erd-edge"'.allMatches(svg).length, drawn); + expect('class="erd-ends"'.allMatches(svg).length, drawn); + expect(''.allMatches(svg).length, drawn); + }); + + test('svg shortens long table and column names to fit the card', () { + const table = 'customer_order_line_items_with_a_very_long_name'; + const column = 'shipping_address_second_line_for_international_orders'; + final s = ErdSchema.fromCatalog(columnRows: [ + [table, column, 'text', '0'], + ], fkRows: const []); + final svg = ErdExport.toSvg(s, ErdLayout.compute(s)); + expect(svg, isNot(contains(table))); + expect(svg, isNot(contains(column))); + expect('…'.allMatches(svg).length, greaterThanOrEqualTo(2)); + }); + + test('svg lines up the column names of a card past the key slot', () { + final svg = ErdExport.toSvg(sample(), ErdLayout.compute(sample())); + double xOf(String name) => double.parse( + RegExp('<text x="([0-9.]+)" y="[0-9.]+"[^>]*>$name</text>') + .firstMatch(svg)! + .group(1)!); + // users: "id" is a PK, "name" is not; both start at the same x. + expect(xOf('name'), xOf('id')); + }); + + test('svg takes its colours from the caller', () { + final s = sample(); + final svg = ErdExport.toSvg(s, ErdLayout.compute(s), + colors: const ErdSvgColors(background: '#123456', edge: '#abcdef')); + expect(svg, contains('fill="#123456"')); + expect(svg, contains('stroke="#abcdef"')); + expect(svg, contains('rx="8.0"')); + }); + test('svg fits long types inside the card with an ellipsis', () { const longType = "enum('pending','paid','shipped','cancelled','refunded')"; final s = ErdSchema.fromCatalog(columnRows: [ @@ -523,6 +577,37 @@ void main() { await t.pump(const Duration(seconds: 1)); }); + testWidgets('PNG export works while a table is picked', (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + final saved = <String, Uint8List>{}; + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: delegate(), dialect: SqlDialect.sqlite), + onSaveFile: (n, b) async => saved[n] = b, + ), + )); + await t.pump(); + await t.pump(); + + // A single tap picks the table once the double-tap window has passed. + await t.tap(find.byKey(const material.ValueKey('erd_table_users'))); + await t.pump(const Duration(milliseconds: 400)); + + await t.tap(find.byKey(const material.ValueKey('erd_export'))); + await t.pumpAndSettle(); + await t.tap(find.text('PNG')); + // The capture waits for a frame without the pick, then for the engine. + for (var i = 0; i < 40 && !saved.containsKey('erd.png'); i++) { + await t.runAsync( + () => Future<void>.delayed(const Duration(milliseconds: 50))); + await t.pump(); + } + expect(saved['erd.png'], isNotNull); + expect(saved['erd.png']!.isNotEmpty, isTrue); + expect(t.takeException(), isNull); + await t.pump(const Duration(seconds: 1)); + }); + testWidgets('a card can be dragged and Auto layout puts it back', (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800));