diff --git a/assets/fonts/EBGaramond-Italic-Variable.ttf b/assets/fonts/EBGaramond-Italic-Variable.ttf new file mode 100644 index 0000000..c7739e4 Binary files /dev/null and b/assets/fonts/EBGaramond-Italic-Variable.ttf differ diff --git a/assets/fonts/EBGaramond-Variable.ttf b/assets/fonts/EBGaramond-Variable.ttf new file mode 100644 index 0000000..644f554 Binary files /dev/null and b/assets/fonts/EBGaramond-Variable.ttf differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..c1ec5e1 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e5db958..c5e9293 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -146,12 +146,17 @@ class ThemeProfile { class AppSettings { final String? homeDirectory; + + /// Folder where all exports (PDF/PPTX) are written. When null, exports land + /// next to the source deck (legacy behaviour). + final String? exportDirectory; final List themeProfiles; final String selectedThemeProfileName; final List recentFiles; const AppSettings({ this.homeDirectory, + this.exportDirectory, this.themeProfiles = const [ThemeProfile()], this.selectedThemeProfileName = 'Standaard', this.recentFiles = const [], @@ -180,17 +185,22 @@ class AppSettings { AppSettings copyWith({ String? homeDirectory, + String? exportDirectory, ThemeProfile? themeProfile, List? themeProfiles, String? selectedThemeProfileName, List? recentFiles, bool clearHomeDirectory = false, + bool clearExportDirectory = false, }) { final nextProfiles = themeProfiles ?? this.themeProfiles; return AppSettings( homeDirectory: clearHomeDirectory ? null : (homeDirectory ?? this.homeDirectory), + exportDirectory: clearExportDirectory + ? null + : (exportDirectory ?? this.exportDirectory), themeProfiles: themeProfile == null ? nextProfiles : [ diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart index 22023f0..db62b42 100644 --- a/lib/services/export_service.dart +++ b/lib/services/export_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:archive/archive.dart'; +import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; @@ -49,22 +50,63 @@ class ExportService { static const int _slideWidthEmu = 12192000; static const int _slideHeightEmu = 6858000; - /// Write [images] to a file derived from [deckPath] (same folder/base name) - /// in the requested [format]. + /// JPEG quality (0–100) used when a PDF is exported in compressed mode. + /// Low enough to shrink photo-heavy decks dramatically while keeping slides + /// legible. + static const int _compressedJpegQuality = 60; + + /// Slides are downscaled to this width (px) in compressed mode. The compressed + /// PDF is meant as a screen handout, so 720p is plenty and shrinks the file + /// further on top of JPEG encoding. Wider slides are never upscaled. + static const int _compressedMaxWidth = 1280; + + /// Timestamp for [time] in UTC, formatted `YYYYMMDDHHMMSS` — + /// e.g. `20260603124547`. Used as a filename prefix so exports sort + /// chronologically and never overwrite each other. + static String natoDtg(DateTime time) { + final t = time.toUtc(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${t.year.toString().padLeft(4, '0')}${two(t.month)}${two(t.day)}' + '${two(t.hour)}${two(t.minute)}${two(t.second)}'; + } + + /// Write [images] to a file named after [deckPath] in the requested [format]. + /// + /// The file name is prefixed with a UTC timestamp (see [natoDtg]), + /// e.g. `20260603124547 deck.pdf`. + /// + /// The file is written to [outputDirectory] when given (created if missing); + /// otherwise it lands next to the source deck (legacy behaviour). + /// + /// When [compress] is set (PDF only), each slide is re-encoded as JPEG instead + /// of being embedded as lossless PNG, and `-compact` is appended to the file + /// name so it never overwrites a full-quality export. Future export( String deckPath, ExportFormat format, - List images, - ) async { + List images, { + bool compress = false, + String? outputDirectory, + }) async { if (images.isEmpty) { return ExportResult.fail('Geen slides om te exporteren.'); } - final outputPath = '${p.withoutExtension(deckPath)}${format.extension}'; + final compactSuffix = compress && format == ExportFormat.pdf + ? '-compact' + : ''; + final dir = (outputDirectory != null && outputDirectory.isNotEmpty) + ? outputDirectory + : p.dirname(deckPath); + final prefix = '${natoDtg(DateTime.now())} '; + final fileName = + '$prefix${p.basenameWithoutExtension(deckPath)}$compactSuffix${format.extension}'; + final outputPath = p.join(dir, fileName); try { + await Directory(dir).create(recursive: true); final Uint8List bytes; switch (format) { case ExportFormat.pdf: - bytes = await _buildPdf(images); + bytes = await _buildPdf(images, compress: compress); case ExportFormat.pptx: bytes = _buildPptx(images); } @@ -77,12 +119,17 @@ class ExportService { // ── PDF ─────────────────────────────────────────────────────────────────── - Future _buildPdf(List images) async { + Future _buildPdf( + List images, { + bool compress = false, + }) async { final doc = pw.Document(); // Page size in points; only the ratio matters for a full-bleed image. const format = PdfPageFormat(1280, 720, marginAll: 0); for (final png in images) { - final image = pw.MemoryImage(png); + // MemoryImage auto-detects PNG vs JPEG from the byte header, so a + // compressed (JPEG) slide embeds just like the lossless one. + final image = pw.MemoryImage(compress ? _toJpeg(png) : png); doc.addPage( pw.Page( pageFormat: format, @@ -93,6 +140,22 @@ class ExportService { return doc.save(); } + /// Downscale a rendered slide PNG to [_compressedMaxWidth] and re-encode it as + /// JPEG at [_compressedJpegQuality]. Slides are full-bleed (no transparency), + /// so dropping the alpha channel is safe. + Uint8List _toJpeg(Uint8List png) { + final decoded = img.decodePng(png); + if (decoded == null) return png; // Unexpected; keep the original bytes. + final resized = decoded.width > _compressedMaxWidth + ? img.copyResize( + decoded, + width: _compressedMaxWidth, + interpolation: img.Interpolation.average, + ) + : decoded; + return img.encodeJpg(resized, quality: _compressedJpegQuality); + } + // ── PPTX (Office Open XML) ───────────────────────────────────────────────── Uint8List _buildPptx(List images) { diff --git a/lib/services/slide_rasterizer.dart b/lib/services/slide_rasterizer.dart index 3752cb7..7a47a15 100644 --- a/lib/services/slide_rasterizer.dart +++ b/lib/services/slide_rasterizer.dart @@ -5,7 +5,6 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:path/path.dart' as p; import '../models/deck.dart'; @@ -39,9 +38,6 @@ class SlideRasterizer { final overlay = Overlay.of(context, rootOverlay: true); final pixelRatio = targetWidth / logicalSize.width; - await _preloadFont(themeProfile.fontFamily); - if (!context.mounted) return const []; - // The global image cache has a modest default budget (~100 MB / 1000 // entries). A deck with several full-resolution photos blows past that, so // images decoded for earlier slides get evicted before later slides are @@ -136,15 +132,6 @@ class SlideRasterizer { } } - static Future _preloadFont(String fontFamily) async { - // Only the bundled Google font needs awaiting; system fonts resolve - // synchronously. - if (fontFamily == 'EB Garamond') { - GoogleFonts.ebGaramond(); - await GoogleFonts.pendingFonts(); - } - } - /// Decode and cache the given (already resolved) image paths, awaiting all of /// them. Nulls and duplicates are ignored; decode errors are swallowed so a /// single missing file never aborts the whole export. diff --git a/lib/state/settings_provider.dart b/lib/state/settings_provider.dart index edf2d8c..cf2772e 100644 --- a/lib/state/settings_provider.dart +++ b/lib/state/settings_provider.dart @@ -30,6 +30,7 @@ class SettingsNotifier extends StateNotifier { final profiles = _uniqueProfiles(loadedProfiles); state = AppSettings( homeDirectory: prefs.getString('homeDirectory'), + exportDirectory: prefs.getString('exportDirectory'), themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles, selectedThemeProfileName: prefs.getString('selectedThemeProfileName') ?? profiles.first.name, @@ -59,6 +60,18 @@ class SettingsNotifier extends StateNotifier { } } + Future setExportDirectory(String? path) async { + state = path == null + ? state.copyWith(clearExportDirectory: true) + : state.copyWith(exportDirectory: path); + final prefs = await SharedPreferences.getInstance(); + if (path == null) { + await prefs.remove('exportDirectory'); + } else { + await prefs.setString('exportDirectory', path); + } + } + /// Persist edits to the profile currently identified by [previousName], /// renaming it in place when the name changed. When no profile matches /// [previousName] (e.g. a freshly created one) the profile is added. The diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 6f0d3b3..42e6a8b 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -918,6 +918,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { projectPath: deck.projectPath, exportService: widget.exportService, tlp: deck.tlp, + exportDirectory: ref.read(settingsProvider).exportDirectory, ); } diff --git a/lib/widgets/dialogs/export_dialog.dart b/lib/widgets/dialogs/export_dialog.dart index e4e1955..cb6d6ce 100644 --- a/lib/widgets/dialogs/export_dialog.dart +++ b/lib/widgets/dialogs/export_dialog.dart @@ -15,6 +15,9 @@ class ExportDialog extends StatefulWidget { final ExportService exportService; final TlpLevel tlp; + /// Folder all exports are written to. Null = next to the source deck. + final String? exportDirectory; + const ExportDialog({ super.key, required this.deckPath, @@ -23,6 +26,7 @@ class ExportDialog extends StatefulWidget { required this.projectPath, required this.exportService, this.tlp = TlpLevel.none, + this.exportDirectory, }); static Future show( @@ -33,6 +37,7 @@ class ExportDialog extends StatefulWidget { required String? projectPath, required ExportService exportService, TlpLevel tlp = TlpLevel.none, + String? exportDirectory, }) { return showDialog( context: context, @@ -44,6 +49,7 @@ class ExportDialog extends StatefulWidget { projectPath: projectPath, exportService: exportService, tlp: tlp, + exportDirectory: exportDirectory, ), ); } @@ -60,7 +66,11 @@ class _ExportDialogState extends State { int _done = 0; int _total = 0; - Future _export(ExportFormat format) async { + /// Image quality for PDF export: false = full-resolution PNG, true = a smaller + /// downscaled JPEG handout. + bool _compress = false; + + Future _export(ExportFormat format, {bool compress = false}) async { setState(() { _loading = true; _result = null; @@ -87,6 +97,8 @@ class _ExportDialogState extends State { widget.deckPath, format, images, + compress: compress, + outputDirectory: widget.exportDirectory, ); if (!mounted) return; @@ -175,20 +187,74 @@ class _ExportDialogState extends State { style: TextStyle(fontSize: 12, color: Color(0xFF64748B)), ), ), - ...ExportFormat.values.map((f) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: OutlinedButton.icon( - onPressed: () => _export(f), - icon: Icon(_formatIcon(f)), - label: Text('Exporteer als ${f.label}'), + const Padding( + padding: EdgeInsets.only(bottom: 6), + child: Text( + 'Afbeeldingskwaliteit (PDF)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF475569), ), - ); - }), + ), + ), + SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.image_outlined), + label: Text('Normaal'), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.compress), + label: Text('Gecomprimeerd'), + ), + ], + selected: {_compress}, + onSelectionChanged: (s) => setState(() => _compress = s.first), + showSelectedIcon: false, + style: const ButtonStyle(visualDensity: VisualDensity.compact), + ), + Padding( + padding: const EdgeInsets.only(top: 4, bottom: 8), + child: Text( + _compress + ? 'JPEG op lagere resolutie — bedoeld als handout, veel kleiner ' + 'bestand (apart opgeslagen als “-compact”).' + : 'Verliesvrije afbeeldingen op volledige resolutie.', + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ), + _exportButton( + icon: _formatIcon(ExportFormat.pdf), + label: 'Exporteer als PDF', + onPressed: () => _export(ExportFormat.pdf, compress: _compress), + ), + _exportButton( + icon: _formatIcon(ExportFormat.pptx), + label: 'Exporteer als ${ExportFormat.pptx.label}', + onPressed: () => _export(ExportFormat.pptx), + ), ], ); } + Widget _exportButton({ + required IconData icon, + required String label, + required VoidCallback onPressed, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(label), + ), + ); + } + IconData _formatIcon(ExportFormat f) { switch (f) { case ExportFormat.pdf: diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index 4eec124..4c97e08 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -2,14 +2,12 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../models/settings.dart'; import '../../state/settings_provider.dart'; import '../../state/tabs_provider.dart'; import '../../theme/app_theme.dart'; TextStyle _fontStyle(String font, TextStyle base) { - if (font == 'EB Garamond') return GoogleFonts.ebGaramond(textStyle: base); return base.copyWith(fontFamily: font); } @@ -26,6 +24,7 @@ class SettingsDialog extends ConsumerStatefulWidget { class _SettingsDialogState extends ConsumerState { late String? _homeDirectory; + late String? _exportDirectory; late ThemeProfile _themeProfile; /// The saved name of the profile currently being edited. Used as a stable @@ -59,6 +58,7 @@ class _SettingsDialogState extends ConsumerState { super.initState(); final settings = ref.read(settingsProvider); _homeDirectory = settings.homeDirectory; + _exportDirectory = settings.exportDirectory; // Reflect the profile the open presentation actually uses, falling back to // the globally selected profile when no deck is open. final deckProfile = ref @@ -99,6 +99,14 @@ class _SettingsDialogState extends ConsumerState { if (result != null) setState(() => _homeDirectory = result); } + Future _pickExportDirectory() async { + final result = await FilePicker.getDirectoryPath( + dialogTitle: 'Map voor exports', + initialDirectory: _exportDirectory ?? _homeDirectory, + ); + if (result != null) setState(() => _exportDirectory = result); + } + Future _pickLogo() async { final result = await FilePicker.pickFiles( dialogTitle: 'Logo kiezen', @@ -135,6 +143,7 @@ class _SettingsDialogState extends ConsumerState { footerText: _footerText.text, ); notifier.setHomeDirectory(_homeDirectory); + notifier.setExportDirectory(_exportDirectory); notifier.saveThemeProfile(profile, previousName: _originalName); // Apply the chosen/edited profile to the presentation that is currently @@ -345,6 +354,38 @@ class _SettingsDialogState extends ConsumerState { ), ], ), + const SizedBox(height: 16), + _sectionTitle('Exportmap'), + Row( + children: [ + Expanded( + child: _pathBox( + _exportDirectory ?? 'Naast het presentatiebestand', + muted: _exportDirectory == null, + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _pickExportDirectory, + icon: const Icon(Icons.folder_open, size: 16), + label: const Text('Kiezen'), + ), + if (_exportDirectory != null) + IconButton( + onPressed: () => setState(() => _exportDirectory = null), + icon: const Icon(Icons.clear, size: 18), + tooltip: 'Verwijder exportmap', + ), + ], + ), + const Padding( + padding: EdgeInsets.only(top: 6), + child: Text( + 'Alle exports (PDF/PPTX) worden hier opgeslagen. Niet ingesteld? ' + 'Dan komt de export naast het presentatiebestand te staan.', + style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ), ], ); } diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index cbc3cde..7332338 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:video_player/video_player.dart'; import '../../models/deck.dart'; import '../../models/settings.dart'; @@ -8,9 +7,9 @@ import '../../models/slide.dart'; import '../../theme/app_theme.dart'; import 'inline_markdown.dart'; -/// Returns a TextStyle with the correct font, using GoogleFonts for web fonts. +/// Returns a TextStyle with the correct font. 'EB Garamond' is bundled with the +/// app (see pubspec.yaml); all other fonts resolve to system families. TextStyle _applyFont(String font, TextStyle base) { - if (font == 'EB Garamond') return GoogleFonts.ebGaramond(textStyle: base); return base.copyWith(fontFamily: font); } diff --git a/pubspec.lock b/pubspec.lock index fb79c33..c76541f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -264,14 +264,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" - url: "https://pub.dev" - source: hosted - version: "8.1.0" hooks: dependency: transitive description: @@ -288,14 +280,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.6" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" http_multi_server: dependency: transitive description: @@ -313,7 +297,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: "direct dev" + dependency: "direct main" description: name: image sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce diff --git a/pubspec.yaml b/pubspec.yaml index d8adf10..49ed9e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,6 @@ dependencies: uuid: ^4.5.1 window_manager: ^0.5.1 shared_preferences: ^2.3.3 - google_fonts: ^8.1.0 pasteboard: ^0.5.0 pdf: ^3.12.0 archive: ^4.0.9 @@ -25,12 +24,12 @@ dependencies: characters: ^1.3.0 url_launcher: ^6.3.0 desktop_drop: ^0.5.0 + image: ^4.8.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - image: ^4.8.0 xml: ^6.6.1 flutter: @@ -38,3 +37,9 @@ flutter: assets: - assets/images/de-winter-wittegeheel.png - assets/themes/ocideck.css + fonts: + - family: EB Garamond + fonts: + - asset: assets/fonts/EBGaramond-Variable.ttf + - asset: assets/fonts/EBGaramond-Italic-Variable.ttf + style: italic diff --git a/test/export_dialog_test.dart b/test/export_dialog_test.dart new file mode 100644 index 0000000..02d40ad --- /dev/null +++ b/test/export_dialog_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/services/export_service.dart'; +import 'package:ocideck/widgets/dialogs/export_dialog.dart'; + +void main() { + testWidgets('export dialog offers a normal/compressed image choice', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ExportDialog( + deckPath: '/tmp/deck.md', + slides: const [], + themeProfile: const ThemeProfile(), + projectPath: null, + exportService: ExportService(), + ), + ), + ), + ); + + // The image-quality selector and both options must be visible on open. + expect(find.text('Afbeeldingskwaliteit (PDF)'), findsOneWidget); + expect( + find.widgetWithText(SegmentedButton, 'Normaal'), + findsOneWidget, + ); + expect( + find.widgetWithText(SegmentedButton, 'Gecomprimeerd'), + findsOneWidget, + ); + expect(find.text('Exporteer als PDF'), findsOneWidget); + }); +} diff --git a/test/export_service_test.dart b/test/export_service_test.dart index 3f73458..a38e04f 100644 --- a/test/export_service_test.dart +++ b/test/export_service_test.dart @@ -15,6 +15,31 @@ Uint8List _png() { return Uint8List.fromList(img.encodePng(image)); } +/// A photo-like PNG full of pseudo-random pixels, where lossless PNG is large +/// and JPEG compression pays off — used to assert the compressed PDF shrinks. +Uint8List _noisyPng() { + // Wider than ExportService's compressed target width so the export exercises + // both the JPEG re-encode and the downscale path. + final image = img.Image(width: 1600, height: 900); + var seed = 1234567; + for (var y = 0; y < image.height; y++) { + for (var x = 0; x < image.width; x++) { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + image.setPixelRgb( + x, + y, + seed & 0xff, + (seed >> 8) & 0xff, + (seed >> 16) & 0xff, + ); + } + } + return Uint8List.fromList(img.encodePng(image)); +} + +/// Matches a leading UTC timestamp prefix: `YYYYMMDDHHMMSS ` (e.g. `20260603124547 `). +final _dtgPrefix = RegExp(r'^\d{14} '); + void main() { late Directory tmp; late ExportService service; @@ -74,6 +99,75 @@ void main() { } }); + test('compressed PDF is written as a separate -compact file', () async { + final images = [_png(), _png()]; + final r = await service.export( + deckPath(), + ExportFormat.pdf, + images, + compress: true, + ); + + expect(r.success, isTrue, reason: r.error); + expect(p.basename(r.outputPath!), endsWith(' deck-compact.pdf')); + expect(p.basename(r.outputPath!), matches(_dtgPrefix)); + final bytes = await File(r.outputPath!).readAsBytes(); + expect(String.fromCharCodes(bytes.take(4)), '%PDF'); + }); + + test('compressed PDF is smaller than the lossless PDF', () async { + final images = [_noisyPng(), _noisyPng()]; + + final lossless = await service.export(deckPath(), ExportFormat.pdf, images); + final compressed = await service.export( + deckPath(), + ExportFormat.pdf, + images, + compress: true, + ); + + expect(lossless.success, isTrue, reason: lossless.error); + expect(compressed.success, isTrue, reason: compressed.error); + + final losslessSize = await File(lossless.outputPath!).length(); + final compressedSize = await File(compressed.outputPath!).length(); + expect(compressedSize, lessThan(losslessSize)); + }); + + test('writes into outputDirectory and creates it when missing', () async { + final exportDir = p.join(tmp.path, 'exports', 'pdf'); + expect(Directory(exportDir).existsSync(), isFalse); + + final r = await service.export(deckPath(), ExportFormat.pdf, [ + _png(), + ], outputDirectory: exportDir); + + expect(r.success, isTrue, reason: r.error); + expect(p.dirname(r.outputPath!), exportDir); + expect(p.basename(r.outputPath!), endsWith(' deck.pdf')); + expect(p.basename(r.outputPath!), matches(_dtgPrefix)); + expect(File(r.outputPath!).existsSync(), isTrue); + }); + + test('without outputDirectory the export lands next to the deck', () async { + final r = await service.export(deckPath(), ExportFormat.pdf, [_png()]); + + expect(r.success, isTrue, reason: r.error); + expect(p.dirname(r.outputPath!), tmp.path); + }); + + test('natoDtg formats a UTC YYYYMMDDHHMMSS timestamp', () { + final t = DateTime.utc(2026, 6, 3, 12, 45, 47); + expect(ExportService.natoDtg(t), '20260603124547'); + }); + + test('natoDtg converts non-UTC input to UTC (zone-independent)', () { + final utc = DateTime.utc(2026, 1, 5, 9, 7, 3); + // toLocal() then format must still yield the original UTC timestamp, + // whatever the machine's time zone is. + expect(ExportService.natoDtg(utc.toLocal()), '20260105090703'); + }); + test('fails gracefully when there are no slides', () async { final r = await service.export(deckPath(), ExportFormat.pdf, const []); expect(r.success, isFalse);