Add TLP classification enforcement with visual marking and export metadata #9
25 changed files with 1296 additions and 47 deletions
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
|
|
|
|||
87
lib/services/classification_enforcement_policy.dart
Normal file
87
lib/services/classification_enforcement_policy.dart
Normal 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);
|
||||
}
|
||||
70
lib/services/export_metadata.dart
Normal file
70
lib/services/export_metadata.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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('&', '&')
|
||||
.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('<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});}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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: (_) => [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
163
test/classification_enforcement_policy_test.dart
Normal file
163
test/classification_enforcement_policy_test.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
62
test/export_metadata_test.dart
Normal file
62
test/export_metadata_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue