Ocideck/lib/services/file_service.dart
Brenno de Winter ee9e2bfc58 Add logger; replace silent catch(_) with logged fallbacks (#2)
Introduce lib/utils/log.dart (logError / logWarning over dart:developer) and
route all 53 previously-bare `catch (_)` blocks through it. Behaviour is
unchanged: every fallback still fails soft (a broken sidecar, unreadable file
or unsupported platform must never crash a presentation) but the cause is now
observable. logError is used for unexpected parse/IO failures, logWarning for
expected best-effort fallbacks; no deck or file contents are ever logged.

Note: file_service, markdown_service, marp_html_service, fullscreen_presenter,
image_carousel_picker and url_launcher_util also carried pre-existing local
changes, bundled here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:39 +02:00

936 lines
30 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
import 'package:flutter/services.dart' show rootBundle;
import '../models/deck.dart';
import '../l10n/app_localizations.dart';
import '../models/settings.dart';
import '../models/chart.dart';
import '../models/slide.dart';
import '../utils/log.dart';
import 'annotation_codec.dart';
import 'caption_service.dart';
import 'image_service.dart';
import 'markdown_service.dart';
/// A presentation found on disk while scanning a directory.
class ScannedPresentation {
final String path;
final String fileName;
final Deck deck;
/// The raw markdown source, kept for maximal full-text search.
final String content;
const ScannedPresentation({
required this.path,
required this.fileName,
required this.deck,
this.content = '',
});
}
class _LogoProjectAsset {
final ThemeProfile profile;
final String? cssUrl;
const _LogoProjectAsset(this.profile, this.cssUrl);
}
class FileService {
final MarkdownService _md;
final ImageService _img;
final ThemeProfile Function() _themeProfile;
final String Function() _languageCode;
final String? Function() _homeDirectory;
final CaptionService _captions = CaptionService();
FileService(
this._md,
this._img,
this._themeProfile, {
String Function()? languageCode,
String? Function()? homeDirectory,
}) : _languageCode = languageCode ?? (() => 'nl'),
_homeDirectory = homeDirectory ?? (() => null);
ThemeProfile get currentThemeProfile => resolveThemeProfile(_themeProfile());
/// The user's active style profile, resolved for [projectPath]. Styling is no
/// longer read from the markdown (the file holds only content); the app
/// applies the current profile whenever a deck is opened.
ThemeProfile activeProfileFor({String? projectPath}) =>
resolveThemeProfile(_themeProfile(), projectPath: projectPath);
/// Resolve a project-relative [path] to an absolute path strictly inside
/// [projectPath], or null for absolute paths or `../` escapes. Used for file
/// references an untrusted deck controls (e.g. a chart's linked CSV) so it
/// can't read arbitrary files outside its own folder.
static String? _projectFile(String? projectPath, String path) {
if (projectPath == null || path.trim().isEmpty || p.isAbsolute(path)) {
return null;
}
final abs = p.normalize(p.join(projectPath, path));
if (abs != projectPath && !p.isWithin(projectPath, abs)) return null;
return abs;
}
ThemeProfile resolveThemeProfile(
ThemeProfile profile, {
String? projectPath,
}) {
final logoPath = profile.logoPath;
if (logoPath == null || logoPath.trim().isEmpty || p.isAbsolute(logoPath)) {
return profile;
}
final bases = [?projectPath, ?_homeDirectory()];
for (final base in bases) {
final candidate = p.normalize(p.join(base, logoPath));
if (File(candidate).existsSync()) {
return profile.copyWith(logoPath: candidate);
}
}
return profile;
}
String _d(String text) => AppLocalizations.sourceFor(_languageCode(), text);
static const _ignoredDirs = {
'images',
'logos',
'themes',
'node_modules',
'build',
'.git',
'.dart_tool',
};
/// Recursively scan [directory] for Marp markdown presentations and parse
/// them into decks. [excludePath] (typically the currently open file) is
/// skipped. Directories such as images/ and themes/ are ignored, and the
/// walk is bounded by [maxDepth] to keep large home folders responsive.
Future<List<ScannedPresentation>> scanPresentations(
String directory, {
String? excludePath,
int maxDepth = 4,
}) async {
final root = Directory(directory);
if (!await root.exists()) return [];
final results = <ScannedPresentation>[];
Future<void> walk(Directory dir, int depth) async {
List<FileSystemEntity> entries;
try {
entries = await dir.list(followLinks: false).toList();
} catch (e) {
logWarning(
'FileService.scanPresentations: directory listing failed',
e,
);
return;
}
for (final entity in entries) {
if (entity is File) {
if (!entity.path.toLowerCase().endsWith('.md')) continue;
if (excludePath != null && p.equals(entity.path, excludePath)) {
continue;
}
String content;
try {
content = await entity.readAsString();
} catch (e) {
logWarning('FileService.scanPresentations: file not readable', e);
continue;
}
final deck = await openDeck(entity.path, content: content);
if (deck != null && deck.slides.isNotEmpty) {
results.add(
ScannedPresentation(
path: entity.path,
fileName: p.basename(entity.path),
deck: deck,
content: content,
),
);
}
} else if (entity is Directory && depth < maxDepth) {
final name = p.basename(entity.path);
if (_ignoredDirs.contains(name) || name.startsWith('.')) continue;
await walk(entity, depth + 1);
}
}
}
await walk(root, 0);
results.sort(
(a, b) =>
a.deck.title.toLowerCase().compareTo(b.deck.title.toLowerCase()),
);
return results;
}
Future<String?> pickMarkdownFile({String? initialDirectory}) async {
final result = await FilePicker.pickFiles(
dialogTitle: _d('Presentatie openen'),
type: FileType.custom,
allowedExtensions: ['md'],
initialDirectory: initialDirectory,
);
return result?.files.single.path;
}
Future<Deck?> openDeck(String filePath, {String? content}) async {
String raw;
if (content != null) {
raw = content;
} else {
final file = File(filePath);
if (!await file.exists()) return null;
raw = await file.readAsString();
}
final parsed = _md.parseDeck(raw, filePath: filePath);
if (parsed == null) return null;
// The file carries only content; apply the active style profile on open.
final deck = parsed.copyWith(
themeProfile: activeProfileFor(projectPath: parsed.projectPath),
);
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
// Re-attach the separate annotation layer from its sidecar, if present.
if (content == null) {
final sidecar = File(_sidecarPath(filePath));
if (await sidecar.exists()) {
try {
final map = AnnotationCodec.decode(
await sidecar.readAsString(),
hydrated.slides,
);
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
} catch (e) {
// A broken sidecar must never block opening the deck.
logWarning('FileService.openDeck: annotation sidecar unreadable', e);
}
}
}
return hydrated;
}
/// Path of the annotation sidecar next to a deck `<name>.md` → `<name>.ink.json`.
String _sidecarPath(String mdPath) => p.setExtension(mdPath, '.ink.json');
/// Write the annotation sidecar next to [filePath], or remove it when empty.
Future<void> _writeSidecar(Deck deck, String filePath) async {
final sidecar = File(_sidecarPath(filePath));
final json = AnnotationCodec.encode(deck.slides, deck.annotations);
if (json == null) {
if (await sidecar.exists()) await sidecar.delete();
} else {
await sidecar.writeAsString(json, flush: true);
}
}
/// Load the external CSV of any chart slide that links one, inlining the data
/// into the in-memory spec so the renderer has it. The markdown on disk keeps
/// only the `source` reference (data is stripped again on save).
Future<Deck> _hydrateCharts(Deck deck) async {
if (deck.projectPath == null) return deck;
var changed = false;
final slides = <Slide>[];
for (final s in deck.slides) {
if (s.type != SlideType.chart) {
slides.add(s);
continue;
}
final spec = ChartSpec.parse(s.customMarkdown);
if (spec.source == null || spec.hasInlineData) {
slides.add(s);
continue;
}
// A chart's CSV link must stay inside the project (no absolute paths or
// `../` escapes) — otherwise an untrusted deck could read arbitrary files.
final abs = _projectFile(deck.projectPath, spec.source!);
final file = abs == null ? null : File(abs);
if (file == null || !await file.exists()) {
slides.add(s);
continue;
}
try {
final csv = await file.readAsString();
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
changed = true;
} catch (e) {
logWarning('FileService._hydrateCharts: chart CSV unreadable', e);
slides.add(s);
}
}
return changed ? deck.copyWith(slides: slides) : deck;
}
/// For packaging: add a chart's linked CSV under data/ and rewrite its source
/// path; if the CSV is missing, fall back to keeping the data inline.
Slide _packChartSlide(Slide s, String? Function(String, String) addAsset) {
final spec = ChartSpec.parse(s.customMarkdown);
final src = spec.source;
if (src == null) return s;
final rel = addAsset(src, chartDataDirName);
if (rel == null) {
return s.copyWith(
customMarkdown: spec.copyWith(clearSource: true).toBlock(),
);
}
return s.copyWith(
customMarkdown: spec.copyWith(source: rel).toBlock(forStorage: true),
);
}
/// Copy any linked chart CSVs into [destDir]/data (used by Save As to a new
/// location). A normal save is a no-op because source and dest coincide.
Future<void> _copyChartData(Deck deck, String destDir) async {
for (final s in deck.slides) {
if (s.type != SlideType.chart) continue;
final src = ChartSpec.parse(s.customMarkdown).source;
if (src == null || p.isAbsolute(src) || deck.projectPath == null) {
continue;
}
final from = File(p.join(deck.projectPath!, src));
final toPath = p.join(destDir, src);
if (from.path == toPath || !from.existsSync()) continue;
final out = File(toPath);
await out.parent.create(recursive: true);
await out.writeAsBytes(await from.readAsBytes(), flush: true);
}
}
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
final safeName = deck.title
.replaceAll(RegExp(r'[^\w\s-]'), '')
.replaceAll(' ', '_');
final result = await FilePicker.saveFile(
dialogTitle: _d('Opslaan als'),
fileName: '$safeName.md',
initialDirectory: initialDirectory,
);
if (result == null) return null;
final path = result.endsWith('.md') ? result : '$result.md';
await _writeProject(deck, path);
return path;
}
Future<Deck> saveDeck(Deck deck, String filePath) async {
return _writeProject(deck, filePath);
}
// ── Draagbaar pakket (uitwisselen / op een ander systeem draaien) ──────────
static const packageExtension = 'ocideck';
String _safeName(String title) {
final cleaned = title
.replaceAll(RegExp(r'[^\w\s-]'), '')
.replaceAll(RegExp(r'\s+'), '_')
.trim();
return cleaned.isEmpty ? 'presentatie' : cleaned;
}
/// Schrijf een zelfstandig pakket (zip): de markdown + álle gebruikte assets
/// (afbeeldingen, media, logo) en de thema-CSS, met onderling relatieve
/// paden. Werkt ongeacht of het deck al is opgeslagen.
Future<void> exportPackage(Deck deck, String destPath) async {
final archive = Archive();
final added = <String>{};
/// Resolve [path] (relatief t.o.v. projectPath of absoluut), voeg het
/// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug.
String? addAsset(String path, String subdir) {
if (path.trim().isEmpty) return null;
final String abs;
if (p.isAbsolute(path)) {
// Absolute paths come from the picker (the user explicitly chose them).
abs = path;
} else if (deck.projectPath != null) {
// A relative asset must not escape the project via `../`.
final resolved = _projectFile(deck.projectPath, path);
if (resolved == null) return null;
abs = resolved;
} else {
abs = path;
}
final file = File(abs);
if (!file.existsSync()) return null;
final rel = p.posix.join(subdir, p.basename(abs));
if (!added.contains(rel)) {
final bytes = file.readAsBytesSync();
archive.add(ArchiveFile(rel, bytes.length, bytes));
added.add(rel);
}
return rel;
}
final slides = [
for (final s in deck.slides)
s.copyWith(
imagePath: addAsset(s.imagePath, 'images') ?? s.imagePath,
imagePath2: addAsset(s.imagePath2, 'images') ?? s.imagePath2,
videoPath: addAsset(s.videoPath, 'media') ?? s.videoPath,
audioPath: addAsset(s.audioPath, 'media') ?? s.audioPath,
),
];
// Chart slides link their data via a CSV path inside the JSON block; bring
// the file along under data/ and rewrite the path to match.
final packedSlides = [
for (final s in slides)
if (s.type == SlideType.chart) _packChartSlide(s, addAsset) else s,
];
final logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos');
final profile = logoRel != null
? deck.themeProfile.copyWith(logoPath: logoRel)
: deck.themeProfile;
final packDeck = deck.copyWith(slides: packedSlides, themeProfile: profile);
// Markdown.
final markdown = _md.generateDeck(packDeck);
final mdBytes = utf8.encode(markdown);
archive.add(
ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes),
);
// Annotation layer travels as a separate sidecar (same base name as the
// markdown), so the .md inside the package stays pure Marp.
final ink = AnnotationCodec.encode(packDeck.slides, packDeck.annotations);
if (ink != null) {
final inkBytes = utf8.encode(ink);
archive.add(
ArchiveFile(
'${_safeName(deck.title)}.ink.json',
inkBytes.length,
inkBytes,
),
);
}
// Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is).
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
if (css != null) {
final cssBytes = utf8.encode(css);
final themeName = packDeck.theme.trim().isEmpty
? 'ocideck'
: packDeck.theme;
archive.add(
ArchiveFile('themes/$themeName.css', cssBytes.length, cssBytes),
);
}
final bytes = ZipEncoder().encodeBytes(archive);
await File(destPath).writeAsBytes(bytes, flush: true);
}
Future<String?> _packageThemeCss(
String themeName,
ThemeProfile profile,
String? logoRel,
) async {
final safe = themeName.trim().isEmpty ? 'ocideck' : themeName;
try {
final base = (await rootBundle.loadString(
'assets/themes/ocideck.css',
)).replaceFirst('@theme ocideck', '@theme $safe');
return _buildThemeCss(
base,
profile,
logoRel == null ? null : '../$logoRel',
);
} catch (e) {
logWarning('FileService._packageThemeCss: theme asset not bundled', e);
return null;
}
}
/// Pak een pakket uit in een nieuwe submap onder [destParentDir]. Geeft het
/// pad naar het uitgepakte markdown-bestand terug (om in een tab te openen).
Future<String?> importPackageBytes(
List<int> zipBytes,
String destParentDir,
) async {
final Archive archive;
try {
archive = ZipDecoder().decodeBytes(zipBytes);
} catch (e, s) {
logError('FileService.importPackageBytes: ZIP decode failed', e, s);
return null;
}
// Kies de markdown met het ondiepste pad (de hoofd-md van het pakket).
ArchiveFile? mdEntry;
for (final f in archive.files) {
if (!f.isFile || !f.name.toLowerCase().endsWith('.md')) continue;
if (mdEntry == null ||
'/'.allMatches(f.name).length < '/'.allMatches(mdEntry.name).length) {
mdEntry = f;
}
}
if (mdEntry == null) return null;
final folderName = p.basenameWithoutExtension(mdEntry.name);
final destDir = _uniqueDir(destParentDir, folderName);
await destDir.create(recursive: true);
// Resolve an archive entry name to a path strictly inside [destDir], or
// null when it would escape (zip-slip: `../`, absolute paths, …).
String? safeOutPath(String entryName) {
final resolved = p.normalize(p.join(destDir.path, entryName));
if (resolved != destDir.path && !p.isWithin(destDir.path, resolved)) {
return null;
}
return resolved;
}
var extracted = 0;
for (final f in archive.files) {
if (!f.isFile) continue;
final outPath = safeOutPath(f.name);
if (outPath == null) continue; // skip path-traversal entries
final content = f.content as List<int>;
// Bound total extracted size so a small zip can't fill the disk (zip bomb).
extracted += content.length;
if (extracted > _maxDownloadBytes) break;
final out = File(outPath);
await out.parent.create(recursive: true);
await out.writeAsBytes(content, flush: true);
}
// The main markdown must itself resolve inside the extraction folder.
final mdPath = safeOutPath(mdEntry.name);
return mdPath;
}
Directory _uniqueDir(String parent, String name) {
var dir = Directory(p.join(parent, name));
var i = 2;
while (dir.existsSync()) {
dir = Directory(p.join(parent, '$name ($i)'));
i++;
}
return dir;
}
/// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt;
/// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het
/// markdown-bestand terug.
/// Cap on how much we download / extract, to bound memory and disk use.
static const _maxDownloadBytes = 64 * 1024 * 1024; // 64 MB
/// Hosts an import must never reach (loopback, private and link-local ranges)
/// so a deck URL can't be used to probe the local machine or intranet (SSRF).
static bool _isBlockedHost(String host) {
final h = host.toLowerCase();
if (h.isEmpty || h == 'localhost' || h.endsWith('.localhost')) return true;
final addr = InternetAddress.tryParse(host);
if (addr == null) return false; // a hostname; can't classify offline
if (addr.isLoopback || addr.isLinkLocal || addr.isMulticast) return true;
final raw = addr.rawAddress;
if (addr.type == InternetAddressType.IPv4) {
final a = raw[0], b = raw[1];
if (a == 0 || a == 10 || a == 127) {
return true; // this-host/private/loopback
}
if (a == 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a == 192 && b == 168) return true; // 192.168.0.0/16
if (a == 169 && b == 254) return true; // 169.254.0.0/16 link-local
} else if ((raw[0] & 0xfe) == 0xfc) {
return true; // fc00::/7 unique-local
}
return false;
}
Future<String?> importFromUrl(String url, String destParentDir) async {
final uri = Uri.tryParse(url.trim());
if (uri == null || !uri.hasScheme) return null;
// Only fetch over web schemes, and never reach private/loopback hosts.
final scheme = uri.scheme.toLowerCase();
if (scheme != 'http' && scheme != 'https') return null;
if (_isBlockedHost(uri.host)) return null;
final List<int> bytes;
try {
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 15);
try {
final request = await client.getUrl(uri);
// Don't auto-follow redirects: a 3xx could point at a private host and
// bypass the SSRF check above.
request.followRedirects = false;
final response = await request.close().timeout(
const Duration(seconds: 30),
);
if (response.statusCode != 200) return null;
if (response.contentLength > _maxDownloadBytes) return null;
final builder = BytesBuilder(copy: false);
await for (final chunk in response) {
builder.add(chunk);
if (builder.length > _maxDownloadBytes) return null; // runaway body
}
bytes = builder.takeBytes();
} finally {
client.close(force: true);
}
} catch (e) {
logError('FileService.importFromUrl: download failed', e);
return null;
}
// Zip-magie 'PK\x03\x04' → pakket; anders als markdown behandelen.
final isZip =
bytes.length >= 4 &&
bytes[0] == 0x50 &&
bytes[1] == 0x4B &&
bytes[2] == 0x03 &&
bytes[3] == 0x04;
if (isZip) {
return importPackageBytes(bytes, destParentDir);
}
// Platte markdown.
final String markdown;
try {
markdown = utf8.decode(bytes);
} catch (e, s) {
logError('FileService.importFromUrl: UTF-8 decode failed', e, s);
return null;
}
if (!markdown.contains('marp') && !markdown.contains('---')) return null;
var base = p.basenameWithoutExtension(uri.path);
if (base.isEmpty) base = 'presentatie';
final destDir = _uniqueDir(destParentDir, base);
await destDir.create(recursive: true);
final mdPath = p.join(destDir.path, '$base.md');
await File(mdPath).writeAsString(markdown);
return mdPath;
}
Future<String?> pickPackageFile({String? initialDirectory}) async {
final result = await FilePicker.pickFiles(
dialogTitle: _d('Pakket importeren'),
type: FileType.custom,
allowedExtensions: [packageExtension, 'zip'],
initialDirectory: initialDirectory,
);
return result?.files.single.path;
}
Future<String?> pickPackageDestination(Deck deck) async {
return FilePicker.saveFile(
dialogTitle: _d('Pakket exporteren'),
fileName: '${_safeName(deck.title)}.$packageExtension',
);
}
Future<Deck> _writeProject(Deck deck, String filePath) async {
final dir = p.dirname(filePath);
final imagesDir = Directory(p.join(dir, 'images'));
final logosDir = Directory(p.join(dir, 'logos'));
final themesDir = Directory(p.join(dir, 'themes'));
await imagesDir.create(recursive: true);
await logosDir.create(recursive: true);
await themesDir.create(recursive: true);
final imageSlides = await _img.copyImagesToProject(deck.slides, dir);
final mediaSlides = await _img.copyMediaToProject(imageSlides, dir);
var updatedDeck = deck.copyWith(slides: mediaSlides, projectPath: dir);
final logoAsset = await _copyLogoToProject(updatedDeck.themeProfile, dir);
updatedDeck = updatedDeck.copyWith(themeProfile: logoAsset.profile);
await _writeImageCaptions(updatedDeck);
await _writeTheme(
themesDir.path,
updatedDeck.theme,
updatedDeck.themeProfile,
logoAsset.cssUrl,
);
// Bring linked chart CSVs along when saving to a new location.
await _copyChartData(deck, dir);
final markdown = _md.generateDeck(updatedDeck);
await File(filePath).writeAsString(markdown);
// Annotations live in a separate sidecar so the Marp .md stays pure.
await _writeSidecar(updatedDeck, filePath);
return updatedDeck;
}
Future<Deck> _hydrateImageCaptions(Deck deck) async {
final slides = <Slide>[];
for (final slide in deck.slides) {
var next = slide;
if (slide.imagePath.isNotEmpty) {
final caption = await _captions.getCaption(
slide.imagePath,
basePath: deck.projectPath,
);
if (caption != null) next = next.copyWith(imageCaption: caption);
}
if (slide.imagePath2.isNotEmpty) {
final caption = await _captions.getCaption(
slide.imagePath2,
basePath: deck.projectPath,
);
if (caption != null) next = next.copyWith(imageCaption2: caption);
}
slides.add(next);
}
return deck.copyWith(slides: slides);
}
Future<void> _writeImageCaptions(Deck deck) async {
for (final slide in deck.slides) {
if (slide.imagePath.isNotEmpty && slide.imageCaption.trim().isNotEmpty) {
await _captions.saveCaption(
slide.imagePath,
slide.imageCaption,
basePath: deck.projectPath,
);
}
if (slide.imagePath2.isNotEmpty &&
slide.imageCaption2.trim().isNotEmpty) {
await _captions.saveCaption(
slide.imagePath2,
slide.imageCaption2,
basePath: deck.projectPath,
);
}
}
}
Future<void> _writeTheme(
String themesPath,
String themeName,
ThemeProfile profile,
String? logoUrl,
) async {
final safeThemeName = themeName.trim().isEmpty ? 'ocideck' : themeName;
final dest = File(p.join(themesPath, '$safeThemeName.css'));
try {
final base = (await rootBundle.loadString(
'assets/themes/ocideck.css',
)).replaceFirst('@theme ocideck', '@theme $safeThemeName');
await dest.writeAsString(_buildThemeCss(base, profile, logoUrl));
} catch (e) {
// Asset not bundled in this build context; skip
logWarning('FileService._writeTheme: theme asset not bundled', e);
}
}
Future<_LogoProjectAsset> _copyLogoToProject(
ThemeProfile profile,
String projectPath,
) async {
final logoPath = profile.logoPath;
if (logoPath == null || logoPath.trim().isEmpty) {
return _LogoProjectAsset(profile, null);
}
final normalized = logoPath.replaceAll('\\', '/');
final relativeLogoPath = p.posix.isRelative(normalized)
? p.posix.normalize(normalized)
: null;
if (relativeLogoPath != null && relativeLogoPath.startsWith('logos/')) {
return _LogoProjectAsset(
profile.copyWith(logoPath: relativeLogoPath),
'../$relativeLogoPath',
);
}
var sourcePath = p.isAbsolute(logoPath)
? logoPath
: p.normalize(p.join(projectPath, logoPath));
var src = File(sourcePath);
if (!await src.exists()) {
final fallback = await _findExistingProjectLogo(projectPath, normalized);
if (fallback == null) {
return _LogoProjectAsset(profile, null);
}
sourcePath = fallback;
src = File(sourcePath);
}
final filename = p.posix.basename(normalized);
if (filename.isEmpty || filename == '.' || filename == '..') {
return _LogoProjectAsset(profile, null);
}
final relativePath = p.posix.join('logos', filename);
final dest = File(p.join(projectPath, relativePath));
if (!p.equals(src.path, dest.path)) {
await dest.parent.create(recursive: true);
await src.copy(dest.path);
}
return _LogoProjectAsset(
profile.copyWith(logoPath: relativePath),
'../$relativePath',
);
}
Future<String?> _findExistingProjectLogo(
String projectPath,
String normalizedLogoPath,
) async {
final filename = p.posix.basename(normalizedLogoPath);
if (filename.isEmpty || filename == '.' || filename == '..') return null;
final candidates = [
p.join(projectPath, 'logos', filename),
p.join(projectPath, 'images', filename),
p.join(projectPath, 'images', 'logo_$filename'),
];
for (final candidate in candidates) {
if (await File(candidate).exists()) return candidate;
}
return null;
}
String _buildThemeCss(String base, ThemeProfile profile, String? logoUrl) {
final logoCss = logoUrl == null
? ''
: '''
section.logo-safe {
${_logoSafePaddingCss(profile)}
}
section.split.logo-safe {
padding: 48px 0 48px var(--split-margin);
}
${_splitLogoSafeCss(profile)}
section::before {
content: "";
position: absolute;
width: ${profile.logoSize}px;
height: ${profile.logoSize}px;
background-image: url("$logoUrl");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
opacity: 0.9;
${_logoPositionCss(profile.logoPosition)}
}
section.no-logo::before {
display: none;
}
''';
return '''
$base
/* OciDeck style profile */
section {
background: ${profile.slideBackgroundColor};
color: ${profile.textColor};
position: relative;
}
section h1,
section h2,
section h3,
section strong {
color: ${profile.textColor};
}
section li::marker {
color: ${profile.accentColor};
}
section.title {
background: ${profile.titleBackgroundColor};
color: ${profile.titleTextColor};
}
section.title h1,
section.title h2 {
color: ${profile.titleTextColor};
}
section.section {
background: ${profile.sectionBackgroundColor};
color: ${profile.titleTextColor};
}
section.section h1 {
color: ${profile.titleTextColor};
}
table {
border-collapse: collapse;
width: 100%;
font-size: 0.72em;
}
th, td {
border: 1px solid ${profile.accentColor};
padding: 0.22em 0.45em;
text-align: left;
color: ${profile.tableTextColor};
}
thead th, tr:first-child th {
background: ${profile.accentColor};
color: ${profile.tableHeaderTextColor};
}
$logoCss
''';
}
String _logoPositionCss(String position) {
switch (position) {
case 'top-left':
return 'top: 40px;\n left: 28px;';
case 'top-right':
return 'top: 40px;\n right: 28px;';
case 'bottom-left':
return 'bottom: 12px;\n left: 28px;';
case 'bottom-right':
default:
return 'bottom: 12px;\n right: 28px;';
}
}
String _logoSafePaddingCss(ThemeProfile profile) {
final reserved = profile.logoSize + 64;
switch (profile.logoPosition) {
case 'top-left':
case 'top-right':
return 'padding-top: ${reserved}px;';
case 'bottom-left':
case 'bottom-right':
default:
return 'padding-bottom: ${reserved}px;';
}
}
String _splitLogoSafeCss(ThemeProfile profile) {
if (profile.logoPosition.endsWith('right')) return '';
final reserved = profile.logoSize + 24;
if (profile.logoPosition.startsWith('top')) {
return '''
section.split.logo-safe .split-text {
padding-top: ${reserved}px;
}
''';
}
return '''
section.split.logo-safe .split-text {
padding-bottom: ${reserved}px;
}
''';
}
}