import '../models/chart.dart'; import '../models/deck.dart'; import '../models/markdown_validation.dart'; import '../models/settings.dart'; import '../models/slide.dart'; import '../models/slide_quality.dart'; import '../utils/color_contrast.dart'; import '../widgets/slides/inline_markdown.dart'; import 'slide_layout_metrics.dart'; /// Analyses deck slides for accessibility and readability quality issues. class SlideQualityAnalyzer { const SlideQualityAnalyzer(); SlideQualityResult analyze(Deck deck) => analyzeSlides( slides: deck.slides, theme: deck.themeProfile, font: deck.themeProfile.fontFamily, ); SlideQualityResult analyzeSlides({ required List slides, required ThemeProfile theme, required String font, }) { final issues = []; _checkThemeContrast(theme, issues); for (var i = 0; i < slides.length; i++) { final slide = slides[i]; if (slide.skipped) continue; _checkAltText(slide, i, issues); _checkSlideContrast(slide, i, theme, issues); _checkTextDensity(slide, i, font, issues); } return SlideQualityResult(issues); } void _checkThemeContrast(ThemeProfile theme, List issues) { void addPairIssue({ required String label, required String foreground, required String background, required bool largeText, String? field, }) { final ratio = hexContrastRatio(foreground, background); if (ratio == null) return; final aaThreshold = largeText ? kWcagAaLargeText : kWcagAaNormalText; if (ratio >= aaThreshold) return; final severity = !largeText && ratio < kWcagCriticalBodyText ? MarkdownValidationSeverity.error : MarkdownValidationSeverity.warning; issues.add( SlideQualityIssue( slideIndex: kDeckWideSlideIndex, kind: SlideQualityIssueKind.themeContrast, category: SlideQualityCategory.contrast, severity: severity, field: field, args: { 'label': label, 'ratio': ratio.toStringAsFixed(1), 'threshold': aaThreshold.toStringAsFixed(1), 'largeText': largeText.toString(), }, ), ); } addPairIssue( label: 'Thema bodytekst', foreground: theme.textColor, background: theme.slideBackgroundColor, largeText: false, field: 'textColor', ); addPairIssue( label: 'Thema titel', foreground: theme.titleTextColor, background: theme.titleBackgroundColor, largeText: true, field: 'titleTextColor', ); addPairIssue( label: 'Thema tabeltekst', foreground: theme.tableTextColor, background: theme.slideBackgroundColor, largeText: false, field: 'tableTextColor', ); addPairIssue( label: 'Thema tabelkop', foreground: theme.tableHeaderTextColor, background: theme.tableHeaderBackgroundColor, largeText: true, field: 'tableHeaderTextColor', ); addPairIssue( label: 'Thema code', foreground: theme.codeTextColor, background: theme.codeBackgroundColor, largeText: false, field: 'codeTextColor', ); } void _checkSlideContrast( Slide slide, int index, ThemeProfile theme, List issues, ) { switch (slide.type) { case SlideType.section: _addSlidePairIssue( issues: issues, slideIndex: index, label: 'Tussentitel', foreground: theme.titleTextColor, background: theme.sectionBackgroundColor, ); case SlideType.title: if (slide.imagePath.isNotEmpty) { _addImageContrastNote(issues, index); } case SlideType.quote: if (slide.imagePath.isNotEmpty) { _addImageContrastNote(issues, index); } default: break; } } void _addImageContrastNote(List issues, int index) { if (issues.any( (i) => i.slideIndex == index && i.kind == SlideQualityIssueKind.imageContrastUnverified, )) { return; } issues.add( SlideQualityIssue( slideIndex: index, kind: SlideQualityIssueKind.imageContrastUnverified, category: SlideQualityCategory.contrast, severity: MarkdownValidationSeverity.warning, ), ); } void _addSlidePairIssue({ required List issues, required int slideIndex, required String label, required String foreground, required String background, }) { final ratio = hexContrastRatio(foreground, background); if (ratio == null) return; const aaThreshold = kWcagAaLargeText; if (ratio >= aaThreshold) return; issues.add( SlideQualityIssue( slideIndex: slideIndex, kind: SlideQualityIssueKind.slideContrast, category: SlideQualityCategory.contrast, severity: MarkdownValidationSeverity.warning, args: { 'label': label, 'ratio': ratio.toStringAsFixed(1), 'threshold': aaThreshold.toStringAsFixed(1), }, ), ); } void _checkAltText(Slide slide, int index, List issues) { void missingCaption({ required String path, required String caption, required String field, required String label, }) { if (path.trim().isEmpty || caption.trim().isNotEmpty) return; issues.add( SlideQualityIssue( slideIndex: index, kind: SlideQualityIssueKind.missingAltCaption, category: SlideQualityCategory.altText, severity: MarkdownValidationSeverity.warning, field: field, args: {'label': label}, ), ); } switch (slide.type) { case SlideType.image: missingCaption( path: slide.imagePath, caption: slide.imageCaption, field: 'imageCaption', label: 'Afbeelding', ); case SlideType.twoImages: missingCaption( path: slide.imagePath, caption: slide.imageCaption, field: 'imageCaption', label: 'Eerste afbeelding', ); missingCaption( path: slide.imagePath2, caption: slide.imageCaption2, field: 'imageCaption2', label: 'Tweede afbeelding', ); case SlideType.bulletsImage: missingCaption( path: slide.imagePath, caption: slide.imageCaption, field: 'imageCaption', label: 'Afbeelding', ); case SlideType.title: if (slide.imagePath.isNotEmpty) { missingCaption( path: slide.imagePath, caption: slide.imageCaption, field: 'imageCaption', label: 'Achtergrondafbeelding', ); } case SlideType.quote: if (slide.imagePath.isNotEmpty) { missingCaption( path: slide.imagePath, caption: slide.imageCaption, field: 'imageCaption', label: 'Achtergrondafbeelding', ); } case SlideType.chart: _checkChartAltText(slide, index, issues); case SlideType.video: _checkMediaAltText( slide: slide, index: index, issues: issues, hasMedia: slide.videoPath.trim().isNotEmpty, label: 'Video', ); case SlideType.bullets: case SlideType.twoBullets: case SlideType.section: case SlideType.table: case SlideType.freeMarkdown: case SlideType.code: break; } } void _checkChartAltText( Slide slide, int index, List issues, ) { final spec = ChartSpec.parse(slide.customMarkdown); if (spec.title.trim().isNotEmpty) return; if (spec.hasInlineData && spec.series.any((s) => s.name.trim().isNotEmpty)) { return; } if (spec.source != null && spec.source!.trim().isNotEmpty) return; issues.add( SlideQualityIssue( slideIndex: index, kind: SlideQualityIssueKind.chartMissingDescription, category: SlideQualityCategory.altText, severity: MarkdownValidationSeverity.warning, field: 'customMarkdown', ), ); } void _checkMediaAltText({ required Slide slide, required int index, required List issues, required bool hasMedia, required String label, }) { if (!hasMedia) return; final hasDescription = slide.title.trim().isNotEmpty || slide.notes.trim().isNotEmpty || slide.subtitle.trim().isNotEmpty; if (hasDescription) return; issues.add( SlideQualityIssue( slideIndex: index, kind: SlideQualityIssueKind.mediaMissingDescription, category: SlideQualityCategory.altText, severity: MarkdownValidationSeverity.warning, field: 'title', args: {'label': label}, ), ); } void _checkTextDensity( Slide slide, int index, String font, List issues, ) { switch (slide.type) { case SlideType.bullets: _addFitScaleIssue( issues, index, bulletsSlideFitScale(slide: slide, font: font), ); case SlideType.twoBullets: _addFitScaleIssue( issues, index, twoBulletsSlideFitScale(slide: slide, font: font), ); case SlideType.bulletsImage: _addFitScaleIssue( issues, index, bulletsImageSlideFitScale(slide: slide, font: font), ); case SlideType.table: _checkTableDensity(slide, index, issues); case SlideType.code: _checkCodeDensity(slide, index, issues); case SlideType.freeMarkdown: _checkFreeMarkdownDensity(slide, index, issues); case SlideType.title: _checkTitleDensity(slide, index, issues); case SlideType.section: case SlideType.image: case SlideType.twoImages: case SlideType.video: case SlideType.quote: case SlideType.chart: break; } } void _addFitScaleIssue( List issues, int index, double scale, ) { if (scale > kTextDensityWarningScale) return; final critical = scale <= kTextDensityCriticalScale + 0.001; issues.add( SlideQualityIssue( slideIndex: index, kind: critical ? SlideQualityIssueKind.textDensityCritical : SlideQualityIssueKind.textDensityWarning, category: SlideQualityCategory.textDensity, severity: critical ? MarkdownValidationSeverity.error : MarkdownValidationSeverity.warning, args: {'percent': _percent(scale)}, ), ); } void _checkTableDensity( Slide slide, int index, List issues, ) { final rows = slide.tableRows.where((r) => r.isNotEmpty).toList(); if (rows.isEmpty) return; final colCount = rows.fold(0, (m, r) => r.length > m ? r.length : m); final w = kReferenceSlideWidth; final cellSize = tableCellFontSize(w, rowCount: rows.length, colCount: colCount); final minimum = tableCellFontMinimum(w); if (cellSize > minimum + 0.001) return; issues.add( SlideQualityIssue( slideIndex: index, kind: SlideQualityIssueKind.tableDensityMinimum, category: SlideQualityCategory.textDensity, severity: MarkdownValidationSeverity.warning, args: { 'rows': '${rows.length}', 'cols': '$colCount', }, ), ); } void _checkCodeDensity(Slide slide, int index, List issues) { final code = slide.customMarkdown; if (code.trim().isEmpty) return; final lines = code.split('\n'); if (lines.length <= 28 && code.length <= 1800) return; issues.add( SlideQualityIssue( slideIndex: index, kind: SlideQualityIssueKind.codeDensityHigh, category: SlideQualityCategory.textDensity, severity: MarkdownValidationSeverity.warning, field: 'customMarkdown', args: {'lines': '${lines.length}'}, ), ); } void _checkFreeMarkdownDensity( Slide slide, int index, List issues, ) { final md = slide.customMarkdown; if (md.trim().isEmpty) return; final lines = md.split('\n').where((l) => l.trim().isNotEmpty).length; if (lines <= 18 && md.length <= 1200) return; issues.add( SlideQualityIssue( slideIndex: index, kind: SlideQualityIssueKind.freeMarkdownDensityHigh, category: SlideQualityCategory.textDensity, severity: MarkdownValidationSeverity.warning, field: 'customMarkdown', args: {'lines': '$lines'}, ), ); } void _checkTitleDensity(Slide slide, int index, List issues) { final titleLen = stripInlineMarkdown(slide.title).length; final subtitleLen = stripInlineMarkdown(slide.subtitle).length; if (titleLen + subtitleLen <= 120) return; issues.add( SlideQualityIssue( slideIndex: index, kind: SlideQualityIssueKind.titleDensityHigh, category: SlideQualityCategory.textDensity, severity: MarkdownValidationSeverity.warning, args: {'chars': '${titleLen + subtitleLen}'}, ), ); } String _percent(double scale) => '${(scale * 100).round()}%'; }