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/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 7e2f6300..55673907 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,5 +1,6 @@ import 'dart:convert'; + import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../motion/querya_motion_scope.dart'; @@ -320,7 +321,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/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); + }); +} diff --git a/test/features/results/charts/quick_chart_view_test.dart b/test/features/results/charts/quick_chart_view_test.dart index a0abd544..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'], @@ -113,7 +119,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 +135,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();