Add slide quality checks for accessibility with export warnings.
Some checks are pending
CI / Format · Analyze · Test (push) Waiting to run
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:
parent
6d90e7d7b4
commit
1fc4d25dcf
27 changed files with 2514 additions and 285 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
80
lib/l10n/slide_quality_localization.dart
Normal file
80
lib/l10n/slide_quality_localization.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
lib/models/slide_quality.dart
Normal file
72
lib/models/slide_quality.dart
Normal 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();
|
||||
}
|
||||
|
|
@ -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.');
|
||||
|
|
|
|||
73
lib/services/quality_export_policy.dart
Normal file
73
lib/services/quality_export_policy.dart
Normal 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(', ')}).';
|
||||
}
|
||||
}
|
||||
410
lib/services/slide_layout_metrics.dart
Normal file
410
lib/services/slide_layout_metrics.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
466
lib/services/slide_quality_analyzer.dart
Normal file
466
lib/services/slide_quality_analyzer.dart
Normal 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()}%';
|
||||
}
|
||||
54
lib/state/deck_quality_provider.dart
Normal file
54
lib/state/deck_quality_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
45
lib/utils/color_contrast.dart
Normal file
45
lib/utils/color_contrast.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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: const Icon(
|
||||
prefixIcon: Icon(
|
||||
Icons.copyright_outlined,
|
||||
size: 16,
|
||||
color: Color(0xFF64748B),
|
||||
color: showHint ? const Color(0xFFB45309) : const Color(0xFF64748B),
|
||||
),
|
||||
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)),
|
||||
),
|
||||
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),
|
||||
),
|
||||
if (showHint) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.d('Voeg alt-tekst / bijschrift toe voor toegankelijkheid'),
|
||||
style: const TextStyle(fontSize: 10, color: Color(0xFFB45309)),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
160
lib/widgets/panels/slide_quality_panel.dart
Normal file
160
lib/widgets/panels/slide_quality_panel.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ class _BulletsPreview extends StatelessWidget {
|
|||
final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
|
||||
// Grow (or, when needed, shrink) the text so it uses the full vertical
|
||||
// space instead of leaving a large empty area below a few short bullets.
|
||||
final scale = _bulletsFitScale(
|
||||
final scale = bulletsFitScale(
|
||||
availW: textAvailW,
|
||||
availH: availH,
|
||||
hasTitle: hasTitle,
|
||||
|
|
@ -65,7 +65,7 @@ class _BulletsPreview extends StatelessWidget {
|
|||
font: font,
|
||||
subtitle: subtitle,
|
||||
subtitleSize: subtitleSize,
|
||||
maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale),
|
||||
maxScale: bulletScaleCap(w, bulletSize, kSplitBulletsMaxScale),
|
||||
listStyle: slide.listStyle,
|
||||
);
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ class _TwoBulletsPreview extends StatelessWidget {
|
|||
final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w);
|
||||
var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
|
||||
if (hasTitle) {
|
||||
availH -= _measureTextHeight(
|
||||
availH -= measureTextHeight(
|
||||
slide.title,
|
||||
titleSize,
|
||||
contentW,
|
||||
|
|
@ -281,7 +281,7 @@ class _TwoBulletsPreview extends StatelessWidget {
|
|||
// Reserve room for the (optional) column headings so the bullets still fit.
|
||||
double headingHeight(String t) => t.isEmpty
|
||||
? 0
|
||||
: _measureTextHeight(
|
||||
: measureTextHeight(
|
||||
t,
|
||||
headingSize,
|
||||
columnW,
|
||||
|
|
@ -293,7 +293,7 @@ class _TwoBulletsPreview extends StatelessWidget {
|
|||
headingHeight(col2Title),
|
||||
);
|
||||
if (hasColumnTitles) availH -= maxHeadingH + headingGap;
|
||||
final leftScale = _bulletsFitScale(
|
||||
final leftScale = bulletsFitScale(
|
||||
availW: columnW,
|
||||
availH: availH,
|
||||
hasTitle: false,
|
||||
|
|
@ -304,10 +304,10 @@ class _TwoBulletsPreview extends StatelessWidget {
|
|||
spacing: spacing,
|
||||
bulletGap: bulletGap,
|
||||
font: font,
|
||||
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
|
||||
maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
|
||||
listStyle: slide.listStyle,
|
||||
);
|
||||
final rightScale = _bulletsFitScale(
|
||||
final rightScale = bulletsFitScale(
|
||||
availW: columnW,
|
||||
availH: availH,
|
||||
hasTitle: false,
|
||||
|
|
@ -318,7 +318,7 @@ class _TwoBulletsPreview extends StatelessWidget {
|
|||
spacing: spacing,
|
||||
bulletGap: bulletGap,
|
||||
font: font,
|
||||
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
|
||||
maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
|
||||
listStyle: slide.listStyle,
|
||||
);
|
||||
// Treat both columns as one composition: the busiest column determines
|
||||
|
|
@ -468,7 +468,7 @@ class _BulletsImagePreview extends StatelessWidget {
|
|||
// still fits the available height at the full column width. This keeps the
|
||||
// text as large as possible and lets it span the full width toward the
|
||||
// image, instead of uniformly shrinking and leaving a wide gap.
|
||||
final scale = _bulletsFitScale(
|
||||
final scale = bulletsFitScale(
|
||||
availW: availW,
|
||||
availH: availH,
|
||||
hasTitle: hasTitle,
|
||||
|
|
@ -479,7 +479,7 @@ class _BulletsImagePreview extends StatelessWidget {
|
|||
spacing: spacing,
|
||||
bulletGap: bulletGap,
|
||||
font: font,
|
||||
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
|
||||
maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
|
||||
listStyle: slide.listStyle,
|
||||
);
|
||||
|
||||
|
|
@ -589,7 +589,7 @@ class _BulletsImagePreview extends StatelessWidget {
|
|||
: b.substring(level);
|
||||
final checked =
|
||||
slide.listStyle == ListStyle.checklist && checklistItemChecked(b);
|
||||
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
|
||||
final fontSize = bulletSize * bulletLevelScale(level) * scale;
|
||||
return _ChecklistBulletRow(
|
||||
bullets: bullets,
|
||||
itemIndex: entry.key,
|
||||
|
|
@ -649,7 +649,7 @@ class _BulletListColumn extends StatelessWidget {
|
|||
: b.substring(level);
|
||||
final checked =
|
||||
listStyle == ListStyle.checklist && checklistItemChecked(b);
|
||||
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
|
||||
final fontSize = bulletSize * bulletLevelScale(level) * scale;
|
||||
return _ChecklistBulletRow(
|
||||
bullets: bullets,
|
||||
itemIndex: entry.key,
|
||||
|
|
@ -670,247 +670,3 @@ class _BulletListColumn extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound for growing bullet text to fill otherwise empty vertical space.
|
||||
const double _kBulletsMaxScale = 3.2;
|
||||
|
||||
/// Split slides have a much narrower column, so short bullet lists can stay
|
||||
/// visually timid unless they are allowed to grow a little further.
|
||||
const double _kSplitBulletsMaxScale = 4.35;
|
||||
|
||||
/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a
|
||||
/// fraction of the slide width: ≈32pt on a standard 16:9 deck (PowerPoint's
|
||||
/// 960pt-wide canvas). Presentation-design guidance consistently puts body
|
||||
/// text at 24–32pt — beyond that it stops aiding readability and starts
|
||||
/// competing with the title. The fit scale multiplies title and bullets
|
||||
/// alike, so capping the bullet size also keeps the hierarchy intact.
|
||||
const double _kBulletMaxFontFraction = 0.0335;
|
||||
|
||||
/// The largest auto-fit scale that keeps bullets at or under
|
||||
/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
|
||||
double _bulletScaleCap(double w, double bulletSize, double layoutMax) =>
|
||||
math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize);
|
||||
|
||||
/// Line height used for bullet body text, shared by rendering and measuring.
|
||||
const double _kBulletLineHeight = 1.16;
|
||||
|
||||
String _bulletMarkerForLevel(int level) {
|
||||
const markers = ['•', '◦', '▪', '▫', '–'];
|
||||
return markers[level.clamp(0, markers.length - 1)];
|
||||
}
|
||||
|
||||
String _listMarker(List<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
46
test/quality_export_policy_test.dart
Normal file
46
test/quality_export_policy_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
259
test/slide_quality_analyzer_test.dart
Normal file
259
test/slide_quality_analyzer_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue