467 lines
13 KiB
Dart
467 lines
13 KiB
Dart
|
|
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<Slide> slides,
|
||
|
|
required ThemeProfile theme,
|
||
|
|
required String font,
|
||
|
|
}) {
|
||
|
|
final issues = <SlideQualityIssue>[];
|
||
|
|
_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<SlideQualityIssue> 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<SlideQualityIssue> 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<SlideQualityIssue> 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<SlideQualityIssue> 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<SlideQualityIssue> 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<SlideQualityIssue> 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<SlideQualityIssue> 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<SlideQualityIssue> 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<SlideQualityIssue> 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<SlideQualityIssue> issues,
|
||
|
|
) {
|
||
|
|
final rows = slide.tableRows.where((r) => r.isNotEmpty).toList();
|
||
|
|
if (rows.isEmpty) return;
|
||
|
|
final colCount = rows.fold<int>(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<SlideQualityIssue> 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<SlideQualityIssue> 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<SlideQualityIssue> 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()}%';
|
||
|
|
}
|