Ocideck/lib/services/export_service.dart
Brenno de Winter e63679978b 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>
2026-06-03 15:03:27 +02:00

418 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
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;
enum ExportFormat { pdf, pptx }
extension ExportFormatExtension on ExportFormat {
String get label {
switch (this) {
case ExportFormat.pdf:
return 'PDF';
case ExportFormat.pptx:
return 'PowerPoint (PPTX)';
}
}
String get extension {
switch (this) {
case ExportFormat.pdf:
return '.pdf';
case ExportFormat.pptx:
return '.pptx';
}
}
}
class ExportResult {
final bool success;
final String? outputPath;
final String? error;
const ExportResult._({required this.success, this.outputPath, this.error});
factory ExportResult.ok(String path) =>
ExportResult._(success: true, outputPath: path);
factory ExportResult.fail(String error) =>
ExportResult._(success: false, error: error);
}
/// Builds PDF and PPTX files from pre-rendered slide images (WYSIWYG export).
/// Slides are expected to be 16:9 PNG bytes (see [SlideRasterizer]).
class ExportService {
// 16:9 widescreen slide size in EMU (English Metric Units): 13.333" x 7.5".
static const int _slideWidthEmu = 12192000;
static const int _slideHeightEmu = 6858000;
/// 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, {
bool compress = false,
String? outputDirectory,
}) async {
if (images.isEmpty) {
return ExportResult.fail('Geen slides om te exporteren.');
}
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, compress: compress);
case ExportFormat.pptx:
bytes = _buildPptx(images);
}
await File(outputPath).writeAsBytes(bytes, flush: true);
return ExportResult.ok(outputPath);
} catch (e) {
return ExportResult.fail('Export fout: $e');
}
}
// ── PDF ───────────────────────────────────────────────────────────────────
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) {
// 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,
build: (_) => pw.Image(image, fit: pw.BoxFit.fill),
),
);
}
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) {
final archive = Archive();
void addText(String name, String content) {
final data = utf8Bytes(content);
archive.add(ArchiveFile(name, data.length, data));
}
final slideCount = images.length;
addText('[Content_Types].xml', _contentTypes(slideCount));
addText('_rels/.rels', _rootRels());
addText('ppt/presentation.xml', _presentationXml(slideCount));
addText('ppt/_rels/presentation.xml.rels', _presentationRels(slideCount));
addText('ppt/presProps.xml', _presProps());
addText('ppt/theme/theme1.xml', _theme1());
addText('ppt/slideMasters/slideMaster1.xml', _slideMaster());
addText('ppt/slideMasters/_rels/slideMaster1.xml.rels', _slideMasterRels());
addText('ppt/slideLayouts/slideLayout1.xml', _slideLayout());
addText('ppt/slideLayouts/_rels/slideLayout1.xml.rels', _slideLayoutRels());
for (var i = 0; i < slideCount; i++) {
final n = i + 1;
addText('ppt/slides/slide$n.xml', _slideXml());
addText('ppt/slides/_rels/slide$n.xml.rels', _slideRels(n));
final png = images[i];
archive.add(ArchiveFile('ppt/media/image$n.png', png.length, png));
}
return ZipEncoder().encodeBytes(archive);
}
static List<int> utf8Bytes(String s) => utf8.encode(s);
String _contentTypes(int count) {
final overrides = StringBuffer();
for (var i = 1; i <= count; i++) {
overrides.write(
'<Override PartName="/ppt/slides/slide$i.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>',
);
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Default Extension="png" ContentType="image/png"/>'
'<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
'<Override PartName="/ppt/presProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"/>'
'<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>'
'<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>'
'<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>'
'$overrides'
'</Types>';
}
String _rootRels() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>'
'</Relationships>';
}
String _presentationXml(int count) {
final sldIds = StringBuffer();
for (var i = 0; i < count; i++) {
// Slide relationship ids start at rId2 (rId1 = master).
sldIds.write('<p:sldId id="${256 + i}" r:id="rId${i + 2}"/>');
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
'<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>'
'<p:sldIdLst>$sldIds</p:sldIdLst>'
'<p:sldSz cx="$_slideWidthEmu" cy="$_slideHeightEmu" type="screen16x9"/>'
'<p:notesSz cx="6858000" cy="9144000"/>'
'</p:presentation>';
}
String _presentationRels(int count) {
final rels = StringBuffer();
rels.write(
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" '
'Target="slideMasters/slideMaster1.xml"/>',
);
for (var i = 0; i < count; i++) {
final n = i + 1;
rels.write(
'<Relationship Id="rId${i + 2}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
'Target="slides/slide$n.xml"/>',
);
}
final presPropsId = 'rId${count + 2}';
final themeId = 'rId${count + 3}';
rels.write(
'<Relationship Id="$presPropsId" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps" '
'Target="presProps.xml"/>',
);
rels.write(
'<Relationship Id="$themeId" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" '
'Target="theme/theme1.xml"/>',
);
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'$rels'
'</Relationships>';
}
String _presProps() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<p:presentationPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>';
}
String _slideMaster() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
'<p:cSld>'
'<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>'
'${_emptySpTree()}'
'</p:cSld>'
'<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" '
'accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" '
'accent6="accent6" hlink="hlink" folHlink="folHlink"/>'
'<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>'
'<p:txStyles><p:titleStyle/><p:bodyStyle/><p:otherStyle/></p:txStyles>'
'</p:sldMaster>';
}
String _slideMasterRels() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" '
'Target="../slideLayouts/slideLayout1.xml"/>'
'<Relationship Id="rId2" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" '
'Target="../theme/theme1.xml"/>'
'</Relationships>';
}
String _slideLayout() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
'type="blank" preserve="1">'
'<p:cSld name="Leeg">${_emptySpTree()}</p:cSld>'
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>'
'</p:sldLayout>';
}
String _slideLayoutRels() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" '
'Target="../slideMasters/slideMaster1.xml"/>'
'</Relationships>';
}
String _slideXml() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
'<p:cSld><p:spTree>'
'<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
'<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
'<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>'
'<p:pic>'
'<p:nvPicPr><p:cNvPr id="2" name="Slide"/>'
'<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>'
'<p:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>'
'<p:spPr><a:xfrm><a:off x="0" y="0"/>'
'<a:ext cx="$_slideWidthEmu" cy="$_slideHeightEmu"/></a:xfrm>'
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>'
'</p:pic>'
'</p:spTree></p:cSld>'
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>'
'</p:sld>';
}
String _slideRels(int n) {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" '
'Target="../slideLayouts/slideLayout1.xml"/>'
'<Relationship Id="rId2" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
'Target="../media/image$n.png"/>'
'</Relationships>';
}
String _emptySpTree() {
return '<p:spTree>'
'<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
'<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
'<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>'
'</p:spTree>';
}
String _theme1() {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office">'
'<a:themeElements>'
'<a:clrScheme name="Office">'
'<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>'
'<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>'
'<a:dk2><a:srgbClr val="44546A"/></a:dk2>'
'<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>'
'<a:accent1><a:srgbClr val="4472C4"/></a:accent1>'
'<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>'
'<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>'
'<a:accent4><a:srgbClr val="FFC000"/></a:accent4>'
'<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>'
'<a:accent6><a:srgbClr val="70AD47"/></a:accent6>'
'<a:hlink><a:srgbClr val="0563C1"/></a:hlink>'
'<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>'
'</a:clrScheme>'
'<a:fontScheme name="Office">'
'<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>'
'<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>'
'</a:fontScheme>'
'<a:fmtScheme name="Office">'
'<a:fillStyleLst>'
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
'</a:fillStyleLst>'
'<a:lnStyleLst>'
'<a:ln w="6350" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>'
'<a:ln w="12700" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>'
'<a:ln w="19050" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>'
'</a:lnStyleLst>'
'<a:effectStyleLst>'
'<a:effectStyle><a:effectLst/></a:effectStyle>'
'<a:effectStyle><a:effectLst/></a:effectStyle>'
'<a:effectStyle><a:effectLst/></a:effectStyle>'
'</a:effectStyleLst>'
'<a:bgFillStyleLst>'
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
'</a:bgFillStyleLst>'
'</a:fmtScheme>'
'</a:themeElements>'
'</a:theme>';
}
}