Add slide quality checks for accessibility with export warnings.
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run

Help authors spot missing alt text, low contrast, and dense slides in the editor, with an optional confirmation step before export when issues remain.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Brenno de Winter 2026-06-16 08:57:18 +02:00
parent 6d90e7d7b4
commit 1fc4d25dcf
27 changed files with 2514 additions and 285 deletions

View file

@ -193,6 +193,14 @@ OciDeck aims for WCAG 2.1 in the editor:
- **Screen readers** — slide thumbnails announce a concise label ("Slide 3/12: - **Screen readers** — slide thumbnails announce a concise label ("Slide 3/12:
title", including the skipped state), charts read out their data as a text title", including the skipped state), charts read out their data as a text
alternative, and the fullscreen presenter announces every slide change. alternative, and the fullscreen presenter announces every slide change.
- **Slide quality** — while you edit, OciDeck checks each slide for missing
alt text/captions, low theme contrast, and overcrowded text (font auto-fit
shrinking too far). Issues appear as badges on slide thumbnails, in a
collapsible **Slide quality** bar in the editor, and inline hints on image
caption fields. Before export you can get a confirmation dialog listing open
issues (**Settings → General → Accessibility → Warn on export**; on by
default). Quality checks warn but do not block export once you choose
**Export anyway**.
## Markdown mode ## Markdown mode

View file

@ -2623,6 +2623,66 @@ const _dutchSourceStringAdditions = {
'You can withdraw your consent at any time. After withdrawal you must accept these terms again.', 'You can withdraw your consent at any time. After withdrawal you must accept these terms again.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.': 'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'If you withdraw your consent, you must accept these terms again when you restart OciDeck.', 'If you withdraw your consent, you must accept these terms again when you restart OciDeck.',
'Slidekwaliteit': 'Slide quality',
'Geen kwaliteitsproblemen gevonden': 'No quality issues found',
'Thema (hele presentatie)': 'Theme (entire presentation)',
'Kwaliteitsprobleem': 'Quality issue',
'Kwaliteitsproblemen': 'Quality issues',
'Kwaliteitsproblemen (inclusief ernstige)':
'Quality issues (including serious ones)',
'Voeg alt-tekst / bijschrift toe voor toegankelijkheid':
'Add alt text / caption for accessibility',
'Alt-tekst': 'Alt text',
'Tekstdichtheid': 'Text density',
'Contrast': 'Contrast',
'heeft geen bijschrift/alt-tekst.': 'has no caption/alt text.',
'contrastverhouding': 'contrast ratio',
'(minimaal ': '(minimum ',
':1 voor normale tekst).': ':1 for normal text).',
':1 voor grote tekst).': ':1 for large text).',
':1).': ':1).',
'Contrast van tekst op of over een afbeelding kan niet automatisch worden gecontroleerd — controleer visueel.':
'Contrast of text on or over an image cannot be checked automatically — verify visually.',
'Grafiek heeft geen titel of beschrijvende data — voeg een titel of seriesnamen toe.':
'Chart has no title or descriptive data — add a title or series names.',
'heeft geen titel of sprekernotities die de inhoud beschrijven.':
'has no title or speaker notes describing the content.',
'Veel tekst op deze slide: het lettertype wordt verkleind tot ':
'A lot of text on this slide: font size is reduced to ',
' van de ontwerpgrootte.': ' of the design size.',
'Veel tekst op deze slide: het lettertype wordt sterk verkleind (':
'A lot of text on this slide: font size is heavily reduced (',
'van de ontwerpgrootte). Overweeg de inhoud te splitsen.':
'of the design size). Consider splitting the content.',
'Grote tabel (': 'Large table (',
' rijen, ': ' rows, ',
' kolommen): celtekst staat op het minimumformaat.':
' columns): cell text is at the minimum size.',
'Veel broncode (': 'A lot of source code (',
' regels) — de tekst wordt sterk verkleind om te passen.':
' lines) — text is heavily reduced to fit.',
'Veel vrije markdown (': 'A lot of free markdown (',
' regels) — controleer of alles leesbaar blijft op de slide.':
' lines) — check that everything stays readable on the slide.',
'Lange titelpagina (': 'Long title slide (',
' tekens) — de tekst wordt verkleind om te passen.':
' characters) — text is reduced to fit.',
'Thema bodytekst': 'Theme body text',
'Thema titel': 'Theme title',
'Thema tabeltekst': 'Theme table text',
'Thema tabelkop': 'Theme table header',
'Thema code': 'Theme code',
'Tussentitel': 'Section heading',
'Eerste afbeelding': 'First image',
'Tweede afbeelding': 'Second image',
'Achtergrondafbeelding': 'Background image',
'Waarschuwing bij export': 'Warn on export',
'Vraag bevestiging voordat je exporteert wanneer er slide-kwaliteitsproblemen zijn.':
'Ask for confirmation before exporting when slide quality issues are present.',
'Kwaliteitsproblemen gevonden': 'Quality issues found',
'Toch exporteren': 'Export anyway',
'… en meer problemen in het kwaliteitspaneel.':
'… and more issues in the quality panel.',
}, },
'it': { 'it': {
'Toegankelijkheid': 'Accessibilità', 'Toegankelijkheid': 'Accessibilità',
@ -2950,6 +3010,77 @@ const _dutchSourceStringAdditions = {
'Puoi revocare il consenso in qualsiasi momento. Dopo la revoca dovrai accettare nuovamente questi termini.', 'Puoi revocare il consenso in qualsiasi momento. Dopo la revoca dovrai accettare nuovamente questi termini.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.': 'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Se revochi il consenso, dovrai accettare nuovamente questi termini al riavvio di OciDeck.', 'Se revochi il consenso, dovrai accettare nuovamente questi termini al riavvio di OciDeck.',
'Controleren': 'Controlla sintassi',
'Syntaxproblemen gevonden': 'Problemi di sintassi trovati',
'De markdown bevat': 'Il markdown contiene',
'Geen syntaxproblemen gevonden': 'Nessun problema di sintassi trovato',
'Terug naar editor': 'Torna all\'editor',
'Toch toepassen': 'Applica comunque',
'fout(en) en': 'errore/i e',
'fout(en),': 'errore/i,',
'waarschuwing(en)': 'avviso/i',
'waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.':
'avviso/i. Le slide potrebbero essere interpretate in modo errato.',
'Slidekwaliteit': 'Qualità slide',
'Geen kwaliteitsproblemen gevonden': 'Nessun problema di qualità trovato',
'Thema (hele presentatie)': 'Tema (intera presentazione)',
'Kwaliteitsprobleem': 'Problema di qualità',
'Kwaliteitsproblemen': 'Problemi di qualità',
'Kwaliteitsproblemen (inclusief ernstige)':
'Problemi di qualità (inclusi quelli gravi)',
'Voeg alt-tekst / bijschrift toe voor toegankelijkheid':
'Aggiungi testo alt / didascalia per l\'accessibilità',
'Alt-tekst': 'Testo alt',
'Tekstdichtheid': 'Densità del testo',
'Contrast': 'Contrasto',
'heeft geen bijschrift/alt-tekst.': 'non ha didascalia/testo alt.',
'contrastverhouding': 'rapporto di contrasto',
'(minimaal ': '(minimo ',
':1 voor normale tekst).': ':1 per testo normale).',
':1 voor grote tekst).': ':1 per testo grande).',
':1).': ':1).',
'Contrast van tekst op of over een afbeelding kan niet automatisch worden gecontroleerd — controleer visueel.':
'Il contrasto del testo su o sopra un\'immagine non può essere verificato automaticamente — controlla visivamente.',
'Grafiek heeft geen titel of beschrijvende data — voeg een titel of seriesnamen toe.':
'Il grafico non ha titolo o dati descrittivi — aggiungi un titolo o nomi delle serie.',
'heeft geen titel of sprekernotities die de inhoud beschrijven.':
'non ha titolo o note del relatore che descrivono il contenuto.',
'Veel tekst op deze slide: het lettertype wordt verkleind tot ':
'Molto testo su questa slide: il carattere è ridotto a ',
' van de ontwerpgrootte.': ' della dimensione di progetto.',
'Veel tekst op deze slide: het lettertype wordt sterk verkleind (':
'Molto testo su questa slide: il carattere è fortemente ridotto (',
'van de ontwerpgrootte). Overweeg de inhoud te splitsen.':
'della dimensione di progetto). Valuta di dividere il contenuto.',
'Grote tabel (': 'Tabella grande (',
' rijen, ': ' righe, ',
' kolommen): celtekst staat op het minimumformaat.':
' colonne): il testo delle celle è alla dimensione minima.',
'Veel broncode (': 'Molto codice sorgente (',
' regels) — de tekst wordt sterk verkleind om te passen.':
' righe) — il testo è fortemente ridotto per adattarsi.',
'Veel vrije markdown (': 'Molto markdown libero (',
' regels) — controleer of alles leesbaar blijft op de slide.':
' righe) — verifica che tutto resti leggibile sulla slide.',
'Lange titelpagina (': 'Diapositiva titolo lunga (',
' tekens) — de tekst wordt verkleind om te passen.':
' caratteri) — il testo è ridotto per adattarsi.',
'Thema bodytekst': 'Testo corpo del tema',
'Thema titel': 'Titolo del tema',
'Thema tabeltekst': 'Testo tabella del tema',
'Thema tabelkop': 'Intestazione tabella del tema',
'Thema code': 'Codice del tema',
'Tussentitel': 'Intestazione di sezione',
'Eerste afbeelding': 'Prima immagine',
'Tweede afbeelding': 'Seconda immagine',
'Achtergrondafbeelding': 'Immagine di sfondo',
'Waarschuwing bij export': 'Avviso in export',
'Vraag bevestiging voordat je exporteert wanneer er slide-kwaliteitsproblemen zijn.':
'Chiedi conferma prima di esportare quando ci sono problemi di qualità delle slide.',
'Kwaliteitsproblemen gevonden': 'Problemi di qualità trovati',
'Toch exporteren': 'Esporta comunque',
'… en meer problemen in het kwaliteitspaneel.':
'… e altri problemi nel pannello qualità.',
}, },
'de': { 'de': {
'Toegankelijkheid': 'Barrierefreiheit', 'Toegankelijkheid': 'Barrierefreiheit',
@ -3277,6 +3408,77 @@ const _dutchSourceStringAdditions = {
'Sie können Ihre Zustimmung jederzeit widerrufen. Nach dem Widerruf müssen Sie diese Bedingungen erneut akzeptieren.', 'Sie können Ihre Zustimmung jederzeit widerrufen. Nach dem Widerruf müssen Sie diese Bedingungen erneut akzeptieren.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.': 'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Wenn Sie Ihre Zustimmung widerrufen, müssen Sie diese Bedingungen beim Neustart von OciDeck erneut akzeptieren.', 'Wenn Sie Ihre Zustimmung widerrufen, müssen Sie diese Bedingungen beim Neustart von OciDeck erneut akzeptieren.',
'Controleren': 'Syntax prüfen',
'Syntaxproblemen gevonden': 'Syntaxprobleme gefunden',
'De markdown bevat': 'Das Markdown enthält',
'Geen syntaxproblemen gevonden': 'Keine Syntaxprobleme gefunden',
'Terug naar editor': 'Zurück zum Editor',
'Toch toepassen': 'Trotzdem anwenden',
'fout(en) en': 'Fehler und',
'fout(en),': 'Fehler,',
'waarschuwing(en)': 'Warnung(en)',
'waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.':
'Warnung(en). Folien können dadurch falsch eingelesen werden.',
'Slidekwaliteit': 'Folienqualität',
'Geen kwaliteitsproblemen gevonden': 'Keine Qualitätsprobleme gefunden',
'Thema (hele presentatie)': 'Design (gesamte Präsentation)',
'Kwaliteitsprobleem': 'Qualitätsproblem',
'Kwaliteitsproblemen': 'Qualitätsprobleme',
'Kwaliteitsproblemen (inclusief ernstige)':
'Qualitätsprobleme (einschließlich schwerwiegender)',
'Voeg alt-tekst / bijschrift toe voor toegankelijkheid':
'Alt-Text / Bildunterschrift für Barrierefreiheit hinzufügen',
'Alt-tekst': 'Alt-Text',
'Tekstdichtheid': 'Textdichte',
'Contrast': 'Kontrast',
'heeft geen bijschrift/alt-tekst.': 'hat keine Bildunterschrift/Alt-Text.',
'contrastverhouding': 'Kontrastverhältnis',
'(minimaal ': '(mindestens ',
':1 voor normale tekst).': ':1 für normalen Text).',
':1 voor grote tekst).': ':1 für großen Text).',
':1).': ':1).',
'Contrast van tekst op of over een afbeelding kan niet automatisch worden gecontroleerd — controleer visueel.':
'Der Kontrast von Text auf oder über einem Bild kann nicht automatisch geprüft werden — visuell prüfen.',
'Grafiek heeft geen titel of beschrijvende data — voeg een titel of seriesnamen toe.':
'Diagramm hat keinen Titel oder beschreibende Daten — Titel oder Seriennamen hinzufügen.',
'heeft geen titel of sprekernotities die de inhoud beschrijven.':
'hat keinen Titel oder Sprechernotizen, die den Inhalt beschreiben.',
'Veel tekst op deze slide: het lettertype wordt verkleind tot ':
'Viel Text auf dieser Folie: Schriftgröße wird verkleinert auf ',
' van de ontwerpgrootte.': ' der Entwurfsgröße.',
'Veel tekst op deze slide: het lettertype wordt sterk verkleind (':
'Viel Text auf dieser Folie: Schriftgröße wird stark verkleinert (',
'van de ontwerpgrootte). Overweeg de inhoud te splitsen.':
'der Entwurfsgröße). Inhalt aufteilen erwägen.',
'Grote tabel (': 'Große Tabelle (',
' rijen, ': ' Zeilen, ',
' kolommen): celtekst staat op het minimumformaat.':
' Spalten): Zelltext ist auf Mindestgröße.',
'Veel broncode (': 'Viel Quellcode (',
' regels) — de tekst wordt sterk verkleind om te passen.':
' Zeilen) — Text wird stark verkleinert, um zu passen.',
'Veel vrije markdown (': 'Viel freies Markdown (',
' regels) — controleer of alles leesbaar blijft op de slide.':
' Zeilen) — prüfen, ob alles auf der Folie lesbar bleibt.',
'Lange titelpagina (': 'Lange Titelfolie (',
' tekens) — de tekst wordt verkleind om te passen.':
' Zeichen) — Text wird verkleinert, um zu passen.',
'Thema bodytekst': 'Design-Fließtext',
'Thema titel': 'Design-Titel',
'Thema tabeltekst': 'Design-Tabellentext',
'Thema tabelkop': 'Design-Tabellenkopf',
'Thema code': 'Design-Code',
'Tussentitel': 'Abschnittsüberschrift',
'Eerste afbeelding': 'Erstes Bild',
'Tweede afbeelding': 'Zweites Bild',
'Achtergrondafbeelding': 'Hintergrundbild',
'Waarschuwing bij export': 'Warnung beim Export',
'Vraag bevestiging voordat je exporteert wanneer er slide-kwaliteitsproblemen zijn.':
'Vor dem Export bestätigen lassen, wenn Folienqualitätsprobleme vorliegen.',
'Kwaliteitsproblemen gevonden': 'Qualitätsprobleme gefunden',
'Toch exporteren': 'Trotzdem exportieren',
'… en meer problemen in het kwaliteitspaneel.':
'… und weitere Probleme im Qualitätsbereich.',
}, },
'fr': { 'fr': {
'Toegankelijkheid': 'Accessibilité', 'Toegankelijkheid': 'Accessibilité',
@ -3608,6 +3810,77 @@ const _dutchSourceStringAdditions = {
'Vous pouvez révoquer votre consentement à tout moment. Après la révocation, vous devrez accepter à nouveau ces conditions.', 'Vous pouvez révoquer votre consentement à tout moment. Après la révocation, vous devrez accepter à nouveau ces conditions.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.': 'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Si vous révoquez votre consentement, vous devrez accepter à nouveau ces conditions au redémarrage d\'OciDeck.', 'Si vous révoquez votre consentement, vous devrez accepter à nouveau ces conditions au redémarrage d\'OciDeck.',
'Controleren': 'Vérifier la syntaxe',
'Syntaxproblemen gevonden': 'Problèmes de syntaxe détectés',
'De markdown bevat': 'Le markdown contient',
'Geen syntaxproblemen gevonden': 'Aucun problème de syntaxe trouvé',
'Terug naar editor': 'Retour à l\'éditeur',
'Toch toepassen': 'Appliquer quand même',
'fout(en) en': 'erreur(s) et',
'fout(en),': 'erreur(s),',
'waarschuwing(en)': 'avertissement(s)',
'waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.':
'avertissement(s). Les slides peuvent être mal interprétées.',
'Slidekwaliteit': 'Qualité des slides',
'Geen kwaliteitsproblemen gevonden': 'Aucun problème de qualité trouvé',
'Thema (hele presentatie)': 'Thème (présentation entière)',
'Kwaliteitsprobleem': 'Problème de qualité',
'Kwaliteitsproblemen': 'Problèmes de qualité',
'Kwaliteitsproblemen (inclusief ernstige)':
'Problèmes de qualité (y compris graves)',
'Voeg alt-tekst / bijschrift toe voor toegankelijkheid':
'Ajoutez un texte alt / une légende pour l\'accessibilité',
'Alt-tekst': 'Texte alt',
'Tekstdichtheid': 'Densité du texte',
'Contrast': 'Contraste',
'heeft geen bijschrift/alt-tekst.': 'n\'a pas de légende/texte alt.',
'contrastverhouding': 'rapport de contraste',
'(minimaal ': '(minimum ',
':1 voor normale tekst).': ':1 pour texte normal).',
':1 voor grote tekst).': ':1 pour grand texte).',
':1).': ':1).',
'Contrast van tekst op of over een afbeelding kan niet automatisch worden gecontroleerd — controleer visueel.':
'Le contraste du texte sur ou au-dessus d\'une image ne peut pas être vérifié automatiquement — vérifiez visuellement.',
'Grafiek heeft geen titel of beschrijvende data — voeg een titel of seriesnamen toe.':
'Le graphique n\'a pas de titre ou de données descriptives — ajoutez un titre ou des noms de séries.',
'heeft geen titel of sprekernotities die de inhoud beschrijven.':
'n\'a pas de titre ou de notes du présentateur décrivant le contenu.',
'Veel tekst op deze slide: het lettertype wordt verkleind tot ':
'Beaucoup de texte sur cette slide : la taille de police est réduite à ',
' van de ontwerpgrootte.': ' de la taille de conception.',
'Veel tekst op deze slide: het lettertype wordt sterk verkleind (':
'Beaucoup de texte sur cette slide : la taille de police est fortement réduite (',
'van de ontwerpgrootte). Overweeg de inhoud te splitsen.':
'de la taille de conception). Envisagez de diviser le contenu.',
'Grote tabel (': 'Grand tableau (',
' rijen, ': ' lignes, ',
' kolommen): celtekst staat op het minimumformaat.':
' colonnes) : le texte des cellules est à la taille minimale.',
'Veel broncode (': 'Beaucoup de code source (',
' regels) — de tekst wordt sterk verkleind om te passen.':
' lignes) — le texte est fortement réduit pour tenir.',
'Veel vrije markdown (': 'Beaucoup de markdown libre (',
' regels) — controleer of alles leesbaar blijft op de slide.':
' lignes) — vérifiez que tout reste lisible sur la slide.',
'Lange titelpagina (': 'Diapositive de titre longue (',
' tekens) — de tekst wordt verkleind om te passen.':
' caractères) — le texte est réduit pour tenir.',
'Thema bodytekst': 'Texte courant du thème',
'Thema titel': 'Titre du thème',
'Thema tabeltekst': 'Texte de tableau du thème',
'Thema tabelkop': 'En-tête de tableau du thème',
'Thema code': 'Code du thème',
'Tussentitel': 'Titre intermédiaire',
'Eerste afbeelding': 'Première image',
'Tweede afbeelding': 'Deuxième image',
'Achtergrondafbeelding': 'Image d\'arrière-plan',
'Waarschuwing bij export': 'Avertissement à l\'export',
'Vraag bevestiging voordat je exporteert wanneer er slide-kwaliteitsproblemen zijn.':
'Demander une confirmation avant l\'export lorsqu\'il y a des problèmes de qualité.',
'Kwaliteitsproblemen gevonden': 'Problèmes de qualité détectés',
'Toch exporteren': 'Exporter quand même',
'… en meer problemen in het kwaliteitspaneel.':
'… et d\'autres problèmes dans le panneau qualité.',
}, },
'es': { 'es': {
'Toegankelijkheid': 'Accesibilidad', 'Toegankelijkheid': 'Accesibilidad',
@ -3935,6 +4208,77 @@ const _dutchSourceStringAdditions = {
'Puedes revocar tu consentimiento en cualquier momento. Tras la revocación, deberás aceptar de nuevo estas condiciones.', 'Puedes revocar tu consentimiento en cualquier momento. Tras la revocación, deberás aceptar de nuevo estas condiciones.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.': 'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Si revocas tu consentimiento, deberás aceptar de nuevo estas condiciones al reiniciar OciDeck.', 'Si revocas tu consentimiento, deberás aceptar de nuevo estas condiciones al reiniciar OciDeck.',
'Controleren': 'Comprobar sintaxis',
'Syntaxproblemen gevonden': 'Problemas de sintaxis encontrados',
'De markdown bevat': 'El markdown contiene',
'Geen syntaxproblemen gevonden': 'No se encontraron problemas de sintaxis',
'Terug naar editor': 'Volver al editor',
'Toch toepassen': 'Aplicar de todos modos',
'fout(en) en': 'error(es) y',
'fout(en),': 'error(es),',
'waarschuwing(en)': 'advertencia(s)',
'waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.':
'advertencia(s). Las diapositivas pueden interpretarse incorrectamente.',
'Slidekwaliteit': 'Calidad de diapositivas',
'Geen kwaliteitsproblemen gevonden': 'No se encontraron problemas de calidad',
'Thema (hele presentatie)': 'Tema (presentación completa)',
'Kwaliteitsprobleem': 'Problema de calidad',
'Kwaliteitsproblemen': 'Problemas de calidad',
'Kwaliteitsproblemen (inclusief ernstige)':
'Problemas de calidad (incluidos graves)',
'Voeg alt-tekst / bijschrift toe voor toegankelijkheid':
'Añade texto alt / leyenda para accesibilidad',
'Alt-tekst': 'Texto alt',
'Tekstdichtheid': 'Densidad de texto',
'Contrast': 'Contraste',
'heeft geen bijschrift/alt-tekst.': 'no tiene leyenda/texto alt.',
'contrastverhouding': 'relación de contraste',
'(minimaal ': '(mínimo ',
':1 voor normale tekst).': ':1 para texto normal).',
':1 voor grote tekst).': ':1 para texto grande).',
':1).': ':1).',
'Contrast van tekst op of over een afbeelding kan niet automatisch worden gecontroleerd — controleer visueel.':
'El contraste del texto sobre una imagen no puede comprobarse automáticamente — verifica visualmente.',
'Grafiek heeft geen titel of beschrijvende data — voeg een titel of seriesnamen toe.':
'El gráfico no tiene título ni datos descriptivos — añade un título o nombres de series.',
'heeft geen titel of sprekernotities die de inhoud beschrijven.':
'no tiene título ni notas del ponente que describan el contenido.',
'Veel tekst op deze slide: het lettertype wordt verkleind tot ':
'Mucho texto en esta diapositiva: el tamaño de fuente se reduce a ',
' van de ontwerpgrootte.': ' del tamaño de diseño.',
'Veel tekst op deze slide: het lettertype wordt sterk verkleind (':
'Mucho texto en esta diapositiva: el tamaño de fuente se reduce mucho (',
'van de ontwerpgrootte). Overweeg de inhoud te splitsen.':
'del tamaño de diseño). Considera dividir el contenido.',
'Grote tabel (': 'Tabla grande (',
' rijen, ': ' filas, ',
' kolommen): celtekst staat op het minimumformaat.':
' columnas): el texto de cel está en el tamaño mínimo.',
'Veel broncode (': 'Mucho código fuente (',
' regels) — de tekst wordt sterk verkleind om te passen.':
' líneas) — el texto se reduce mucho para caber.',
'Veel vrije markdown (': 'Mucho markdown libre (',
' regels) — controleer of alles leesbaar blijft op de slide.':
' líneas) — comprueba que todo siga legible en la diapositiva.',
'Lange titelpagina (': 'Diapositiva de título larga (',
' tekens) — de tekst wordt verkleind om te passen.':
' caracteres) — el texto se reduce para caber.',
'Thema bodytekst': 'Texto principal del tema',
'Thema titel': 'Título del tema',
'Thema tabeltekst': 'Texto de tabla del tema',
'Thema tabelkop': 'Encabezado de tabla del tema',
'Thema code': 'Código del tema',
'Tussentitel': 'Título intermedio',
'Eerste afbeelding': 'Primera imagen',
'Tweede afbeelding': 'Segunda imagen',
'Achtergrondafbeelding': 'Imagen de fondo',
'Waarschuwing bij export': 'Advertencia al exportar',
'Vraag bevestiging voordat je exporteert wanneer er slide-kwaliteitsproblemen zijn.':
'Pedir confirmación antes de exportar cuando hay problemas de calidad en las diapositivas.',
'Kwaliteitsproblemen gevonden': 'Problemas de calidad encontrados',
'Toch exporteren': 'Exportar de todos modos',
'… en meer problemen in het kwaliteitspaneel.':
'… y más problemas en el panel de calidad.',
}, },
'fy': { 'fy': {
'Toegankelijkheid': 'Tagonklikens', 'Toegankelijkheid': 'Tagonklikens',
@ -4255,6 +4599,77 @@ const _dutchSourceStringAdditions = {
'Jo kinne jo tastimming op elk momint ynlûke. Nei it ynlûken moatte jo dizze betingsten opnij akseptearje.', 'Jo kinne jo tastimming op elk momint ynlûke. Nei it ynlûken moatte jo dizze betingsten opnij akseptearje.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.': 'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'As jo jo tastimming ynlûke, moatte jo dizze betingsten opnij akseptearje as jo OciDeck opnij begjinne.', 'As jo jo tastimming ynlûke, moatte jo dizze betingsten opnij akseptearje as jo OciDeck opnij begjinne.',
'Controleren': 'Syntax kontrolearje',
'Syntaxproblemen gevonden': 'Syntaxproblemen fûn',
'De markdown bevat': 'De markdown befettet',
'Geen syntaxproblemen gevonden': 'Gjin syntaxproblemen fûn',
'Terug naar editor': 'Werom nei de editor',
'Toch toepassen': 'Dochs tapasse',
'fout(en) en': 'flater(s) en',
'fout(en),': 'flater(s),',
'waarschuwing(en)': 'warnings',
'waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.':
'warnings. Dizenn kinne dêrtroch ferkeard ynladen wurde.',
'Slidekwaliteit': 'Dia-kwaliteit',
'Geen kwaliteitsproblemen gevonden': 'Gjin kwaliteitsproblemen fûn',
'Thema (hele presentatie)': 'Tema (hiele presintaasje)',
'Kwaliteitsprobleem': 'Kwaliteitsprobleem',
'Kwaliteitsproblemen': 'Kwaliteitsproblemen',
'Kwaliteitsproblemen (inclusief ernstige)':
'Kwaliteitsproblemen (ynklusyf earnstige)',
'Voeg alt-tekst / bijschrift toe voor toegankelijkheid':
'Foegje alt-tekst / byskrift ta foar tagonklikheid',
'Alt-tekst': 'Alt-tekst',
'Tekstdichtheid': 'Teksttichtheid',
'Contrast': 'Kontrast',
'heeft geen bijschrift/alt-tekst.': 'hat gjin byskrift/alt-tekst.',
'contrastverhouding': 'kontrastferhâlding',
'(minimaal ': '(minimaal ',
':1 voor normale tekst).': ':1 foar normale tekst).',
':1 voor grote tekst).': ':1 foar grutte tekst).',
':1).': ':1).',
'Contrast van tekst op of over een afbeelding kan niet automatisch worden gecontroleerd — controleer visueel.':
'Kontrast fan tekst op of oer in ôfbylding kin net automatysk kontrolearre wurde — kontrolearje fisueel.',
'Grafiek heeft geen titel of beschrijvende data — voeg een titel of seriesnamen toe.':
'Grafyk hat gjin titel of beskriuwende data — foegje in titel of serjenammen ta.',
'heeft geen titel of sprekernotities die de inhoud beschrijven.':
'hat gjin titel of sprekernotysjes dy\'t de ynhâld beskriuwe.',
'Veel tekst op deze slide: het lettertype wordt verkleind tot ':
'In protte tekst op dizze dia: lettergrutte wurdt ferlytse ta ',
' van de ontwerpgrootte.': ' fan de ûntwerpgrootte.',
'Veel tekst op deze slide: het lettertype wordt sterk verkleind (':
'In protte tekst op dizze dia: lettergrutte wurdt sterk ferlytse (',
'van de ontwerpgrootte). Overweeg de inhoud te splitsen.':
'fan de ûntwerpgrootte). Split de ynhâld op.',
'Grote tabel (': 'Grutte tabel (',
' rijen, ': ' rigen, ',
' kolommen): celtekst staat op het minimumformaat.':
' kolommen): seltekst stiet op minimale grutte.',
'Veel broncode (': 'In protte boarnkoade (',
' regels) — de tekst wordt sterk verkleind om te passen.':
' rigels) — tekst wurdt sterk ferlytse om te passen.',
'Veel vrije markdown (': 'In protte frije markdown (',
' regels) — controleer of alles leesbaar blijft op de slide.':
' rigels) — kontrolearje oft alles lêsber bliuwt op de dia.',
'Lange titelpagina (': 'Lange titeldia (',
' tekens) — de tekst wordt verkleind om te passen.':
' tekens) — tekst wurdt ferlytse om te passen.',
'Thema bodytekst': 'Tema-bodytekst',
'Thema titel': 'Tema-titel',
'Thema tabeltekst': 'Tema-tabeltekst',
'Thema tabelkop': 'Tema-tabelkop',
'Thema code': 'Tema-koade',
'Tussentitel': 'Tussentitel',
'Eerste afbeelding': 'Earste ôfbylding',
'Tweede afbeelding': 'Twadde ôfbylding',
'Achtergrondafbeelding': 'Eftergrûnôfbylding',
'Waarschuwing bij export': 'Warskôging by eksport',
'Vraag bevestiging voordat je exporteert wanneer er slide-kwaliteitsproblemen zijn.':
'Freeg befêstiging foardat jo eksportearje as der dia-kwaliteitsproblemen binne.',
'Kwaliteitsproblemen gevonden': 'Kwaliteitsproblemen fûn',
'Toch exporteren': 'Dochs eksportearje',
'… en meer problemen in het kwaliteitspaneel.':
'… en mear problemen yn it kwaliteitspaneel.',
}, },
'pap': { 'pap': {
'Toegankelijkheid': 'Aksesibilidat', 'Toegankelijkheid': 'Aksesibilidat',
@ -4578,5 +4993,76 @@ const _dutchSourceStringAdditions = {
'Bo por retirá bo konsentimentu na kualkier momentu. Despues di retirá, bo tin ku aseptá e kondishonnan akí di nobo.', 'Bo por retirá bo konsentimentu na kualkier momentu. Despues di retirá, bo tin ku aseptá e kondishonnan akí di nobo.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.': 'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Si bo retirá bo konsentimentu, bo tin ku aseptá e kondishonnan akí di nobo ora bo start OciDeck di nobo.', 'Si bo retirá bo konsentimentu, bo tin ku aseptá e kondishonnan akí di nobo ora bo start OciDeck di nobo.',
'Controleren': 'Verifiká sintaxis',
'Syntaxproblemen gevonden': 'Probleman di sintaxis haña',
'De markdown bevat': 'E markdown tin',
'Geen syntaxproblemen gevonden': 'No a haña problema di sintaxis',
'Terug naar editor': 'Bai bek na e editor',
'Toch toepassen': 'Apliká igualmente',
'fout(en) en': 'eror(nan) i',
'fout(en),': 'eror(nan),',
'waarschuwing(en)': 'advertensia(nan)',
'waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.':
'advertensia(nan). Slide-nan por wòrdu lesá mal.',
'Slidekwaliteit': 'Kalidad di slide',
'Geen kwaliteitsproblemen gevonden': 'No a haña problema di kalidad',
'Thema (hele presentatie)': 'Tema (presentashon kompleto)',
'Kwaliteitsprobleem': 'Problema di kalidad',
'Kwaliteitsproblemen': 'Problemanan di kalidad',
'Kwaliteitsproblemen (inclusief ernstige)':
'Problemanan di kalidad (inclusí serio)',
'Voeg alt-tekst / bijschrift toe voor toegankelijkheid':
'Agregá alt-tekst / caption pa aksesibilidad',
'Alt-tekst': 'Alt-tekst',
'Tekstdichtheid': 'Densidat di teksto',
'Contrast': 'Kontraste',
'heeft geen bijschrift/alt-tekst.': 'no tin caption/alt-tekst.',
'contrastverhouding': 'proporcion di kontraste',
'(minimaal ': '(mínimo ',
':1 voor normale tekst).': ':1 pa teksto normal).',
':1 voor grote tekst).': ':1 pa teksto grandi).',
':1).': ':1).',
'Contrast van tekst op of over een afbeelding kan niet automatisch worden gecontroleerd — controleer visueel.':
'Kontraste di teksto riba un imágen no por wòrdu verifiká automáticamente — verifiká visualmente.',
'Grafiek heeft geen titel of beschrijvende data — voeg een titel of seriesnamen toe.':
'Gráfiko no tin título ni datonan deskriptivo — agregá un título of nòmber di serienan.',
'heeft geen titel of sprekernotities die de inhoud beschrijven.':
'no tin título ni notanan di presentadó ku ta deskribí e kontenido.',
'Veel tekst op deze slide: het lettertype wordt verkleind tot ':
'Hopi teksto riba e slide aki: tamaño di letra ta baha te ',
' van de ontwerpgrootte.': ' di e tamaño di diseño.',
'Veel tekst op deze slide: het lettertype wordt sterk verkleind (':
'Hopi teksto riba e slide aki: tamaño di letra ta baha hopi (',
'van de ontwerpgrootte). Overweeg de inhoud te splitsen.':
'di e tamaño di diseño). Konsiderá di dividí e kontenido.',
'Grote tabel (': 'Tabel grandi (',
' rijen, ': ' fila, ',
' kolommen): celtekst staat op het minimumformaat.':
' kolom): teksto di sel ta na tamaño mínimo.',
'Veel broncode (': 'Hopi kódigo fuente (',
' regels) — de tekst wordt sterk verkleind om te passen.':
' regel) — teksto ta baha hopi pa kaba.',
'Veel vrije markdown (': 'Hopi markdown libre (',
' regels) — controleer of alles leesbaar blijft op de slide.':
' regel) — verifiká si tur kos ta lesibel riba e slide.',
'Lange titelpagina (': 'Slide di título largo (',
' tekens) — de tekst wordt verkleind om te passen.':
' karakter) — teksto ta baha pa kaba.',
'Thema bodytekst': 'Teksto principal di tema',
'Thema titel': 'Título di tema',
'Thema tabeltekst': 'Teksto di tabel di tema',
'Thema tabelkop': 'Header di tabel di tema',
'Thema code': 'Kódigo di tema',
'Tussentitel': 'Título intermedio',
'Eerste afbeelding': 'Promé imágen',
'Tweede afbeelding': 'Segundo imágen',
'Achtergrondafbeelding': 'Imágen di fondo',
'Waarschuwing bij export': 'Advertensia na export',
'Vraag bevestiging voordat je exporteert wanneer er slide-kwaliteitsproblemen zijn.':
'Pidi konfirmashon promé ku exportá ora tin problema di kalidad di slide.',
'Kwaliteitsproblemen gevonden': 'Problemanan di kalidad haña',
'Toch exporteren': 'Exportá igualmente',
'… en meer problemen in het kwaliteitspaneel.':
'… i mas problema den e panel di kalidad.',
}, },
}; };

View file

@ -0,0 +1,80 @@
import '../l10n/app_localizations.dart';
import '../models/markdown_validation.dart';
import '../models/slide_quality.dart';
String slideQualityCategoryLabel(
AppLocalizations l10n,
SlideQualityCategory category,
) {
return switch (category) {
SlideQualityCategory.altText => l10n.d('Alt-tekst'),
SlideQualityCategory.contrast => l10n.d('Contrast'),
SlideQualityCategory.textDensity => l10n.d('Tekstdichtheid'),
};
}
String formatSlideQualityIssue(AppLocalizations l10n, SlideQualityIssue issue) {
String label(String key) => l10n.d(issue.args[key] ?? key);
return switch (issue.kind) {
SlideQualityIssueKind.missingAltCaption =>
'${label('label')} ${l10n.d('heeft geen bijschrift/alt-tekst.')}',
SlideQualityIssueKind.themeContrast => _formatThemeContrast(l10n, issue),
SlideQualityIssueKind.slideContrast => _formatSlideContrast(l10n, issue),
SlideQualityIssueKind.imageContrastUnverified => l10n.d(
'Contrast van tekst op of over een afbeelding kan niet automatisch worden gecontroleerd — controleer visueel.',
),
SlideQualityIssueKind.chartMissingDescription => l10n.d(
'Grafiek heeft geen titel of beschrijvende data — voeg een titel of seriesnamen toe.',
),
SlideQualityIssueKind.mediaMissingDescription =>
'${label('label')} ${l10n.d('heeft geen titel of sprekernotities die de inhoud beschrijven.')}',
SlideQualityIssueKind.textDensityWarning =>
'${l10n.d('Veel tekst op deze slide: het lettertype wordt verkleind tot ')}'
'${issue.args['percent']}${l10n.d(' van de ontwerpgrootte.')}',
SlideQualityIssueKind.textDensityCritical =>
'${l10n.d('Veel tekst op deze slide: het lettertype wordt sterk verkleind (')}'
'${issue.args['percent']}${l10n.d('van de ontwerpgrootte). Overweeg de inhoud te splitsen.')}',
SlideQualityIssueKind.tableDensityMinimum =>
'${l10n.d('Grote tabel (')}${issue.args['rows']}${l10n.d(' rijen, ')}'
'${issue.args['cols']}${l10n.d(' kolommen): celtekst staat op het minimumformaat.')}',
SlideQualityIssueKind.codeDensityHigh =>
'${l10n.d('Veel broncode (')}${issue.args['lines']}'
'${l10n.d(' regels) — de tekst wordt sterk verkleind om te passen.')}',
SlideQualityIssueKind.freeMarkdownDensityHigh =>
'${l10n.d('Veel vrije markdown (')}${issue.args['lines']}'
'${l10n.d(' regels) — controleer of alles leesbaar blijft op de slide.')}',
SlideQualityIssueKind.titleDensityHigh =>
'${l10n.d('Lange titelpagina (')}${issue.args['chars']}'
'${l10n.d(' tekens) — de tekst wordt verkleind om te passen.')}',
};
}
String _formatThemeContrast(AppLocalizations l10n, SlideQualityIssue issue) {
final large = issue.args['largeText'] == 'true';
final suffix = large
? l10n.d(':1 voor grote tekst).')
: l10n.d(':1 voor normale tekst).');
return '${l10n.d(issue.args['label'] ?? '')}: ${l10n.d('contrastverhouding')} '
'${issue.args['ratio']}:1 ${l10n.d('(minimaal ')}${issue.args['threshold']}$suffix';
}
String _formatSlideContrast(AppLocalizations l10n, SlideQualityIssue issue) {
return '${l10n.d(issue.args['label'] ?? '')}: ${l10n.d('contrastverhouding')} '
'${issue.args['ratio']}:1 ${l10n.d('(minimaal ')}${issue.args['threshold']}${l10n.d(':1).')}';
}
MarkdownValidationSeverity? slideQualitySeverityForField({
required SlideQualityResult result,
required int slideIndex,
required String field,
}) {
final match = result.issues.where(
(i) => i.slideIndex == slideIndex && i.field == field,
);
if (match.isEmpty) return null;
if (match.any((i) => i.severity == MarkdownValidationSeverity.error)) {
return MarkdownValidationSeverity.error;
}
return MarkdownValidationSeverity.warning;
}

View file

@ -391,6 +391,10 @@ class AppSettings {
/// presenter. 0 = geen aftelling. Live aanpasbaar tijdens presenteren (K). /// presenter. 0 = geen aftelling. Live aanpasbaar tijdens presenteren (K).
final int presentationTargetSeconds; final int presentationTargetSeconds;
/// Toon een waarschuwing vóór export wanneer de slide-kwaliteitscontrole
/// problemen vindt (alt-tekst, contrast, tekstdichtheid).
final bool qualityWarningsOnExport;
const AppSettings({ const AppSettings({
this.languageCode = 'nl', this.languageCode = 'nl',
this.homeDirectory, this.homeDirectory,
@ -403,6 +407,7 @@ class AppSettings {
this.maxReleaseExportTlpKey, this.maxReleaseExportTlpKey,
this.uiTextScale = 1.0, this.uiTextScale = 1.0,
this.presentationTargetSeconds = 0, this.presentationTargetSeconds = 0,
this.qualityWarningsOnExport = true,
}); });
ThemeProfile get themeProfile { ThemeProfile get themeProfile {
@ -457,6 +462,7 @@ class AppSettings {
String? maxReleaseExportTlpKey, String? maxReleaseExportTlpKey,
double? uiTextScale, double? uiTextScale,
int? presentationTargetSeconds, int? presentationTargetSeconds,
bool? qualityWarningsOnExport,
bool clearHomeDirectory = false, bool clearHomeDirectory = false,
bool clearExportDirectory = false, bool clearExportDirectory = false,
bool clearMaxReleaseExportTlp = false, bool clearMaxReleaseExportTlp = false,
@ -497,6 +503,8 @@ class AppSettings {
uiTextScale: uiTextScale ?? this.uiTextScale, uiTextScale: uiTextScale ?? this.uiTextScale,
presentationTargetSeconds: presentationTargetSeconds:
presentationTargetSeconds ?? this.presentationTargetSeconds, presentationTargetSeconds ?? this.presentationTargetSeconds,
qualityWarningsOnExport:
qualityWarningsOnExport ?? this.qualityWarningsOnExport,
); );
} }
} }

View file

@ -0,0 +1,72 @@
import 'markdown_validation.dart';
/// Sentinel index for deck-wide issues (theme contrast, etc.).
const int kDeckWideSlideIndex = -1;
enum SlideQualityCategory { altText, contrast, textDensity }
enum SlideQualityIssueKind {
missingAltCaption,
themeContrast,
slideContrast,
imageContrastUnverified,
chartMissingDescription,
mediaMissingDescription,
textDensityWarning,
textDensityCritical,
tableDensityMinimum,
codeDensityHigh,
freeMarkdownDensityHigh,
titleDensityHigh,
}
class SlideQualityIssue {
final int slideIndex;
final SlideQualityIssueKind kind;
final SlideQualityCategory category;
final MarkdownValidationSeverity severity;
/// Optional hint for UI focus, e.g. `imageCaption` or `textColor`.
final String? field;
/// Structured parameters for localized formatting ([formatSlideQualityIssue]).
final Map<String, String> args;
const SlideQualityIssue({
required this.slideIndex,
required this.kind,
required this.category,
required this.severity,
this.field,
this.args = const {},
});
bool get isDeckWide => slideIndex == kDeckWideSlideIndex;
}
class SlideQualityResult {
final List<SlideQualityIssue> issues;
const SlideQualityResult(this.issues);
bool get hasIssues => issues.isNotEmpty;
int get errorCount => issues
.where((i) => i.severity == MarkdownValidationSeverity.error)
.length;
int get warningCount => issues
.where((i) => i.severity == MarkdownValidationSeverity.warning)
.length;
List<SlideQualityIssue> forSlide(int slideIndex) =>
issues.where((i) => i.slideIndex == slideIndex).toList();
bool hasCategoryOnSlide(int slideIndex, SlideQualityCategory category) =>
issues.any(
(i) => i.slideIndex == slideIndex && i.category == category,
);
List<SlideQualityIssue> get deckWideIssues =>
issues.where((i) => i.isDeckWide).toList();
}

View file

@ -11,6 +11,8 @@ import 'package:pdf/widgets.dart' as pw;
import '../models/deck.dart'; import '../models/deck.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import 'classification_policy.dart'; import 'classification_policy.dart';
import '../models/slide_quality.dart';
import 'quality_export_policy.dart';
import 'marp_html_service.dart'; import 'marp_html_service.dart';
enum ExportFormat { pdf, pptx, html } enum ExportFormat { pdf, pptx, html }
@ -107,6 +109,9 @@ class ExportService {
ThemeProfile? themeProfile, ThemeProfile? themeProfile,
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
ClassificationPolicy policy = const ClassificationPolicy(), ClassificationPolicy policy = const ClassificationPolicy(),
SlideQualityResult? qualityResult,
QualityExportPolicy qualityPolicy = const QualityExportPolicy(),
bool qualityAcknowledged = false,
}) async { }) async {
// Classificatie-gate. Dit is het enige chokepoint waar elk formaat // Classificatie-gate. Dit is het enige chokepoint waar elk formaat
// (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de // (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de
@ -116,6 +121,14 @@ class ExportService {
if (!decision.allowed) { if (!decision.allowed) {
return ExportResult.fail(decision.reason!); return ExportResult.fail(decision.reason!);
} }
final quality = qualityResult ?? const SlideQualityResult([]);
final qualityDecision = qualityPolicy.evaluate(
quality,
acknowledged: qualityAcknowledged,
);
if (!qualityDecision.allowed) {
return ExportResult.fail(qualityDecision.reason!);
}
if (format == ExportFormat.html) { if (format == ExportFormat.html) {
if (markdown == null || markdown.trim().isEmpty) { if (markdown == null || markdown.trim().isEmpty) {
return ExportResult.fail('Geen inhoud om te exporteren.'); return ExportResult.fail('Geen inhoud om te exporteren.');

View file

@ -0,0 +1,73 @@
import '../models/slide_quality.dart';
/// Result of the export quality gate: may export proceed without extra steps?
class QualityExportDecision {
/// Whether [ExportService] may write the file now.
final bool allowed;
/// Human-readable reason when [allowed] is false (acknowledgement required).
final String? reason;
final int errorCount;
final int warningCount;
const QualityExportDecision._({
required this.allowed,
this.reason,
this.errorCount = 0,
this.warningCount = 0,
});
const QualityExportDecision.allow()
: this._(allowed: true);
factory QualityExportDecision.needsAcknowledgement({
required int errorCount,
required int warningCount,
required String reason,
}) => QualityExportDecision._(
allowed: false,
reason: reason,
errorCount: errorCount,
warningCount: warningCount,
);
}
/// Soft export gate for slide quality issues warns by default, never blocks
/// once the user explicitly acknowledges (see [evaluate]).
class QualityExportPolicy {
/// When false, quality issues are ignored at export time.
final bool enabled;
const QualityExportPolicy({this.enabled = true});
factory QualityExportPolicy.fromEnabled(bool enabled) =>
QualityExportPolicy(enabled: enabled);
bool get isActive => enabled;
QualityExportDecision evaluate(
SlideQualityResult result, {
bool acknowledged = false,
}) {
if (!enabled || !result.hasIssues || acknowledged) {
return const QualityExportDecision.allow();
}
return QualityExportDecision.needsAcknowledgement(
errorCount: result.errorCount,
warningCount: result.warningCount,
reason: _buildReason(result),
);
}
String _buildReason(SlideQualityResult result) {
final parts = <String>[];
if (result.errorCount > 0) {
parts.add('${result.errorCount} ernstige probleem(en)');
}
if (result.warningCount > 0) {
parts.add('${result.warningCount} waarschuwing(en)');
}
return 'De presentatie heeft kwaliteitsproblemen (${parts.join(', ')}).';
}
}

View file

@ -0,0 +1,410 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../models/slide.dart';
import '../widgets/slides/inline_markdown.dart';
/// Reference slide width (960pt) used for consistent quality metrics across
/// deck sizes matches the PowerPoint 16:9 canvas the previews emulate.
const double kReferenceSlideWidth = 960.0;
const double kBulletsMaxScale = 3.2;
const double kSplitBulletsMaxScale = 4.35;
/// Hard ceiling for rendered bullet text as a fraction of slide width.
const double kBulletMaxFontFraction = 0.0335;
const double kBulletLineHeight = 1.16;
/// Text-density warning when auto-fit shrinks below this scale factor.
const double kTextDensityWarningScale = 0.70;
/// Matches the minimum scale passed to [bulletsFitScale] in previews.
const double kTextDensityCriticalScale = 0.20;
/// The largest auto-fit scale that keeps bullets at or under
/// [kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
double bulletScaleCap(double w, double bulletSize, double layoutMax) =>
math.min(layoutMax, kBulletMaxFontFraction * w / bulletSize);
String bulletListMarker(List<String> items, int index, ListStyle style) {
int levelOf(String item) {
var level = 0;
while (level < item.length && item[level] == '\t') {
level++;
}
return level;
}
final level = levelOf(items[index]);
if (style == ListStyle.bullets) return _bulletMarkerForLevel(level);
if (style == ListStyle.checklist) {
return checklistItemChecked(items[index]) ? '' : '';
}
var number = 0;
for (var i = 0; i <= index; i++) {
final itemLevel = levelOf(items[i]);
if (itemLevel == level) number++;
if (itemLevel < level) number = 0;
}
return '$number.';
}
String _bulletMarkerForLevel(int level) {
const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)];
}
double bulletLevelScale(int level) {
if (level <= 0) return 1.0;
if (level == 1) return 0.86;
if (level == 2) return 0.80;
return 0.76;
}
/// Largest scale in [minScale, maxScale] for which the bullet block fits
/// [availH] at the full column width.
double bulletsFitScale({
required double availW,
required double availH,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
double minScale = kTextDensityCriticalScale,
double maxScale = 1.0,
ListStyle listStyle = ListStyle.bullets,
}) {
if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0;
final budget = availH * 0.98;
double measure(double scale) => bulletsBlockHeight(
scale: scale,
availW: availW,
listStyle: listStyle,
hasTitle: hasTitle,
title: title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
);
if (measure(maxScale) <= budget) return maxScale;
double lo, hi;
if (maxScale > 1.0 && measure(1.0) <= budget) {
lo = 1.0;
hi = maxScale;
} else {
lo = minScale;
hi = maxScale > 1.0 ? 1.0 : maxScale;
}
for (var i = 0; i < 24; i++) {
final mid = (lo + hi) / 2;
if (measure(mid) <= budget) {
lo = mid;
} else {
hi = mid;
}
}
return lo;
}
double bulletsBlockHeight({
required double scale,
required double availW,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
ListStyle listStyle = ListStyle.bullets,
}) {
var height = 0.0;
if (hasTitle) {
height += measureTextHeight(
title,
titleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if (subtitle.isNotEmpty) {
height += spacing * scale * 0.4;
height += measureTextHeight(
subtitle,
subtitleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) {
height += spacing * scale;
}
for (var i = 0; i < bullets.length; i++) {
final b = bullets[i];
final level = bulletLevel(b);
final text = listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final fontSize = bulletSize * bulletLevelScale(level) * scale;
final indent = level * bulletSize * 1.05 * scale;
final marker = '${bulletListMarker(bullets, i, listStyle)} ';
final markerW = measureTextWidth(
marker,
fontSize,
bold: true,
fontFamily: font,
);
final wrapW = (availW - indent - markerW).clamp(1.0, availW);
final textH = measureTextHeight(
text,
fontSize,
wrapW,
lineHeight: kBulletLineHeight,
fontFamily: font,
);
final markerH = measureTextHeight(
marker,
fontSize,
double.infinity,
fontFamily: font,
);
height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
}
return height;
}
double measureTextHeight(
String text,
double fontSize,
double maxWidth, {
double? lineHeight,
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
height: lineHeight,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
return painter.height;
}
double measureTextWidth(
String text,
double fontSize, {
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout();
return painter.width;
}
/// Table cell font size fraction used in [table_preview.dart].
double tableCellFontSize(double w, {required int rowCount, required int colCount}) {
final density = (rowCount + colCount).clamp(2, 24);
return (w * 0.025 * (10 / (density + 6))).clamp(w * 0.010, w * 0.021);
}
/// Minimum table cell font fraction (lower clamp bound in previews).
double tableCellFontMinimum(double w) => w * 0.010;
/// Layout metrics for a standard bullets slide at [kReferenceSlideWidth].
double bulletsSlideFitScale({
required Slide slide,
required String font,
bool includeChecklistProgress = true,
}) {
final w = kReferenceSlideWidth;
final pad = w * 0.07;
final vPad = w * 0.05;
final titleSize = w * 0.042;
final subtitleSize = w * 0.030;
final bulletSize = w * 0.026;
final spacing = pad * 0.5;
final bulletGap = w * 0.006;
final bullets = slide.bullets.where((b) => b.trimLeft().isNotEmpty).toList();
final showProgress =
includeChecklistProgress &&
slide.listStyle == ListStyle.checklist &&
slide.showChecklistProgress &&
bullets.isNotEmpty;
final slideHeight = w * 9 / 16;
final availW = (w - pad * 2).clamp(w * 0.12, w);
final progressGap = w * 0.025;
final progressW = w * 0.34;
final textAvailW = showProgress
? (availW - progressGap - progressW).clamp(w * 0.12, availW)
: availW;
final availH = slideHeight - vPad * 2;
return bulletsFitScale(
availW: textAvailW,
availH: availH,
hasTitle: slide.title.isNotEmpty,
title: slide.title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: slide.subtitle,
subtitleSize: subtitleSize,
maxScale: bulletScaleCap(w, bulletSize, kSplitBulletsMaxScale),
listStyle: slide.listStyle,
);
}
/// Layout metrics for a two-column bullets slide.
double twoBulletsSlideFitScale({required Slide slide, required String font}) {
final w = kReferenceSlideWidth;
final pad = w * 0.07;
final vPad = w * 0.05;
final leftBullets = slide.bullets.where((b) => b.trimLeft().isNotEmpty).toList();
final rightBullets = slide.bullets2
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final dense = math.max(leftBullets.length, rightBullets.length) > 12;
final titleSize = w * (dense ? 0.034 : 0.04);
final bulletSize = w * 0.024;
final spacing = pad * (dense ? 0.28 : 0.38);
final bulletGap = w * (dense ? 0.0036 : 0.0055);
final columnGap = w * 0.055;
final col1Title = slide.columnTitle1.trim();
final col2Title = slide.columnTitle2.trim();
final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty;
final headingSize = w * (dense ? 0.023 : 0.03);
final headingGap = w * (dense ? 0.007 : 0.012);
final slideHeight = w * 9 / 16;
final contentW = (w - pad * 2).clamp(w * 0.12, w);
final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w);
var availH = slideHeight - vPad * 2;
if (slide.title.isNotEmpty) {
availH -= measureTextHeight(
slide.title,
titleSize,
contentW,
bold: true,
fontFamily: font,
);
availH -= spacing;
}
double headingHeight(String t) => t.isEmpty
? 0
: measureTextHeight(
t,
headingSize,
columnW,
bold: true,
fontFamily: font,
);
final maxHeadingH = math.max(
headingHeight(col1Title),
headingHeight(col2Title),
);
if (hasColumnTitles) availH -= maxHeadingH + headingGap;
final leftScale = bulletsFitScale(
availW: columnW,
availH: availH,
hasTitle: false,
title: '',
bullets: leftBullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
listStyle: slide.listStyle,
);
final rightScale = bulletsFitScale(
availW: columnW,
availH: availH,
hasTitle: false,
title: '',
bullets: rightBullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
listStyle: slide.listStyle,
);
return math.min(leftScale, rightScale);
}
/// Layout metrics for a bullets + image slide.
double bulletsImageSlideFitScale({required Slide slide, required String font}) {
final w = kReferenceSlideWidth;
final leftPad = w * 0.038;
final verticalPad = w * 0.042;
final gap = leftPad;
final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40)
.clamp(0.1, 0.70);
final imgWidth = w * imgFraction;
final bulletSize = w * 0.031;
final titleSize = w * 0.042;
final spacing = verticalPad * 0.32;
final bulletGap = w * 0.005;
final bullets = slide.bullets.where((b) => b.trimLeft().isNotEmpty).toList();
final slideHeight = w * 9 / 16;
final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w);
final availH = slideHeight - verticalPad * 2;
return bulletsFitScale(
availW: availW,
availH: availH,
hasTitle: slide.title.isNotEmpty,
title: slide.title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
listStyle: slide.listStyle,
);
}

View file

@ -0,0 +1,466 @@
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()}%';
}

View file

@ -0,0 +1,54 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import '../models/deck.dart';
import '../models/slide_quality.dart';
import '../services/slide_quality_analyzer.dart';
import 'deck_provider.dart';
final slideQualityAnalyzerProvider = Provider<SlideQualityAnalyzer>(
(_) => const SlideQualityAnalyzer(),
);
final deckQualityProvider =
StateNotifierProvider<DeckQualityNotifier, SlideQualityResult>((ref) {
return DeckQualityNotifier(ref);
});
class DeckQualityNotifier extends StateNotifier<SlideQualityResult> {
DeckQualityNotifier(this._ref) : super(const SlideQualityResult([])) {
_schedule(_ref.read(deckProvider).deck, immediate: true);
_ref.listen<DeckState>(deckProvider, (_, next) => _schedule(next.deck));
}
final Ref _ref;
Timer? _debounce;
void _schedule(Deck? deck, {bool immediate = false}) {
_debounce?.cancel();
if (deck == null) {
state = const SlideQualityResult([]);
return;
}
if (immediate) {
state = _ref.read(slideQualityAnalyzerProvider).analyze(deck);
return;
}
_debounce = Timer(const Duration(milliseconds: 300), () {
final current = _ref.read(deckProvider).deck;
if (current == null) {
state = const SlideQualityResult([]);
return;
}
state = _ref.read(slideQualityAnalyzerProvider).analyze(current);
});
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
}

View file

@ -58,6 +58,8 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0), uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
presentationTargetSeconds: (prefs.getInt('presentationTargetSeconds') ?? 0) presentationTargetSeconds: (prefs.getInt('presentationTargetSeconds') ?? 0)
.clamp(0, 86400), .clamp(0, 86400),
qualityWarningsOnExport:
prefs.getBool('qualityWarningsOnExport') ?? true,
); );
} }
@ -91,6 +93,12 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
await prefs.setInt('presentationTargetSeconds', clamped); await prefs.setInt('presentationTargetSeconds', clamped);
} }
Future<void> setQualityWarningsOnExport(bool enabled) async {
state = state.copyWith(qualityWarningsOnExport: enabled);
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('qualityWarningsOnExport', enabled);
}
Future<void> addRecentFile(String path) async { Future<void> addRecentFile(String path) async {
final updated = [ final updated = [
path, path,

View file

@ -0,0 +1,45 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// Parses a hex colour string (`#RRGGBB` or `RRGGBB`). Returns `null` when
/// invalid so callers can skip the pair instead of throwing.
Color? parseHexColor(String? value) {
if (value == null || value.trim().isEmpty) return null;
var hex = value.trim();
if (!hex.startsWith('#')) hex = '#$hex';
if (!RegExp(r'^#[0-9A-Fa-f]{6}$').hasMatch(hex)) return null;
return Color(int.parse(hex.substring(1), radix: 16) + 0xFF000000);
}
/// WCAG 2.1 relative luminance contrast ratio between two sRGB colours.
double contrastRatio(Color foreground, Color background) {
final l1 = foreground.computeLuminance();
final l2 = background.computeLuminance();
final lighter = math.max(l1, l2);
final darker = math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/// Returns the contrast ratio for a hex pair, or `null` when either colour is
/// invalid.
double? hexContrastRatio(String foreground, String background) {
final fg = parseHexColor(foreground);
final bg = parseHexColor(background);
if (fg == null || bg == null) return null;
return contrastRatio(fg, bg);
}
/// WCAG 2.1 level AA thresholds.
const double kWcagAaNormalText = 4.5;
const double kWcagAaLargeText = 3.0;
/// Body text below this ratio is treated as a hard quality error.
const double kWcagCriticalBodyText = 3.0;
bool meetsWcagAa(String foreground, String background, {bool largeText = false}) {
final ratio = hexContrastRatio(foreground, background);
if (ratio == null) return true;
final threshold = largeText ? kWcagAaLargeText : kWcagAaNormalText;
return ratio >= threshold;
}

View file

@ -10,7 +10,9 @@ import '../services/caption_service.dart';
import '../services/description_service.dart'; import '../services/description_service.dart';
import '../services/classification_policy.dart'; import '../services/classification_policy.dart';
import '../services/export_service.dart'; import '../services/export_service.dart';
import '../services/quality_export_policy.dart';
import '../services/recovery_service.dart'; import '../services/recovery_service.dart';
import '../services/slide_quality_analyzer.dart';
import '../state/deck_provider.dart'; import '../state/deck_provider.dart';
import '../state/editor_provider.dart'; import '../state/editor_provider.dart';
import '../state/settings_provider.dart'; import '../state/settings_provider.dart';
@ -592,6 +594,14 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
policy: ClassificationPolicy.fromKey( policy: ClassificationPolicy.fromKey(
ref.read(settingsProvider).maxReleaseExportTlpKey, ref.read(settingsProvider).maxReleaseExportTlpKey,
), ),
qualityResult: const SlideQualityAnalyzer().analyzeSlides(
slides: slides,
theme: deck.themeProfile,
font: deck.themeProfile.fontFamily,
),
qualityPolicy: QualityExportPolicy.fromEnabled(
ref.read(settingsProvider).qualityWarningsOnExport,
),
exportDirectory: ref.read(settingsProvider).exportDirectory, exportDirectory: ref.read(settingsProvider).exportDirectory,
// Inline chart data so the HTML export can render charts standalone, // Inline chart data so the HTML export can render charts standalone,
// even when a chart links an external CSV. // even when a chart links an external CSV.

View file

@ -2,12 +2,16 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/deck.dart'; import '../../models/deck.dart';
import '../../models/markdown_validation.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../models/slide_quality.dart';
import '../../services/classification_policy.dart'; import '../../services/classification_policy.dart';
import '../../services/export_service.dart'; import '../../services/export_service.dart';
import '../../services/quality_export_policy.dart';
import '../../services/slide_rasterizer.dart'; import '../../services/slide_rasterizer.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../l10n/slide_quality_localization.dart';
/// Exports the deck by rendering the on-screen slide previews to images and /// Exports the deck by rendering the on-screen slide previews to images and
/// packing them into a PDF or PPTX (WYSIWYG the export matches the preview). /// packing them into a PDF or PPTX (WYSIWYG the export matches the preview).
@ -22,6 +26,12 @@ class ExportDialog extends StatefulWidget {
/// Classificatie-gate. Standaard geen plafond (alles mag). /// Classificatie-gate. Standaard geen plafond (alles mag).
final ClassificationPolicy policy; final ClassificationPolicy policy;
/// Slide-kwaliteit van de te exporteren slides.
final SlideQualityResult qualityResult;
/// Soft gate waarschuwing vóór export wanneer ingeschakeld.
final QualityExportPolicy qualityPolicy;
/// Folder all exports are written to. Null = next to the source deck. /// Folder all exports are written to. Null = next to the source deck.
final String? exportDirectory; final String? exportDirectory;
@ -37,6 +47,8 @@ class ExportDialog extends StatefulWidget {
required this.exportService, required this.exportService,
this.tlp = TlpLevel.none, this.tlp = TlpLevel.none,
this.policy = const ClassificationPolicy(), this.policy = const ClassificationPolicy(),
this.qualityResult = const SlideQualityResult([]),
this.qualityPolicy = const QualityExportPolicy(),
this.exportDirectory, this.exportDirectory,
this.markdown = '', this.markdown = '',
}); });
@ -50,6 +62,8 @@ class ExportDialog extends StatefulWidget {
required ExportService exportService, required ExportService exportService,
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
ClassificationPolicy policy = const ClassificationPolicy(), ClassificationPolicy policy = const ClassificationPolicy(),
SlideQualityResult qualityResult = const SlideQualityResult([]),
QualityExportPolicy qualityPolicy = const QualityExportPolicy(),
String? exportDirectory, String? exportDirectory,
String markdown = '', String markdown = '',
}) { }) {
@ -64,6 +78,8 @@ class ExportDialog extends StatefulWidget {
exportService: exportService, exportService: exportService,
tlp: tlp, tlp: tlp,
policy: policy, policy: policy,
qualityResult: qualityResult,
qualityPolicy: qualityPolicy,
exportDirectory: exportDirectory, exportDirectory: exportDirectory,
markdown: markdown, markdown: markdown,
), ),
@ -86,7 +102,86 @@ class _ExportDialogState extends State<ExportDialog> {
/// downscaled JPEG handout. /// downscaled JPEG handout.
bool _compress = false; bool _compress = false;
Future<bool> _confirmQualityExport() async {
final decision = widget.qualityPolicy.evaluate(widget.qualityResult);
if (decision.allowed) return true;
final l10n = context.l10n;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
final issues = widget.qualityResult.issues.take(8).toList();
return AlertDialog(
title: Text(l10n.d('Kwaliteitsproblemen gevonden')),
content: SizedBox(
width: 480,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${decision.errorCount} ${l10n.d('fout(en),')} '
'${decision.warningCount} ${l10n.d('waarschuwing(en)')}',
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final issue in issues)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
issue.isDeckWide
? '${l10n.d('Thema (hele presentatie)')}: '
'${formatSlideQualityIssue(l10n, issue)}'
: '${l10n.d('Slide')} ${issue.slideIndex + 1}: '
'${formatSlideQualityIssue(l10n, issue)}',
style: TextStyle(
fontSize: 12,
color: issue.severity ==
MarkdownValidationSeverity.error
? Colors.red.shade800
: const Color(0xFF92400E),
),
),
),
if (widget.qualityResult.issues.length > issues.length)
Text(
l10n.d('… en meer problemen in het kwaliteitspaneel.'),
style: const TextStyle(
fontSize: 11,
color: Color(0xFF94A3B8),
),
),
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.t('cancel')),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.d('Toch exporteren')),
),
],
);
},
);
return confirmed == true;
}
Future<void> _export(ExportFormat format, {bool compress = false}) async { Future<void> _export(ExportFormat format, {bool compress = false}) async {
if (!await _confirmQualityExport()) return;
final l10n = context.l10n; final l10n = context.l10n;
// HTML renders from Markdown in the browser, so it needs no slide raster. // HTML renders from Markdown in the browser, so it needs no slide raster.
final needsRaster = format != ExportFormat.html; final needsRaster = format != ExportFormat.html;
@ -140,6 +235,9 @@ class _ExportDialogState extends State<ExportDialog> {
themeProfile: widget.themeProfile, themeProfile: widget.themeProfile,
tlp: widget.tlp, tlp: widget.tlp,
policy: widget.policy, policy: widget.policy,
qualityResult: widget.qualityResult,
qualityPolicy: widget.qualityPolicy,
qualityAcknowledged: true,
); );
if (!mounted) return; if (!mounted) return;
@ -263,6 +361,7 @@ class _ExportDialogState extends State<ExportDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (widget.qualityResult.hasIssues) _qualityBanner(l10n),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Text( child: Text(
@ -334,6 +433,43 @@ class _ExportDialogState extends State<ExportDialog> {
); );
} }
Widget _qualityBanner(AppLocalizations l10n) {
final hasErrors = widget.qualityResult.errorCount > 0;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: hasErrors ? const Color(0xFFFEE2E2) : const Color(0xFFFEF3C7),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: hasErrors ? const Color(0xFFFECACA) : const Color(0xFFFDE68A),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.accessibility_new_outlined,
size: 16,
color: hasErrors ? Colors.red.shade700 : const Color(0xFF92400E),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${l10n.d('Slidekwaliteit')}: '
'${widget.qualityResult.errorCount} ${l10n.d('fout(en),')} '
'${widget.qualityResult.warningCount} ${l10n.d('waarschuwing(en)')}',
style: TextStyle(
fontSize: 11,
color: hasErrors ? Colors.red.shade800 : const Color(0xFF92400E),
),
),
),
],
),
);
}
Widget _exportButton({ Widget _exportButton({
required IconData icon, required IconData icon,
required String label, required String label,

View file

@ -449,6 +449,26 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
), ),
), ),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
l10n.d('Waarschuwing bij export'),
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
l10n.d(
'Vraag bevestiging voordat je exporteert wanneer er slide-kwaliteitsproblemen zijn.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
value: ref.watch(
settingsProvider.select((s) => s.qualityWarningsOnExport),
),
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setQualityWarningsOnExport(value),
),
const SizedBox(height: 16), const SizedBox(height: 16),
_sectionTitle(l10n.d('Presentatie')), _sectionTitle(l10n.d('Presentatie')),
_presentationTargetField(), _presentationTargetField(),

View file

@ -5,6 +5,9 @@ import '../../services/caption_service.dart';
import '../../services/description_service.dart'; import '../../services/description_service.dart';
import '../../services/image_service.dart'; import '../../services/image_service.dart';
import '../../state/tabs_provider.dart'; import '../../state/tabs_provider.dart';
import '../../l10n/slide_quality_localization.dart';
import '../../state/deck_quality_provider.dart';
import '../../state/editor_provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../dialogs/image_carousel_picker.dart'; import '../dialogs/image_carousel_picker.dart';
@ -199,6 +202,7 @@ class ImagePickerBar extends ConsumerWidget {
final VoidCallback? onClear; final VoidCallback? onClear;
final ValueChanged<String>? onCaptionChanged; final ValueChanged<String>? onCaptionChanged;
final String label; final String label;
final String captionField;
const ImagePickerBar({ const ImagePickerBar({
super.key, super.key,
@ -212,6 +216,7 @@ class ImagePickerBar extends ConsumerWidget {
this.onClear, this.onClear,
this.onCaptionChanged, this.onCaptionChanged,
this.label = 'Geen afbeelding gekozen', this.label = 'Geen afbeelding gekozen',
this.captionField = 'imageCaption',
}); });
Future<void> _openCarousel( Future<void> _openCarousel(
@ -405,6 +410,7 @@ class ImagePickerBar extends ConsumerWidget {
imagePath: imagePath, imagePath: imagePath,
captionBasePath: captionBasePath, captionBasePath: captionBasePath,
captionService: captions, captionService: captions,
captionField: captionField,
onChanged: onCaptionChanged!, onChanged: onCaptionChanged!,
), ),
], ],
@ -414,11 +420,12 @@ class ImagePickerBar extends ConsumerWidget {
} }
/// Captionveld met auto-save naar sidecar. /// Captionveld met auto-save naar sidecar.
class _CaptionField extends StatefulWidget { class _CaptionField extends ConsumerStatefulWidget {
final String caption; final String caption;
final String imagePath; final String imagePath;
final String? captionBasePath; final String? captionBasePath;
final CaptionService captionService; final CaptionService captionService;
final String captionField;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _CaptionField({ const _CaptionField({
@ -426,14 +433,15 @@ class _CaptionField extends StatefulWidget {
required this.imagePath, required this.imagePath,
this.captionBasePath, this.captionBasePath,
required this.captionService, required this.captionService,
required this.captionField,
required this.onChanged, required this.onChanged,
}); });
@override @override
State<_CaptionField> createState() => _CaptionFieldState(); ConsumerState<_CaptionField> createState() => _CaptionFieldState();
} }
class _CaptionFieldState extends State<_CaptionField> { class _CaptionFieldState extends ConsumerState<_CaptionField> {
late final TextEditingController _ctrl; late final TextEditingController _ctrl;
@override @override
@ -486,30 +494,58 @@ class _CaptionFieldState extends State<_CaptionField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return TextField( final slideIndex = ref.watch(editorProvider).selectedIndex;
final severity = slideQualitySeverityForField(
result: ref.watch(deckQualityProvider),
slideIndex: slideIndex,
field: widget.captionField,
);
final showHint = severity != null && widget.caption.trim().isEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _ctrl, controller: _ctrl,
decoration: InputDecoration( decoration: InputDecoration(
hintText: l10n.d('Caption / bronvermelding (bijv. © Naam Fotograaf)'), hintText: l10n.d('Caption / bronvermelding (bijv. © Naam Fotograaf)'),
hintStyle: const TextStyle(fontSize: 12, color: Color(0xFFB0BEC5)), hintStyle: const TextStyle(fontSize: 12, color: Color(0xFFB0BEC5)),
prefixIcon: const Icon( prefixIcon: Icon(
Icons.copyright_outlined, Icons.copyright_outlined,
size: 16, size: 16,
color: Color(0xFF64748B), color: showHint ? const Color(0xFFB45309) : const Color(0xFF64748B),
), ),
isDense: true, isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
filled: true, filled: true,
fillColor: const Color(0xFFF8FAFC), fillColor: showHint ? const Color(0xFFFFFBEB) : const Color(0xFFF8FAFC),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: showHint ? const Color(0xFFF59E0B) : const Color(0xFFCBD5E1),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: showHint ? const Color(0xFFD97706) : const Color(0xFF64748B),
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFCBD5E1)), borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
), ),
), ),
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
),
if (showHint) ...[
const SizedBox(height: 4),
Text(
l10n.d('Voeg alt-tekst / bijschrift toe voor toegankelijkheid'),
style: const TextStyle(fontSize: 10, color: Color(0xFFB45309)),
),
],
],
); );
} }
} }

View file

@ -107,6 +107,7 @@ class _TwoImagesEditorState extends ConsumerState<TwoImagesEditor> {
ImagePickerBar( ImagePickerBar(
imagePath: widget.slide.imagePath2, imagePath: widget.slide.imagePath2,
imageCaption: widget.slide.imageCaption2, imageCaption: widget.slide.imageCaption2,
captionField: 'imageCaption2',
searchPaths: widget.searchPaths, searchPaths: widget.searchPaths,
captionBasePath: widget.captionBasePath, captionBasePath: widget.captionBasePath,
onPicked: (path, caption) => widget.onUpdate( onPicked: (path, caption) => widget.onUpdate(

View file

@ -23,6 +23,7 @@ import '../editors/title_editor.dart';
import '../editors/two_bullets_editor.dart'; import '../editors/two_bullets_editor.dart';
import '../editors/two_images_editor.dart'; import '../editors/two_images_editor.dart';
import '../editors/video_slide_editor.dart'; import '../editors/video_slide_editor.dart';
import '../panels/slide_quality_panel.dart';
import '../editors/markdown_deck_editor.dart'; import '../editors/markdown_deck_editor.dart';
class EditorPanel extends ConsumerWidget { class EditorPanel extends ConsumerWidget {
@ -91,6 +92,8 @@ class EditorPanel extends ConsumerWidget {
deckNotifier.updateThemeProfile(settings.themeProfile), deckNotifier.updateThemeProfile(settings.themeProfile),
), ),
const Divider(height: 1), const Divider(height: 1),
const SlideQualityPanel(),
const Divider(height: 1),
// Slide editor body // Slide editor body
Expanded( Expanded(

View file

@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../l10n/app_localizations.dart';
import '../../l10n/slide_quality_localization.dart';
import '../../models/markdown_validation.dart';
import '../../models/slide_quality.dart';
import '../../state/deck_quality_provider.dart';
import '../../state/editor_provider.dart';
class SlideQualityPanel extends ConsumerStatefulWidget {
const SlideQualityPanel({super.key});
@override
ConsumerState<SlideQualityPanel> createState() => _SlideQualityPanelState();
}
class _SlideQualityPanelState extends ConsumerState<SlideQualityPanel> {
var _expanded = false;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final result = ref.watch(deckQualityProvider);
final hasErrors = result.errorCount > 0;
final color = !result.hasIssues
? const Color(0xFFECFDF5)
: hasErrors
? const Color(0xFFFEE2E2)
: const Color(0xFFFEF3C7);
final iconColor = !result.hasIssues
? const Color(0xFF047857)
: hasErrors
? Colors.red.shade700
: const Color(0xFF92400E);
final summary = result.hasIssues
? '${result.errorCount} ${l10n.d('fout(en),')} '
'${result.warningCount} ${l10n.d('waarschuwing(en)')}'
: l10n.d('Geen kwaliteitsproblemen gevonden');
return Material(
color: color,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
onTap: result.hasIssues ? () => setState(() => _expanded = !_expanded) : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Icon(
result.hasIssues
? Icons.accessibility_new_outlined
: Icons.check_circle_outline,
size: 14,
color: iconColor,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'${l10n.d('Slidekwaliteit')}: $summary',
style: TextStyle(fontSize: 11, color: iconColor),
),
),
if (result.hasIssues)
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: iconColor,
),
],
),
),
),
if (_expanded && result.hasIssues)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
itemCount: result.issues.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final issue = result.issues[index];
return _QualityIssueTile(
issue: issue,
onTap: issue.isDeckWide
? null
: () {
ref
.read(editorProvider.notifier)
.select(issue.slideIndex);
},
);
},
),
),
],
),
);
}
}
class _QualityIssueTile extends StatelessWidget {
final SlideQualityIssue issue;
final VoidCallback? onTap;
const _QualityIssueTile({required this.issue, this.onTap});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final isError = issue.severity == MarkdownValidationSeverity.error;
final color = isError ? Colors.red.shade700 : const Color(0xFF92400E);
final location = issue.isDeckWide
? l10n.d('Thema (hele presentatie)')
: '${l10n.d('Slide')} ${issue.slideIndex + 1}';
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isError ? Icons.error_outline : Icons.warning_amber_outlined,
size: 13,
color: color,
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$location · ${slideQualityCategoryLabel(l10n, issue.category)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
Text(
formatSlideQualityIssue(l10n, issue),
style: TextStyle(fontSize: 10, color: color),
),
],
),
),
if (onTap != null)
Icon(Icons.arrow_forward, size: 12, color: color.withValues(alpha: 0.7)),
],
),
),
);
}
}

View file

@ -52,7 +52,7 @@ class _BulletsPreview extends StatelessWidget {
final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
// Grow (or, when needed, shrink) the text so it uses the full vertical // Grow (or, when needed, shrink) the text so it uses the full vertical
// space instead of leaving a large empty area below a few short bullets. // space instead of leaving a large empty area below a few short bullets.
final scale = _bulletsFitScale( final scale = bulletsFitScale(
availW: textAvailW, availW: textAvailW,
availH: availH, availH: availH,
hasTitle: hasTitle, hasTitle: hasTitle,
@ -65,7 +65,7 @@ class _BulletsPreview extends StatelessWidget {
font: font, font: font,
subtitle: subtitle, subtitle: subtitle,
subtitleSize: subtitleSize, subtitleSize: subtitleSize,
maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale), maxScale: bulletScaleCap(w, bulletSize, kSplitBulletsMaxScale),
listStyle: slide.listStyle, listStyle: slide.listStyle,
); );
@ -269,7 +269,7 @@ class _TwoBulletsPreview extends StatelessWidget {
final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w); final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w);
var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
if (hasTitle) { if (hasTitle) {
availH -= _measureTextHeight( availH -= measureTextHeight(
slide.title, slide.title,
titleSize, titleSize,
contentW, contentW,
@ -281,7 +281,7 @@ class _TwoBulletsPreview extends StatelessWidget {
// Reserve room for the (optional) column headings so the bullets still fit. // Reserve room for the (optional) column headings so the bullets still fit.
double headingHeight(String t) => t.isEmpty double headingHeight(String t) => t.isEmpty
? 0 ? 0
: _measureTextHeight( : measureTextHeight(
t, t,
headingSize, headingSize,
columnW, columnW,
@ -293,7 +293,7 @@ class _TwoBulletsPreview extends StatelessWidget {
headingHeight(col2Title), headingHeight(col2Title),
); );
if (hasColumnTitles) availH -= maxHeadingH + headingGap; if (hasColumnTitles) availH -= maxHeadingH + headingGap;
final leftScale = _bulletsFitScale( final leftScale = bulletsFitScale(
availW: columnW, availW: columnW,
availH: availH, availH: availH,
hasTitle: false, hasTitle: false,
@ -304,10 +304,10 @@ class _TwoBulletsPreview extends StatelessWidget {
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font, font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
listStyle: slide.listStyle, listStyle: slide.listStyle,
); );
final rightScale = _bulletsFitScale( final rightScale = bulletsFitScale(
availW: columnW, availW: columnW,
availH: availH, availH: availH,
hasTitle: false, hasTitle: false,
@ -318,7 +318,7 @@ class _TwoBulletsPreview extends StatelessWidget {
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font, font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
listStyle: slide.listStyle, listStyle: slide.listStyle,
); );
// Treat both columns as one composition: the busiest column determines // Treat both columns as one composition: the busiest column determines
@ -468,7 +468,7 @@ class _BulletsImagePreview extends StatelessWidget {
// still fits the available height at the full column width. This keeps the // still fits the available height at the full column width. This keeps the
// text as large as possible and lets it span the full width toward the // text as large as possible and lets it span the full width toward the
// image, instead of uniformly shrinking and leaving a wide gap. // image, instead of uniformly shrinking and leaving a wide gap.
final scale = _bulletsFitScale( final scale = bulletsFitScale(
availW: availW, availW: availW,
availH: availH, availH: availH,
hasTitle: hasTitle, hasTitle: hasTitle,
@ -479,7 +479,7 @@ class _BulletsImagePreview extends StatelessWidget {
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font, font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
listStyle: slide.listStyle, listStyle: slide.listStyle,
); );
@ -589,7 +589,7 @@ class _BulletsImagePreview extends StatelessWidget {
: b.substring(level); : b.substring(level);
final checked = final checked =
slide.listStyle == ListStyle.checklist && checklistItemChecked(b); slide.listStyle == ListStyle.checklist && checklistItemChecked(b);
final fontSize = bulletSize * _bulletLevelScale(level) * scale; final fontSize = bulletSize * bulletLevelScale(level) * scale;
return _ChecklistBulletRow( return _ChecklistBulletRow(
bullets: bullets, bullets: bullets,
itemIndex: entry.key, itemIndex: entry.key,
@ -649,7 +649,7 @@ class _BulletListColumn extends StatelessWidget {
: b.substring(level); : b.substring(level);
final checked = final checked =
listStyle == ListStyle.checklist && checklistItemChecked(b); listStyle == ListStyle.checklist && checklistItemChecked(b);
final fontSize = bulletSize * _bulletLevelScale(level) * scale; final fontSize = bulletSize * bulletLevelScale(level) * scale;
return _ChecklistBulletRow( return _ChecklistBulletRow(
bullets: bullets, bullets: bullets,
itemIndex: entry.key, itemIndex: entry.key,
@ -670,247 +670,3 @@ class _BulletListColumn extends StatelessWidget {
); );
} }
} }
/// Upper bound for growing bullet text to fill otherwise empty vertical space.
const double _kBulletsMaxScale = 3.2;
/// Split slides have a much narrower column, so short bullet lists can stay
/// visually timid unless they are allowed to grow a little further.
const double _kSplitBulletsMaxScale = 4.35;
/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a
/// fraction of the slide width: 32pt on a standard 16:9 deck (PowerPoint's
/// 960pt-wide canvas). Presentation-design guidance consistently puts body
/// text at 2432pt beyond that it stops aiding readability and starts
/// competing with the title. The fit scale multiplies title and bullets
/// alike, so capping the bullet size also keeps the hierarchy intact.
const double _kBulletMaxFontFraction = 0.0335;
/// The largest auto-fit scale that keeps bullets at or under
/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
double _bulletScaleCap(double w, double bulletSize, double layoutMax) =>
math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize);
/// Line height used for bullet body text, shared by rendering and measuring.
const double _kBulletLineHeight = 1.16;
String _bulletMarkerForLevel(int level) {
const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)];
}
String _listMarker(List<String> items, int index, ListStyle style) {
int levelOf(String item) {
var level = 0;
while (level < item.length && item[level] == '\t') {
level++;
}
return level;
}
final level = levelOf(items[index]);
if (style == ListStyle.bullets) return _bulletMarkerForLevel(level);
if (style == ListStyle.checklist) {
return checklistItemChecked(items[index]) ? '' : '';
}
var number = 0;
for (var i = 0; i <= index; i++) {
final itemLevel = levelOf(items[i]);
if (itemLevel == level) number++;
if (itemLevel < level) number = 0;
}
return '$number.';
}
double _bulletLevelScale(int level) {
if (level <= 0) return 1.0;
if (level == 1) return 0.86;
if (level == 2) return 0.80;
return 0.76;
}
/// Largest scale in [minScale, maxScale] for which the bullet block fits
/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this
/// also grows the text *above* its design size when there is spare vertical
/// room, so short slides use the full height instead of clustering at the top.
double _bulletsFitScale({
required double availW,
required double availH,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
double minScale = 0.2,
double maxScale = 1.0,
ListStyle listStyle = ListStyle.bullets,
}) {
if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0;
// 2% safety margin so minor measurement differences never overflow.
final budget = availH * 0.98;
double measure(double scale) => _bulletsBlockHeight(
scale: scale,
availW: availW,
listStyle: listStyle,
hasTitle: hasTitle,
title: title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
);
// Everything already fits at the largest allowed size use it.
if (measure(maxScale) <= budget) return maxScale;
// Otherwise binary-search the largest scale that fits. Search upward from the
// design size when it fits, downward when even the design size overflows.
double lo, hi;
if (maxScale > 1.0 && measure(1.0) <= budget) {
lo = 1.0;
hi = maxScale;
} else {
lo = minScale;
hi = maxScale > 1.0 ? 1.0 : maxScale;
}
for (var i = 0; i < 24; i++) {
final mid = (lo + hi) / 2;
if (measure(mid) <= budget) {
lo = mid;
} else {
hi = mid;
}
}
return lo;
}
double _bulletsBlockHeight({
required double scale,
required double availW,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
ListStyle listStyle = ListStyle.bullets,
}) {
var height = 0.0;
if (hasTitle) {
height += _measureTextHeight(
title,
titleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if (subtitle.isNotEmpty) {
height += spacing * scale * 0.4;
height += _measureTextHeight(
subtitle,
subtitleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) {
height += spacing * scale;
}
for (var i = 0; i < bullets.length; i++) {
final b = bullets[i];
int level = 0;
while (level < b.length && b[level] == '\t') {
level++;
}
// Measure exactly what gets rendered: checklists strip the `[x] ` prefix
// and use a checkbox marker, numbered lists use `N.`. Measuring the raw
// string with a bullet marker over-counts the height and would shrink the
// text below the space it actually needs.
final text = listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
final indent = level * bulletSize * 1.05 * scale;
final marker = '${_listMarker(bullets, i, listStyle)} ';
final markerW = _measureTextWidth(
marker,
fontSize,
bold: true,
fontFamily: font,
);
final wrapW = (availW - indent - markerW).clamp(1.0, availW);
final textH = _measureTextHeight(
text,
fontSize,
wrapW,
lineHeight: _kBulletLineHeight,
fontFamily: font,
);
final markerH = _measureTextHeight(
marker,
fontSize,
double.infinity,
fontFamily: font,
);
height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
}
return height;
}
double _measureTextHeight(
String text,
double fontSize,
double maxWidth, {
double? lineHeight,
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
height: lineHeight,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
return painter.height;
}
double _measureTextWidth(
String text,
double fontSize, {
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout();
return painter.width;
}

View file

@ -225,7 +225,7 @@ class _ChecklistBulletRow extends StatelessWidget {
? SystemMouseCursors.click ? SystemMouseCursors.click
: MouseCursor.defer, : MouseCursor.defer,
child: Text( child: Text(
'${_listMarker(bullets, itemIndex, listStyle)} ', '${bulletListMarker(bullets, itemIndex, listStyle)} ',
style: TextStyle( style: TextStyle(
fontSize: fontSize, fontSize: fontSize,
color: _hexColor(profile.accentColor), color: _hexColor(profile.accentColor),
@ -242,7 +242,7 @@ class _ChecklistBulletRow extends StatelessWidget {
font, font,
TextStyle( TextStyle(
fontSize: fontSize, fontSize: fontSize,
height: _kBulletLineHeight, height: kBulletLineHeight,
color: _hexColor(profile.textColor), color: _hexColor(profile.textColor),
decoration: checked && profile.checklistStrikeThrough decoration: checked && profile.checklistStrikeThrough
? TextDecoration.lineThrough ? TextDecoration.lineThrough

View file

@ -16,6 +16,7 @@ import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../services/slide_layout_metrics.dart';
import '../../utils/log.dart'; import '../../utils/log.dart';
import 'inline_markdown.dart'; import 'inline_markdown.dart';

View file

@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/markdown_validation.dart';
import '../../models/deck.dart'; import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../state/deck_quality_provider.dart';
import '../../state/slide_clipboard_provider.dart'; import '../../state/slide_clipboard_provider.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@ -47,6 +49,11 @@ class SlideThumbnail extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n; final l10n = context.l10n;
final skipped = slide.skipped; final skipped = slide.skipped;
final slideIssues = ref.watch(deckQualityProvider).forSlide(index);
final hasQualityErrors = slideIssues.any(
(i) => i.severity == MarkdownValidationSeverity.error,
);
final hasQualityWarnings = slideIssues.isNotEmpty;
final borderColor = isSelected final borderColor = isSelected
? AppTheme.accent ? AppTheme.accent
: skipped : skipped
@ -62,7 +69,8 @@ class SlideThumbnail extends ConsumerWidget {
final semanticLabel = final semanticLabel =
'${l10n.d('Slide')} ${index + 1}/$slideCount: ' '${l10n.d('Slide')} ${index + 1}/$slideCount: '
'${title.isNotEmpty ? title : l10n.d(slide.type.label)}' '${title.isNotEmpty ? title : l10n.d(slide.type.label)}'
'${skipped ? ' (${l10n.d('Overgeslagen')})' : ''}'; '${skipped ? ' (${l10n.d('Overgeslagen')})' : ''}'
'${hasQualityWarnings ? ' (${l10n.d('Kwaliteitsprobleem')})' : ''}';
return Semantics( return Semantics(
button: true, button: true,
@ -139,6 +147,34 @@ class SlideThumbnail extends ConsumerWidget {
), ),
), ),
), ),
if (hasQualityWarnings)
Positioned(
top: 4,
right: 4,
child: Tooltip(
message: hasQualityErrors
? l10n.d(
'Kwaliteitsproblemen (inclusief ernstige)',
)
: l10n.d('Kwaliteitsproblemen'),
child: Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: Color(
hasQualityErrors
? 0xCCD32F2F
: 0xCCB45309,
),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.accessibility_new_outlined,
size: 10,
color: Colors.white,
),
),
),
),
], ],
), ),
), ),

View file

@ -52,6 +52,8 @@ void main() {
'Slide', 'Slide',
'slide', 'slide',
'Spider', 'Spider',
'Contrast',
':1).',
}; };
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")'''); final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib') final files = Directory('lib')
@ -89,6 +91,8 @@ void main() {
'SLIDES', 'SLIDES',
'Slide', 'Slide',
'slide', 'slide',
'Contrast',
':1).',
}; };
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")'''); final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib') final files = Directory('lib')

View file

@ -6,8 +6,11 @@ import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:ocideck/models/deck.dart'; 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_policy.dart'; import 'package:ocideck/services/classification_policy.dart';
import 'package:ocideck/services/export_service.dart'; import 'package:ocideck/services/export_service.dart';
import 'package:ocideck/services/quality_export_policy.dart';
import 'package:ocideck/services/marp_html_service.dart'; import 'package:ocideck/services/marp_html_service.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
@ -93,6 +96,41 @@ void main() {
expect(r.success, isTrue, reason: r.error); expect(r.success, isTrue, reason: r.error);
}); });
test(
'quality gate blocks export until acknowledged, writes nothing',
() async {
const policy = QualityExportPolicy();
const quality = SlideQualityResult([
SlideQualityIssue(
slideIndex: 0,
kind: SlideQualityIssueKind.missingAltCaption,
category: SlideQualityCategory.altText,
severity: MarkdownValidationSeverity.warning,
),
]);
final blocked = await service.export(
deckPath(),
ExportFormat.pdf,
[_png()],
qualityResult: quality,
qualityPolicy: policy,
);
expect(blocked.success, isFalse);
expect(blocked.error, contains('kwaliteitsproblemen'));
final allowed = await service.export(
deckPath(),
ExportFormat.pdf,
[_png()],
qualityResult: quality,
qualityPolicy: policy,
qualityAcknowledged: true,
);
expect(allowed.success, isTrue, reason: allowed.error);
},
);
test('exports a PDF that starts with the PDF magic header', () async { test('exports a PDF that starts with the PDF magic header', () async {
final images = [_png(), _png()]; final images = [_png(), _png()];
final r = await service.export(deckPath(), ExportFormat.pdf, images); final r = await service.export(deckPath(), ExportFormat.pdf, images);

View file

@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/markdown_validation.dart';
import 'package:ocideck/models/slide_quality.dart';
import 'package:ocideck/services/quality_export_policy.dart';
void main() {
group('QualityExportPolicy', () {
test('allows export when disabled even with issues', () {
const policy = QualityExportPolicy(enabled: false);
const result = SlideQualityResult([
SlideQualityIssue(
slideIndex: 0,
kind: SlideQualityIssueKind.missingAltCaption,
category: SlideQualityCategory.altText,
severity: MarkdownValidationSeverity.warning,
),
]);
expect(policy.evaluate(result).allowed, isTrue);
});
test('allows export when there are no issues', () {
const policy = QualityExportPolicy();
expect(policy.evaluate(const SlideQualityResult([])).allowed, isTrue);
});
test('requires acknowledgement when enabled and issues exist', () {
const policy = QualityExportPolicy();
const result = SlideQualityResult([
SlideQualityIssue(
slideIndex: 0,
kind: SlideQualityIssueKind.missingAltCaption,
category: SlideQualityCategory.altText,
severity: MarkdownValidationSeverity.warning,
),
]);
final pending = policy.evaluate(result);
expect(pending.allowed, isFalse);
expect(pending.warningCount, 1);
expect(pending.reason, contains('kwaliteitsproblemen'));
expect(policy.evaluate(result, acknowledged: true).allowed, isTrue);
});
});
}

View file

@ -0,0 +1,259 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/markdown_validation.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/models/slide_quality.dart';
import 'package:ocideck/services/slide_layout_metrics.dart';
import 'package:ocideck/services/slide_quality_analyzer.dart';
import 'package:ocideck/utils/color_contrast.dart';
void main() {
const analyzer = SlideQualityAnalyzer();
group('color_contrast', () {
test('black on white meets AA for normal text', () {
expect(meetsWcagAa('#000000', '#FFFFFF'), isTrue);
expect(hexContrastRatio('#000000', '#FFFFFF'), closeTo(21.0, 0.5));
});
test('light gray on white fails AA for normal text', () {
expect(meetsWcagAa('#CCCCCC', '#FFFFFF'), isFalse);
});
test('invalid hex returns null ratio', () {
expect(hexContrastRatio('not-a-color', '#FFFFFF'), isNull);
});
});
group('SlideQualityAnalyzer', () {
test('clean deck has no issues', () {
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.bullets).copyWith(
title: 'Kort',
bullets: ['Eerste punt'],
),
],
);
final result = analyzer.analyze(deck);
expect(result.hasIssues, isFalse);
});
test('detects missing image caption', () {
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.image).copyWith(
imagePath: 'images/photo.jpg',
),
],
);
final result = analyzer.analyze(deck);
expect(
result.issues.any(
(i) =>
i.kind == SlideQualityIssueKind.missingAltCaption &&
i.field == 'imageCaption',
),
isTrue,
);
});
test('does not warn when image caption is present', () {
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.image).copyWith(
imagePath: 'images/photo.jpg',
imageCaption: 'Teamfoto 2024',
),
],
);
final result = analyzer.analyze(deck);
expect(
result.issues.where((i) => i.category == SlideQualityCategory.altText),
isEmpty,
);
});
test('detects low theme body contrast as deck-wide issue', () {
final deck = Deck(
title: 'Demo',
themeProfile: const ThemeProfile(
textColor: '#CCCCCC',
slideBackgroundColor: '#FFFFFF',
),
slides: [Slide.create(SlideType.bullets)],
);
final result = analyzer.analyze(deck);
expect(
result.issues.any(
(i) => i.isDeckWide && i.kind == SlideQualityIssueKind.themeContrast,
),
isTrue,
);
});
test('detects dense bullet slide text', () {
final longBullet = 'Lorem ipsum dolor sit amet, ' * 8;
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.bullets).copyWith(
title: 'Overvol',
bullets: List.generate(14, (_) => longBullet),
),
],
);
final result = analyzer.analyze(deck);
expect(
result.issues.any(
(i) => i.category == SlideQualityCategory.textDensity,
),
isTrue,
);
});
test('critical text density is reported as error', () {
final longBullet = 'Woord ' * 60;
final slide = Slide.create(SlideType.bullets).copyWith(
title: 'Extreem ' * 20,
subtitle: 'Ondertitel ' * 20,
bullets: List.generate(30, (_) => longBullet),
);
expect(
bulletsSlideFitScale(slide: slide, font: 'Arial'),
lessThanOrEqualTo(kTextDensityCriticalScale + 0.001),
);
final result = analyzer.analyzeSlides(
slides: [slide],
theme: const ThemeProfile(),
font: 'Arial',
);
expect(
result.issues.any(
(i) =>
i.kind == SlideQualityIssueKind.textDensityCritical &&
i.severity == MarkdownValidationSeverity.error,
),
isTrue,
);
});
test('detects chart without title or descriptive data', () {
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.chart).copyWith(
customMarkdown: const ChartSpec().toBlock(),
),
],
);
final result = analyzer.analyze(deck);
expect(
result.issues.any(
(i) => i.kind == SlideQualityIssueKind.chartMissingDescription,
),
isTrue,
);
});
test('accepts chart with title', () {
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.chart).copyWith(
customMarkdown: const ChartSpec(title: 'Omzet').toBlock(),
),
],
);
final result = analyzer.analyze(deck);
expect(
result.issues.where((i) => i.category == SlideQualityCategory.altText),
isEmpty,
);
});
test('detects video without descriptive text', () {
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.video).copyWith(
videoPath: 'media/demo.mp4',
),
],
);
final result = analyzer.analyze(deck);
expect(
result.issues.any(
(i) => i.kind == SlideQualityIssueKind.mediaMissingDescription,
),
isTrue,
);
});
test('warns about contrast on title slide with background image', () {
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.title).copyWith(
title: 'Welkom',
imagePath: 'images/bg.jpg',
imageCaption: 'Achtergrond',
),
],
);
final result = analyzer.analyze(deck);
expect(
result.issues.any(
(i) => i.kind == SlideQualityIssueKind.imageContrastUnverified,
),
isTrue,
);
});
test('skips skipped slides', () {
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.image).copyWith(
imagePath: 'images/hidden.jpg',
skipped: true,
),
],
);
final result = analyzer.analyze(deck);
expect(result.hasIssues, isFalse);
});
test('forSlide returns only matching slide issues', () {
final deck = Deck(
title: 'Demo',
slides: [
Slide.create(SlideType.bullets),
Slide.create(SlideType.image).copyWith(
imagePath: 'images/photo.jpg',
),
],
);
final result = analyzer.analyze(deck);
expect(result.forSlide(0), isEmpty);
expect(result.forSlide(1), isNotEmpty);
});
});
}