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 {
|
||||
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
|
||||
: [
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -918,6 +918,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
projectPath: deck.projectPath,
|
||||
exportService: widget.exportService,
|
||||
tlp: deck.tlp,
|
||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
18
pubspec.lock
18
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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));
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue