From a5cd1af982348683a780fb53ceacba4ed2b0b05d Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 12 Mar 2026 19:15:28 +0000 Subject: [PATCH] Fix color contrast false positives; table caption INFO; dismiss button more visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color contrast: - Sample pixels 8px apart vertically instead of adjacent horizontal pixels - Filter out near-uniform pairs (|Δlum| < 0.08) — eliminates photo/gradient noise - ERROR threshold: >60% of significant edges fail (was 15% of all pixels) - WARNING threshold: >30% (was 5%) - Returns early with 'image-only page' if <20 significant edges found Tables: - Caption warning downgraded WARNING → INFO (table may have visible title nearby) - Does not count toward check pass/fail anymore Dismiss button: - Renamed 'Dismiss' → '✕ False Positive' (clearer intent) - Added background color so it's visible against card - font-size 11→12px, padding increased Co-Authored-By: Claude Sonnet 4.6 --- css/styles.css | 9 ++-- enterprise_pdf_checker.py | 96 +++++++++++++++++++++++---------------- js/results.js | 4 +- 3 files changed, 63 insertions(+), 46 deletions(-) diff --git a/css/styles.css b/css/styles.css index b701693..06e0fa1 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1160,17 +1160,18 @@ h1::before { } .btn-dismiss { - background: none; + background: var(--surface-alt); border: 1px solid var(--border); - color: var(--text-muted); - font-size: 11px; + color: var(--text-secondary); + font-size: 12px; cursor: pointer; - padding: 3px 8px; + padding: 4px 10px; border-radius: var(--radius-sm); font-family: var(--font-display); font-weight: 600; transition: all 0.15s; margin-left: 8px; + white-space: nowrap; } .btn-dismiss:hover { diff --git a/enterprise_pdf_checker.py b/enterprise_pdf_checker.py index 505a3ce..60a598d 100644 --- a/enterprise_pdf_checker.py +++ b/enterprise_pdf_checker.py @@ -205,47 +205,58 @@ class ColorContrastChecker: return (lighter + 0.05) / (darker + 0.05) @staticmethod - def check_image_contrast(image: Image.Image, sample_size: int = 500) -> Dict: - """Sample image for contrast issues""" + def check_image_contrast(image: Image.Image, sample_size: int = 1000) -> Dict: + """Sample image for contrast issues. + + Compares pixel pairs that are 8px apart vertically — more likely to + cross a text-stroke / background boundary than adjacent pixels. + Only considers pairs where luminance actually differs (|Δlum| > 0.08), + which filters out uniform photo areas and focuses on real edges. + """ if image.mode != 'RGB': image = image.convert('RGB') width, height = image.size - samples = [] rng = np.random.default_rng(seed=42) + significant = [] # pairs that cross a meaningful light/dark boundary - for _ in range(min(sample_size, width * height // 100)): - x = rng.integers(0, max(1, width - 2)) - y = rng.integers(0, max(1, height - 1)) + attempts = min(sample_size * 4, width * height // 20) + for _ in range(attempts): + x = int(rng.integers(0, width)) + y = int(rng.integers(0, max(1, height - 9))) try: - color1 = image.getpixel((x, y)) - color2 = image.getpixel((min(x + 1, width - 1), y)) + c1 = image.getpixel((x, y)) + c2 = image.getpixel((x, y + 8)) + l1 = ColorContrastChecker.get_luminance(c1) + l2 = ColorContrastChecker.get_luminance(c2) - ratio = ColorContrastChecker.calculate_contrast_ratio(color1, color2) - samples.append({ - 'ratio': ratio, - 'colors': (color1, color2), - 'position': (x, y) - }) + if abs(l1 - l2) < 0.08: + continue # near-uniform area (photo gradient, blank space) — skip + + ratio = ColorContrastChecker.calculate_contrast_ratio(c1, c2) + significant.append({'ratio': ratio, 'colors': (c1, c2), 'position': (x, y)}) + + if len(significant) >= sample_size: + break except (IndexError, TypeError, ValueError): continue - - if not samples: - return {'error': 'Could not sample colors'} - - fail_aa_normal = [s for s in samples if s['ratio'] < ColorContrastChecker.WCAG_AA_NORMAL] - fail_aa_large = [s for s in samples if s['ratio'] < ColorContrastChecker.WCAG_AA_LARGE] - + + if len(significant) < 20: + return {'error': 'Insufficient contrast edges to analyse (image-only page)'} + + fail_aa = [s for s in significant if s['ratio'] < ColorContrastChecker.WCAG_AA_NORMAL] + fail_large = [s for s in significant if s['ratio'] < ColorContrastChecker.WCAG_AA_LARGE] + return { - 'total_samples': len(samples), - 'fail_aa_normal_count': len(fail_aa_normal), - 'fail_aa_large_count': len(fail_aa_large), - 'fail_aa_normal_percent': len(fail_aa_normal) / len(samples) * 100, - 'fail_aa_large_percent': len(fail_aa_large) / len(samples) * 100, - 'worst_ratio': min(s['ratio'] for s in samples), - 'best_ratio': max(s['ratio'] for s in samples), - 'avg_ratio': sum(s['ratio'] for s in samples) / len(samples) + 'total_samples': len(significant), + 'fail_aa_normal_count': len(fail_aa), + 'fail_aa_large_count': len(fail_large), + 'fail_aa_normal_percent': len(fail_aa) / len(significant) * 100, + 'fail_aa_large_percent': len(fail_large) / len(significant) * 100, + 'worst_ratio': min(s['ratio'] for s in significant), + 'best_ratio': max(s['ratio'] for s in significant), + 'avg_ratio': sum(s['ratio'] for s in significant) / len(significant), } @@ -994,24 +1005,29 @@ Respond in JSON format: if 'error' in contrast_results: continue - # Check for significant issues - if contrast_results['fail_aa_normal_percent'] > 15: + # Only flag edges that actually cross a light/dark boundary (filtered in sampler). + # >60% of those edges failing = genuine contrast problem. + # 30-60% = worth a warning. Below 30% = pass. + fail_pct = contrast_results['fail_aa_normal_percent'] + if fail_pct > 60: self.add_issue( Severity.ERROR, "Color Contrast", - f"Page {i+1}: {contrast_results['fail_aa_normal_percent']:.1f}% of samples fail WCAG AA (4.5:1)", + f"Page {i+1}: {fail_pct:.1f}% of text-edge samples fail WCAG AA (4.5:1) — " + f"low contrast text likely present", wcag_criterion="1.4.3", - recommendation="Review and increase color contrast to meet WCAG AA standards", + recommendation="Use Colour Contrast Analyser to identify and fix low-contrast text", page_number=i+1, details=contrast_results ) - elif contrast_results['fail_aa_normal_percent'] > 5: + elif fail_pct > 30: self.add_issue( Severity.WARNING, "Color Contrast", - f"Page {i+1}: {contrast_results['fail_aa_normal_percent']:.1f}% of samples have low contrast", + f"Page {i+1}: {fail_pct:.1f}% of text-edge samples fail WCAG AA — " + f"verify contrast manually with Colour Contrast Analyser", wcag_criterion="1.4.3", - recommendation="Use Colour Contrast Analyser to verify specific areas", + recommendation="Check text against its background using the Colour Contrast Analyser tool", page_number=i+1, details=contrast_results ) @@ -1408,14 +1424,14 @@ Respond in JSON format: if not stats['has_caption'] and total_cells > 6: self.add_issue( - Severity.WARNING, + Severity.INFO, "Tables", f"Table {table_num}: no Caption element ({stats['rows']} rows, ~{total_cells} cells). " - f"A visible caption helps all users understand the table's purpose.", + f"A Caption helps screen readers identify the table — ensure a visible title exists nearby.", wcag_criterion="1.3.1", - recommendation="Add a Caption as the first child of the Table element" + recommendation="Add a Caption as the first child of the Table element if no visible title precedes it" ) - issues_added = True + # Not counted as a hard issue — don't set issues_added = True return not issues_added diff --git a/js/results.js b/js/results.js index f34d7f0..bd9891e 100644 --- a/js/results.js +++ b/js/results.js @@ -124,8 +124,8 @@ function createIssueCard(issue, issueNumber, globalIndex) { : ''; const dismissBtn = isDismissed - ? `` - : ``; + ? `` + : ``; return `