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:
parent
85ff813568
commit
e63679978b
15 changed files with 448 additions and 55 deletions
BIN
assets/fonts/EBGaramond-Italic-Variable.ttf
Normal file
BIN
assets/fonts/EBGaramond-Italic-Variable.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/EBGaramond-Variable.ttf
Normal file
BIN
assets/fonts/EBGaramond-Variable.ttf
Normal file
Binary file not shown.
93
assets/fonts/OFL.txt
Normal file
93
assets/fonts/OFL.txt
Normal 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.
|
||||||
|
|
@ -146,12 +146,17 @@ class ThemeProfile {
|
||||||
|
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
final String? homeDirectory;
|
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 List<ThemeProfile> themeProfiles;
|
||||||
final String selectedThemeProfileName;
|
final String selectedThemeProfileName;
|
||||||
final List<String> recentFiles;
|
final List<String> recentFiles;
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.homeDirectory,
|
this.homeDirectory,
|
||||||
|
this.exportDirectory,
|
||||||
this.themeProfiles = const [ThemeProfile()],
|
this.themeProfiles = const [ThemeProfile()],
|
||||||
this.selectedThemeProfileName = 'Standaard',
|
this.selectedThemeProfileName = 'Standaard',
|
||||||
this.recentFiles = const [],
|
this.recentFiles = const [],
|
||||||
|
|
@ -180,17 +185,22 @@ class AppSettings {
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
String? homeDirectory,
|
String? homeDirectory,
|
||||||
|
String? exportDirectory,
|
||||||
ThemeProfile? themeProfile,
|
ThemeProfile? themeProfile,
|
||||||
List<ThemeProfile>? themeProfiles,
|
List<ThemeProfile>? themeProfiles,
|
||||||
String? selectedThemeProfileName,
|
String? selectedThemeProfileName,
|
||||||
List<String>? recentFiles,
|
List<String>? recentFiles,
|
||||||
bool clearHomeDirectory = false,
|
bool clearHomeDirectory = false,
|
||||||
|
bool clearExportDirectory = false,
|
||||||
}) {
|
}) {
|
||||||
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
homeDirectory: clearHomeDirectory
|
homeDirectory: clearHomeDirectory
|
||||||
? null
|
? null
|
||||||
: (homeDirectory ?? this.homeDirectory),
|
: (homeDirectory ?? this.homeDirectory),
|
||||||
|
exportDirectory: clearExportDirectory
|
||||||
|
? null
|
||||||
|
: (exportDirectory ?? this.exportDirectory),
|
||||||
themeProfiles: themeProfile == null
|
themeProfiles: themeProfile == null
|
||||||
? nextProfiles
|
? nextProfiles
|
||||||
: [
|
: [
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:archive/archive.dart';
|
import 'package:archive/archive.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
@ -49,22 +50,63 @@ class ExportService {
|
||||||
static const int _slideWidthEmu = 12192000;
|
static const int _slideWidthEmu = 12192000;
|
||||||
static const int _slideHeightEmu = 6858000;
|
static const int _slideHeightEmu = 6858000;
|
||||||
|
|
||||||
/// Write [images] to a file derived from [deckPath] (same folder/base name)
|
/// JPEG quality (0–100) used when a PDF is exported in compressed mode.
|
||||||
/// in the requested [format].
|
/// 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(
|
Future<ExportResult> export(
|
||||||
String deckPath,
|
String deckPath,
|
||||||
ExportFormat format,
|
ExportFormat format,
|
||||||
List<Uint8List> images,
|
List<Uint8List> images, {
|
||||||
) async {
|
bool compress = false,
|
||||||
|
String? outputDirectory,
|
||||||
|
}) async {
|
||||||
if (images.isEmpty) {
|
if (images.isEmpty) {
|
||||||
return ExportResult.fail('Geen slides om te exporteren.');
|
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 {
|
try {
|
||||||
|
await Directory(dir).create(recursive: true);
|
||||||
final Uint8List bytes;
|
final Uint8List bytes;
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case ExportFormat.pdf:
|
case ExportFormat.pdf:
|
||||||
bytes = await _buildPdf(images);
|
bytes = await _buildPdf(images, compress: compress);
|
||||||
case ExportFormat.pptx:
|
case ExportFormat.pptx:
|
||||||
bytes = _buildPptx(images);
|
bytes = _buildPptx(images);
|
||||||
}
|
}
|
||||||
|
|
@ -77,12 +119,17 @@ class ExportService {
|
||||||
|
|
||||||
// ── PDF ───────────────────────────────────────────────────────────────────
|
// ── PDF ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<Uint8List> _buildPdf(List<Uint8List> images) async {
|
Future<Uint8List> _buildPdf(
|
||||||
|
List<Uint8List> images, {
|
||||||
|
bool compress = false,
|
||||||
|
}) async {
|
||||||
final doc = pw.Document();
|
final doc = pw.Document();
|
||||||
// Page size in points; only the ratio matters for a full-bleed image.
|
// Page size in points; only the ratio matters for a full-bleed image.
|
||||||
const format = PdfPageFormat(1280, 720, marginAll: 0);
|
const format = PdfPageFormat(1280, 720, marginAll: 0);
|
||||||
for (final png in images) {
|
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(
|
doc.addPage(
|
||||||
pw.Page(
|
pw.Page(
|
||||||
pageFormat: format,
|
pageFormat: format,
|
||||||
|
|
@ -93,6 +140,22 @@ class ExportService {
|
||||||
return doc.save();
|
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) ─────────────────────────────────────────────────
|
// ── PPTX (Office Open XML) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
Uint8List _buildPptx(List<Uint8List> images) {
|
Uint8List _buildPptx(List<Uint8List> images) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
|
|
@ -39,9 +38,6 @@ class SlideRasterizer {
|
||||||
final overlay = Overlay.of(context, rootOverlay: true);
|
final overlay = Overlay.of(context, rootOverlay: true);
|
||||||
final pixelRatio = targetWidth / logicalSize.width;
|
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
|
// The global image cache has a modest default budget (~100 MB / 1000
|
||||||
// entries). A deck with several full-resolution photos blows past that, so
|
// entries). A deck with several full-resolution photos blows past that, so
|
||||||
// images decoded for earlier slides get evicted before later slides are
|
// 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
|
/// Decode and cache the given (already resolved) image paths, awaiting all of
|
||||||
/// them. Nulls and duplicates are ignored; decode errors are swallowed so a
|
/// them. Nulls and duplicates are ignored; decode errors are swallowed so a
|
||||||
/// single missing file never aborts the whole export.
|
/// single missing file never aborts the whole export.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
final profiles = _uniqueProfiles(loadedProfiles);
|
final profiles = _uniqueProfiles(loadedProfiles);
|
||||||
state = AppSettings(
|
state = AppSettings(
|
||||||
homeDirectory: prefs.getString('homeDirectory'),
|
homeDirectory: prefs.getString('homeDirectory'),
|
||||||
|
exportDirectory: prefs.getString('exportDirectory'),
|
||||||
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
|
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
|
||||||
selectedThemeProfileName:
|
selectedThemeProfileName:
|
||||||
prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
|
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],
|
/// Persist edits to the profile currently identified by [previousName],
|
||||||
/// renaming it in place when the name changed. When no profile matches
|
/// renaming it in place when the name changed. When no profile matches
|
||||||
/// [previousName] (e.g. a freshly created one) the profile is added. The
|
/// [previousName] (e.g. a freshly created one) the profile is added. The
|
||||||
|
|
|
||||||
|
|
@ -918,6 +918,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
projectPath: deck.projectPath,
|
projectPath: deck.projectPath,
|
||||||
exportService: widget.exportService,
|
exportService: widget.exportService,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ class ExportDialog extends StatefulWidget {
|
||||||
final ExportService exportService;
|
final ExportService exportService;
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
/// Folder all exports are written to. Null = next to the source deck.
|
||||||
|
final String? exportDirectory;
|
||||||
|
|
||||||
const ExportDialog({
|
const ExportDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.deckPath,
|
required this.deckPath,
|
||||||
|
|
@ -23,6 +26,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required this.projectPath,
|
required this.projectPath,
|
||||||
required this.exportService,
|
required this.exportService,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.exportDirectory,
|
||||||
});
|
});
|
||||||
|
|
||||||
static Future<void> show(
|
static Future<void> show(
|
||||||
|
|
@ -33,6 +37,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required String? projectPath,
|
required String? projectPath,
|
||||||
required ExportService exportService,
|
required ExportService exportService,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
String? exportDirectory,
|
||||||
}) {
|
}) {
|
||||||
return showDialog(
|
return showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -44,6 +49,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
projectPath: projectPath,
|
projectPath: projectPath,
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
exportDirectory: exportDirectory,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +66,11 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
int _done = 0;
|
int _done = 0;
|
||||||
int _total = 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(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_result = null;
|
_result = null;
|
||||||
|
|
@ -87,6 +97,8 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
widget.deckPath,
|
widget.deckPath,
|
||||||
format,
|
format,
|
||||||
images,
|
images,
|
||||||
|
compress: compress,
|
||||||
|
outputDirectory: widget.exportDirectory,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -175,18 +187,72 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
style: TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
style: TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...ExportFormat.values.map((f) {
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _export(f),
|
onPressed: onPressed,
|
||||||
icon: Icon(_formatIcon(f)),
|
icon: Icon(icon),
|
||||||
label: Text('Exporteer als ${f.label}'),
|
label: Text(label),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _formatIcon(ExportFormat f) {
|
IconData _formatIcon(ExportFormat f) {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,12 @@ import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../state/settings_provider.dart';
|
import '../../state/settings_provider.dart';
|
||||||
import '../../state/tabs_provider.dart';
|
import '../../state/tabs_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
|
||||||
TextStyle _fontStyle(String font, TextStyle base) {
|
TextStyle _fontStyle(String font, TextStyle base) {
|
||||||
if (font == 'EB Garamond') return GoogleFonts.ebGaramond(textStyle: base);
|
|
||||||
return base.copyWith(fontFamily: font);
|
return base.copyWith(fontFamily: font);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,6 +24,7 @@ class SettingsDialog extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
late String? _homeDirectory;
|
late String? _homeDirectory;
|
||||||
|
late String? _exportDirectory;
|
||||||
late ThemeProfile _themeProfile;
|
late ThemeProfile _themeProfile;
|
||||||
|
|
||||||
/// The saved name of the profile currently being edited. Used as a stable
|
/// The saved name of the profile currently being edited. Used as a stable
|
||||||
|
|
@ -59,6 +58,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
super.initState();
|
super.initState();
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
_homeDirectory = settings.homeDirectory;
|
_homeDirectory = settings.homeDirectory;
|
||||||
|
_exportDirectory = settings.exportDirectory;
|
||||||
// Reflect the profile the open presentation actually uses, falling back to
|
// Reflect the profile the open presentation actually uses, falling back to
|
||||||
// the globally selected profile when no deck is open.
|
// the globally selected profile when no deck is open.
|
||||||
final deckProfile = ref
|
final deckProfile = ref
|
||||||
|
|
@ -99,6 +99,14 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
if (result != null) setState(() => _homeDirectory = result);
|
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 {
|
Future<void> _pickLogo() async {
|
||||||
final result = await FilePicker.pickFiles(
|
final result = await FilePicker.pickFiles(
|
||||||
dialogTitle: 'Logo kiezen',
|
dialogTitle: 'Logo kiezen',
|
||||||
|
|
@ -135,6 +143,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
footerText: _footerText.text,
|
footerText: _footerText.text,
|
||||||
);
|
);
|
||||||
notifier.setHomeDirectory(_homeDirectory);
|
notifier.setHomeDirectory(_homeDirectory);
|
||||||
|
notifier.setExportDirectory(_exportDirectory);
|
||||||
notifier.saveThemeProfile(profile, previousName: _originalName);
|
notifier.saveThemeProfile(profile, previousName: _originalName);
|
||||||
|
|
||||||
// Apply the chosen/edited profile to the presentation that is currently
|
// 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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
|
|
@ -8,9 +7,9 @@ import '../../models/slide.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import 'inline_markdown.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) {
|
TextStyle _applyFont(String font, TextStyle base) {
|
||||||
if (font == 'EB Garamond') return GoogleFonts.ebGaramond(textStyle: base);
|
|
||||||
return base.copyWith(fontFamily: font);
|
return base.copyWith(fontFamily: font);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
18
pubspec.lock
18
pubspec.lock
|
|
@ -264,14 +264,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
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:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -288,14 +280,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.6"
|
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:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -313,7 +297,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
image:
|
||||||
dependency: "direct dev"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ dependencies:
|
||||||
uuid: ^4.5.1
|
uuid: ^4.5.1
|
||||||
window_manager: ^0.5.1
|
window_manager: ^0.5.1
|
||||||
shared_preferences: ^2.3.3
|
shared_preferences: ^2.3.3
|
||||||
google_fonts: ^8.1.0
|
|
||||||
pasteboard: ^0.5.0
|
pasteboard: ^0.5.0
|
||||||
pdf: ^3.12.0
|
pdf: ^3.12.0
|
||||||
archive: ^4.0.9
|
archive: ^4.0.9
|
||||||
|
|
@ -25,12 +24,12 @@ dependencies:
|
||||||
characters: ^1.3.0
|
characters: ^1.3.0
|
||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
desktop_drop: ^0.5.0
|
desktop_drop: ^0.5.0
|
||||||
|
image: ^4.8.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
image: ^4.8.0
|
|
||||||
xml: ^6.6.1
|
xml: ^6.6.1
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
|
|
@ -38,3 +37,9 @@ flutter:
|
||||||
assets:
|
assets:
|
||||||
- assets/images/de-winter-wittegeheel.png
|
- assets/images/de-winter-wittegeheel.png
|
||||||
- assets/themes/ocideck.css
|
- assets/themes/ocideck.css
|
||||||
|
fonts:
|
||||||
|
- family: EB Garamond
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/EBGaramond-Variable.ttf
|
||||||
|
- asset: assets/fonts/EBGaramond-Italic-Variable.ttf
|
||||||
|
style: italic
|
||||||
|
|
|
||||||
37
test/export_dialog_test.dart
Normal file
37
test/export_dialog_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,31 @@ Uint8List _png() {
|
||||||
return Uint8List.fromList(img.encodePng(image));
|
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() {
|
void main() {
|
||||||
late Directory tmp;
|
late Directory tmp;
|
||||||
late ExportService service;
|
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 {
|
test('fails gracefully when there are no slides', () async {
|
||||||
final r = await service.export(deckPath(), ExportFormat.pdf, const []);
|
final r = await service.export(deckPath(), ExportFormat.pdf, const []);
|
||||||
expect(r.success, isFalse);
|
expect(r.success, isFalse);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue