Bundle EB Garamond font and add PDF export options

Privacy: replace the runtime google_fonts fetch with a locally bundled
EB Garamond (variable TTFs + OFL license), so the app no longer contacts
Google's servers. Removes the google_fonts dependency.

PDF export:
- Add a normal/compressed image-quality choice in the export dialog.
  Compressed re-encodes slides as JPEG (q60) at 1280px for a small handout,
  saved as a separate "-compact" file.
- Add a configurable export directory (Settings → Exportmap); when unset,
  exports land next to the deck as before.
- Prefix every export with a UTC timestamp (YYYYMMDDHHMMSS) so exports sort
  chronologically and never overwrite each other.

Tests: export service (compression, output dir, timestamp) and an export
dialog widget test asserting the quality choice renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-03 15:03:27 +02:00
parent 85ff813568
commit e63679978b
15 changed files with 448 additions and 55 deletions

Binary file not shown.

Binary file not shown.

93
assets/fonts/OFL.txt Normal file
View file

@ -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.

View file

@ -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<ThemeProfile> themeProfiles;
final String selectedThemeProfileName;
final List<String> 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<ThemeProfile>? themeProfiles,
String? selectedThemeProfileName,
List<String>? 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
: [

View file

@ -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 (0100) 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<ExportResult> export(
String deckPath,
ExportFormat format,
List<Uint8List> images,
) async {
List<Uint8List> 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<Uint8List> _buildPdf(List<Uint8List> images) async {
Future<Uint8List> _buildPdf(
List<Uint8List> 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<Uint8List> images) {

View file

@ -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<void> _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.

View file

@ -30,6 +30,7 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
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<AppSettings> {
}
}
Future<void> 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

View file

@ -918,6 +918,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
projectPath: deck.projectPath,
exportService: widget.exportService,
tlp: deck.tlp,
exportDirectory: ref.read(settingsProvider).exportDirectory,
);
}

View file

@ -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<void> 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<ExportDialog> {
int _done = 0;
int _total = 0;
Future<void> _export(ExportFormat format) async {
/// Image quality for PDF export: false = full-resolution PNG, true = a smaller
/// downscaled JPEG handout.
bool _compress = false;
Future<void> _export(ExportFormat format, {bool compress = false}) async {
setState(() {
_loading = true;
_result = null;
@ -87,6 +97,8 @@ class _ExportDialogState extends State<ExportDialog> {
widget.deckPath,
format,
images,
compress: compress,
outputDirectory: widget.exportDirectory,
);
if (!mounted) return;
@ -175,20 +187,74 @@ class _ExportDialogState extends State<ExportDialog> {
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<bool>(
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:

View file

@ -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<SettingsDialog> {
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<SettingsDialog> {
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<SettingsDialog> {
if (result != null) setState(() => _homeDirectory = result);
}
Future<void> _pickExportDirectory() async {
final result = await FilePicker.getDirectoryPath(
dialogTitle: 'Map voor exports',
initialDirectory: _exportDirectory ?? _homeDirectory,
);
if (result != null) setState(() => _exportDirectory = result);
}
Future<void> _pickLogo() async {
final result = await FilePicker.pickFiles(
dialogTitle: 'Logo kiezen',
@ -135,6 +143,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
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<SettingsDialog> {
),
],
),
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)),
),
),
],
);
}

View file

@ -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);
}

View file

@ -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

View file

@ -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

View file

@ -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<bool>, 'Normaal'),
findsOneWidget,
);
expect(
find.widgetWithText(SegmentedButton<bool>, 'Gecomprimeerd'),
findsOneWidget,
);
expect(find.text('Exporteer als PDF'), findsOneWidget);
});
}

View file

@ -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);