From 4f7758b422a06571f40318aca4bb32e89b84bed6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 14:28:05 +0300 Subject: [PATCH 1/4] feat(export): chart and ERD PNG are drawn from the light SVG by default (#1163) PNG export was a screenshot of the current theme, so with a dark theme the PNG was dark even though the SVG was light. With the light palette, the PNG is now rasterised from the same SVG document the SVG export saves (flutter_svg's PictureInfo on a canvas, at the same scale the ERD already capped at 8192 px). With the current-theme setting, PNG stays a screenshot, as before. The chart's SVG builder is shared by the SVG and PNG exports. --- lib/core/export/svg_png.dart | 34 +++++++++++++++++++ lib/features/erd/erd_view.dart | 18 +++++++--- .../results/charts/quick_chart_view.dart | 28 ++++++++++++--- test/core/export/svg_png_test.dart | 20 +++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 lib/core/export/svg_png.dart create mode 100644 test/core/export/svg_png_test.dart diff --git a/lib/core/export/svg_png.dart b/lib/core/export/svg_png.dart new file mode 100644 index 00000000..a4d3f0b5 --- /dev/null +++ b/lib/core/export/svg_png.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter_svg/flutter_svg.dart'; + +/// Renders an SVG document to PNG bytes at [scale] times its own size. The PNG +/// is drawn from the same document the SVG export saves, so the two match. +Future svgToPng(String svg, {double scale = 2}) async { + final info = await vg.loadPicture(SvgStringLoader(svg), null); + try { + final width = (info.size.width * scale).ceil(); + final height = (info.size.height * scale).ceil(); + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas( + recorder, ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble())); + canvas.scale(scale); + canvas.drawPicture(info.picture); + final picture = recorder.endRecording(); + try { + final image = await picture.toImage(width, height); + try { + final data = await image.toByteData(format: ui.ImageByteFormat.png); + if (data == null) throw StateError('the PNG could not be encoded'); + return data.buffer.asUint8List(); + } finally { + image.dispose(); + } + } finally { + picture.dispose(); + } + } finally { + info.picture.dispose(); + } +} diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 28e22ee8..8881a665 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -20,6 +20,7 @@ import 'package:querya_desktop/features/erd/erd_geometry.dart'; 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'; +import 'package:querya_desktop/core/export/svg_png.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; import 'package:querya_desktop/shared/widgets/querya_action_menu.dart'; @@ -477,10 +478,19 @@ 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 + /// PNG of the diagram. With the light palette it is drawn from the same SVG + /// the SVG export saves, so the two match. With the current theme it is a + /// screenshot of the canvas: a picked table, the hover highlight and the edge /// label are cleared for the capture, and the pick comes back afterwards. - Future _exportPng() async { + Future _exportPng(ErdSchema schema, ErdLayout layout) async { + final currentTheme = await AppSettings.instance.getExportCurrentTheme(); + if (!mounted) return; + if (!currentTheme) { + final scale = ErdExport.pngPixelRatio(layout.size); + final svg = ErdExport.toSvg(schema, layout, routes: _routes); + await _save('$_fileStem.png', await svgToPng(svg, scale: scale)); + return; + } final boundary = _boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return; @@ -582,7 +592,7 @@ class _ErdViewState extends material.State { case _ExportAction.svg: unawaited(_exportSvg(schema, layout)); case _ExportAction.png: - unawaited(_exportPng()); + unawaited(_exportPng(schema, layout)); case _ExportAction.toggleTheme: unawaited(_toggleExportTheme()); case _ExportAction.copyMermaid: diff --git a/lib/features/results/charts/quick_chart_view.dart b/lib/features/results/charts/quick_chart_view.dart index 7965b9ac..f875efb1 100644 --- a/lib/features/results/charts/quick_chart_view.dart +++ b/lib/features/results/charts/quick_chart_view.dart @@ -8,6 +8,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/rendering.dart'; +import 'package:querya_desktop/core/export/svg_png.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/features/results/charts/chart_data.dart'; import 'package:querya_desktop/features/results/charts/chart_format.dart'; @@ -193,7 +194,17 @@ class _QuickChartViewState extends material.State { return null; } + /// PNG of the chart. With the light palette it is drawn from the same SVG the + /// SVG export saves; with the current theme it is a screenshot of the screen. Future _export() async { + final currentTheme = await AppSettings.instance.getExportCurrentTheme(); + if (!mounted) return; + if (!currentTheme) { + final svg = _chartSvg(currentTheme: false); + if (svg == null) return; + await (widget.onSavePng ?? _defaultPngSaver)(await svgToPng(svg)); + return; + } final boundary = _boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return; @@ -203,14 +214,14 @@ class _QuickChartViewState extends material.State { await (widget.onSavePng ?? _defaultPngSaver)(data.buffer.asUint8List()); } - Future _exportSvg() async { + /// The chart as an SVG document, in the light palette or the current theme. + /// Null when there is no value column to chart. + String? _chartSvg({required bool currentTheme}) { final valueCol = _valueCol; - if (valueCol == null) return; - final currentTheme = await AppSettings.instance.getExportCurrentTheme(); - if (!mounted) return; + if (valueCol == null) return null; final palette = context.semanticPalette; final wb = context.workbench; - final svg = ChartSvg.build( + return ChartSvg.build( points: _series(valueCol).points, type: _type, title: _chartTitle(), @@ -232,6 +243,13 @@ class _QuickChartViewState extends material.State { ) : ChartSvg.light, ); + } + + Future _exportSvg() async { + final currentTheme = await AppSettings.instance.getExportCurrentTheme(); + if (!mounted) return; + final svg = _chartSvg(currentTheme: currentTheme); + if (svg == null) return; await (widget.onSaveSvg ?? _defaultSvgSaver)(svg); } diff --git a/test/core/export/svg_png_test.dart b/test/core/export/svg_png_test.dart new file mode 100644 index 00000000..e853b270 --- /dev/null +++ b/test/core/export/svg_png_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/export/svg_png.dart'; + +void main() { + testWidgets('an SVG rasterises to a PNG at its size times the scale (#1163)', + (t) async { + const svg = '' + ''; + final bytes = await t.runAsync(() => svgToPng(svg, scale: 2)); + + expect(bytes, isNotNull); + final png = bytes!; + // The PNG signature, then the IHDR width and height (big-endian). + expect(png.sublist(0, 4), [0x89, 0x50, 0x4e, 0x47]); + int u32(int at) => + (png[at] << 24) | (png[at + 1] << 16) | (png[at + 2] << 8) | png[at + 3]; + expect(u32(16), 40); + expect(u32(20), 20); + }); +} From 34b78c603ff67257a460f2b2b35ad90f9de375ef Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 14:32:11 +0300 Subject: [PATCH 2/4] test(charts): allow up to 10 s for an export to be saved (#1163) The export reads its setting from the local database first; opening it in a fresh test process can take longer than the 2 s the chart tests waited. --- test/features/results/charts/quick_chart_view_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/features/results/charts/quick_chart_view_test.dart b/test/features/results/charts/quick_chart_view_test.dart index a0abd544..a25261a7 100644 --- a/test/features/results/charts/quick_chart_view_test.dart +++ b/test/features/results/charts/quick_chart_view_test.dart @@ -113,7 +113,7 @@ void main() { await t.pumpAndSettle(); await t.tap(find.text('SVG')); // The export reads its setting first, which is real I/O. - for (var i = 0; i < 40 && saved == null; i++) { + for (var i = 0; i < 200 && saved == null; i++) { await t.runAsync( () => Future.delayed(const Duration(milliseconds: 50))); await t.pump(); @@ -129,7 +129,7 @@ void main() { await t.pumpAndSettle(); saved = null; await t.tap(find.text('SVG')); - for (var i = 0; i < 40 && saved == null; i++) { + for (var i = 0; i < 200 && saved == null; i++) { await t.runAsync( () => Future.delayed(const Duration(milliseconds: 50))); await t.pump(); From cc0fe973feaa6550f9daa418fc75540ffdccb0df Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 14:35:56 +0300 Subject: [PATCH 3/4] test(charts): answer the export theme setting without the local database (#1163) The export reads its setting from the local database; in CI several test processes share that database, so the chart export tests waited on a read that sometimes stalled. A test seam answers the setting in the chart tests; the application still reads the database. --- lib/core/storage/app_settings.dart | 8 ++++++++ test/features/results/charts/quick_chart_view_test.dart | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 7e2f6300..707bf1a0 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart' show visibleForTesting; + import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../motion/querya_motion_scope.dart'; @@ -320,7 +322,13 @@ class AppSettings { /// Chart and diagram exports. False (default): a light palette for documents. /// True: the colours of the current theme. + /// Test seam: answers [getExportCurrentTheme] without the local database. + @visibleForTesting + static Future Function()? exportCurrentThemeOverride; + Future getExportCurrentTheme() async { + final override = exportCurrentThemeOverride; + if (override != null) return override(); try { final value = await LocalDb.instance.getAppSetting( AppSettingsKeys.exportCurrentTheme, diff --git a/test/features/results/charts/quick_chart_view_test.dart b/test/features/results/charts/quick_chart_view_test.dart index a25261a7..e9fcf344 100644 --- a/test/features/results/charts/quick_chart_view_test.dart +++ b/test/features/results/charts/quick_chart_view_test.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/features/results/charts/chart_data.dart'; import 'package:querya_desktop/features/results/charts/quick_chart_view.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -10,6 +11,11 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../../../support/querya_theme_test_shell.dart'; void main() { + // The export reads its theme setting; the tests answer it without the + // local database, which is shared by test processes in CI. + setUp(() => AppSettings.exportCurrentThemeOverride = () async => false); + tearDown(() => AppSettings.exportCurrentThemeOverride = null); + const columns = ['name', 'amount']; final rows = [ ['a', '1'], From ab0075166662fd760d9b7657c0f3738c57ea12f7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 14:38:50 +0300 Subject: [PATCH 4/4] fix(settings): drop the redundant foundation import (#1163) --- lib/core/storage/app_settings.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 707bf1a0..55673907 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:shadcn_flutter/shadcn_flutter.dart';