Add TLP classification enforcement with visual marking and export metadata #9

Merged
brenno merged 1 commit from feature/classification-enforcement into main 2026-06-16 08:52:08 +00:00
25 changed files with 1296 additions and 47 deletions

View file

@ -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.',
},
};

View file

@ -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 {

View file

@ -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.02.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<String>? 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,

View file

@ -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);
}

View file

@ -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 = <String>[];
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;
}

View file

@ -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<Uint8List> _buildPdf(
List<Uint8List> 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<Uint8List> images, {List<String>? notes}) {
Uint8List _buildPptx(
List<Uint8List> images, {
required ExportDocumentMetadata metadata,
required String fallbackTitle,
List<String>? 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 {
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Default Extension="png" ContentType="image/png"/>'
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
'<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
'<Override PartName="/ppt/presProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"/>'
'<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>'
@ -381,9 +426,55 @@ class ExportService {
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>'
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
'</Relationships>';
}
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
? ''
: '<dc:description>${_xmlEscape(description)}</dc:description>';
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<cp:coreProperties '
'xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
'xmlns:dcterms="http://purl.org/dc/terms/" '
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
'<dc:title>$title</dc:title>'
'<dc:subject>$subject</dc:subject>'
'<dc:creator>$creator</dc:creator>'
'$descXml'
'<cp:keywords>$keywords</cp:keywords>'
'<cp:lastModifiedBy>${_xmlEscape(metadata.producer)}</cp:lastModifiedBy>'
'<dcterms:created xsi:type="dcterms:W3CDTF">${iso(now)}</dcterms:created>'
'<dcterms:modified xsi:type="dcterms:W3CDTF">${iso(now)}</dcterms:modified>'
'</cp:coreProperties>';
}
String _appProps(ExportDocumentMetadata metadata) {
final company = metadata.organization.trim();
final companyXml = company.isEmpty
? ''
: '<Company>${_xmlEscape(company)}</Company>';
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
'<Application>${_xmlEscape(metadata.producer)}</Application>'
'$companyXml'
'</Properties>';
}
String _presentationXml(int count, bool hasNotes) {
final sldIds = StringBuffer();
for (var i = 0; i < count; i++) {

View file

@ -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<String> build(String deckMarkdown, {ThemeProfile? theme}) async {
Future<String> 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) => '<script>${_guard(code)}</script>';
final meta = metadata ?? const ExportDocumentMetadata();
final title = _htmlAttr(meta.displayTitle(fallbackTitle));
final headMeta = _htmlHeadMeta(meta, fallbackTitle: fallbackTitle);
final banner = meta.htmlClassification == null
? ''
: '<div class="tlp-export-banner">${_htmlAttr(meta.htmlClassification!)}</div>';
return '<!doctype html>\n'
'<html lang="nl"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<title>OciDeck export</title>'
'<title>$title</title>'
'$headMeta'
'<style>$css\n$hljsCss</style>'
'<script>$_mathjaxConfig</script>'
'${inline(marked)}'
@ -68,6 +83,7 @@ class MarpHtmlService {
'${inline(mathjax)}'
'${inline(mermaid)}'
'</head><body>'
'$banner'
'$sections'
'${inline(_renderScript)}'
'</body></html>';
@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
}
static String _htmlHeadMeta(
ExportDocumentMetadata meta, {
required String fallbackTitle,
}) {
final buf = StringBuffer();
void tag(String name, String content) {
if (content.trim().isEmpty) return;
buf.write('<meta name="$name" content="${_htmlAttr(content)}">');
}
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});}

View file

@ -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,
),
),
),

View file

@ -55,6 +55,11 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
: '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<AppSettings> {
}
}
/// Stel het vereiste minimumniveau voor export in, of `null` om uit te zetten.
Future<void> 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<void> setRequireClassificationOnExport(bool enabled) async {
state = state.copyWith(requireClassificationOnExport: enabled);
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('requireClassificationOnExport', enabled);
}
Future<void> setClassificationWatermarkEnabled(bool enabled) async {
state = state.copyWith(classificationWatermarkEnabled: enabled);
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('classificationWatermarkEnabled', enabled);
}
Future<void> setUiTextScale(double scale) async {
final clamped = scale.clamp(1.0, 2.0).toDouble();
state = state.copyWith(uiTextScale: clamped);

View file

@ -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),

View file

@ -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<void> 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<ExportDialog> {
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<ExportDialog> {
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<ExportDialog> {
// 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,

View file

@ -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<SettingsDialog> {
.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<SettingsDialog> {
);
}
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<String?> 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<String?>(
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.

View file

@ -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<PreviewPanel> {
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<PreviewPanel> {
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<PreviewPanel> {
// 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,
),
),
],

View file

@ -215,6 +215,9 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
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<SlideListPanel> {
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<SlideListPanel> {
themeProfile: deck.themeProfile,
slideCount: deck.slides.length,
tlp: deck.tlp,
organization: deck.organization,
onTap: () => _onSlideTap(i),
onToggleSkip: () => notifier.toggleSkip(i),
onCopyImage: () => _copySlideAsImage(slide),

View file

@ -39,6 +39,8 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
List<Slide> _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<AudienceWindowApp> {
_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<AudienceWindowApp> {
slideNumber: _index + 1,
slideCount: _slides.length,
tlp: _tlp,
organization: _organization,
showClassificationWatermark: _showClassificationWatermark,
presentationMode: true,
onChecklistItemToggle: (column, itemIndex) =>
_send('checklistToggle', {

View file

@ -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<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? 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<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? 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<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? 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<FullscreenPresenter> {
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<FullscreenPresenter> {
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<FullscreenPresenter> {
slide: widget.slides[i],
projectPath: widget.projectPath,
themeProfile: widget.themeProfile,
tlp: widget.tlp,
organization: widget.organization,
showClassificationWatermark:
widget.showClassificationWatermark,
),
),
),

View file

@ -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<TlpLevel> 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<TlpLevel>(
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: (_) => [

View file

@ -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),
),
),
);

View file

@ -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,

View file

@ -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)

View file

@ -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,
);
}
});
});
}

View file

@ -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');
});
});
}

View file

@ -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<File>().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<int>,
);
expect(core, contains('<dc:title>Strategie</dc:title>'));
expect(core, contains('<dc:subject>TLP:GREEN — Strategie</dc:subject>'));
expect(core, contains('<cp:keywords>'));
expect(core, contains('TLP:GREEN'));
final app = utf8.decode(
archive.files.firstWhere((f) => f.name == 'docProps/app.xml').content
as List<int>,
);
expect(app, contains('<Company>Acme BV</Company>'));
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(

View file

@ -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('<script src')));
});
test('build() stamps classification metadata and banner in the head', () async {
final service = MarpHtmlService(loadAsset: _diskLoader);
const md = '# Slide\n';
final html = await service.build(
md,
metadata: const ExportDocumentMetadata(
title: 'Rapport',
author: 'Bob',
tlp: TlpLevel.amber,
),
fallbackTitle: 'deck',
);
expect(html, contains('<title>Rapport</title>'));
expect(html, contains('name="classification" content="TLP:AMBER"'));
expect(html, contains('name="tlp" content="amber"'));
expect(html, contains('name="author" content="Bob"'));
expect(html, contains('class="tlp-export-banner">TLP:AMBER</div>'));
});
test('build() neutralises a closing-script breakout in content', () async {
final service = MarpHtmlService(loadAsset: _diskLoader);
final html = await service.build('# X\n\nfoo </script> bar');

View file

@ -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(),