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

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

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

View file

@ -193,6 +193,14 @@ OciDeck aims for WCAG 2.1 in the editor:
- **Screen readers** — slide thumbnails announce a concise label ("Slide 3/12:
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

View file

@ -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.',
},
};

View file

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

View file

@ -391,6 +391,10 @@ class AppSettings {
/// presenter. 0 = geen aftelling. Live aanpasbaar tijdens presenteren (K).
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,
);
}
}

View file

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

View file

@ -11,6 +11,8 @@ import 'package:pdf/widgets.dart' as pw;
import '../models/deck.dart';
import '../models/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.');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,9 @@ import '../services/caption_service.dart';
import '../services/description_service.dart';
import '../services/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.

View file

@ -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<ExportDialog> {
/// downscaled JPEG handout.
bool _compress = false;
Future<bool> _confirmQualityExport() async {
final decision = widget.qualityPolicy.evaluate(widget.qualityResult);
if (decision.allowed) return true;
final l10n = context.l10n;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
final issues = widget.qualityResult.issues.take(8).toList();
return AlertDialog(
title: Text(l10n.d('Kwaliteitsproblemen gevonden')),
content: SizedBox(
width: 480,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${decision.errorCount} ${l10n.d('fout(en),')} '
'${decision.warningCount} ${l10n.d('waarschuwing(en)')}',
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final issue in issues)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
issue.isDeckWide
? '${l10n.d('Thema (hele presentatie)')}: '
'${formatSlideQualityIssue(l10n, issue)}'
: '${l10n.d('Slide')} ${issue.slideIndex + 1}: '
'${formatSlideQualityIssue(l10n, issue)}',
style: TextStyle(
fontSize: 12,
color: issue.severity ==
MarkdownValidationSeverity.error
? Colors.red.shade800
: const Color(0xFF92400E),
),
),
),
if (widget.qualityResult.issues.length > issues.length)
Text(
l10n.d('… en meer problemen in het kwaliteitspaneel.'),
style: const TextStyle(
fontSize: 11,
color: Color(0xFF94A3B8),
),
),
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.t('cancel')),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.d('Toch exporteren')),
),
],
);
},
);
return confirmed == true;
}
Future<void> _export(ExportFormat format, {bool compress = false}) async {
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<ExportDialog> {
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<ExportDialog> {
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<ExportDialog> {
);
}
Widget _qualityBanner(AppLocalizations l10n) {
final hasErrors = widget.qualityResult.errorCount > 0;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: hasErrors ? const Color(0xFFFEE2E2) : const Color(0xFFFEF3C7),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: hasErrors ? const Color(0xFFFECACA) : const Color(0xFFFDE68A),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.accessibility_new_outlined,
size: 16,
color: hasErrors ? Colors.red.shade700 : const Color(0xFF92400E),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${l10n.d('Slidekwaliteit')}: '
'${widget.qualityResult.errorCount} ${l10n.d('fout(en),')} '
'${widget.qualityResult.warningCount} ${l10n.d('waarschuwing(en)')}',
style: TextStyle(
fontSize: 11,
color: hasErrors ? Colors.red.shade800 : const Color(0xFF92400E),
),
),
),
],
),
);
}
Widget _exportButton({
required IconData icon,
required String label,

View file

@ -449,6 +449,26 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
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(),

View file

@ -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<String>? 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<void> _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<String> 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)),
),
],
],
);
}
}

View file

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

View file

@ -23,6 +23,7 @@ import '../editors/title_editor.dart';
import '../editors/two_bullets_editor.dart';
import '../editors/two_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(

View file

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

View file

@ -52,7 +52,7 @@ class _BulletsPreview extends StatelessWidget {
final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
// 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 2432pt beyond that it stops aiding readability and starts
/// competing with the title. The fit scale multiplies title and bullets
/// alike, so capping the bullet size also keeps the hierarchy intact.
const double _kBulletMaxFontFraction = 0.0335;
/// The largest auto-fit scale that keeps bullets at or under
/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
double _bulletScaleCap(double w, double bulletSize, double layoutMax) =>
math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize);
/// Line height used for bullet body text, shared by rendering and measuring.
const double _kBulletLineHeight = 1.16;
String _bulletMarkerForLevel(int level) {
const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)];
}
String _listMarker(List<String> items, int index, ListStyle style) {
int levelOf(String item) {
var level = 0;
while (level < item.length && item[level] == '\t') {
level++;
}
return level;
}
final level = levelOf(items[index]);
if (style == ListStyle.bullets) return _bulletMarkerForLevel(level);
if (style == ListStyle.checklist) {
return checklistItemChecked(items[index]) ? '' : '';
}
var number = 0;
for (var i = 0; i <= index; i++) {
final itemLevel = levelOf(items[i]);
if (itemLevel == level) number++;
if (itemLevel < level) number = 0;
}
return '$number.';
}
double _bulletLevelScale(int level) {
if (level <= 0) return 1.0;
if (level == 1) return 0.86;
if (level == 2) return 0.80;
return 0.76;
}
/// Largest scale in [minScale, maxScale] for which the bullet block fits
/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this
/// also grows the text *above* its design size when there is spare vertical
/// room, so short slides use the full height instead of clustering at the top.
double _bulletsFitScale({
required double availW,
required double availH,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
double minScale = 0.2,
double maxScale = 1.0,
ListStyle listStyle = ListStyle.bullets,
}) {
if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0;
// 2% safety margin so minor measurement differences never overflow.
final budget = availH * 0.98;
double measure(double scale) => _bulletsBlockHeight(
scale: scale,
availW: availW,
listStyle: listStyle,
hasTitle: hasTitle,
title: title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
);
// Everything already fits at the largest allowed size use it.
if (measure(maxScale) <= budget) return maxScale;
// Otherwise binary-search the largest scale that fits. Search upward from the
// design size when it fits, downward when even the design size overflows.
double lo, hi;
if (maxScale > 1.0 && measure(1.0) <= budget) {
lo = 1.0;
hi = maxScale;
} else {
lo = minScale;
hi = maxScale > 1.0 ? 1.0 : maxScale;
}
for (var i = 0; i < 24; i++) {
final mid = (lo + hi) / 2;
if (measure(mid) <= budget) {
lo = mid;
} else {
hi = mid;
}
}
return lo;
}
double _bulletsBlockHeight({
required double scale,
required double availW,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
ListStyle listStyle = ListStyle.bullets,
}) {
var height = 0.0;
if (hasTitle) {
height += _measureTextHeight(
title,
titleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if (subtitle.isNotEmpty) {
height += spacing * scale * 0.4;
height += _measureTextHeight(
subtitle,
subtitleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) {
height += spacing * scale;
}
for (var i = 0; i < bullets.length; i++) {
final b = bullets[i];
int level = 0;
while (level < b.length && b[level] == '\t') {
level++;
}
// Measure exactly what gets rendered: checklists strip the `[x] ` prefix
// and use a checkbox marker, numbered lists use `N.`. Measuring the raw
// string with a bullet marker over-counts the height and would shrink the
// text below the space it actually needs.
final text = listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
final indent = level * bulletSize * 1.05 * scale;
final marker = '${_listMarker(bullets, i, listStyle)} ';
final markerW = _measureTextWidth(
marker,
fontSize,
bold: true,
fontFamily: font,
);
final wrapW = (availW - indent - markerW).clamp(1.0, availW);
final textH = _measureTextHeight(
text,
fontSize,
wrapW,
lineHeight: _kBulletLineHeight,
fontFamily: font,
);
final markerH = _measureTextHeight(
marker,
fontSize,
double.infinity,
fontFamily: font,
);
height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
}
return height;
}
double _measureTextHeight(
String text,
double fontSize,
double maxWidth, {
double? lineHeight,
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
height: lineHeight,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
return painter.height;
}
double _measureTextWidth(
String text,
double fontSize, {
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout();
return painter.width;
}

View file

@ -225,7 +225,7 @@ class _ChecklistBulletRow extends StatelessWidget {
? SystemMouseCursors.click
: 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

View file

@ -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';

View file

@ -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,
),
),
),
),
],
),
),

View file

@ -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')

View file

@ -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);

View file

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

View file

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