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
34 changes: 34 additions & 0 deletions lib/core/export/svg_png.dart
Original file line number Diff line number Diff line change
@@ -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<Uint8List> 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();
}
}
7 changes: 7 additions & 0 deletions lib/core/storage/app_settings.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:convert';


import 'package:shadcn_flutter/shadcn_flutter.dart';

import '../motion/querya_motion_scope.dart';
Expand Down Expand Up @@ -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<bool> Function()? exportCurrentThemeOverride;

Future<bool> getExportCurrentTheme() async {
final override = exportCurrentThemeOverride;
if (override != null) return override();
try {
final value = await LocalDb.instance.getAppSetting(
AppSettingsKeys.exportCurrentTheme,
Expand Down
18 changes: 14 additions & 4 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -477,10 +478,19 @@ 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
/// 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<void> _exportPng() async {
Future<void> _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;
Expand Down Expand Up @@ -582,7 +592,7 @@ class _ErdViewState extends material.State<ErdView> {
case _ExportAction.svg:
unawaited(_exportSvg(schema, layout));
case _ExportAction.png:
unawaited(_exportPng());
unawaited(_exportPng(schema, layout));
case _ExportAction.toggleTheme:
unawaited(_toggleExportTheme());
case _ExportAction.copyMermaid:
Expand Down
28 changes: 23 additions & 5 deletions lib/features/results/charts/quick_chart_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -193,7 +194,17 @@ class _QuickChartViewState extends material.State<QuickChartView> {
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<void> _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;
Expand All @@ -203,14 +214,14 @@ class _QuickChartViewState extends material.State<QuickChartView> {
await (widget.onSavePng ?? _defaultPngSaver)(data.buffer.asUint8List());
}

Future<void> _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(),
Expand All @@ -232,6 +243,13 @@ class _QuickChartViewState extends material.State<QuickChartView> {
)
: ChartSvg.light,
);
}

Future<void> _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);
}

Expand Down
20 changes: 20 additions & 0 deletions test/core/export/svg_png_test.dart
Original file line number Diff line number Diff line change
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10">'
'<rect width="20" height="10" fill="#ff0000"/></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);
});
}
10 changes: 8 additions & 2 deletions test/features/results/charts/quick_chart_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ 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';

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'],
Expand Down Expand Up @@ -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<void>.delayed(const Duration(milliseconds: 50)));
await t.pump();
Expand All @@ -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<void>.delayed(const Duration(milliseconds: 50)));
await t.pump();
Expand Down
Loading