diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 86bbecc..8143c6a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -193,6 +193,14 @@ OciDeck aims for WCAG 2.1 in the editor: - **Screen readers** — slide thumbnails announce a concise label ("Slide 3/12: title", including the skipped state), charts read out their data as a text 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 diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 54af171..e44e427 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2623,6 +2623,66 @@ const _dutchSourceStringAdditions = { '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.': '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': { 'Toegankelijkheid': 'Accessibilità', @@ -2950,6 +3010,77 @@ const _dutchSourceStringAdditions = { '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.': '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': { '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.', '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.', + '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': { '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.', '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.', + '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': { '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.', '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.', + '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': { '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.', '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.', + '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': { '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.', '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.', + '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.', }, }; diff --git a/lib/l10n/slide_quality_localization.dart b/lib/l10n/slide_quality_localization.dart new file mode 100644 index 0000000..6d9a332 --- /dev/null +++ b/lib/l10n/slide_quality_localization.dart @@ -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; +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 60f71e5..f3a3835 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -391,6 +391,10 @@ class AppSettings { /// presenter. 0 = geen aftelling. Live aanpasbaar tijdens presenteren (K). final int presentationTargetSeconds; + /// Toon een waarschuwing vóór export wanneer de slide-kwaliteitscontrole + /// problemen vindt (alt-tekst, contrast, tekstdichtheid). + final bool qualityWarningsOnExport; + const AppSettings({ this.languageCode = 'nl', this.homeDirectory, @@ -403,6 +407,7 @@ class AppSettings { this.maxReleaseExportTlpKey, this.uiTextScale = 1.0, this.presentationTargetSeconds = 0, + this.qualityWarningsOnExport = true, }); ThemeProfile get themeProfile { @@ -457,6 +462,7 @@ class AppSettings { String? maxReleaseExportTlpKey, double? uiTextScale, int? presentationTargetSeconds, + bool? qualityWarningsOnExport, bool clearHomeDirectory = false, bool clearExportDirectory = false, bool clearMaxReleaseExportTlp = false, @@ -497,6 +503,8 @@ class AppSettings { uiTextScale: uiTextScale ?? this.uiTextScale, presentationTargetSeconds: presentationTargetSeconds ?? this.presentationTargetSeconds, + qualityWarningsOnExport: + qualityWarningsOnExport ?? this.qualityWarningsOnExport, ); } } diff --git a/lib/models/slide_quality.dart b/lib/models/slide_quality.dart new file mode 100644 index 0000000..528adb1 --- /dev/null +++ b/lib/models/slide_quality.dart @@ -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 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 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 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 get deckWideIssues => + issues.where((i) => i.isDeckWide).toList(); +} diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart index 6c64e45..ee9b05a 100644 --- a/lib/services/export_service.dart +++ b/lib/services/export_service.dart @@ -11,6 +11,8 @@ import 'package:pdf/widgets.dart' as pw; import '../models/deck.dart'; import '../models/settings.dart'; import 'classification_policy.dart'; +import '../models/slide_quality.dart'; +import 'quality_export_policy.dart'; import 'marp_html_service.dart'; enum ExportFormat { pdf, pptx, html } @@ -107,6 +109,9 @@ class ExportService { ThemeProfile? themeProfile, TlpLevel tlp = TlpLevel.none, ClassificationPolicy policy = const ClassificationPolicy(), + SlideQualityResult? qualityResult, + QualityExportPolicy qualityPolicy = const QualityExportPolicy(), + bool qualityAcknowledged = false, }) async { // Classificatie-gate. Dit is het enige chokepoint waar elk formaat // (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de @@ -116,6 +121,14 @@ class ExportService { if (!decision.allowed) { 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 (markdown == null || markdown.trim().isEmpty) { return ExportResult.fail('Geen inhoud om te exporteren.'); diff --git a/lib/services/quality_export_policy.dart b/lib/services/quality_export_policy.dart new file mode 100644 index 0000000..8f6097c --- /dev/null +++ b/lib/services/quality_export_policy.dart @@ -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 = []; + 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(', ')}).'; + } +} diff --git a/lib/services/slide_layout_metrics.dart b/lib/services/slide_layout_metrics.dart new file mode 100644 index 0000000..a431b20 --- /dev/null +++ b/lib/services/slide_layout_metrics.dart @@ -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 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 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 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, + ); +} diff --git a/lib/services/slide_quality_analyzer.dart b/lib/services/slide_quality_analyzer.dart new file mode 100644 index 0000000..29ef0ae --- /dev/null +++ b/lib/services/slide_quality_analyzer.dart @@ -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 slides, + required ThemeProfile theme, + required String font, + }) { + final issues = []; + _checkThemeContrast(theme, issues); + for (var i = 0; i < slides.length; i++) { + final slide = slides[i]; + if (slide.skipped) continue; + _checkAltText(slide, i, issues); + _checkSlideContrast(slide, i, theme, issues); + _checkTextDensity(slide, i, font, issues); + } + return SlideQualityResult(issues); + } + + void _checkThemeContrast(ThemeProfile theme, List issues) { + void addPairIssue({ + required String label, + required String foreground, + required String background, + required bool largeText, + String? field, + }) { + final ratio = hexContrastRatio(foreground, background); + if (ratio == null) return; + final aaThreshold = largeText ? kWcagAaLargeText : kWcagAaNormalText; + if (ratio >= aaThreshold) return; + + final severity = !largeText && ratio < kWcagCriticalBodyText + ? MarkdownValidationSeverity.error + : MarkdownValidationSeverity.warning; + + issues.add( + SlideQualityIssue( + slideIndex: kDeckWideSlideIndex, + kind: SlideQualityIssueKind.themeContrast, + category: SlideQualityCategory.contrast, + severity: severity, + field: field, + args: { + 'label': label, + 'ratio': ratio.toStringAsFixed(1), + 'threshold': aaThreshold.toStringAsFixed(1), + 'largeText': largeText.toString(), + }, + ), + ); + } + + addPairIssue( + label: 'Thema bodytekst', + foreground: theme.textColor, + background: theme.slideBackgroundColor, + largeText: false, + field: 'textColor', + ); + addPairIssue( + label: 'Thema titel', + foreground: theme.titleTextColor, + background: theme.titleBackgroundColor, + largeText: true, + field: 'titleTextColor', + ); + addPairIssue( + label: 'Thema tabeltekst', + foreground: theme.tableTextColor, + background: theme.slideBackgroundColor, + largeText: false, + field: 'tableTextColor', + ); + addPairIssue( + label: 'Thema tabelkop', + foreground: theme.tableHeaderTextColor, + background: theme.tableHeaderBackgroundColor, + largeText: true, + field: 'tableHeaderTextColor', + ); + addPairIssue( + label: 'Thema code', + foreground: theme.codeTextColor, + background: theme.codeBackgroundColor, + largeText: false, + field: 'codeTextColor', + ); + } + + void _checkSlideContrast( + Slide slide, + int index, + ThemeProfile theme, + List issues, + ) { + switch (slide.type) { + case SlideType.section: + _addSlidePairIssue( + issues: issues, + slideIndex: index, + label: 'Tussentitel', + foreground: theme.titleTextColor, + background: theme.sectionBackgroundColor, + ); + case SlideType.title: + if (slide.imagePath.isNotEmpty) { + _addImageContrastNote(issues, index); + } + case SlideType.quote: + if (slide.imagePath.isNotEmpty) { + _addImageContrastNote(issues, index); + } + default: + break; + } + } + + void _addImageContrastNote(List issues, int index) { + if (issues.any( + (i) => + i.slideIndex == index && + i.kind == SlideQualityIssueKind.imageContrastUnverified, + )) { + return; + } + issues.add( + SlideQualityIssue( + slideIndex: index, + kind: SlideQualityIssueKind.imageContrastUnverified, + category: SlideQualityCategory.contrast, + severity: MarkdownValidationSeverity.warning, + ), + ); + } + + void _addSlidePairIssue({ + required List issues, + required int slideIndex, + required String label, + required String foreground, + required String background, + }) { + final ratio = hexContrastRatio(foreground, background); + if (ratio == null) return; + const aaThreshold = kWcagAaLargeText; + if (ratio >= aaThreshold) return; + + issues.add( + SlideQualityIssue( + slideIndex: slideIndex, + kind: SlideQualityIssueKind.slideContrast, + category: SlideQualityCategory.contrast, + severity: MarkdownValidationSeverity.warning, + args: { + 'label': label, + 'ratio': ratio.toStringAsFixed(1), + 'threshold': aaThreshold.toStringAsFixed(1), + }, + ), + ); + } + + void _checkAltText(Slide slide, int index, List issues) { + void missingCaption({ + required String path, + required String caption, + required String field, + required String label, + }) { + if (path.trim().isEmpty || caption.trim().isNotEmpty) return; + issues.add( + SlideQualityIssue( + slideIndex: index, + kind: SlideQualityIssueKind.missingAltCaption, + category: SlideQualityCategory.altText, + severity: MarkdownValidationSeverity.warning, + field: field, + args: {'label': label}, + ), + ); + } + + switch (slide.type) { + case SlideType.image: + missingCaption( + path: slide.imagePath, + caption: slide.imageCaption, + field: 'imageCaption', + label: 'Afbeelding', + ); + case SlideType.twoImages: + missingCaption( + path: slide.imagePath, + caption: slide.imageCaption, + field: 'imageCaption', + label: 'Eerste afbeelding', + ); + missingCaption( + path: slide.imagePath2, + caption: slide.imageCaption2, + field: 'imageCaption2', + label: 'Tweede afbeelding', + ); + case SlideType.bulletsImage: + missingCaption( + path: slide.imagePath, + caption: slide.imageCaption, + field: 'imageCaption', + label: 'Afbeelding', + ); + case SlideType.title: + if (slide.imagePath.isNotEmpty) { + missingCaption( + path: slide.imagePath, + caption: slide.imageCaption, + field: 'imageCaption', + label: 'Achtergrondafbeelding', + ); + } + case SlideType.quote: + if (slide.imagePath.isNotEmpty) { + missingCaption( + path: slide.imagePath, + caption: slide.imageCaption, + field: 'imageCaption', + label: 'Achtergrondafbeelding', + ); + } + case SlideType.chart: + _checkChartAltText(slide, index, issues); + case SlideType.video: + _checkMediaAltText( + slide: slide, + index: index, + issues: issues, + hasMedia: slide.videoPath.trim().isNotEmpty, + label: 'Video', + ); + case SlideType.bullets: + case SlideType.twoBullets: + case SlideType.section: + case SlideType.table: + case SlideType.freeMarkdown: + case SlideType.code: + break; + } + } + + void _checkChartAltText( + Slide slide, + int index, + List issues, + ) { + final spec = ChartSpec.parse(slide.customMarkdown); + if (spec.title.trim().isNotEmpty) return; + if (spec.hasInlineData && spec.series.any((s) => s.name.trim().isNotEmpty)) { + return; + } + if (spec.source != null && spec.source!.trim().isNotEmpty) return; + + issues.add( + SlideQualityIssue( + slideIndex: index, + kind: SlideQualityIssueKind.chartMissingDescription, + category: SlideQualityCategory.altText, + severity: MarkdownValidationSeverity.warning, + field: 'customMarkdown', + ), + ); + } + + void _checkMediaAltText({ + required Slide slide, + required int index, + required List issues, + required bool hasMedia, + required String label, + }) { + if (!hasMedia) return; + final hasDescription = slide.title.trim().isNotEmpty || + slide.notes.trim().isNotEmpty || + slide.subtitle.trim().isNotEmpty; + if (hasDescription) return; + + issues.add( + SlideQualityIssue( + slideIndex: index, + kind: SlideQualityIssueKind.mediaMissingDescription, + category: SlideQualityCategory.altText, + severity: MarkdownValidationSeverity.warning, + field: 'title', + args: {'label': label}, + ), + ); + } + + void _checkTextDensity( + Slide slide, + int index, + String font, + List issues, + ) { + switch (slide.type) { + case SlideType.bullets: + _addFitScaleIssue( + issues, + index, + bulletsSlideFitScale(slide: slide, font: font), + ); + case SlideType.twoBullets: + _addFitScaleIssue( + issues, + index, + twoBulletsSlideFitScale(slide: slide, font: font), + ); + case SlideType.bulletsImage: + _addFitScaleIssue( + issues, + index, + bulletsImageSlideFitScale(slide: slide, font: font), + ); + case SlideType.table: + _checkTableDensity(slide, index, issues); + case SlideType.code: + _checkCodeDensity(slide, index, issues); + case SlideType.freeMarkdown: + _checkFreeMarkdownDensity(slide, index, issues); + case SlideType.title: + _checkTitleDensity(slide, index, issues); + case SlideType.section: + case SlideType.image: + case SlideType.twoImages: + case SlideType.video: + case SlideType.quote: + case SlideType.chart: + break; + } + } + + void _addFitScaleIssue( + List issues, + int index, + double scale, + ) { + if (scale > kTextDensityWarningScale) return; + + final critical = scale <= kTextDensityCriticalScale + 0.001; + issues.add( + SlideQualityIssue( + slideIndex: index, + kind: critical + ? SlideQualityIssueKind.textDensityCritical + : SlideQualityIssueKind.textDensityWarning, + category: SlideQualityCategory.textDensity, + severity: critical + ? MarkdownValidationSeverity.error + : MarkdownValidationSeverity.warning, + args: {'percent': _percent(scale)}, + ), + ); + } + + void _checkTableDensity( + Slide slide, + int index, + List issues, + ) { + final rows = slide.tableRows.where((r) => r.isNotEmpty).toList(); + if (rows.isEmpty) return; + final colCount = rows.fold(0, (m, r) => r.length > m ? r.length : m); + final w = kReferenceSlideWidth; + final cellSize = tableCellFontSize(w, rowCount: rows.length, colCount: colCount); + final minimum = tableCellFontMinimum(w); + if (cellSize > minimum + 0.001) return; + + issues.add( + SlideQualityIssue( + slideIndex: index, + kind: SlideQualityIssueKind.tableDensityMinimum, + category: SlideQualityCategory.textDensity, + severity: MarkdownValidationSeverity.warning, + args: { + 'rows': '${rows.length}', + 'cols': '$colCount', + }, + ), + ); + } + + void _checkCodeDensity(Slide slide, int index, List issues) { + final code = slide.customMarkdown; + if (code.trim().isEmpty) return; + final lines = code.split('\n'); + if (lines.length <= 28 && code.length <= 1800) return; + + issues.add( + SlideQualityIssue( + slideIndex: index, + kind: SlideQualityIssueKind.codeDensityHigh, + category: SlideQualityCategory.textDensity, + severity: MarkdownValidationSeverity.warning, + field: 'customMarkdown', + args: {'lines': '${lines.length}'}, + ), + ); + } + + void _checkFreeMarkdownDensity( + Slide slide, + int index, + List issues, + ) { + final md = slide.customMarkdown; + if (md.trim().isEmpty) return; + final lines = md.split('\n').where((l) => l.trim().isNotEmpty).length; + if (lines <= 18 && md.length <= 1200) return; + + issues.add( + SlideQualityIssue( + slideIndex: index, + kind: SlideQualityIssueKind.freeMarkdownDensityHigh, + category: SlideQualityCategory.textDensity, + severity: MarkdownValidationSeverity.warning, + field: 'customMarkdown', + args: {'lines': '$lines'}, + ), + ); + } + + void _checkTitleDensity(Slide slide, int index, List issues) { + final titleLen = stripInlineMarkdown(slide.title).length; + final subtitleLen = stripInlineMarkdown(slide.subtitle).length; + if (titleLen + subtitleLen <= 120) return; + + issues.add( + SlideQualityIssue( + slideIndex: index, + kind: SlideQualityIssueKind.titleDensityHigh, + category: SlideQualityCategory.textDensity, + severity: MarkdownValidationSeverity.warning, + args: {'chars': '${titleLen + subtitleLen}'}, + ), + ); + } + + String _percent(double scale) => '${(scale * 100).round()}%'; +} diff --git a/lib/state/deck_quality_provider.dart b/lib/state/deck_quality_provider.dart new file mode 100644 index 0000000..e4b1fc3 --- /dev/null +++ b/lib/state/deck_quality_provider.dart @@ -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( + (_) => const SlideQualityAnalyzer(), +); + +final deckQualityProvider = + StateNotifierProvider((ref) { + return DeckQualityNotifier(ref); + }); + +class DeckQualityNotifier extends StateNotifier { + DeckQualityNotifier(this._ref) : super(const SlideQualityResult([])) { + _schedule(_ref.read(deckProvider).deck, immediate: true); + _ref.listen(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(); + } +} diff --git a/lib/state/settings_provider.dart b/lib/state/settings_provider.dart index a555e61..68d7293 100644 --- a/lib/state/settings_provider.dart +++ b/lib/state/settings_provider.dart @@ -58,6 +58,8 @@ class SettingsNotifier extends StateNotifier { uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0), presentationTargetSeconds: (prefs.getInt('presentationTargetSeconds') ?? 0) .clamp(0, 86400), + qualityWarningsOnExport: + prefs.getBool('qualityWarningsOnExport') ?? true, ); } @@ -91,6 +93,12 @@ class SettingsNotifier extends StateNotifier { await prefs.setInt('presentationTargetSeconds', clamped); } + Future setQualityWarningsOnExport(bool enabled) async { + state = state.copyWith(qualityWarningsOnExport: enabled); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('qualityWarningsOnExport', enabled); + } + Future addRecentFile(String path) async { final updated = [ path, diff --git a/lib/utils/color_contrast.dart b/lib/utils/color_contrast.dart new file mode 100644 index 0000000..e57c8a6 --- /dev/null +++ b/lib/utils/color_contrast.dart @@ -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; +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 90d4818..bb11ac2 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -10,7 +10,9 @@ import '../services/caption_service.dart'; import '../services/description_service.dart'; import '../services/classification_policy.dart'; import '../services/export_service.dart'; +import '../services/quality_export_policy.dart'; import '../services/recovery_service.dart'; +import '../services/slide_quality_analyzer.dart'; import '../state/deck_provider.dart'; import '../state/editor_provider.dart'; import '../state/settings_provider.dart'; @@ -592,6 +594,14 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { policy: ClassificationPolicy.fromKey( 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, // Inline chart data so the HTML export can render charts standalone, // even when a chart links an external CSV. diff --git a/lib/widgets/dialogs/export_dialog.dart b/lib/widgets/dialogs/export_dialog.dart index a3711f1..8ec6975 100644 --- a/lib/widgets/dialogs/export_dialog.dart +++ b/lib/widgets/dialogs/export_dialog.dart @@ -2,12 +2,16 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../../models/deck.dart'; +import '../../models/markdown_validation.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; +import '../../models/slide_quality.dart'; import '../../services/classification_policy.dart'; import '../../services/export_service.dart'; +import '../../services/quality_export_policy.dart'; import '../../services/slide_rasterizer.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 /// 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). 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. final String? exportDirectory; @@ -37,6 +47,8 @@ class ExportDialog extends StatefulWidget { required this.exportService, this.tlp = TlpLevel.none, this.policy = const ClassificationPolicy(), + this.qualityResult = const SlideQualityResult([]), + this.qualityPolicy = const QualityExportPolicy(), this.exportDirectory, this.markdown = '', }); @@ -50,6 +62,8 @@ class ExportDialog extends StatefulWidget { required ExportService exportService, TlpLevel tlp = TlpLevel.none, ClassificationPolicy policy = const ClassificationPolicy(), + SlideQualityResult qualityResult = const SlideQualityResult([]), + QualityExportPolicy qualityPolicy = const QualityExportPolicy(), String? exportDirectory, String markdown = '', }) { @@ -64,6 +78,8 @@ class ExportDialog extends StatefulWidget { exportService: exportService, tlp: tlp, policy: policy, + qualityResult: qualityResult, + qualityPolicy: qualityPolicy, exportDirectory: exportDirectory, markdown: markdown, ), @@ -86,7 +102,86 @@ class _ExportDialogState extends State { /// downscaled JPEG handout. bool _compress = false; + Future _confirmQualityExport() async { + final decision = widget.qualityPolicy.evaluate(widget.qualityResult); + if (decision.allowed) return true; + + final l10n = context.l10n; + final confirmed = await showDialog( + 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 _export(ExportFormat format, {bool compress = false}) async { + if (!await _confirmQualityExport()) return; + final l10n = context.l10n; // HTML renders from Markdown in the browser, so it needs no slide raster. final needsRaster = format != ExportFormat.html; @@ -140,6 +235,9 @@ class _ExportDialogState extends State { themeProfile: widget.themeProfile, tlp: widget.tlp, policy: widget.policy, + qualityResult: widget.qualityResult, + qualityPolicy: widget.qualityPolicy, + qualityAcknowledged: true, ); if (!mounted) return; @@ -263,6 +361,7 @@ class _ExportDialogState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (widget.qualityResult.hasIssues) _qualityBanner(l10n), Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( @@ -334,6 +433,43 @@ class _ExportDialogState extends State { ); } + 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({ required IconData icon, required String label, diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index 5e9e085..1e30de2 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -449,6 +449,26 @@ class _SettingsDialogState extends ConsumerState { 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), _sectionTitle(l10n.d('Presentatie')), _presentationTargetField(), diff --git a/lib/widgets/editors/_editor_field.dart b/lib/widgets/editors/_editor_field.dart index 112c954..06f5a77 100644 --- a/lib/widgets/editors/_editor_field.dart +++ b/lib/widgets/editors/_editor_field.dart @@ -5,6 +5,9 @@ import '../../services/caption_service.dart'; import '../../services/description_service.dart'; import '../../services/image_service.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 '../dialogs/image_carousel_picker.dart'; @@ -199,6 +202,7 @@ class ImagePickerBar extends ConsumerWidget { final VoidCallback? onClear; final ValueChanged? onCaptionChanged; final String label; + final String captionField; const ImagePickerBar({ super.key, @@ -212,6 +216,7 @@ class ImagePickerBar extends ConsumerWidget { this.onClear, this.onCaptionChanged, this.label = 'Geen afbeelding gekozen', + this.captionField = 'imageCaption', }); Future _openCarousel( @@ -405,6 +410,7 @@ class ImagePickerBar extends ConsumerWidget { imagePath: imagePath, captionBasePath: captionBasePath, captionService: captions, + captionField: captionField, onChanged: onCaptionChanged!, ), ], @@ -414,11 +420,12 @@ class ImagePickerBar extends ConsumerWidget { } /// Captionveld met auto-save naar sidecar. -class _CaptionField extends StatefulWidget { +class _CaptionField extends ConsumerStatefulWidget { final String caption; final String imagePath; final String? captionBasePath; final CaptionService captionService; + final String captionField; final ValueChanged onChanged; const _CaptionField({ @@ -426,14 +433,15 @@ class _CaptionField extends StatefulWidget { required this.imagePath, this.captionBasePath, required this.captionService, + required this.captionField, required this.onChanged, }); @override - State<_CaptionField> createState() => _CaptionFieldState(); + ConsumerState<_CaptionField> createState() => _CaptionFieldState(); } -class _CaptionFieldState extends State<_CaptionField> { +class _CaptionFieldState extends ConsumerState<_CaptionField> { late final TextEditingController _ctrl; @override @@ -486,30 +494,58 @@ class _CaptionFieldState extends State<_CaptionField> { @override Widget build(BuildContext context) { final l10n = context.l10n; - return TextField( - controller: _ctrl, - decoration: InputDecoration( - hintText: l10n.d('Caption / bronvermelding (bijv. © Naam Fotograaf)'), - hintStyle: const TextStyle(fontSize: 12, color: Color(0xFFB0BEC5)), - prefixIcon: const Icon( - Icons.copyright_outlined, - size: 16, - color: Color(0xFF64748B), + 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, + decoration: InputDecoration( + hintText: l10n.d('Caption / bronvermelding (bijv. © Naam Fotograaf)'), + hintStyle: const TextStyle(fontSize: 12, color: Color(0xFFB0BEC5)), + prefixIcon: Icon( + Icons.copyright_outlined, + size: 16, + color: showHint ? const Color(0xFFB45309) : const Color(0xFF64748B), + ), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + filled: true, + fillColor: showHint ? const Color(0xFFFFFBEB) : const Color(0xFFF8FAFC), + 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), + borderSide: const BorderSide(color: Color(0xFFCBD5E1)), + ), + ), + style: const TextStyle(fontSize: 12), ), - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), - filled: true, - fillColor: const Color(0xFFF8FAFC), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: Color(0xFFCBD5E1)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: Color(0xFFCBD5E1)), - ), - ), - 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)), + ), + ], + ], ); } } diff --git a/lib/widgets/editors/two_images_editor.dart b/lib/widgets/editors/two_images_editor.dart index 95a1642..6c0fe78 100644 --- a/lib/widgets/editors/two_images_editor.dart +++ b/lib/widgets/editors/two_images_editor.dart @@ -107,6 +107,7 @@ class _TwoImagesEditorState extends ConsumerState { ImagePickerBar( imagePath: widget.slide.imagePath2, imageCaption: widget.slide.imageCaption2, + captionField: 'imageCaption2', searchPaths: widget.searchPaths, captionBasePath: widget.captionBasePath, onPicked: (path, caption) => widget.onUpdate( diff --git a/lib/widgets/panels/editor_panel.dart b/lib/widgets/panels/editor_panel.dart index 6386956..7a5b459 100644 --- a/lib/widgets/panels/editor_panel.dart +++ b/lib/widgets/panels/editor_panel.dart @@ -23,6 +23,7 @@ import '../editors/title_editor.dart'; import '../editors/two_bullets_editor.dart'; import '../editors/two_images_editor.dart'; import '../editors/video_slide_editor.dart'; +import '../panels/slide_quality_panel.dart'; import '../editors/markdown_deck_editor.dart'; class EditorPanel extends ConsumerWidget { @@ -91,6 +92,8 @@ class EditorPanel extends ConsumerWidget { deckNotifier.updateThemeProfile(settings.themeProfile), ), const Divider(height: 1), + const SlideQualityPanel(), + const Divider(height: 1), // ── Slide editor body ──────────────────────────────────────────── Expanded( diff --git a/lib/widgets/panels/slide_quality_panel.dart b/lib/widgets/panels/slide_quality_panel.dart new file mode 100644 index 0000000..4a07020 --- /dev/null +++ b/lib/widgets/panels/slide_quality_panel.dart @@ -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 createState() => _SlideQualityPanelState(); +} + +class _SlideQualityPanelState extends ConsumerState { + 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)), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/slides/previews/bullets_previews.dart b/lib/widgets/slides/previews/bullets_previews.dart index 7217b0c..770cae7 100644 --- a/lib/widgets/slides/previews/bullets_previews.dart +++ b/lib/widgets/slides/previews/bullets_previews.dart @@ -52,7 +52,7 @@ class _BulletsPreview extends StatelessWidget { final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); // 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. - final scale = _bulletsFitScale( + final scale = bulletsFitScale( availW: textAvailW, availH: availH, hasTitle: hasTitle, @@ -65,7 +65,7 @@ class _BulletsPreview extends StatelessWidget { font: font, subtitle: subtitle, subtitleSize: subtitleSize, - maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale), + maxScale: bulletScaleCap(w, bulletSize, kSplitBulletsMaxScale), listStyle: slide.listStyle, ); @@ -269,7 +269,7 @@ class _TwoBulletsPreview extends StatelessWidget { final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w); var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); if (hasTitle) { - availH -= _measureTextHeight( + availH -= measureTextHeight( slide.title, titleSize, contentW, @@ -281,7 +281,7 @@ class _TwoBulletsPreview extends StatelessWidget { // Reserve room for the (optional) column headings so the bullets still fit. double headingHeight(String t) => t.isEmpty ? 0 - : _measureTextHeight( + : measureTextHeight( t, headingSize, columnW, @@ -293,7 +293,7 @@ class _TwoBulletsPreview extends StatelessWidget { headingHeight(col2Title), ); if (hasColumnTitles) availH -= maxHeadingH + headingGap; - final leftScale = _bulletsFitScale( + final leftScale = bulletsFitScale( availW: columnW, availH: availH, hasTitle: false, @@ -304,10 +304,10 @@ class _TwoBulletsPreview extends StatelessWidget { spacing: spacing, bulletGap: bulletGap, font: font, - maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), + maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale), listStyle: slide.listStyle, ); - final rightScale = _bulletsFitScale( + final rightScale = bulletsFitScale( availW: columnW, availH: availH, hasTitle: false, @@ -318,7 +318,7 @@ class _TwoBulletsPreview extends StatelessWidget { spacing: spacing, bulletGap: bulletGap, font: font, - maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), + maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale), listStyle: slide.listStyle, ); // 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 // text as large as possible and lets it span the full width toward the // image, instead of uniformly shrinking and leaving a wide gap. - final scale = _bulletsFitScale( + final scale = bulletsFitScale( availW: availW, availH: availH, hasTitle: hasTitle, @@ -479,7 +479,7 @@ class _BulletsImagePreview extends StatelessWidget { spacing: spacing, bulletGap: bulletGap, font: font, - maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), + maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale), listStyle: slide.listStyle, ); @@ -589,7 +589,7 @@ class _BulletsImagePreview extends StatelessWidget { : b.substring(level); final checked = slide.listStyle == ListStyle.checklist && checklistItemChecked(b); - final fontSize = bulletSize * _bulletLevelScale(level) * scale; + final fontSize = bulletSize * bulletLevelScale(level) * scale; return _ChecklistBulletRow( bullets: bullets, itemIndex: entry.key, @@ -649,7 +649,7 @@ class _BulletListColumn extends StatelessWidget { : b.substring(level); final checked = listStyle == ListStyle.checklist && checklistItemChecked(b); - final fontSize = bulletSize * _bulletLevelScale(level) * scale; + final fontSize = bulletSize * bulletLevelScale(level) * scale; return _ChecklistBulletRow( bullets: bullets, 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 24–32pt — 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 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 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 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; -} diff --git a/lib/widgets/slides/previews/checklist_previews.dart b/lib/widgets/slides/previews/checklist_previews.dart index 188a175..9a30980 100644 --- a/lib/widgets/slides/previews/checklist_previews.dart +++ b/lib/widgets/slides/previews/checklist_previews.dart @@ -225,7 +225,7 @@ class _ChecklistBulletRow extends StatelessWidget { ? SystemMouseCursors.click : MouseCursor.defer, child: Text( - '${_listMarker(bullets, itemIndex, listStyle)} ', + '${bulletListMarker(bullets, itemIndex, listStyle)} ', style: TextStyle( fontSize: fontSize, color: _hexColor(profile.accentColor), @@ -242,7 +242,7 @@ class _ChecklistBulletRow extends StatelessWidget { font, TextStyle( fontSize: fontSize, - height: _kBulletLineHeight, + height: kBulletLineHeight, color: _hexColor(profile.textColor), decoration: checked && profile.checklistStrikeThrough ? TextDecoration.lineThrough diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index 16edc1a..acbf7a5 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -16,6 +16,7 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../theme/app_theme.dart'; +import '../../services/slide_layout_metrics.dart'; import '../../utils/log.dart'; import 'inline_markdown.dart'; diff --git a/lib/widgets/slides/slide_thumbnail.dart b/lib/widgets/slides/slide_thumbnail.dart index 51d5433..f4c2e4c 100644 --- a/lib/widgets/slides/slide_thumbnail.dart +++ b/lib/widgets/slides/slide_thumbnail.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/markdown_validation.dart'; import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; +import '../../state/deck_quality_provider.dart'; import '../../state/slide_clipboard_provider.dart'; import '../../theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; @@ -47,6 +49,11 @@ class SlideThumbnail extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; 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 ? AppTheme.accent : skipped @@ -62,7 +69,8 @@ class SlideThumbnail extends ConsumerWidget { final semanticLabel = '${l10n.d('Slide')} ${index + 1}/$slideCount: ' '${title.isNotEmpty ? title : l10n.d(slide.type.label)}' - '${skipped ? ' (${l10n.d('Overgeslagen')})' : ''}'; + '${skipped ? ' (${l10n.d('Overgeslagen')})' : ''}' + '${hasQualityWarnings ? ' (${l10n.d('Kwaliteitsprobleem')})' : ''}'; return Semantics( 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, + ), + ), + ), + ), ], ), ), diff --git a/test/app_localizations_test.dart b/test/app_localizations_test.dart index 8e63a05..b05cef1 100644 --- a/test/app_localizations_test.dart +++ b/test/app_localizations_test.dart @@ -52,6 +52,8 @@ void main() { 'Slide', 'slide', 'Spider', + 'Contrast', + ':1).', }; final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")'''); final files = Directory('lib') @@ -89,6 +91,8 @@ void main() { 'SLIDES', 'Slide', 'slide', + 'Contrast', + ':1).', }; final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")'''); final files = Directory('lib') diff --git a/test/export_service_test.dart b/test/export_service_test.dart index 6247d5c..0810b1f 100644 --- a/test/export_service_test.dart +++ b/test/export_service_test.dart @@ -6,8 +6,11 @@ import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/models/markdown_validation.dart'; +import 'package:ocideck/models/slide_quality.dart'; import 'package:ocideck/services/classification_policy.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:path/path.dart' as p; import 'package:xml/xml.dart'; @@ -93,6 +96,41 @@ void main() { 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 { final images = [_png(), _png()]; final r = await service.export(deckPath(), ExportFormat.pdf, images); diff --git a/test/quality_export_policy_test.dart b/test/quality_export_policy_test.dart new file mode 100644 index 0000000..db1387c --- /dev/null +++ b/test/quality_export_policy_test.dart @@ -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); + }); + }); +} diff --git a/test/slide_quality_analyzer_test.dart b/test/slide_quality_analyzer_test.dart new file mode 100644 index 0000000..b401266 --- /dev/null +++ b/test/slide_quality_analyzer_test.dart @@ -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); + }); + }); +}