From 173f1a3f269f4d66a750e039e4e8ca8929d1b492 Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Tue, 16 Jun 2026 10:35:55 +0200 Subject: [PATCH] Add TLP classification enforcement with visual marking and export metadata. Extend export gates with optional floor and required classification, stamp PDF/PPTX/HTML metadata, and show banners and watermarks WYSIWYG across editor, presenter, and export. Co-authored-by: Cursor --- lib/l10n/app_localizations.dart | 119 +++++++++++++ lib/models/deck.dart | 7 + lib/models/settings.dart | 26 +++ .../classification_enforcement_policy.dart | 87 ++++++++++ lib/services/export_metadata.dart | 70 ++++++++ lib/services/export_service.dart | 107 +++++++++++- lib/services/marp_html_service.dart | 51 +++++- lib/services/slide_rasterizer.dart | 4 + lib/state/settings_provider.dart | 30 ++++ lib/widgets/app_shell.dart | 30 +++- lib/widgets/dialogs/export_dialog.dart | 34 +++- lib/widgets/dialogs/settings_dialog.dart | 117 +++++++++++++ lib/widgets/panels/preview_panel.dart | 13 +- lib/widgets/panels/slide_list_panel.dart | 5 + lib/widgets/presentation/audience_window.dart | 7 + .../presentation/fullscreen_presenter.dart | 33 ++++ lib/widgets/shell/status_bar.dart | 21 ++- lib/widgets/slides/previews/overlays.dart | 94 +++++++++- lib/widgets/slides/slide_preview.dart | 29 +++- lib/widgets/slides/slide_thumbnail.dart | 7 + ...lassification_enforcement_policy_test.dart | 163 ++++++++++++++++++ test/export_metadata_test.dart | 62 +++++++ test/export_service_test.dart | 115 +++++++++++- test/marp_html_service_test.dart | 22 +++ test/tlp_test.dart | 90 +++++++++- 25 files changed, 1296 insertions(+), 47 deletions(-) create mode 100644 lib/services/classification_enforcement_policy.dart create mode 100644 lib/services/export_metadata.dart create mode 100644 test/classification_enforcement_policy_test.dart create mode 100644 test/export_metadata_test.dart diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e44e427..655eddb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2683,6 +2683,23 @@ const _dutchSourceStringAdditions = { 'Toch exporteren': 'Export anyway', '… en meer problemen in het kwaliteitspaneel.': '… and more issues in the quality panel.', + 'Classificatie-handhaving': 'Classification enforcement', + 'Vrijgaveplafond': 'Release ceiling', + 'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.': + 'Highest TLP level allowed to export. Empty = no ceiling.', + 'Vereist minimumniveau': 'Required minimum level', + 'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.': + 'Lowest classification a deck must have to export. Empty = no minimum.', + 'Geen plafond': 'No ceiling', + 'Geen minimum': 'No minimum', + 'Classificatie verplicht': 'Classification required', + 'Weiger export wanneer het deck geen TLP-niveau heeft.': + 'Refuse export when the deck has no TLP level.', + 'Classificatie-watermerk': 'Classification watermark', + 'Toon een diagonaal watermerk met TLP en organisatie op elke slide.': + 'Show a diagonal watermark with TLP and organization on every slide.', + 'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.': + 'Set a TLP level — export is blocked by the classification policy.', }, 'it': { 'Toegankelijkheid': 'Accessibilità', @@ -3081,6 +3098,23 @@ const _dutchSourceStringAdditions = { 'Toch exporteren': 'Esporta comunque', '… en meer problemen in het kwaliteitspaneel.': '… e altri problemi nel pannello qualità.', + 'Classificatie-handhaving': 'Applicazione classificazione', + 'Vrijgaveplafond': 'Limite di rilascio', + 'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.': + 'Livello TLP massimo esportabile. Vuoto = nessun limite.', + 'Vereist minimumniveau': 'Livello minimo richiesto', + 'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.': + 'Classificazione minima richiesta per esportare. Vuoto = nessun minimo.', + 'Geen plafond': 'Nessun limite', + 'Geen minimum': 'Nessun minimo', + 'Classificatie verplicht': 'Classificazione obbligatoria', + 'Weiger export wanneer het deck geen TLP-niveau heeft.': + 'Rifiuta l\'export se il deck non ha un livello TLP.', + 'Classificatie-watermerk': 'Filigrana di classificazione', + 'Toon een diagonaal watermerk met TLP en organisatie op elke slide.': + 'Mostra una filigrana diagonale con TLP e organizzazione su ogni slide.', + 'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.': + 'Imposta un livello TLP — l\'export è bloccato dalla policy di classificazione.', }, 'de': { 'Toegankelijkheid': 'Barrierefreiheit', @@ -3479,6 +3513,23 @@ const _dutchSourceStringAdditions = { 'Toch exporteren': 'Trotzdem exportieren', '… en meer problemen in het kwaliteitspaneel.': '… und weitere Probleme im Qualitätsbereich.', + 'Classificatie-handhaving': 'Klassifizierungsdurchsetzung', + 'Vrijgaveplafond': 'Freigabeobergrenze', + 'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.': + 'Höchstes exportierbares TLP-Niveau. Leer = keine Obergrenze.', + 'Vereist minimumniveau': 'Erforderliches Mindestniveau', + 'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.': + 'Niedrigste Klassifizierung zum Exportieren. Leer = kein Minimum.', + 'Geen plafond': 'Keine Obergrenze', + 'Geen minimum': 'Kein Minimum', + 'Classificatie verplicht': 'Klassifizierung erforderlich', + 'Weiger export wanneer het deck geen TLP-niveau heeft.': + 'Export verweigern, wenn das Deck kein TLP-Niveau hat.', + 'Classificatie-watermerk': 'Klassifizierungs-Wasserzeichen', + 'Toon een diagonaal watermerk met TLP en organisatie op elke slide.': + 'Diagonales Wasserzeichen mit TLP und Organisation auf jeder Folie.', + 'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.': + 'TLP-Niveau festlegen — Export durch Klassifizierungsrichtlinie blockiert.', }, 'fr': { 'Toegankelijkheid': 'Accessibilité', @@ -3881,6 +3932,23 @@ const _dutchSourceStringAdditions = { 'Toch exporteren': 'Exporter quand même', '… en meer problemen in het kwaliteitspaneel.': '… et d\'autres problèmes dans le panneau qualité.', + 'Classificatie-handhaving': 'Application de la classification', + 'Vrijgaveplafond': 'Plafond de diffusion', + 'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.': + 'Niveau TLP maximal exportable. Vide = pas de plafond.', + 'Vereist minimumniveau': 'Niveau minimum requis', + 'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.': + 'Classification minimale requise pour exporter. Vide = pas de minimum.', + 'Geen plafond': 'Pas de plafond', + 'Geen minimum': 'Pas de minimum', + 'Classificatie verplicht': 'Classification obligatoire', + 'Weiger export wanneer het deck geen TLP-niveau heeft.': + 'Refuser l\'export si le deck n\'a pas de niveau TLP.', + 'Classificatie-watermerk': 'Filigrane de classification', + 'Toon een diagonaal watermerk met TLP en organisatie op elke slide.': + 'Filigrane diagonal avec TLP et organisation sur chaque diapositive.', + 'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.': + 'Définissez un niveau TLP — export bloqué par la politique de classification.', }, 'es': { 'Toegankelijkheid': 'Accesibilidad', @@ -4279,6 +4347,23 @@ const _dutchSourceStringAdditions = { 'Toch exporteren': 'Exportar de todos modos', '… en meer problemen in het kwaliteitspaneel.': '… y más problemas en el panel de calidad.', + 'Classificatie-handhaving': 'Aplicación de clasificación', + 'Vrijgaveplafond': 'Techo de difusión', + 'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.': + 'Nivel TLP máximo exportable. Vacío = sin techo.', + 'Vereist minimumniveau': 'Nivel mínimo requerido', + 'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.': + 'Clasificación mínima para exportar. Vacío = sin mínimo.', + 'Geen plafond': 'Sin techo', + 'Geen minimum': 'Sin mínimo', + 'Classificatie verplicht': 'Clasificación obligatoria', + 'Weiger export wanneer het deck geen TLP-niveau heeft.': + 'Rechazar exportación si el deck no tiene nivel TLP.', + 'Classificatie-watermerk': 'Marca de agua de clasificación', + 'Toon een diagonaal watermerk met TLP en organisatie op elke slide.': + 'Marca de agua diagonal con TLP y organización en cada diapositiva.', + 'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.': + 'Establece un nivel TLP — exportación bloqueada por la política de clasificación.', }, 'fy': { 'Toegankelijkheid': 'Tagonklikens', @@ -4670,6 +4755,23 @@ const _dutchSourceStringAdditions = { 'Toch exporteren': 'Dochs eksportearje', '… en meer problemen in het kwaliteitspaneel.': '… en mear problemen yn it kwaliteitspaneel.', + 'Classificatie-handhaving': 'Klassifikaasje-ôfstimming', + 'Vrijgaveplafond': 'Frijwaringsplafond', + 'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.': + 'Heechste TLP-nivo dat eksportearje mei. Leech = gjin plafond.', + 'Vereist minimumniveau': 'Fereaske minimumnivo', + 'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.': + 'Lechste klassifikaasje om te eksportearjen. Leech = gjin minimum.', + 'Geen plafond': 'Gjin plafond', + 'Geen minimum': 'Gjin minimum', + 'Classificatie verplicht': 'Klassifikaasje ferplicht', + 'Weiger export wanneer het deck geen TLP-niveau heeft.': + 'Eksport wegerje as it deck gjin TLP-nivo hat.', + 'Classificatie-watermerk': 'Klassifikaasje-watermerk', + 'Toon een diagonaal watermerk met TLP en organisatie op elke slide.': + 'Diagonaal watermerk mei TLP en organisaasje op elke slide.', + 'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.': + 'Stel in TLP-nivo yn — eksport blokkearre troch it klassifikaasjebelied.', }, 'pap': { 'Toegankelijkheid': 'Aksesibilidat', @@ -5064,5 +5166,22 @@ const _dutchSourceStringAdditions = { 'Toch exporteren': 'Exportá igualmente', '… en meer problemen in het kwaliteitspaneel.': '… i mas problema den e panel di kalidad.', + 'Classificatie-handhaving': 'Aplikashon di klasifikashon', + 'Vrijgaveplafond': 'Techo di difushon', + 'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.': + 'Máksimo nivel TLP pa exportá. Bashí = sin techo.', + 'Vereist minimumniveau': 'Mínimo rekerí', + 'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.': + 'Klasifikashon mínimo pa exportá. Bashí = sin mínimo.', + 'Geen plafond': 'Sin techo', + 'Geen minimum': 'Sin mínimo', + 'Classificatie verplicht': 'Klasifikashon obligatorio', + 'Weiger export wanneer het deck geen TLP-niveau heeft.': + 'Negá exportá ora e deck no tin nivel TLP.', + 'Classificatie-watermerk': 'Watermark di klasifikashon', + 'Toon een diagonaal watermerk met TLP en organisatie op elke slide.': + 'Watermark diagonal ku TLP i organisashon riba kada slide.', + 'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.': + 'Pone un nivel TLP — exportá blokeá pa e polítika di klasifikashon.', }, }; diff --git a/lib/models/deck.dart b/lib/models/deck.dart index c995fb5..95f6514 100644 --- a/lib/models/deck.dart +++ b/lib/models/deck.dart @@ -14,6 +14,13 @@ enum TlpLevel { none, clear, green, amber, amberStrict, red } bool slideVisibleAtTlp(Slide slide, TlpLevel presentationTlp) => slide.tlp.index <= presentationTlp.index; +/// Strengste classificatie voor markering op een slide: het hoogste van het +/// deck-niveau en het per-slide niveau. +TlpLevel effectiveTlp({ + required TlpLevel deckTlp, + required TlpLevel slideTlp, +}) => deckTlp.index >= slideTlp.index ? deckTlp : slideTlp; + extension TlpLevelX on TlpLevel { /// De officiële markering die op de slides verschijnt ('' bij [none]). String get label { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index f3a3835..baa0765 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -382,6 +382,18 @@ class AppSettings { /// blokkeert alleen decks die er bovenuit zijn geclassificeerd. final String? maxReleaseExportTlpKey; + /// Optioneel minimumniveau voor export-handhaving (TLP-sleutel). Decks + /// onder dit niveau (inclusief ongeclassificeerd) worden geweigerd zodra dit + /// is ingesteld. Standaard uit — backward compatible. + final String? minRequiredExportTlpKey; + + /// Weiger export wanneer het deck geen TLP-niveau heeft ([TlpLevel.none]). + /// Standaard uit. Kan samen met [minRequiredExportTlpKey] worden gebruikt. + final bool requireClassificationOnExport; + + /// Diagonaal classificatie-watermerk op slides (fase 2). Standaard uit. + final bool classificationWatermarkEnabled; + /// Scale factor for all interface text (1.0–2.0), on top of the system /// text scaling. The slide canvas itself is never scaled: slides are a /// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%. @@ -405,6 +417,9 @@ class AppSettings { this.selectedAppAppearanceProfileName = 'Basic', this.recentFiles = const [], this.maxReleaseExportTlpKey, + this.minRequiredExportTlpKey, + this.requireClassificationOnExport = false, + this.classificationWatermarkEnabled = false, this.uiTextScale = 1.0, this.presentationTargetSeconds = 0, this.qualityWarningsOnExport = true, @@ -460,12 +475,16 @@ class AppSettings { String? selectedAppAppearanceProfileName, List? recentFiles, String? maxReleaseExportTlpKey, + String? minRequiredExportTlpKey, + bool? requireClassificationOnExport, + bool? classificationWatermarkEnabled, double? uiTextScale, int? presentationTargetSeconds, bool? qualityWarningsOnExport, bool clearHomeDirectory = false, bool clearExportDirectory = false, bool clearMaxReleaseExportTlp = false, + bool clearMinRequiredExportTlp = false, }) { final nextProfiles = themeProfiles ?? this.themeProfiles; return AppSettings( @@ -500,6 +519,13 @@ class AppSettings { maxReleaseExportTlpKey: clearMaxReleaseExportTlp ? null : (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey), + minRequiredExportTlpKey: clearMinRequiredExportTlp + ? null + : (minRequiredExportTlpKey ?? this.minRequiredExportTlpKey), + requireClassificationOnExport: + requireClassificationOnExport ?? this.requireClassificationOnExport, + classificationWatermarkEnabled: + classificationWatermarkEnabled ?? this.classificationWatermarkEnabled, uiTextScale: uiTextScale ?? this.uiTextScale, presentationTargetSeconds: presentationTargetSeconds ?? this.presentationTargetSeconds, diff --git a/lib/services/classification_enforcement_policy.dart b/lib/services/classification_enforcement_policy.dart new file mode 100644 index 0000000..c60ae8d --- /dev/null +++ b/lib/services/classification_enforcement_policy.dart @@ -0,0 +1,87 @@ +import '../models/deck.dart'; +import '../models/settings.dart'; +import 'classification_policy.dart'; + +/// Centrale beslisser voor classificatie-handhaving bij export (Variant A). +/// +/// Breidt het bestaande vrijgaveplafond uit met een optioneel **minimumniveau** +/// en een vlag om ongeclassificeerde decks te weigeren. Evaluatie blijft puur +/// Evaluatie blijft puur en fail-closed — de gate hangt aan [ExportService.export]. +class ClassificationEnforcementPolicy { + /// Hoogste TLP-niveau dat geëxporteerd mag worden (vrijgaveplafond). + final TlpLevel? maxReleaseLevel; + + /// Laagste vereiste deck-classificatie; alles eronder wordt geweigerd. + final TlpLevel? minRequiredLevel; + + /// Wanneer `true`, mag een deck zonder classificatie ([TlpLevel.none]) niet + /// exporteren, ook als [minRequiredLevel] niet is ingesteld. + final bool requireClassification; + + const ClassificationEnforcementPolicy({ + this.maxReleaseLevel, + this.minRequiredLevel, + this.requireClassification = false, + }); + + /// Alleen het plafond — backward compatible met [ClassificationPolicy]. + factory ClassificationEnforcementPolicy.fromMaxReleaseKey(String? key) => + ClassificationEnforcementPolicy( + maxReleaseLevel: key == null ? null : TlpLevelX.fromKey(key), + ); + + /// Volledig beleid uit app-instellingen. + factory ClassificationEnforcementPolicy.fromAppSettings(AppSettings settings) { + return ClassificationEnforcementPolicy( + maxReleaseLevel: settings.maxReleaseExportTlpKey == null + ? null + : TlpLevelX.fromKey(settings.maxReleaseExportTlpKey!), + minRequiredLevel: settings.minRequiredExportTlpKey == null + ? null + : TlpLevelX.fromKey(settings.minRequiredExportTlpKey!), + requireClassification: settings.requireClassificationOnExport, + ); + } + + /// Of er minstens één handhavingsregel actief is. + bool get hasGate => + maxReleaseLevel != null || + minRequiredLevel != null || + requireClassification; + + /// Beoordeel of een deck met niveau [deckLevel] geëxporteerd mag worden. + ExportDecision evaluate(TlpLevel deckLevel) { + if (requireClassification && deckLevel == TlpLevel.none) { + return ExportDecision.block( + 'Export geblokkeerd door classificatiebeleid: stel een TLP-niveau in ' + 'voor deze presentatie.', + ); + } + + final floor = minRequiredLevel; + if (floor != null && deckLevel.index < floor.index) { + final actual = deckLevel == TlpLevel.none + ? 'niet geclassificeerd' + : deckLevel.label; + return ExportDecision.block( + 'Export geblokkeerd door classificatiebeleid: dit deck is $actual, ' + 'lager dan het vereiste minimum ${floor.label}.', + ); + } + + final ceiling = maxReleaseLevel; + if (ceiling != null && deckLevel.index > ceiling.index) { + return ExportDecision.block( + 'Export geblokkeerd door classificatiebeleid: dit deck is ' + '${deckLevel.label}, hoger dan het toegestane vrijgaveniveau ' + '${ceiling.label}.', + ); + } + + return const ExportDecision.allow(); + } + + /// Het plafond als losse [ClassificationPolicy] (bestaande export-call sites). + ClassificationPolicy get releasePolicy => + ClassificationPolicy(maxReleaseLevel: maxReleaseLevel); +} diff --git a/lib/services/export_metadata.dart b/lib/services/export_metadata.dart new file mode 100644 index 0000000..08522e1 --- /dev/null +++ b/lib/services/export_metadata.dart @@ -0,0 +1,70 @@ +import '../models/deck.dart'; + +/// Producer string embedded in PDF/PPTX metadata. +const kOciDeckProducer = 'OciDeck 1.0.0'; + +/// Document metadata stamped into PDF, PPTX and HTML exports. +class ExportDocumentMetadata { + final String title; + final String author; + final String organization; + final String description; + final String keywords; + final TlpLevel tlp; + + const ExportDocumentMetadata({ + this.title = '', + this.author = '', + this.organization = '', + this.description = '', + this.keywords = '', + this.tlp = TlpLevel.none, + }); + + factory ExportDocumentMetadata.fromDeck(Deck deck) => ExportDocumentMetadata( + title: deck.title, + author: deck.author, + organization: deck.organization, + description: deck.description, + keywords: deck.keywords, + tlp: deck.tlp, + ); + + /// Fallback title when [title] is empty. + String displayTitle(String fallback) => + title.trim().isNotEmpty ? title.trim() : fallback; + + /// PDF/PPTX Subject — classificatie vooraan wanneer gezet. + String subject(String fallbackTitle) { + final name = displayTitle(fallbackTitle); + if (tlp == TlpLevel.none) return name; + return '${tlp.label} — $name'; + } + + /// Comma-separated keywords for PDF Info dict and PPTX core props. + String exportKeywords() { + final parts = []; + final deckKeywords = keywords.trim(); + if (deckKeywords.isNotEmpty) parts.add(deckKeywords); + if (tlp != TlpLevel.none) { + parts.addAll(['TLP', tlp.label, tlp.key]); + } + parts.add('OciDeck'); + return parts.join(', '); + } + + /// dc:creator / PDF Author — auteur, anders organisatie. + String get documentAuthor { + if (author.trim().isNotEmpty) return author.trim(); + if (organization.trim().isNotEmpty) return organization.trim(); + return 'OciDeck'; + } + + String get producer => kOciDeckProducer; + + String? get htmlDescription => + description.trim().isNotEmpty ? description.trim() : null; + + String? get htmlClassification => + tlp == TlpLevel.none ? null : tlp.label; +} diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart index ee9b05a..06efcc0 100644 --- a/lib/services/export_service.dart +++ b/lib/services/export_service.dart @@ -10,8 +10,9 @@ import 'package:pdf/widgets.dart' as pw; import '../models/deck.dart'; import '../models/settings.dart'; -import 'classification_policy.dart'; +import 'classification_enforcement_policy.dart'; import '../models/slide_quality.dart'; +import 'export_metadata.dart'; import 'quality_export_policy.dart'; import 'marp_html_service.dart'; @@ -108,16 +109,18 @@ class ExportService { String? markdown, ThemeProfile? themeProfile, TlpLevel tlp = TlpLevel.none, - ClassificationPolicy policy = const ClassificationPolicy(), + ClassificationEnforcementPolicy enforcementPolicy = + const ClassificationEnforcementPolicy(), SlideQualityResult? qualityResult, QualityExportPolicy qualityPolicy = const QualityExportPolicy(), bool qualityAcknowledged = false, + ExportDocumentMetadata? metadata, }) async { // Classificatie-gate. Dit is het enige chokepoint waar elk formaat // (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de // UI-laag: zo kan geen exportpad de gate omzeilen. Fail-closed — bij een // weigering wordt er niets gebouwd of weggeschreven. - final decision = policy.evaluate(tlp); + final decision = enforcementPolicy.evaluate(tlp); if (!decision.allowed) { return ExportResult.fail(decision.reason!); } @@ -136,6 +139,10 @@ class ExportService { } else if (images.isEmpty) { return ExportResult.fail('Geen slides om te exporteren.'); } + final fallbackTitle = p.basenameWithoutExtension(deckPath); + final docMeta = + metadata ?? + ExportDocumentMetadata(title: fallbackTitle, tlp: tlp); final compactSuffix = compress && format == ExportFormat.pdf ? '-compact' : ''; @@ -151,12 +158,29 @@ class ExportService { final Uint8List bytes; switch (format) { case ExportFormat.pdf: - bytes = await _buildPdf(images, compress: compress); + bytes = await _buildPdf( + images, + metadata: docMeta, + fallbackTitle: fallbackTitle, + compress: compress, + ); case ExportFormat.pptx: - bytes = _buildPptx(images, notes: notes); + bytes = _buildPptx( + images, + metadata: docMeta, + fallbackTitle: fallbackTitle, + notes: notes, + ); case ExportFormat.html: bytes = Uint8List.fromList( - utf8.encode(await _html.build(markdown!, theme: themeProfile)), + utf8.encode( + await _html.build( + markdown!, + theme: themeProfile, + metadata: docMeta, + fallbackTitle: fallbackTitle, + ), + ), ); } await File(outputPath).writeAsBytes(bytes, flush: true); @@ -170,9 +194,18 @@ class ExportService { Future _buildPdf( List images, { + required ExportDocumentMetadata metadata, + required String fallbackTitle, bool compress = false, }) async { - final doc = pw.Document(); + final doc = pw.Document( + title: metadata.displayTitle(fallbackTitle), + author: metadata.documentAuthor, + subject: metadata.subject(fallbackTitle), + keywords: metadata.exportKeywords(), + creator: metadata.producer, + producer: metadata.producer, + ); // 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) { @@ -207,7 +240,12 @@ class ExportService { // ── PPTX (Office Open XML) ───────────────────────────────────────────────── - Uint8List _buildPptx(List images, {List? notes}) { + Uint8List _buildPptx( + List images, { + required ExportDocumentMetadata metadata, + required String fallbackTitle, + List? notes, + }) { final archive = Archive(); void addText(String name, String content) { final data = utf8Bytes(content); @@ -226,6 +264,11 @@ class ExportService { addText('[Content_Types].xml', _contentTypes(slideCount, noteFor.keys)); addText('_rels/.rels', _rootRels()); + addText( + 'docProps/core.xml', + _coreProps(metadata, fallbackTitle: fallbackTitle), + ); + addText('docProps/app.xml', _appProps(metadata)); addText('ppt/presentation.xml', _presentationXml(slideCount, hasNotes)); addText( 'ppt/_rels/presentation.xml.rels', @@ -368,6 +411,8 @@ class ExportService { '' '' '' + '' + '' '' '' '' @@ -381,9 +426,55 @@ class ExportService { return '' '' '' + '' + '' ''; } + String _coreProps( + ExportDocumentMetadata metadata, { + required String fallbackTitle, + }) { + final now = DateTime.now().toUtc(); + String iso(DateTime t) => t.toIso8601String(); + final title = _xmlEscape(metadata.displayTitle(fallbackTitle)); + final subject = _xmlEscape(metadata.subject(fallbackTitle)); + final creator = _xmlEscape(metadata.documentAuthor); + final keywords = _xmlEscape(metadata.exportKeywords()); + final description = metadata.htmlDescription; + final descXml = description == null + ? '' + : '${_xmlEscape(description)}'; + return '' + '' + '$title' + '$subject' + '$creator' + '$descXml' + '$keywords' + '${_xmlEscape(metadata.producer)}' + '${iso(now)}' + '${iso(now)}' + ''; + } + + String _appProps(ExportDocumentMetadata metadata) { + final company = metadata.organization.trim(); + final companyXml = company.isEmpty + ? '' + : '${_xmlEscape(company)}'; + return '' + '' + '${_xmlEscape(metadata.producer)}' + '$companyXml' + ''; + } + String _presentationXml(int count, bool hasNotes) { final sldIds = StringBuffer(); for (var i = 0; i < count; i++) { diff --git a/lib/services/marp_html_service.dart b/lib/services/marp_html_service.dart index 3f3f9e8..d505397 100644 --- a/lib/services/marp_html_service.dart +++ b/lib/services/marp_html_service.dart @@ -5,8 +5,10 @@ import 'dart:typed_data'; import 'package:flutter/services.dart' show rootBundle; import '../models/chart.dart'; +import '../models/deck.dart'; import '../models/settings.dart'; import '../utils/log.dart'; +import 'export_metadata.dart'; /// Builds a single, self-contained HTML file from a deck's Marp Markdown. /// @@ -37,7 +39,12 @@ class MarpHtmlService { /// Builds the HTML. When [theme] is given, the slides take that profile's /// colours and font so the export matches the in-app / PDF look. - Future build(String deckMarkdown, {ThemeProfile? theme}) async { + Future build( + String deckMarkdown, { + ThemeProfile? theme, + ExportDocumentMetadata? metadata, + String fallbackTitle = 'Presentatie', + }) async { final marked = await loadAsset('$_assetDir/marked.min.js'); final purify = await loadAsset('$_assetDir/purify.min.js'); final hljs = await loadAsset('$_assetDir/highlight.min.js'); @@ -56,10 +63,18 @@ class MarpHtmlService { String inline(String code) => ''; + final meta = metadata ?? const ExportDocumentMetadata(); + final title = _htmlAttr(meta.displayTitle(fallbackTitle)); + final headMeta = _htmlHeadMeta(meta, fallbackTitle: fallbackTitle); + final banner = meta.htmlClassification == null + ? '' + : '
${_htmlAttr(meta.htmlClassification!)}
'; + return '\n' '' '' - 'OciDeck export' + '$title' + '$headMeta' '' '' '${inline(marked)}' @@ -68,6 +83,7 @@ class MarpHtmlService { '${inline(mathjax)}' '${inline(mermaid)}' '' + '$banner' '$sections' '${inline(_renderScript)}' ''; @@ -673,9 +689,40 @@ body{background:#1e1e1e;font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Ar .slide img{max-width:100%} .slide blockquote{border-left:4px solid #ccc;margin:.5em 0;padding-left:16px;color:#555} .slide table{border-collapse:collapse}.slide th,.slide td{border:1px solid #ccc;padding:6px 12px;font-size:20px} +.tlp-export-banner{position:fixed;top:0;left:0;right:0;background:#000;color:#ffc000;text-align:center;font:700 14px/2.4 monospace;z-index:9999;letter-spacing:.06em} @media print{body{background:#fff}.slide{margin:0;box-shadow:none;border-radius:0;page-break-after:always;width:100%;min-height:100vh}} '''; + static String _htmlAttr(String value) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<'); + } + + static String _htmlHeadMeta( + ExportDocumentMetadata meta, { + required String fallbackTitle, + }) { + final buf = StringBuffer(); + void tag(String name, String content) { + if (content.trim().isEmpty) return; + buf.write(''); + } + + tag('author', meta.documentAuthor); + tag('keywords', meta.exportKeywords()); + final desc = meta.htmlDescription; + if (desc != null) tag('description', desc); + final classification = meta.htmlClassification; + if (classification != null) { + tag('classification', classification); + tag('tlp', meta.tlp.key); + } + tag('generator', meta.producer); + return buf.toString(); + } + static const _renderScript = r''' (function(){ if(window.marked&&marked.setOptions){marked.setOptions({gfm:true,breaks:false});} diff --git a/lib/services/slide_rasterizer.dart b/lib/services/slide_rasterizer.dart index ca300d5..4c9a19b 100644 --- a/lib/services/slide_rasterizer.dart +++ b/lib/services/slide_rasterizer.dart @@ -32,6 +32,8 @@ class SlideRasterizer { required ThemeProfile themeProfile, required String? projectPath, TlpLevel tlp = TlpLevel.none, + bool showClassificationWatermark = false, + String organization = '', int targetWidth = 1920, void Function(int done, int total)? onProgress, void Function(String phase, int done, int total)? onStage, @@ -86,6 +88,8 @@ class SlideRasterizer { slideNumber: i + 1, slideCount: slides.length, tlp: tlp, + showClassificationWatermark: showClassificationWatermark, + organization: organization, ), ), ), diff --git a/lib/state/settings_provider.dart b/lib/state/settings_provider.dart index 68d7293..16f4347 100644 --- a/lib/state/settings_provider.dart +++ b/lib/state/settings_provider.dart @@ -55,6 +55,11 @@ class SettingsNotifier extends StateNotifier { : 'Basic', recentFiles: prefs.getStringList('recentFiles') ?? [], maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'), + minRequiredExportTlpKey: prefs.getString('minRequiredExportTlp'), + requireClassificationOnExport: + prefs.getBool('requireClassificationOnExport') ?? false, + classificationWatermarkEnabled: + prefs.getBool('classificationWatermarkEnabled') ?? false, uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0), presentationTargetSeconds: (prefs.getInt('presentationTargetSeconds') ?? 0) .clamp(0, 86400), @@ -77,6 +82,31 @@ class SettingsNotifier extends StateNotifier { } } + /// Stel het vereiste minimumniveau voor export in, of `null` om uit te zetten. + Future setMinRequiredExportTlp(String? key) async { + state = key == null + ? state.copyWith(clearMinRequiredExportTlp: true) + : state.copyWith(minRequiredExportTlpKey: key); + final prefs = await SharedPreferences.getInstance(); + if (key == null) { + await prefs.remove('minRequiredExportTlp'); + } else { + await prefs.setString('minRequiredExportTlp', key); + } + } + + Future setRequireClassificationOnExport(bool enabled) async { + state = state.copyWith(requireClassificationOnExport: enabled); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('requireClassificationOnExport', enabled); + } + + Future setClassificationWatermarkEnabled(bool enabled) async { + state = state.copyWith(classificationWatermarkEnabled: enabled); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('classificationWatermarkEnabled', enabled); + } + Future setUiTextScale(double scale) async { final clamped = scale.clamp(1.0, 2.0).toDouble(); state = state.copyWith(uiTextScale: clamped); diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index bb11ac2..9ee78b7 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -8,7 +8,8 @@ import '../models/deck.dart'; import '../models/slide.dart'; import '../services/caption_service.dart'; import '../services/description_service.dart'; -import '../services/classification_policy.dart'; +import '../services/classification_enforcement_policy.dart'; +import '../services/export_metadata.dart'; import '../services/export_service.dart'; import '../services/quality_export_policy.dart'; import '../services/recovery_service.dart'; @@ -554,6 +555,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { themeProfile: deck.themeProfile, initialIndex: initial, tlp: deck.tlp, + organization: deck.organization, + showClassificationWatermark: + ref.read(settingsProvider).classificationWatermarkEnabled, targetDuration: () { final secs = ref.read(settingsProvider).presentationTargetSeconds; return secs > 0 ? Duration(seconds: secs) : null; @@ -591,8 +595,8 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { projectPath: deck.projectPath, exportService: widget.exportService, tlp: deck.tlp, - policy: ClassificationPolicy.fromKey( - ref.read(settingsProvider).maxReleaseExportTlpKey, + enforcementPolicy: ClassificationEnforcementPolicy.fromAppSettings( + ref.read(settingsProvider), ), qualityResult: const SlideQualityAnalyzer().analyzeSlides( slides: slides, @@ -608,14 +612,31 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { markdown: ref .read(markdownServiceProvider) .generateDeck(deck.copyWith(slides: slides), inlineChartData: true), + organization: deck.organization, + showClassificationWatermark: + ref.read(settingsProvider).classificationWatermarkEnabled, + documentMetadata: ExportDocumentMetadata( + title: deck.title, + author: deck.author, + organization: deck.organization, + description: deck.description, + keywords: deck.keywords, + tlp: deck.tlp, + ), ); } final canExport = deckState.filePath != null && !deckState.isDirty; + final enforcement = ClassificationEnforcementPolicy.fromAppSettings( + ref.watch(settingsProvider), + ); + final classificationDecision = enforcement.evaluate(deck.tlp); final exportTooltip = deckState.filePath == null ? l10n.t('exportNeedsSave') : deckState.isDirty ? l10n.t('exportNeedsClean') + : !classificationDecision.allowed + ? classificationDecision.reason! : l10n.t('exportReady'); void toggleMarkdownMode() { @@ -788,6 +809,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { const SizedBox(width: 16), _TlpChip( tlp: deck.tlp, + warnUnset: + !classificationDecision.allowed && + deck.tlp == TlpLevel.none, onSelected: (level) => deckNotifier.updateInfo(tlp: level), ), const SizedBox(width: 6), diff --git a/lib/widgets/dialogs/export_dialog.dart b/lib/widgets/dialogs/export_dialog.dart index 8ec6975..713f761 100644 --- a/lib/widgets/dialogs/export_dialog.dart +++ b/lib/widgets/dialogs/export_dialog.dart @@ -6,7 +6,8 @@ import '../../models/markdown_validation.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../models/slide_quality.dart'; -import '../../services/classification_policy.dart'; +import '../../services/classification_enforcement_policy.dart'; +import '../../services/export_metadata.dart'; import '../../services/export_service.dart'; import '../../services/quality_export_policy.dart'; import '../../services/slide_rasterizer.dart'; @@ -23,8 +24,8 @@ class ExportDialog extends StatefulWidget { final ExportService exportService; final TlpLevel tlp; - /// Classificatie-gate. Standaard geen plafond (alles mag). - final ClassificationPolicy policy; + /// Classificatie-handhaving (plafond, minimum, verplicht classificeren). + final ClassificationEnforcementPolicy enforcementPolicy; /// Slide-kwaliteit van de te exporteren slides. final SlideQualityResult qualityResult; @@ -38,6 +39,10 @@ class ExportDialog extends StatefulWidget { /// The deck's Marp Markdown, used for the self-contained HTML export. final String markdown; + final String organization; + final bool showClassificationWatermark; + final ExportDocumentMetadata documentMetadata; + const ExportDialog({ super.key, required this.deckPath, @@ -46,11 +51,14 @@ class ExportDialog extends StatefulWidget { required this.projectPath, required this.exportService, this.tlp = TlpLevel.none, - this.policy = const ClassificationPolicy(), + this.enforcementPolicy = const ClassificationEnforcementPolicy(), this.qualityResult = const SlideQualityResult([]), this.qualityPolicy = const QualityExportPolicy(), this.exportDirectory, this.markdown = '', + this.organization = '', + this.showClassificationWatermark = false, + this.documentMetadata = const ExportDocumentMetadata(), }); static Future show( @@ -61,11 +69,15 @@ class ExportDialog extends StatefulWidget { required String? projectPath, required ExportService exportService, TlpLevel tlp = TlpLevel.none, - ClassificationPolicy policy = const ClassificationPolicy(), + ClassificationEnforcementPolicy enforcementPolicy = + const ClassificationEnforcementPolicy(), SlideQualityResult qualityResult = const SlideQualityResult([]), QualityExportPolicy qualityPolicy = const QualityExportPolicy(), String? exportDirectory, String markdown = '', + String organization = '', + bool showClassificationWatermark = false, + ExportDocumentMetadata documentMetadata = const ExportDocumentMetadata(), }) { return showDialog( context: context, @@ -77,11 +89,14 @@ class ExportDialog extends StatefulWidget { projectPath: projectPath, exportService: exportService, tlp: tlp, - policy: policy, + enforcementPolicy: enforcementPolicy, qualityResult: qualityResult, qualityPolicy: qualityPolicy, exportDirectory: exportDirectory, markdown: markdown, + organization: organization, + showClassificationWatermark: showClassificationWatermark, + documentMetadata: documentMetadata, ), ); } @@ -206,6 +221,8 @@ class _ExportDialogState extends State { themeProfile: widget.themeProfile, projectPath: widget.projectPath, tlp: widget.tlp, + showClassificationWatermark: widget.showClassificationWatermark, + organization: widget.organization, onProgress: (done, total) { if (mounted) setState(() => _done = done); }, @@ -234,10 +251,11 @@ class _ExportDialogState extends State { markdown: widget.markdown, themeProfile: widget.themeProfile, tlp: widget.tlp, - policy: widget.policy, + enforcementPolicy: widget.enforcementPolicy, qualityResult: widget.qualityResult, qualityPolicy: widget.qualityPolicy, qualityAcknowledged: true, + metadata: widget.documentMetadata, ); if (!mounted) return; @@ -341,7 +359,7 @@ class _ExportDialogState extends State { // Pre-flight classificatie-gate: blokkeert de export al vóór een poging, // zodat de gebruiker meteen de reden ziet. De service handhaaft dezelfde // regel nog eens als backstop, dus dit is puur UX — niet de beveiliging. - final decision = widget.policy.evaluate(widget.tlp); + final decision = widget.enforcementPolicy.evaluate(widget.tlp); if (!decision.allowed) { return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index 1e30de2..cb3f2c6 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -2,6 +2,7 @@ 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 '../../models/deck.dart'; import '../../models/settings.dart'; import '../../state/settings_provider.dart'; import '../../state/tabs_provider.dart'; @@ -470,6 +471,9 @@ class _SettingsDialogState extends ConsumerState { .setQualityWarningsOnExport(value), ), const SizedBox(height: 16), + _sectionTitle(l10n.d('Classificatie-handhaving')), + _classificationEnforcementSection(l10n), + const SizedBox(height: 16), _sectionTitle(l10n.d('Presentatie')), _presentationTargetField(), Padding( @@ -576,6 +580,119 @@ class _SettingsDialogState extends ConsumerState { ); } + Widget _classificationEnforcementSection(AppLocalizations l10n) { + final settings = ref.watch(settingsProvider); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _tlpPolicyDropdown( + label: l10n.d('Vrijgaveplafond'), + help: l10n.d( + 'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.', + ), + noneLabel: l10n.d('Geen plafond'), + currentKey: settings.maxReleaseExportTlpKey, + includeNoneLevel: true, + onChanged: (key) => + ref.read(settingsProvider.notifier).setMaxReleaseExportTlp(key), + ), + const SizedBox(height: 12), + _tlpPolicyDropdown( + label: l10n.d('Vereist minimumniveau'), + help: l10n.d( + 'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.', + ), + noneLabel: l10n.d('Geen minimum'), + currentKey: settings.minRequiredExportTlpKey, + includeNoneLevel: false, + onChanged: (key) => + ref.read(settingsProvider.notifier).setMinRequiredExportTlp(key), + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + l10n.d('Classificatie verplicht'), + style: const TextStyle(fontSize: 13), + ), + subtitle: Text( + l10n.d('Weiger export wanneer het deck geen TLP-niveau heeft.'), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + value: settings.requireClassificationOnExport, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setRequireClassificationOnExport(value), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + l10n.d('Classificatie-watermerk'), + style: const TextStyle(fontSize: 13), + ), + subtitle: Text( + l10n.d( + 'Toon een diagonaal watermerk met TLP en organisatie op elke slide.', + ), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + value: settings.classificationWatermarkEnabled, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setClassificationWatermarkEnabled(value), + ), + ], + ); + } + + Widget _tlpPolicyDropdown({ + required String label, + required String help, + required String noneLabel, + required String? currentKey, + required ValueChanged onChanged, + bool includeNoneLevel = true, + }) { + final levels = includeNoneLevel + ? TlpLevel.values + : TlpLevel.values.where((level) => level != TlpLevel.none); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InputDecorator( + decoration: InputDecoration( + labelText: label, + isDense: true, + prefixIcon: const Icon(Icons.shield_outlined, size: 18), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: currentKey, + isExpanded: true, + isDense: true, + items: [ + DropdownMenuItem(value: null, child: Text(noneLabel)), + for (final level in levels) + DropdownMenuItem( + value: level.key, + child: Text(level.label), + ), + ], + onChanged: onChanged, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + help, + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ), + ], + ); + } + /// Dropdown met veelgebruikte doeltijden voor de presenter-aftelling. De /// opgeslagen waarde snapt naar de dichtstbijzijnde optie; fijnregelen kan /// live met toets K tijdens het presenteren. diff --git a/lib/widgets/panels/preview_panel.dart b/lib/widgets/panels/preview_panel.dart index 5daa044..d4bfff9 100644 --- a/lib/widgets/panels/preview_panel.dart +++ b/lib/widgets/panels/preview_panel.dart @@ -7,6 +7,7 @@ import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../state/deck_provider.dart'; import '../../state/editor_provider.dart'; +import '../../state/settings_provider.dart'; import '../../theme/app_theme.dart'; import '../../utils/url_launcher_util.dart'; import '../../l10n/app_localizations.dart'; @@ -90,6 +91,7 @@ class _PreviewPanelState extends ConsumerState { final deckState = ref.watch(deckProvider); final deck = deckState.deck!; final editor = ref.watch(editorProvider); + final settings = ref.watch(settingsProvider); final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1); final slide = deck.slides[idx]; @@ -237,6 +239,9 @@ class _PreviewPanelState extends ConsumerState { slideNumber: idx + 1, slideCount: deck.slides.length, tlp: deck.tlp, + organization: deck.organization, + showClassificationWatermark: + settings.classificationWatermarkEnabled, // In de editor mag audio/video bediend worden, maar // niet vanzelf starten (anders dreunt het door op // elke slide-wissel). @@ -341,7 +346,7 @@ class _PreviewPanelState extends ConsumerState { // ── Full-deck preview overlay ───────────────────────────────────────────────── -class FullDeckPreview extends StatelessWidget { +class FullDeckPreview extends ConsumerWidget { final Deck deck; final ThemeProfile themeProfile; @@ -352,8 +357,10 @@ class FullDeckPreview extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; + final showWatermark = + ref.watch(settingsProvider).classificationWatermarkEnabled; return Scaffold( backgroundColor: const Color(0xFF1E2028), appBar: AppBar( @@ -399,6 +406,8 @@ class FullDeckPreview extends StatelessWidget { slideNumber: i + 1, slideCount: deck.slides.length, tlp: deck.tlp, + organization: deck.organization, + showClassificationWatermark: showWatermark, ), ), ], diff --git a/lib/widgets/panels/slide_list_panel.dart b/lib/widgets/panels/slide_list_panel.dart index 961b3bd..82e5c86 100644 --- a/lib/widgets/panels/slide_list_panel.dart +++ b/lib/widgets/panels/slide_list_panel.dart @@ -215,6 +215,9 @@ class _SlideListPanelState extends ConsumerState { themeProfile: deck.themeProfile, projectPath: deck.projectPath, tlp: deck.tlp, + organization: deck.organization, + showClassificationWatermark: + ref.read(settingsProvider).classificationWatermarkEnabled, ); if (images.isNotEmpty) bytes = images.first; } catch (e) { @@ -535,6 +538,7 @@ class _SlideListPanelState extends ConsumerState { themeProfile: deck.themeProfile, slideCount: deck.slides.length, tlp: deck.tlp, + organization: deck.organization, onTap: () => _onSlideTap(index), onToggleSkip: () => notifier.toggleSkip(index), onCopyImage: () => _copySlideAsImage(slide), @@ -596,6 +600,7 @@ class _SlideListPanelState extends ConsumerState { themeProfile: deck.themeProfile, slideCount: deck.slides.length, tlp: deck.tlp, + organization: deck.organization, onTap: () => _onSlideTap(i), onToggleSkip: () => notifier.toggleSkip(i), onCopyImage: () => _copySlideAsImage(slide), diff --git a/lib/widgets/presentation/audience_window.dart b/lib/widgets/presentation/audience_window.dart index 6749269..bcb3b90 100644 --- a/lib/widgets/presentation/audience_window.dart +++ b/lib/widgets/presentation/audience_window.dart @@ -39,6 +39,8 @@ class _AudienceWindowAppState extends State { List _slides = const []; ThemeProfile _theme = const ThemeProfile(); TlpLevel _tlp = TlpLevel.none; + String _organization = ''; + bool _showClassificationWatermark = false; String? _projectPath; int _index = 0; int _blank = 0; // 0 = none, 1 = black, 2 = white @@ -62,6 +64,9 @@ class _AudienceWindowAppState extends State { _slides = deck?.slides ?? const []; _theme = deck?.themeProfile ?? const ThemeProfile(); _tlp = deck?.tlp ?? TlpLevel.none; + _organization = deck?.organization ?? ''; + _showClassificationWatermark = + widget.args['classificationWatermarkEnabled'] as bool? ?? false; // Pre-existing strokes passed at creation, keyed by index. final ink = widget.args['ink']; if (ink is Map) { @@ -189,6 +194,8 @@ class _AudienceWindowAppState extends State { slideNumber: _index + 1, slideCount: _slides.length, tlp: _tlp, + organization: _organization, + showClassificationWatermark: _showClassificationWatermark, presentationMode: true, onChecklistItemToggle: (column, itemIndex) => _send('checklistToggle', { diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index a8776a9..be749c8 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -58,6 +58,8 @@ class FullscreenPresenter extends StatefulWidget { final ThemeProfile themeProfile; final int initialIndex; final TlpLevel tlp; + final String organization; + final bool showClassificationWatermark; /// Optionele doeltijd voor de aftelling/oefenklok. Null = geen aftelling. /// Sessie-only; live aanpasbaar in de presenter (toets K). @@ -81,6 +83,8 @@ class FullscreenPresenter extends StatefulWidget { required this.themeProfile, required this.initialIndex, this.tlp = TlpLevel.none, + this.organization = '', + this.showClassificationWatermark = false, this.targetDuration, this.audience, this.initialAnnotations = const {}, @@ -98,6 +102,8 @@ class FullscreenPresenter extends StatefulWidget { required ThemeProfile themeProfile, required int initialIndex, TlpLevel tlp = TlpLevel.none, + String organization = '', + bool showClassificationWatermark = false, Duration? targetDuration, Map> annotations = const {}, void Function(Map>)? onAnnotationsChanged, @@ -128,6 +134,8 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + organization: organization, + showClassificationWatermark: showClassificationWatermark, targetDuration: targetDuration, annotations: annotations, onAnnotationsChanged: onAnnotationsChanged, @@ -141,6 +149,8 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + organization: organization, + showClassificationWatermark: showClassificationWatermark, targetDuration: targetDuration, annotations: annotations, onAnnotationsChanged: onAnnotationsChanged, @@ -156,6 +166,8 @@ class FullscreenPresenter extends StatefulWidget { required ThemeProfile themeProfile, required int initialIndex, TlpLevel tlp = TlpLevel.none, + String organization = '', + bool showClassificationWatermark = false, Duration? targetDuration, Map> annotations = const {}, void Function(Map>)? onAnnotationsChanged, @@ -176,6 +188,8 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + organization: organization, + showClassificationWatermark: showClassificationWatermark, targetDuration: targetDuration, initialAnnotations: annotations, onAnnotationsChanged: onAnnotationsChanged, @@ -203,6 +217,8 @@ class FullscreenPresenter extends StatefulWidget { required ThemeProfile themeProfile, required int initialIndex, TlpLevel tlp = TlpLevel.none, + String organization = '', + bool showClassificationWatermark = false, Duration? targetDuration, Map> annotations = const {}, void Function(Map>)? onAnnotationsChanged, @@ -219,6 +235,7 @@ class FullscreenPresenter extends StatefulWidget { projectPath: projectPath, themeProfile: themeProfile, tlp: tlp, + organization: organization, ), inlineStyleProfile: true, ); @@ -236,6 +253,7 @@ class FullscreenPresenter extends StatefulWidget { 'projectPath': projectPath, 'index': initialIndex, 'ink': inkByIndex, + 'classificationWatermarkEnabled': showClassificationWatermark, }); WindowController? audience; @@ -268,6 +286,8 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + organization: organization, + showClassificationWatermark: showClassificationWatermark, annotations: annotations, onAnnotationsChanged: onAnnotationsChanged, onSlideChanged: onSlideChanged, @@ -290,6 +310,8 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + organization: organization, + showClassificationWatermark: showClassificationWatermark, audience: audienceHandle, initialAnnotations: annotations, onAnnotationsChanged: onAnnotationsChanged, @@ -1609,6 +1631,9 @@ class _FullscreenPresenterState extends State { slideNumber: _index + 1, slideCount: widget.slides.length, tlp: widget.tlp, + organization: widget.organization, + showClassificationWatermark: + widget.showClassificationWatermark, presentationMode: true, onChecklistItemToggle: (column, itemIndex) => _toggleChecklistItem( @@ -1745,6 +1770,10 @@ class _FullscreenPresenterState extends State { slide: nextSlide, projectPath: widget.projectPath, themeProfile: widget.themeProfile, + tlp: widget.tlp, + organization: widget.organization, + showClassificationWatermark: + widget.showClassificationWatermark, presentationMode: true, ), ) @@ -2098,6 +2127,10 @@ class _FullscreenPresenterState extends State { slide: widget.slides[i], projectPath: widget.projectPath, themeProfile: widget.themeProfile, + tlp: widget.tlp, + organization: widget.organization, + showClassificationWatermark: + widget.showClassificationWatermark, ), ), ), diff --git a/lib/widgets/shell/status_bar.dart b/lib/widgets/shell/status_bar.dart index 17ca208..7858f63 100644 --- a/lib/widgets/shell/status_bar.dart +++ b/lib/widgets/shell/status_bar.dart @@ -233,23 +233,32 @@ class _ActionsDivider extends StatelessWidget { /// bij klikken een keuzelijst met alle niveaus (incl. "Geen"). class _TlpChip extends StatelessWidget { final TlpLevel tlp; + final bool warnUnset; final ValueChanged onSelected; - const _TlpChip({required this.tlp, required this.onSelected}); + const _TlpChip({ + required this.tlp, + this.warnUnset = false, + required this.onSelected, + }); @override Widget build(BuildContext context) { final l10n = context.l10n; final isSet = tlp != TlpLevel.none; final fg = Color(tlp.foreground); + final borderColor = warnUnset + ? const Color(0xFFF59E0B) + : (isSet ? fg.withValues(alpha: 0.7) : Colors.white24); final child = Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), decoration: BoxDecoration( - color: isSet ? Colors.black : Colors.transparent, + color: isSet ? Colors.black : (warnUnset ? Colors.black45 : Colors.transparent), borderRadius: BorderRadius.circular(6), border: Border.all( - color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24, + color: borderColor, + width: warnUnset ? 1.5 : 1, ), ), child: Row( @@ -279,7 +288,11 @@ class _TlpChip extends StatelessWidget { ); return PopupMenuButton( - tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'), + tooltip: warnUnset + ? l10n.d( + 'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.', + ) + : l10n.d('TLP-classificatie (Traffic Light Protocol)'), position: PopupMenuPosition.under, onSelected: onSelected, itemBuilder: (_) => [ diff --git a/lib/widgets/slides/previews/overlays.dart b/lib/widgets/slides/previews/overlays.dart index 8f9394b..5b3ae2c 100644 --- a/lib/widgets/slides/previews/overlays.dart +++ b/lib/widgets/slides/previews/overlays.dart @@ -58,6 +58,90 @@ double _tlpBadgeWidth(double w, TlpLevel tlp) => double _tlpVerticalReserve(double w) => w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w); +/// Hoogte van de classificatiebanner bovenaan de slide. +double _classificationBannerHeight(double w) => w * 0.038; + +TextStyle _tlpMarkingTextStyle(double w, TlpLevel tlp, {double scale = 1}) => + TextStyle( + color: Color(tlp.foreground), + fontSize: w * _kTlpFont * scale, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], + height: 1.0, + ); + +/// Volledige breedte bovenaan: de officiële TLP-markering als banner. +class _ClassificationBanner extends StatelessWidget { + final TlpLevel tlp; + final double w; + + const _ClassificationBanner({required this.tlp, required this.w}); + + @override + Widget build(BuildContext context) { + return Positioned( + top: 0, + left: 0, + right: 0, + height: _classificationBannerHeight(w), + child: ColoredBox( + color: Colors.black, + child: Center( + child: Text( + tlp.label, + style: _tlpMarkingTextStyle(w, tlp, scale: 1.05), + ), + ), + ), + ); + } +} + +/// Optioneel diagonaal watermerk over de slide-inhoud. +class _ClassificationWatermark extends StatelessWidget { + final TlpLevel tlp; + final double w; + final String organization; + + const _ClassificationWatermark({ + required this.tlp, + required this.w, + this.organization = '', + }); + + String get _label { + final org = organization.trim(); + return org.isEmpty ? tlp.label : '${tlp.label} · $org'; + } + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: IgnorePointer( + child: Center( + child: Transform.rotate( + angle: -0.35, + child: Text( + _label, + textAlign: TextAlign.center, + style: TextStyle( + color: Color(tlp.foreground).withValues(alpha: 0.12), + fontSize: w * 0.105, + fontWeight: FontWeight.w800, + letterSpacing: w * 0.003, + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], + ), + ), + ), + ), + ), + ); + } +} + /// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak, /// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat. class _TlpOverlay extends StatelessWidget { @@ -91,15 +175,7 @@ class _TlpOverlay extends StatelessWidget { ), child: Text( tlp.label, - style: TextStyle( - color: Color(tlp.foreground), - fontSize: w * _kTlpFont, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - fontFamily: 'monospace', - fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], - height: 1.0, - ), + style: _tlpMarkingTextStyle(w, tlp), ), ), ); diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index acbf7a5..3874402 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -144,9 +144,16 @@ class SlidePreviewWidget extends StatelessWidget { final int? slideNumber; final int? slideCount; - /// TLP-classificatie van de presentatie; getoond als markering op de slide. + /// TLP-classificatie van de presentatie (deck-niveau). Samen met + /// [slide.tlp] bepaalt dit de zichtbare markering via [effectiveTlp]. final TlpLevel tlp; + /// Diagonaal classificatie-watermerk (organisatie-instelling). + final bool showClassificationWatermark; + + /// Organisatienaam voor het watermerk (uit deck-metadata). + final String organization; + /// Of audio/video op deze slide afgespeeld kan worden (de audioknop verschijnt /// en video kan starten). Standaard uit — thumbnails en export spelen niets. final bool enableMedia; @@ -178,6 +185,8 @@ class SlidePreviewWidget extends StatelessWidget { this.slideNumber, this.slideCount, this.tlp = TlpLevel.none, + this.showClassificationWatermark = false, + this.organization = '', this.enableMedia = false, this.autoplayMedia = false, this.presentationMode = false, @@ -188,8 +197,9 @@ class SlidePreviewWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final markingTlp = effectiveTlp(deckTlp: tlp, slideTlp: slide.tlp); final hasBottomRightTlp = - tlp != TlpLevel.none && + markingTlp != TlpLevel.none && !((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) && themeProfile.logoPosition == 'bottom-right'); // Make the widget self-sufficient for text rendering. On screen it sits @@ -227,6 +237,7 @@ class SlidePreviewWidget extends StatelessWidget { } Widget _buildSlide() { + final markingTlp = effectiveTlp(deckTlp: tlp, slideTlp: slide.tlp); return LayoutBuilder( builder: (_, constraints) { final w = constraints.maxWidth; @@ -237,17 +248,23 @@ class SlidePreviewWidget extends StatelessWidget { fit: StackFit.expand, children: [ _buildContent(w), + if (showClassificationWatermark && markingTlp != TlpLevel.none) + _ClassificationWatermark( + tlp: markingTlp, + w: w, + organization: organization, + ), _FooterOverlay( slide: slide, w: w, profile: themeProfile, slideNumber: slideNumber, slideCount: slideCount, - tlp: tlp, + tlp: markingTlp, ), - if (tlp != TlpLevel.none) + if (markingTlp != TlpLevel.none) _TlpOverlay( - tlp: tlp, + tlp: markingTlp, w: w, profile: themeProfile, hasLogo: @@ -261,6 +278,8 @@ class SlidePreviewWidget extends StatelessWidget { position: themeProfile.logoPosition, size: w * (themeProfile.logoSize / 1280), ), + if (markingTlp != TlpLevel.none) + _ClassificationBanner(tlp: markingTlp, w: w), if (enableMedia && slide.audioPath.isNotEmpty) _AudioPlayback( audioPath: slide.audioPath, diff --git a/lib/widgets/slides/slide_thumbnail.dart b/lib/widgets/slides/slide_thumbnail.dart index f4c2e4c..f82a8dc 100644 --- a/lib/widgets/slides/slide_thumbnail.dart +++ b/lib/widgets/slides/slide_thumbnail.dart @@ -5,6 +5,7 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../state/deck_quality_provider.dart'; +import '../../state/settings_provider.dart'; import '../../state/slide_clipboard_provider.dart'; import '../../theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; @@ -22,6 +23,7 @@ class SlideThumbnail extends ConsumerWidget { final ThemeProfile themeProfile; final int slideCount; final TlpLevel tlp; + final String organization; final VoidCallback onTap; final VoidCallback onDuplicate; final VoidCallback onDelete; @@ -43,6 +45,7 @@ class SlideThumbnail extends ConsumerWidget { this.themeProfile = const ThemeProfile(), this.slideCount = 1, this.tlp = TlpLevel.none, + this.organization = '', }); @override @@ -50,6 +53,8 @@ class SlideThumbnail extends ConsumerWidget { final l10n = context.l10n; final skipped = slide.skipped; final slideIssues = ref.watch(deckQualityProvider).forSlide(index); + final showWatermark = + ref.watch(settingsProvider).classificationWatermarkEnabled; final hasQualityErrors = slideIssues.any( (i) => i.severity == MarkdownValidationSeverity.error, ); @@ -111,6 +116,8 @@ class SlideThumbnail extends ConsumerWidget { slideNumber: index + 1, slideCount: slideCount, tlp: tlp, + organization: organization, + showClassificationWatermark: showWatermark, ), ), if (skipped) diff --git a/test/classification_enforcement_policy_test.dart b/test/classification_enforcement_policy_test.dart new file mode 100644 index 0000000..15c0263 --- /dev/null +++ b/test/classification_enforcement_policy_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/services/classification_enforcement_policy.dart'; +import 'package:ocideck/services/classification_policy.dart'; + +void main() { + group('ClassificationEnforcementPolicy', () { + test('without rules every level is allowed', () { + const policy = ClassificationEnforcementPolicy(); + expect(policy.hasGate, isFalse); + for (final level in TlpLevel.values) { + expect(policy.evaluate(level).allowed, isTrue, reason: level.name); + } + }); + + group('release ceiling (plafond)', () { + test('allows levels at or below the ceiling', () { + const policy = ClassificationEnforcementPolicy( + maxReleaseLevel: TlpLevel.amber, + ); + expect(policy.hasGate, isTrue); + for (final level in [ + TlpLevel.none, + TlpLevel.clear, + TlpLevel.green, + TlpLevel.amber, + ]) { + expect(policy.evaluate(level).allowed, isTrue, reason: level.name); + } + }); + + test('blocks levels above the ceiling', () { + const policy = ClassificationEnforcementPolicy( + maxReleaseLevel: TlpLevel.amber, + ); + for (final level in [TlpLevel.amberStrict, TlpLevel.red]) { + final decision = policy.evaluate(level); + expect(decision.allowed, isFalse, reason: level.name); + expect(decision.reason, contains(level.label)); + expect(decision.reason, contains(TlpLevel.amber.label)); + } + }); + + test('a ceiling of none only allows unclassified decks', () { + const policy = ClassificationEnforcementPolicy( + maxReleaseLevel: TlpLevel.none, + ); + expect(policy.evaluate(TlpLevel.none).allowed, isTrue); + expect(policy.evaluate(TlpLevel.clear).allowed, isFalse); + }); + }); + + group('minimum level (vloer)', () { + test('blocks decks below the required minimum', () { + const policy = ClassificationEnforcementPolicy( + minRequiredLevel: TlpLevel.green, + ); + expect(policy.evaluate(TlpLevel.none).allowed, isFalse); + expect(policy.evaluate(TlpLevel.clear).allowed, isFalse); + expect(policy.evaluate(TlpLevel.green).allowed, isTrue); + expect(policy.evaluate(TlpLevel.amber).allowed, isTrue); + }); + + test('explains when the deck is unclassified', () { + const policy = ClassificationEnforcementPolicy( + minRequiredLevel: TlpLevel.green, + ); + final decision = policy.evaluate(TlpLevel.none); + expect(decision.reason, contains('niet geclassificeerd')); + expect(decision.reason, contains(TlpLevel.green.label)); + }); + }); + + group('requireClassification', () { + test('blocks unclassified decks when enabled', () { + const policy = ClassificationEnforcementPolicy( + requireClassification: true, + ); + expect(policy.hasGate, isTrue); + expect(policy.evaluate(TlpLevel.none).allowed, isFalse); + expect(policy.evaluate(TlpLevel.clear).allowed, isTrue); + }); + + test('does not block classified decks on its own', () { + const policy = ClassificationEnforcementPolicy( + requireClassification: true, + ); + for (final level in TlpLevel.values.where((l) => l != TlpLevel.none)) { + expect(policy.evaluate(level).allowed, isTrue, reason: level.name); + } + }); + }); + + test('floor is checked before ceiling', () { + const policy = ClassificationEnforcementPolicy( + minRequiredLevel: TlpLevel.green, + maxReleaseLevel: TlpLevel.amber, + ); + final low = policy.evaluate(TlpLevel.clear); + expect(low.allowed, isFalse); + expect(low.reason, contains('minimum')); + + final high = policy.evaluate(TlpLevel.red); + expect(high.allowed, isFalse); + expect(high.reason, contains('vrijgaveniveau')); + }); + + group('fromAppSettings', () { + test('maps stored keys and flags', () { + const settings = AppSettings( + maxReleaseExportTlpKey: 'amber', + minRequiredExportTlpKey: 'green', + requireClassificationOnExport: true, + classificationWatermarkEnabled: true, + ); + final policy = ClassificationEnforcementPolicy.fromAppSettings(settings); + expect(policy.maxReleaseLevel, TlpLevel.amber); + expect(policy.minRequiredLevel, TlpLevel.green); + expect(policy.requireClassification, isTrue); + expect(policy.hasGate, isTrue); + }); + + test('defaults leave enforcement off', () { + final policy = ClassificationEnforcementPolicy.fromAppSettings( + const AppSettings(), + ); + expect(policy.hasGate, isFalse); + expect(policy.evaluate(TlpLevel.red).allowed, isTrue); + }); + }); + + test('releasePolicy matches the legacy ClassificationPolicy', () { + const enforcement = ClassificationEnforcementPolicy( + maxReleaseLevel: TlpLevel.green, + minRequiredLevel: TlpLevel.clear, + requireClassification: true, + ); + const legacy = ClassificationPolicy(maxReleaseLevel: TlpLevel.green); + for (final level in TlpLevel.values) { + expect( + enforcement.releasePolicy.evaluate(level).allowed, + legacy.evaluate(level).allowed, + reason: level.name, + ); + } + }); + + test('fromMaxReleaseKey is backward compatible with fromKey', () { + final enforcement = ClassificationEnforcementPolicy.fromMaxReleaseKey( + TlpLevel.amber.key, + ); + final legacy = ClassificationPolicy.fromKey(TlpLevel.amber.key); + for (final level in TlpLevel.values) { + expect( + enforcement.evaluate(level).allowed, + legacy.evaluate(level).allowed, + reason: level.name, + ); + } + }); + }); +} diff --git a/test/export_metadata_test.dart b/test/export_metadata_test.dart new file mode 100644 index 0000000..d5474bb --- /dev/null +++ b/test/export_metadata_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/services/export_metadata.dart'; + +void main() { + group('ExportDocumentMetadata', () { + test('subject prefixes classification when set', () { + const meta = ExportDocumentMetadata( + title: 'Kwartaalupdate', + tlp: TlpLevel.amber, + ); + expect(meta.subject('deck'), 'TLP:AMBER — Kwartaalupdate'); + }); + + test('subject falls back to title when unclassified', () { + const meta = ExportDocumentMetadata(title: 'Kwartaalupdate'); + expect(meta.subject('deck'), 'Kwartaalupdate'); + expect(meta.subject('deck'), meta.displayTitle('deck')); + }); + + test('exportKeywords merges deck keywords and TLP markers', () { + const meta = ExportDocumentMetadata( + keywords: 'kwartaal, cijfers', + tlp: TlpLevel.green, + ); + expect( + meta.exportKeywords(), + 'kwartaal, cijfers, TLP, TLP:GREEN, green, OciDeck', + ); + }); + + test('exportKeywords always includes OciDeck', () { + expect(const ExportDocumentMetadata().exportKeywords(), 'OciDeck'); + }); + + test('documentAuthor prefers author over organization', () { + const meta = ExportDocumentMetadata( + author: 'Alex', + organization: 'Acme', + ); + expect(meta.documentAuthor, 'Alex'); + }); + + test('fromDeck copies deck fields', () { + final meta = ExportDocumentMetadata.fromDeck( + Deck( + title: 'Rapport', + author: 'Bob', + organization: 'Org', + description: 'Intern', + keywords: 'rapport', + tlp: TlpLevel.red, + slides: [Slide.create(SlideType.title)], + ), + ); + expect(meta.subject('x'), 'TLP:RED — Rapport'); + expect(meta.exportKeywords(), contains('TLP:RED')); + expect(meta.documentAuthor, 'Bob'); + }); + }); +} diff --git a/test/export_service_test.dart b/test/export_service_test.dart index 0810b1f..0e85b9d 100644 --- a/test/export_service_test.dart +++ b/test/export_service_test.dart @@ -8,8 +8,10 @@ import 'package:image/image.dart' as img; import 'package:ocideck/models/deck.dart'; import 'package:ocideck/models/markdown_validation.dart'; import 'package:ocideck/models/slide_quality.dart'; +import 'package:ocideck/services/classification_enforcement_policy.dart'; import 'package:ocideck/services/classification_policy.dart'; import 'package:ocideck/services/export_service.dart'; +import 'package:ocideck/services/export_metadata.dart'; import 'package:ocideck/services/quality_export_policy.dart'; import 'package:ocideck/services/marp_html_service.dart'; import 'package:path/path.dart' as p; @@ -64,13 +66,15 @@ void main() { test( 'classificatie-gate blocks an over-classified export, writes nothing', () async { - const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.green); + const policy = ClassificationEnforcementPolicy( + maxReleaseLevel: TlpLevel.green, + ); final r = await service.export( deckPath(), ExportFormat.pdf, [_png()], tlp: TlpLevel.red, - policy: policy, + enforcementPolicy: policy, ); expect(r.success, isFalse); @@ -85,17 +89,62 @@ void main() { ); test('classificatie-gate allows an export at or below the ceiling', () async { - const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber); + const policy = ClassificationEnforcementPolicy( + maxReleaseLevel: TlpLevel.amber, + ); final r = await service.export( deckPath(), ExportFormat.pdf, [_png()], tlp: TlpLevel.green, - policy: policy, + enforcementPolicy: policy, ); expect(r.success, isTrue, reason: r.error); }); + test( + 'enforcement blocks export below the required minimum, writes nothing', + () async { + const policy = ClassificationEnforcementPolicy( + minRequiredLevel: TlpLevel.green, + ); + final r = await service.export( + deckPath(), + ExportFormat.pdf, + [_png()], + tlp: TlpLevel.clear, + enforcementPolicy: policy, + ); + + expect(r.success, isFalse); + expect(r.error, contains('minimum')); + expect( + tmp.listSync().whereType().where( + (f) => p.extension(f.path) == '.pdf', + ), + isEmpty, + ); + }, + ); + + test( + 'enforcement blocks unclassified export when classification is required', + () async { + const policy = ClassificationEnforcementPolicy( + requireClassification: true, + ); + final r = await service.export( + deckPath(), + ExportFormat.pdf, + [_png()], + enforcementPolicy: policy, + ); + + expect(r.success, isFalse); + expect(r.error, contains('TLP-niveau')); + }, + ); + test( 'quality gate blocks export until acknowledged, writes nothing', () async { @@ -140,6 +189,27 @@ void main() { expect(String.fromCharCodes(bytes.take(4)), '%PDF'); }); + test('PDF embeds classification metadata when classified', () async { + const metadata = ExportDocumentMetadata( + title: 'Kwartaalupdate', + author: 'Alex', + keywords: 'kwartaal', + tlp: TlpLevel.amber, + ); + final r = await service.export( + deckPath(), + ExportFormat.pdf, + [_png()], + tlp: TlpLevel.amber, + metadata: metadata, + ); + expect(r.success, isTrue, reason: r.error); + final text = String.fromCharCodes(await File(r.outputPath!).readAsBytes()); + expect(text, contains('TLP:AMBER')); + expect(text, contains('Kwartaalupdate')); + expect(text, contains('OciDeck')); + }); + test('exports a valid PPTX zip with the expected parts', () async { final images = [_png(), _png()]; final r = await service.export(deckPath(), ExportFormat.pptx, images); @@ -153,6 +223,8 @@ void main() { expect(names, contains('[Content_Types].xml')); expect(names, contains('_rels/.rels')); + expect(names, contains('docProps/core.xml')); + expect(names, contains('docProps/app.xml')); expect(names, contains('ppt/presentation.xml')); expect(names, contains('ppt/slideMasters/slideMaster1.xml')); expect(names, contains('ppt/slideLayouts/slideLayout1.xml')); @@ -175,6 +247,41 @@ void main() { } }); + test('PPTX core properties carry classification metadata', () async { + const metadata = ExportDocumentMetadata( + title: 'Strategie', + organization: 'Acme BV', + tlp: TlpLevel.green, + ); + final r = await service.export( + deckPath(), + ExportFormat.pptx, + [_png()], + tlp: TlpLevel.green, + metadata: metadata, + ); + expect(r.success, isTrue, reason: r.error); + + final archive = ZipDecoder().decodeBytes( + await File(r.outputPath!).readAsBytes(), + ); + final core = utf8.decode( + archive.files.firstWhere((f) => f.name == 'docProps/core.xml').content + as List, + ); + expect(core, contains('Strategie')); + expect(core, contains('TLP:GREEN — Strategie')); + expect(core, contains('')); + expect(core, contains('TLP:GREEN')); + + final app = utf8.decode( + archive.files.firstWhere((f) => f.name == 'docProps/app.xml').content + as List, + ); + expect(app, contains('Acme BV')); + expect(app, contains('OciDeck')); + }); + test('PPTX without notes has no notesSlide/notesMaster parts', () async { final r = await service.export(deckPath(), ExportFormat.pptx, [_png()]); final archive = ZipDecoder().decodeBytes( diff --git a/test/marp_html_service_test.dart b/test/marp_html_service_test.dart index 1941acb..5769682 100644 --- a/test/marp_html_service_test.dart +++ b/test/marp_html_service_test.dart @@ -2,7 +2,9 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/deck.dart'; import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/services/export_metadata.dart'; import 'package:ocideck/services/marp_html_service.dart'; /// Reads the vendored libraries straight from the repo (tests run at the root). @@ -69,6 +71,26 @@ void main() {} expect(html, isNot(contains(' bar'); diff --git a/test/tlp_test.dart b/test/tlp_test.dart index 3e96072..9c6728f 100644 --- a/test/tlp_test.dart +++ b/test/tlp_test.dart @@ -76,6 +76,34 @@ void main() { }); }); + group('effectiveTlp', () { + test('uses the stricter of deck and slide level', () { + expect( + effectiveTlp(deckTlp: TlpLevel.green, slideTlp: TlpLevel.none), + TlpLevel.green, + ); + expect( + effectiveTlp(deckTlp: TlpLevel.green, slideTlp: TlpLevel.amber), + TlpLevel.amber, + ); + expect( + effectiveTlp(deckTlp: TlpLevel.none, slideTlp: TlpLevel.red), + TlpLevel.red, + ); + expect( + effectiveTlp(deckTlp: TlpLevel.amberStrict, slideTlp: TlpLevel.amber), + TlpLevel.amberStrict, + ); + }); + + test('returns none when neither level is set', () { + expect( + effectiveTlp(deckTlp: TlpLevel.none, slideTlp: TlpLevel.none), + TlpLevel.none, + ); + }); + }); + group('TLP marking on slides', () { Widget host(TlpLevel tlp) => MaterialApp( home: Scaffold( @@ -97,7 +125,8 @@ void main() { testWidgets('renders the marking when a level is set', (tester) async { await tester.pumpWidget(host(TlpLevel.red)); await tester.pump(); - expect(find.text('TLP:RED'), findsOneWidget); + // Banner bovenaan + hoek-badge. + expect(find.text('TLP:RED'), findsNWidgets(2)); }); testWidgets('renders nothing when none', (tester) async { @@ -106,6 +135,55 @@ void main() { expect(find.textContaining('TLP:'), findsNothing); }); + testWidgets('uses the stricter per-slide classification', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 800, + height: 450, + child: SlidePreviewWidget( + slide: Slide.create( + SlideType.bullets, + ).copyWith(title: 'T', bullets: ['a'], tlp: TlpLevel.amber), + tlp: TlpLevel.green, + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(find.text('TLP:AMBER'), findsNWidgets(2)); + expect(find.text('TLP:GREEN'), findsNothing); + }); + + testWidgets('shows a diagonal watermark when enabled', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 800, + height: 450, + child: SlidePreviewWidget( + slide: Slide.create( + SlideType.bullets, + ).copyWith(title: 'T', bullets: ['a']), + tlp: TlpLevel.amber, + organization: 'Acme BV', + showClassificationWatermark: true, + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(find.text('TLP:AMBER · Acme BV'), findsOneWidget); + }); + testWidgets('right-side image caption aligns with the TLP badge', ( tester, ) async { @@ -131,7 +209,15 @@ void main() { await tester.pump(); final captionRight = tester.getTopRight(find.text(caption)).dx; - final tlpRight = tester.getTopRight(find.text('TLP:RED')).dx; + final tlpMarks = find.text('TLP:RED'); + expect(tlpMarks, findsNWidgets(2)); + var tlpRight = 0.0; + for (var i = 0; i < 2; i++) { + final topRight = tester.getTopRight(tlpMarks.at(i)); + if (topRight.dy > 200) { + tlpRight = topRight.dx; + } + } expect( (captionRight - tlpRight).abs(), -- 2.45.3