Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 94 additions & 23 deletions lib/features/erd/erd_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,59 @@ 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._();

/// 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;
Expand Down Expand Up @@ -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<ErdRoute>? 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 `<title>`, 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)}.'
Expand All @@ -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);
Expand All @@ -126,49 +177,69 @@ 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</title></path>')
..writeln('<path d="$ends" fill="none" stroke="#64748b" stroke-width="1.5"/>');
..writeln('<path class="erd-edge" d="${_routePath(r.points)}" '
'fill="none" stroke="${colors.edge}" stroke-width="1.5" '
'stroke-linejoin="round"><title>$title</title></path>')
..writeln('<path class="erd-ends" d="$ends" fill="none" '
'stroke="${colors.edge}" stroke-width="1.5"/>');
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);
final left = rect.left, top = rect.top, cw = rect.width, ch = rect.height;
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('<clipPath id="$clip"><rect x="${_n(left)}" y="${_n(top)}" '
'width="${_n(cw)}" height="${_n(ch)}" rx="6"/></clipPath>')
'width="${_n(cw)}" height="${_n(ch)}" rx="$rx"/></clipPath>')
..writeln('<rect x="${_n(left)}" y="${_n(top)}" width="${_n(cw)}" '
'height="${_n(ch)}" rx="6" fill="#f8fafc" stroke="#94a3b8"/>')
'height="${_n(ch)}" rx="$rx" fill="${colors.card}" '
'stroke="${colors.border}"/>')
..writeln('<g clip-path="url(#$clip)">')
..writeln('<rect x="${_n(left)}" y="${_n(top)}" width="${_n(cw)}" '
'height="${_n(ErdLayout.headerHeight)}" fill="#e0f2fe"/>')
'height="${_n(ErdLayout.headerHeight)}" fill="${colors.header}"/>')
..writeln('<text x="${_n(left + 10)}" y="${_n(top + 21)}" '
'font-weight="bold">${_esc(_fit(t.name, inner, _nameCharPx))}</text>');
'font-weight="bold">'
'${_esc(_fit(t.name, inner - countWidth, _nameCharPx))}</text>')
..writeln('<text x="${_n(left + cw - 10)}" y="${_n(top + 21)}" '
'text-anchor="end" font-size="10" fill="${colors.muted}">'
'$count</text>');
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('<text x="${_n(x)}" y="${_n(baseline)}"'
..writeln('<text x="${_n(nameX)}" y="${_n(baseline)}"'
'${c.isPrimaryKey ? ' font-weight="bold"' : ''}>'
'${_esc(_fit(c.name, nameAvail, _nameCharPx))}</text>')
..writeln('<text x="${_n(left + cw - 10)}" y="${_n(baseline)}" '
'text-anchor="end" font-size="10" fill="#64748b">'
'${_esc(_fit(c.isNullable ? '${c.type}?' : c.type, typeAvail, _typeCharPx))}</text>');
'text-anchor="end" font-size="10" font-family="monospace" '
'fill="${colors.muted}">'
'${_esc(_fit(type, typeAvail, _typeCharPx))}</text>');
}
b.writeln('</g>');
}
Expand Down
78 changes: 64 additions & 14 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' show min;
import 'dart:typed_data';
Expand Down Expand Up @@ -403,23 +404,71 @@ class _ErdViewState extends material.State<ErdView> {
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<void> _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),
);
}

/// `<database>-erd`, or `erd` when the database name is unknown.
Expand All @@ -436,9 +485,10 @@ class _ErdViewState extends material.State<ErdView> {
_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(
Expand Down
85 changes: 85 additions & 0 deletions test/features/erd/erd_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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('<title>'.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: [
Expand Down Expand Up @@ -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));
Expand Down
Loading