550 lines
17 KiB
JavaScript
550 lines
17 KiB
JavaScript
#!/usr/bin/env bun
|
|
|
|
/**
|
|
* Presentation Quality Validator
|
|
*
|
|
* Scores presentations 0-100 against best practices from presentation masters.
|
|
* Implements validation rules from principles/validation-rules.md
|
|
*
|
|
* Usage:
|
|
* bun validate-presentation.js --presentation presentation.json
|
|
*
|
|
* Output: JSON with score, breakdown, critical issues, warnings, recommendations
|
|
*/
|
|
|
|
const SCORING_WEIGHTS = {
|
|
simplicity: 10,
|
|
visual_dominance: 10,
|
|
story_structure: 10,
|
|
one_idea_per_slide: 10,
|
|
typography: 8,
|
|
layout: 7,
|
|
color_contrast: 7,
|
|
media_quality: 8,
|
|
cognitive_load: 20,
|
|
data_integrity: 10
|
|
};
|
|
|
|
const CRITICAL_VIOLATIONS = {
|
|
font_too_small: { penalty: -20, threshold: 30 },
|
|
too_many_concepts: { penalty: -15, threshold: 10 },
|
|
bullet_points: { penalty: -10 },
|
|
paragraphs: { penalty: -10 },
|
|
poor_contrast: { penalty: -15, threshold: 4.5 },
|
|
default_template: { penalty: -20 }
|
|
};
|
|
|
|
const WARNING_FLAGS = {
|
|
too_many_words: { penalty: -2, threshold: 6 },
|
|
too_many_slides: { penalty: -5, ratio: 0.75 },
|
|
no_images: { penalty: -10 },
|
|
text_heavy: { penalty: -3, threshold: 3 },
|
|
inconsistent_fonts: { penalty: -5, threshold: 2 },
|
|
low_res_images: { penalty: -3 }
|
|
};
|
|
|
|
function countWords(text) {
|
|
if (!text) return 0;
|
|
return text.trim().split(/\s+/).filter(w => w.length > 0).length;
|
|
}
|
|
|
|
function detectBulletPoints(text) {
|
|
if (!text) return false;
|
|
return /[•\-*]\s/.test(text) || /^\s*[\d]+\.\s/m.test(text);
|
|
}
|
|
|
|
function detectParagraphs(text) {
|
|
if (!text) return false;
|
|
const sentences = text.split(/[.!?]/).filter(s => s.trim().length > 0);
|
|
return sentences.length > 2;
|
|
}
|
|
|
|
function estimateContrastRatio(slide) {
|
|
// Simplified contrast estimation
|
|
// In real implementation, would analyze actual colors
|
|
if (slide.contrast_ratio) return slide.contrast_ratio;
|
|
|
|
// Conservative default
|
|
return 7.0;
|
|
}
|
|
|
|
function countElements(slide) {
|
|
let elements = 0;
|
|
if (slide.title) elements++;
|
|
if (slide.text) elements++;
|
|
if (slide.image) elements++;
|
|
if (slide.chart) elements++;
|
|
return elements;
|
|
}
|
|
|
|
function scoreSimplicity(slides) {
|
|
let score = 0;
|
|
const maxScore = SCORING_WEIGHTS.simplicity;
|
|
|
|
for (const slide of slides) {
|
|
const wordCount = countWords(slide.text);
|
|
const elements = countElements(slide);
|
|
|
|
// Word count scoring
|
|
let wordScore = 0;
|
|
if (wordCount <= 3) wordScore = 10;
|
|
else if (wordCount <= 6) wordScore = 8;
|
|
else if (wordCount <= 10) wordScore = 5;
|
|
else wordScore = 0;
|
|
|
|
// Visual clutter scoring
|
|
let elementScore = 0;
|
|
if (elements === 1) elementScore = 10;
|
|
else if (elements <= 3) elementScore = 8;
|
|
else if (elements <= 5) elementScore = 5;
|
|
else elementScore = 0;
|
|
|
|
score += (wordScore + elementScore) / 2;
|
|
}
|
|
|
|
return Math.min(maxScore, score / slides.length);
|
|
}
|
|
|
|
function scoreVisualDominance(slides) {
|
|
let score = 0;
|
|
const maxScore = SCORING_WEIGHTS.visual_dominance;
|
|
|
|
const slidesWithImages = slides.filter(s => s.image || s.chart).length;
|
|
const imageRatio = slidesWithImages / slides.length;
|
|
|
|
// Image quality check
|
|
const highResImages = slides.filter(s => {
|
|
if (!s.image) return false;
|
|
return s.image.resolution >= 2000;
|
|
}).length;
|
|
|
|
const imageQualityScore = slidesWithImages > 0
|
|
? (highResImages / slidesWithImages) * 10
|
|
: 0;
|
|
|
|
// Text-to-visual ratio
|
|
const textVsVisualScore = imageRatio >= 0.8 ? 10 :
|
|
imageRatio >= 0.5 ? 5 : 0;
|
|
|
|
score = (imageQualityScore + textVsVisualScore) / 2;
|
|
|
|
return Math.min(maxScore, score);
|
|
}
|
|
|
|
function scoreStoryStructure(presentation) {
|
|
const maxScore = SCORING_WEIGHTS.story_structure;
|
|
|
|
// Check if presentation has defined structure
|
|
if (!presentation.framework) return maxScore * 0.3; // partial credit
|
|
|
|
const framework = presentation.framework.toLowerCase();
|
|
|
|
// Narrative arc check
|
|
let narrativeScore = 0;
|
|
if (presentation.slides.length >= 3) {
|
|
const hasOpening = presentation.slides[0].pattern === 'title';
|
|
const hasClosing = presentation.slides[presentation.slides.length - 1].pattern === 'transition' ||
|
|
presentation.slides[presentation.slides.length - 1].pattern === 'big-idea';
|
|
|
|
if (hasOpening && hasClosing) narrativeScore = 10;
|
|
else if (hasOpening || hasClosing) narrativeScore = 5;
|
|
}
|
|
|
|
// Emotional beats check
|
|
const bigIdeaSlides = presentation.slides.filter(s => s.pattern === 'big-idea').length;
|
|
const emotionalScore = bigIdeaSlides >= 3 ? 10 :
|
|
bigIdeaSlides >= 1 ? 5 : 0;
|
|
|
|
return Math.min(maxScore, (narrativeScore + emotionalScore) / 2);
|
|
}
|
|
|
|
function scoreOneIdeaPerSlide(slides) {
|
|
const maxScore = SCORING_WEIGHTS.one_idea_per_slide;
|
|
|
|
let clearSlides = 0;
|
|
for (const slide of slides) {
|
|
const wordCount = countWords(slide.text);
|
|
const hasBullets = detectBulletPoints(slide.text);
|
|
const elements = countElements(slide);
|
|
|
|
// Single concept if: few words, no bullets, few elements
|
|
if (wordCount <= 10 && !hasBullets && elements <= 3) {
|
|
clearSlides++;
|
|
}
|
|
}
|
|
|
|
const ratio = clearSlides / slides.length;
|
|
|
|
if (ratio === 1.0) return maxScore;
|
|
if (ratio >= 0.7) return maxScore * 0.7;
|
|
if (ratio >= 0.5) return maxScore * 0.3;
|
|
return 0;
|
|
}
|
|
|
|
function scoreTypography(slides) {
|
|
const maxScore = SCORING_WEIGHTS.typography;
|
|
|
|
// Font size check
|
|
const minFontSize = Math.min(...slides.map(s => s.min_font_size || 36));
|
|
|
|
let fontSizeScore = 0;
|
|
if (minFontSize >= 36) fontSizeScore = 8;
|
|
else if (minFontSize >= 30) fontSizeScore = 6;
|
|
else if (minFontSize >= 24) fontSizeScore = 2;
|
|
else fontSizeScore = 0;
|
|
|
|
// Font consistency
|
|
const fontFamilies = new Set(slides.map(s => s.font_family || 'default'));
|
|
let consistencyScore = 0;
|
|
if (fontFamilies.size === 1) consistencyScore = 8;
|
|
else if (fontFamilies.size === 2) consistencyScore = 6;
|
|
else consistencyScore = 0;
|
|
|
|
return Math.min(maxScore, (fontSizeScore + consistencyScore) / 2);
|
|
}
|
|
|
|
function scoreLayout(slides) {
|
|
const maxScore = SCORING_WEIGHTS.layout;
|
|
|
|
// Simplified layout scoring
|
|
// In real implementation, would analyze actual layouts
|
|
let hierarchyScore = 7; // assume good by default
|
|
let whitespaceScore = 7; // assume good by default
|
|
let alignmentScore = 7; // assume good by default
|
|
|
|
return Math.min(maxScore, (hierarchyScore + whitespaceScore + alignmentScore) / 3);
|
|
}
|
|
|
|
function scoreColorContrast(slides) {
|
|
const maxScore = SCORING_WEIGHTS.color_contrast;
|
|
|
|
const avgContrast = slides.reduce((sum, s) => sum + estimateContrastRatio(s), 0) / slides.length;
|
|
|
|
let contrastScore = 0;
|
|
if (avgContrast >= 7.0) contrastScore = 7;
|
|
else if (avgContrast >= 4.5) contrastScore = 5;
|
|
else contrastScore = 0;
|
|
|
|
return Math.min(maxScore, contrastScore);
|
|
}
|
|
|
|
function scoreMediaQuality(slides) {
|
|
const maxScore = SCORING_WEIGHTS.media_quality;
|
|
|
|
const slidesWithMedia = slides.filter(s => s.image || s.chart);
|
|
if (slidesWithMedia.length === 0) return 0;
|
|
|
|
// Resolution check
|
|
const highRes = slidesWithMedia.filter(s => {
|
|
if (s.image) return s.image.resolution >= 2000;
|
|
return true; // charts assumed high quality
|
|
}).length;
|
|
|
|
const resolutionScore = (highRes / slidesWithMedia.length) * 8;
|
|
|
|
// Relevance is assumed (would need manual review)
|
|
const relevanceScore = 8;
|
|
|
|
return Math.min(maxScore, (resolutionScore + relevanceScore) / 2);
|
|
}
|
|
|
|
function scoreCognitiveLoad(slides, presentation) {
|
|
const maxScore = SCORING_WEIGHTS.cognitive_load;
|
|
|
|
// Mayer's 12 principles adherence (simplified)
|
|
let principlesFollowed = 0;
|
|
|
|
// 1. Coherence (extraneous material excluded)
|
|
const avgWordCount = slides.reduce((sum, s) => sum + countWords(s.text), 0) / slides.length;
|
|
if (avgWordCount <= 10) principlesFollowed++;
|
|
|
|
// 2. Signaling (essential material highlighted)
|
|
const hasTransitions = slides.some(s => s.pattern === 'transition');
|
|
if (hasTransitions) principlesFollowed++;
|
|
|
|
// 3. Redundancy (graphics + narration, not graphics + text + narration)
|
|
const textHeavySlides = slides.filter(s => countWords(s.text) > 15).length;
|
|
if (textHeavySlides / slides.length < 0.2) principlesFollowed++;
|
|
|
|
// 4. Spatial contiguity (related words/pictures near each other)
|
|
principlesFollowed++; // assume good
|
|
|
|
// 5. Temporal contiguity (corresponding narration/animation together)
|
|
principlesFollowed++; // assume good
|
|
|
|
// 6. Segmenting (user-paced)
|
|
principlesFollowed++; // presentations are inherently segmented
|
|
|
|
// 7. Pre-training (key concepts introduced early)
|
|
const hasOpening = slides[0]?.pattern === 'title';
|
|
if (hasOpening) principlesFollowed++;
|
|
|
|
// 8. Modality (graphics + narration better than graphics + text)
|
|
const avgTextPerSlide = slides.reduce((sum, s) => sum + (s.text || '').length, 0) / slides.length;
|
|
if (avgTextPerSlide < 100) principlesFollowed++;
|
|
|
|
// 9-12: Multimedia, personalization, voice, image principles
|
|
principlesFollowed += 4; // assume followed
|
|
|
|
// Score based on principles followed
|
|
if (principlesFollowed >= 10) return maxScore;
|
|
if (principlesFollowed >= 7) return maxScore * 0.75;
|
|
if (principlesFollowed >= 4) return maxScore * 0.5;
|
|
return maxScore * 0.25;
|
|
}
|
|
|
|
function scoreDataIntegrity(slides) {
|
|
const maxScore = SCORING_WEIGHTS.data_integrity;
|
|
|
|
const dataSlides = slides.filter(s => s.chart || s.pattern === 'data-viz');
|
|
if (dataSlides.length === 0) return maxScore; // not applicable, full credit
|
|
|
|
// Lie factor check (if provided)
|
|
let lieFactor = 1.0; // assume honest by default
|
|
if (dataSlides[0].lie_factor) {
|
|
lieFactor = dataSlides[0].lie_factor;
|
|
}
|
|
|
|
let lieFactorScore = 0;
|
|
if (lieFactor >= 0.95 && lieFactor <= 1.05) lieFactorScore = 10;
|
|
else if (lieFactor >= 0.90 && lieFactor <= 1.10) lieFactorScore = 7;
|
|
else if (lieFactor >= 0.80 && lieFactor <= 1.20) lieFactorScore = 3;
|
|
else lieFactorScore = 0;
|
|
|
|
// Data-ink ratio (assume maximized by default)
|
|
const dataInkScore = 10;
|
|
|
|
return Math.min(maxScore, (lieFactorScore + dataInkScore) / 2);
|
|
}
|
|
|
|
function checkCriticalViolations(slides, presentation) {
|
|
const violations = [];
|
|
|
|
// Font size check
|
|
const minFont = Math.min(...slides.map(s => s.min_font_size || 36));
|
|
if (minFont < CRITICAL_VIOLATIONS.font_too_small.threshold) {
|
|
violations.push({
|
|
type: 'font_too_small',
|
|
penalty: CRITICAL_VIOLATIONS.font_too_small.penalty,
|
|
message: `CRITICAL: Minimum font size is ${minFont}pt (required: 30pt+)`,
|
|
slides_affected: slides.filter(s => (s.min_font_size || 36) < 30).map(s => s.index)
|
|
});
|
|
}
|
|
|
|
// Concept count check
|
|
if (slides.length > CRITICAL_VIOLATIONS.too_many_concepts.threshold) {
|
|
violations.push({
|
|
type: 'too_many_concepts',
|
|
penalty: CRITICAL_VIOLATIONS.too_many_concepts.penalty,
|
|
message: `CRITICAL: ${slides.length} slides exceeds Kawasaki's 10 core concepts limit`
|
|
});
|
|
}
|
|
|
|
// Bullet points check
|
|
const bulletSlides = slides.filter(s => detectBulletPoints(s.text));
|
|
if (bulletSlides.length > 0) {
|
|
violations.push({
|
|
type: 'bullet_points',
|
|
penalty: CRITICAL_VIOLATIONS.bullet_points.penalty * bulletSlides.length,
|
|
message: `CRITICAL: ${bulletSlides.length} slides have bullet points`,
|
|
slides_affected: bulletSlides.map(s => s.index)
|
|
});
|
|
}
|
|
|
|
// Paragraphs check
|
|
const paragraphSlides = slides.filter(s => detectParagraphs(s.text));
|
|
if (paragraphSlides.length > 0) {
|
|
violations.push({
|
|
type: 'paragraphs',
|
|
penalty: CRITICAL_VIOLATIONS.paragraphs.penalty * paragraphSlides.length,
|
|
message: `CRITICAL: ${paragraphSlides.length} slides have paragraphs (>2 sentences)`,
|
|
slides_affected: paragraphSlides.map(s => s.index)
|
|
});
|
|
}
|
|
|
|
// Contrast check
|
|
const avgContrast = slides.reduce((sum, s) => sum + estimateContrastRatio(s), 0) / slides.length;
|
|
if (avgContrast < CRITICAL_VIOLATIONS.poor_contrast.threshold) {
|
|
violations.push({
|
|
type: 'poor_contrast',
|
|
penalty: CRITICAL_VIOLATIONS.poor_contrast.penalty,
|
|
message: `CRITICAL: Average contrast ratio ${avgContrast.toFixed(1)}:1 (required: 4.5:1+)`
|
|
});
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
function checkWarnings(slides, presentation) {
|
|
const warnings = [];
|
|
|
|
// Word count warnings
|
|
const wordySlides = slides.filter(s => countWords(s.text) > WARNING_FLAGS.too_many_words.threshold);
|
|
if (wordySlides.length > 0) {
|
|
warnings.push({
|
|
type: 'too_many_words',
|
|
penalty: WARNING_FLAGS.too_many_words.penalty * wordySlides.length,
|
|
message: `${wordySlides.length} slides exceed 6-word limit (Seth Godin standard)`,
|
|
slides_affected: wordySlides.map(s => ({
|
|
index: s.index,
|
|
words: countWords(s.text)
|
|
}))
|
|
});
|
|
}
|
|
|
|
// Slide count for duration
|
|
if (presentation.duration_minutes) {
|
|
const recommendedSlides = presentation.duration_minutes * WARNING_FLAGS.too_many_slides.ratio;
|
|
if (slides.length > recommendedSlides) {
|
|
warnings.push({
|
|
type: 'too_many_slides',
|
|
penalty: WARNING_FLAGS.too_many_slides.penalty,
|
|
message: `${slides.length} slides for ${presentation.duration_minutes}min (recommended: ≤${Math.round(recommendedSlides)})`
|
|
});
|
|
}
|
|
}
|
|
|
|
// Images check
|
|
const slidesWithImages = slides.filter(s => s.image || s.chart).length;
|
|
if (slidesWithImages === 0) {
|
|
warnings.push({
|
|
type: 'no_images',
|
|
penalty: WARNING_FLAGS.no_images.penalty,
|
|
message: 'No images detected - presentations should be visual-dominant'
|
|
});
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
function generateRecommendations(violations, warnings, scores) {
|
|
const recommendations = [];
|
|
|
|
// Address critical violations first
|
|
if (violations.length > 0) {
|
|
recommendations.push({
|
|
priority: 'CRITICAL',
|
|
action: 'Fix all critical violations before presenting',
|
|
details: violations.map(v => v.message)
|
|
});
|
|
}
|
|
|
|
// Typography recommendations
|
|
if (scores.typography < 6) {
|
|
recommendations.push({
|
|
priority: 'high',
|
|
action: 'Increase font sizes to 36pt minimum',
|
|
details: ['Guy Kawasaki: 30pt absolute minimum', 'TED: 42pt recommended']
|
|
});
|
|
}
|
|
|
|
// Simplicity recommendations
|
|
if (scores.simplicity < 7) {
|
|
recommendations.push({
|
|
priority: 'high',
|
|
action: 'Simplify slides - reduce word count and elements',
|
|
details: ['Seth Godin: 6 words maximum per slide', 'One idea per slide']
|
|
});
|
|
}
|
|
|
|
// Visual recommendations
|
|
if (scores.visual_dominance < 7) {
|
|
recommendations.push({
|
|
priority: 'medium',
|
|
action: 'Add more high-quality images',
|
|
details: ['Garr Reynolds: Pictures > text', '80% of slides should have visuals']
|
|
});
|
|
}
|
|
|
|
return recommendations;
|
|
}
|
|
|
|
function validatePresentation(presentation) {
|
|
const slides = presentation.slides || [];
|
|
|
|
// Calculate scores
|
|
const scores = {
|
|
simplicity: scoreSimplicity(slides),
|
|
visual_dominance: scoreVisualDominance(slides),
|
|
story_structure: scoreStoryStructure(presentation),
|
|
one_idea_per_slide: scoreOneIdeaPerSlide(slides),
|
|
typography: scoreTypography(slides),
|
|
layout: scoreLayout(slides),
|
|
color_contrast: scoreColorContrast(slides),
|
|
media_quality: scoreMediaQuality(slides),
|
|
cognitive_load: scoreCognitiveLoad(slides, presentation),
|
|
data_integrity: scoreDataIntegrity(slides)
|
|
};
|
|
|
|
// Calculate total
|
|
const baseScore = Object.values(scores).reduce((sum, score) => sum + score, 0);
|
|
|
|
// Check violations
|
|
const violations = checkCriticalViolations(slides, presentation);
|
|
const warnings = checkWarnings(slides, presentation);
|
|
|
|
// Apply penalties
|
|
const violationPenalty = violations.reduce((sum, v) => sum + v.penalty, 0);
|
|
const warningPenalty = warnings.reduce((sum, w) => sum + w.penalty, 0);
|
|
|
|
const finalScore = Math.max(0, Math.min(100, baseScore + violationPenalty + warningPenalty));
|
|
|
|
// Generate report
|
|
const status = finalScore >= 90 ? 'Exceptional' :
|
|
finalScore >= 80 ? 'Excellent' :
|
|
finalScore >= 70 ? 'Good' :
|
|
finalScore >= 60 ? 'Acceptable' : 'Poor';
|
|
|
|
const recommendations = generateRecommendations(violations, warnings, scores);
|
|
|
|
return {
|
|
overall_score: Math.round(finalScore),
|
|
status,
|
|
score_breakdown: Object.entries(scores).map(([category, score]) => ({
|
|
category,
|
|
score: Math.round(score * 10) / 10,
|
|
max: SCORING_WEIGHTS[category]
|
|
})),
|
|
critical_violations: violations,
|
|
warnings,
|
|
recommendations,
|
|
summary: {
|
|
total_slides: slides.length,
|
|
avg_words_per_slide: Math.round(slides.reduce((sum, s) => sum + countWords(s.text), 0) / slides.length),
|
|
slides_with_images: slides.filter(s => s.image || s.chart).length,
|
|
min_font_size: Math.min(...slides.map(s => s.min_font_size || 36))
|
|
}
|
|
};
|
|
}
|
|
|
|
// CLI Interface
|
|
function main() {
|
|
const args = {};
|
|
|
|
for (let i = 2; i < process.argv.length; i++) {
|
|
if (process.argv[i].startsWith('--')) {
|
|
const key = process.argv[i].replace(/^--/, '');
|
|
const value = process.argv[i + 1];
|
|
args[key] = value;
|
|
i++;
|
|
}
|
|
}
|
|
|
|
if (!args.presentation) {
|
|
console.error('Usage: bun validate-presentation.js --presentation presentation.json');
|
|
process.exit(1);
|
|
}
|
|
|
|
const fs = require('fs');
|
|
const presentation = JSON.parse(fs.readFileSync(args.presentation, 'utf-8'));
|
|
|
|
const report = validatePresentation(presentation);
|
|
console.log(JSON.stringify(report, null, 2));
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main();
|
|
}
|
|
|
|
export { validatePresentation };
|