feat: add ADA Font Analyzer v1.0 — three-tab browser tool with TTF binary parser

This commit is contained in:
2026-05-05 16:14:35 -05:00
parent 2b2fc1f632
commit 2d70cc1073
+787
View File
@@ -0,0 +1,787 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADA §703 Font Analyzer — Digital Signage</title>
<style>
:root {
--bg:#f7f6f3;--surface:#fff;--surface2:#f0efe9;--border:#e2e0d8;--border2:#c8c6bc;
--text:#1a1916;--text2:#6b6860;--text3:#9c9a92;
--green:#1d6f42;--green-bg:#eaf4ed;--amber:#8a5c00;--amber-bg:#fef7e0;
--red:#8b1f1f;--red-bg:#fdeaea;--blue:#1a4f8a;--blue-bg:#e8f0fb;
--accent:#1a4f8a;--radius:10px;--radius-sm:6px;font-size:15px;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;}
.page{max-width:1100px;margin:0 auto;padding:1.5rem 1.25rem 3rem;}
.page-header{margin-bottom:1.5rem;border-bottom:1px solid var(--border);padding-bottom:1rem;}
.page-header h1{font-size:1.25rem;font-weight:600;letter-spacing:-0.02em;}
.page-header p{font-size:0.825rem;color:var(--text2);margin-top:3px;}
.two-col{display:grid;grid-template-columns:1fr 360px;gap:1.25rem;align-items:start;}
@media(max-width:800px){.two-col{grid-template-columns:1fr;}}
/* tabs */
.tabs{display:flex;gap:2px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:3px;margin-bottom:1rem;width:fit-content;}
.tab{padding:5px 14px;font-size:.8rem;font-weight:500;border:none;background:transparent;color:var(--text2);cursor:pointer;border-radius:4px;transition:all .15s;white-space:nowrap;}
.tab.active{background:var(--surface);color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.08);}
.tab-panel{display:none;}.tab-panel.active{display:block;}
/* cards */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1rem 1.1rem;}
.card-title{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text3);margin-bottom:.75rem;}
/* badges */
.badge{display:inline-flex;align-items:center;gap:4px;font-size:.72rem;font-weight:600;padding:2px 8px;border-radius:20px;white-space:nowrap;}
.badge-green{background:var(--green-bg);color:var(--green);}
.badge-amber{background:var(--amber-bg);color:var(--amber);}
.badge-red{background:var(--red-bg);color:var(--red);}
.badge-blue{background:var(--blue-bg);color:var(--blue);}
/* filter bar */
.filter-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:1rem;}
.filter-bar input[type=text]{flex:1;min-width:160px;height:34px;border:1px solid var(--border2);border-radius:var(--radius-sm);padding:0 10px;font-size:.825rem;background:var(--surface);color:var(--text);outline:none;transition:border-color .15s;}
.filter-bar input[type=text]:focus{border-color:var(--accent);}
.pill{padding:4px 12px;font-size:.775rem;font-weight:500;border:1px solid var(--border2);background:var(--surface);color:var(--text2);border-radius:20px;cursor:pointer;transition:all .15s;}
.pill.active{background:var(--accent);color:#fff;border-color:var(--accent);}
/* font grid */
.font-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:10px;}
.font-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:.9rem 1rem;transition:border-color .15s,box-shadow .15s;position:relative;}
.font-card:hover{border-color:var(--border2);box-shadow:0 2px 8px rgba(0,0,0,.05);}
.font-card.selected{border-color:var(--accent);border-width:1.5px;box-shadow:0 0 0 3px rgba(26,79,138,.08);}
.font-card-header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px;}
.font-card-name{font-size:.9rem;font-weight:600;color:var(--text);}
.font-card-meta{font-size:.75rem;color:var(--text3);margin-top:1px;}
.font-preview-text{font-size:28px;line-height:1.2;margin:8px 0;color:var(--text);min-height:38px;}
.font-card-notes{font-size:.75rem;color:var(--text2);line-height:1.45;margin:6px 0;}
.criteria-dots{display:flex;gap:4px;margin-top:8px;flex-wrap:wrap;}
.criteria-dot-wrap{display:flex;align-items:center;gap:3px;font-size:.68rem;color:var(--text3);}
.dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;}
.dot-green{background:#22c55e;}.dot-amber{background:#f59e0b;}.dot-red{background:#ef4444;}
.check-icon{position:absolute;top:8px;right:8px;width:18px;height:18px;border-radius:50%;background:var(--accent);display:none;align-items:center;justify-content:center;}
.font-card.selected .check-icon{display:flex;}
.font-card-actions{display:flex;gap:5px;margin-top:9px;padding-top:8px;border-top:1px solid var(--border);flex-wrap:wrap;}
.action-btn{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;font-size:.72rem;font-weight:500;border:1px solid var(--border2);border-radius:4px;background:var(--surface);color:var(--text2);cursor:pointer;text-decoration:none;transition:all .15s;white-space:nowrap;line-height:1.6;}
.action-btn:hover{background:var(--surface2);color:var(--text);}
.action-btn.sel-btn.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.action-btn.sel-btn.active:hover{opacity:.88;}
.action-btn svg{width:11px;height:11px;flex-shrink:0;}
/* sidebar */
.selected-list{display:flex;flex-direction:column;gap:6px;min-height:40px;}
.selected-item{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:var(--surface2);border-radius:var(--radius-sm);font-size:.8rem;}
.selected-item-actions{display:flex;gap:4px;align-items:center;}
.empty-state{font-size:.8rem;color:var(--text3);font-style:italic;padding:8px 0;}
.dl-btn-main{width:100%;margin-top:8px;padding:8px;font-size:.8rem;font-weight:600;border:1px solid var(--accent);border-radius:var(--radius-sm);background:var(--accent);color:#fff;cursor:pointer;transition:opacity .15s;}
.dl-btn-main:hover{opacity:.88;}.dl-btn-main:disabled{opacity:.4;cursor:not-allowed;}
.odoo-note{font-size:.72rem;color:var(--text2);background:var(--blue-bg);border:1px solid #c0d4f0;border-radius:var(--radius-sm);padding:.6rem .75rem;line-height:1.5;margin-top:.75rem;}
/* test panel */
.test-panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.1rem;}
.test-panel h2{font-size:.9rem;font-weight:600;margin-bottom:4px;}
.test-panel .sub{font-size:.775rem;color:var(--text2);margin-bottom:1rem;line-height:1.5;}
.input-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:1rem;}
.input-row input[type=text]{flex:1;min-width:200px;height:36px;border:1px solid var(--border2);border-radius:var(--radius-sm);padding:0 10px;font-size:.825rem;background:var(--surface);color:var(--text);outline:none;}
.input-row input[type=text]:focus{border-color:var(--accent);}
.btn-primary{padding:0 16px;height:36px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius-sm);font-size:.825rem;font-weight:600;cursor:pointer;white-space:nowrap;transition:opacity .15s;display:inline-flex;align-items:center;gap:5px;}
.btn-primary:hover{opacity:.88;}.btn-primary:disabled{opacity:.4;cursor:not-allowed;}
.weight-selector{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:1rem;}
.weight-btn{padding:3px 10px;font-size:.775rem;border:1px solid var(--border);border-radius:4px;background:var(--surface2);color:var(--text2);cursor:pointer;transition:all .15s;}
.weight-btn.active{background:var(--text);color:var(--bg);border-color:var(--text);}
.weight-btn:disabled{opacity:.3;cursor:not-allowed;}
.preview-zone{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:1rem 1.1rem;margin-bottom:1rem;min-height:70px;}
.preview-label{font-size:.7rem;color:var(--text3);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;}
/* scoring */
.score-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:1rem;}
@media(max-width:600px){.score-grid{grid-template-columns:1fr;}}
.score-item{background:var(--surface2);border-radius:var(--radius-sm);padding:.65rem .8rem;border-left:3px solid transparent;}
.score-item.pass{border-left-color:#22c55e;}.score-item.warn{border-left-color:#f59e0b;}.score-item.fail{border-left-color:#ef4444;}
.score-item-name{font-size:.72rem;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.04em;}
.score-item-val{font-size:.88rem;font-weight:600;color:var(--text);margin:2px 0;}
.score-item-note{font-size:.72rem;color:var(--text3);line-height:1.4;}
.overall-score{display:flex;align-items:center;gap:12px;padding:.85rem 1rem;border-radius:var(--radius-sm);margin-bottom:1rem;}
.overall-score.full{background:var(--green-bg);}.overall-score.cond{background:var(--amber-bg);}.overall-score.fail{background:var(--red-bg);}
.score-num{font-size:2rem;font-weight:700;line-height:1;}
.overall-score.full .score-num{color:var(--green);}.overall-score.cond .score-num{color:var(--amber);}.overall-score.fail .score-num{color:var(--red);}
.score-label{font-size:.8rem;font-weight:600;}.score-sublabel{font-size:.72rem;color:var(--text2);margin-top:2px;}
.canvas-hidden{position:absolute;visibility:hidden;pointer-events:none;left:-9999px;}
.divider{border:none;border-top:1px solid var(--border);margin:1rem 0;}
.hint{font-size:.72rem;color:var(--text3);line-height:1.5;font-style:italic;}
.count-label{font-size:.75rem;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.6rem;}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:white;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:4px;}
@keyframes spin{to{transform:rotate(360deg);}}
.dl-section{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:.75rem .9rem;margin-bottom:1rem;}
.dl-section-title{font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);margin-bottom:.5rem;}
.dl-link{display:inline-flex;align-items:center;gap:5px;font-size:.78rem;color:var(--blue);text-decoration:none;padding:4px 0;}
.dl-link:hover{text-decoration:underline;}
.dl-note{font-size:.7rem;color:var(--text3);line-height:1.5;margin-top:4px;}
/* TTF Upload tab */
.upload-zone{border:2px dashed var(--border2);border-radius:var(--radius);padding:2.5rem 1.5rem;text-align:center;cursor:pointer;transition:all .2s;background:var(--surface);}
.upload-zone:hover,.upload-zone.drag{border-color:var(--accent);background:var(--blue-bg);}
.upload-zone input[type=file]{display:none;}
.upload-icon{font-size:2rem;margin-bottom:.5rem;opacity:.4;}
.upload-title{font-size:.9rem;font-weight:600;color:var(--text);margin-bottom:4px;}
.upload-sub{font-size:.775rem;color:var(--text2);}
.upload-btn{margin-top:1rem;padding:6px 18px;font-size:.825rem;font-weight:600;border:1.5px solid var(--accent);border-radius:var(--radius-sm);background:transparent;color:var(--accent);cursor:pointer;transition:all .15s;}
.upload-btn:hover{background:var(--accent);color:#fff;}
.ttf-result{display:none;}
.ttf-result.active{display:block;}
.metric-table{width:100%;border-collapse:collapse;font-size:.8rem;}
.metric-table th{text-align:left;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);padding:4px 0;border-bottom:1px solid var(--border);}
.metric-table td{padding:5px 0;border-bottom:1px solid var(--border);vertical-align:top;}
.metric-table td:first-child{color:var(--text2);width:42%;padding-right:8px;}
.metric-table td:nth-child(2){font-weight:600;color:var(--text);width:30%;}
.metric-table td:nth-child(3){text-align:right;}
.method-pill{font-size:.68rem;padding:1px 6px;border-radius:10px;font-weight:600;background:var(--green-bg);color:var(--green);}
.method-pill.canvas{background:var(--blue-bg);color:var(--blue);}
.ttf-preview-zone{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:1rem 1.1rem;margin:1rem 0;}
.ttf-preview-large{font-size:42px;line-height:1.2;margin-bottom:6px;color:var(--text);}
.ttf-preview-sentence{font-size:18px;color:var(--text);line-height:1.6;margin-bottom:4px;}
.ttf-preview-chars{font-size:20px;color:var(--text);letter-spacing:.1em;}
.filename-badge{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:20px;font-size:.78rem;font-weight:500;color:var(--text2);margin-bottom:1rem;}
.reset-btn{border:none;background:none;color:var(--text3);cursor:pointer;font-size:.8rem;padding:0 0 0 6px;}
.reset-btn:hover{color:var(--red);}
.source-note{font-size:.68rem;padding:2px 7px;border-radius:10px;font-weight:600;display:inline-block;margin-left:6px;}
.source-ttf{background:var(--green-bg);color:var(--green);}
.source-canvas{background:var(--blue-bg);color:var(--blue);}
</style>
</head>
<body>
<canvas class="canvas-hidden" id="measure-canvas" width="800" height="300"></canvas>
<div class="page">
<div class="page-header">
<h1>ADA §703.5 Font Compliance Analyzer — Digital Signage</h1>
<p>Curated Google Fonts evaluated against the 2010 ADA Standards for Accessible Design, Chapter 7 (Signs). Test any Google Font by name or upload a TTF/OTF file for direct metric analysis.</p>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('browse')">Browse curated fonts</button>
<button class="tab" onclick="switchTab('test')">Test a Google Font</button>
<button class="tab" onclick="switchTab('upload')">Upload TTF / OTF file</button>
</div>
<!-- BROWSE TAB -->
<div class="tab-panel active" id="tab-browse">
<div class="two-col">
<div>
<div class="filter-bar">
<input type="text" id="search" placeholder="Search by name, style…" oninput="renderGrid()">
<button class="pill active" onclick="setFilter('all',this)">All</button>
<button class="pill" onclick="setFilter('best',this)">Fully compliant</button>
<button class="pill" onclick="setFilter('transit',this)">Transit-tested</button>
<button class="pill" onclick="setFilter('caution',this)">Use with caution</button>
</div>
<div class="count-label" id="count-label">25 fonts</div>
<div class="font-grid" id="font-grid"></div>
</div>
<div style="position:sticky;top:1rem;">
<div class="card" style="margin-bottom:10px;">
<div class="card-title">Selected for download</div>
<div class="selected-list" id="selected-list"><p class="empty-state">Click fonts to select them</p></div>
<div id="dl-actions" style="display:none;margin-top:10px;">
<button class="dl-btn-main" id="dl-all-btn" onclick="downloadAllSelected()">&#8659; Download all selected (ZIP per family)</button>
<p style="font-size:.7rem;color:var(--text3);margin-top:6px;line-height:1.5;">Each family downloads as a ZIP from Google Fonts containing all weights as TTF/OTF files. Allow pop-ups or click each download icon individually.</p>
</div>
<div class="odoo-note">To embed in Odoo Knowledge: host this file and paste its URL into a page block using <strong>/ &#8594; HTML Block</strong> or an <code>&lt;iframe&gt;</code> in the article source.</div>
</div>
<div class="card">
<div class="card-title">ADA §703.5 criteria</div>
<div style="display:flex;flex-direction:column;gap:5px;">
<div class="criteria-dot-wrap"><div class="dot dot-green"></div><span style="color:var(--text2);">Sans-serif, conventional form (§703.5.3)</span></div>
<div class="criteria-dot-wrap"><div class="dot dot-green"></div><span style="color:var(--text2);">O:I width ratio 55110% (§703.5.4)</span></div>
<div class="criteria-dot-wrap"><div class="dot dot-green"></div><span style="color:var(--text2);">Stroke weight 1030% of height (§703.5.7)</span></div>
<div class="criteria-dot-wrap"><div class="dot dot-green"></div><span style="color:var(--text2);">Character spacing 1035% (§703.5.8)</span></div>
<div class="criteria-dot-wrap"><div class="dot dot-green"></div><span style="color:var(--text2);">No italic/script/decorative (§703.5.3)</span></div>
<p style="font-size:.7rem;color:var(--text3);margin-top:4px;"><span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:#f59e0b;margin-right:3px;vertical-align:middle;"></span>= passes with weight restrictions</p>
</div>
</div>
</div>
</div>
</div>
<!-- TEST GOOGLE FONT TAB -->
<div class="tab-panel" id="tab-test">
<div class="two-col">
<div>
<div class="test-panel">
<h2>Test any Google Font for ADA §703.5 compliance</h2>
<p class="sub">Enter a Google Font name exactly as it appears on fonts.google.com. The tool renders test characters on a canvas and measures key geometric ratios against ADA signage standards.</p>
<div class="input-row">
<input type="text" id="font-input" placeholder="e.g. Barlow, Fira Sans, Space Grotesk" onkeydown="if(event.key==='Enter')testFont()">
<button class="btn-primary" id="test-btn" onclick="testFont()">Analyze font</button>
</div>
<div id="weight-section" style="display:none;">
<p class="card-title" style="margin-bottom:6px;">Available weights — select one to test</p>
<div class="weight-selector" id="weight-selector"></div>
</div>
<div id="preview-section" style="display:none;">
<div class="preview-zone">
<div class="preview-label">Character preview — uppercase proportions &amp; stroke</div>
<div id="prev-large" style="font-size:42px;line-height:1.1;margin-bottom:4px;color:var(--text);">IO Hamburgefons</div>
<div id="prev-chars" style="font-size:22px;color:var(--text);letter-spacing:0.08em;">I O H B D 1 l 0 8 Q W M</div>
</div>
<div class="preview-zone" style="background:var(--text);border-color:var(--text);">
<div class="preview-label" style="color:#999;">Light-on-dark contrast test</div>
<div id="prev-large-inv" style="font-size:42px;line-height:1.1;color:#fff;">IO Hamburgefons</div>
</div>
</div>
<div id="score-section" style="display:none;">
<hr class="divider">
<div class="overall-score" id="overall-box">
<div class="score-num" id="overall-num">&#8211;</div>
<div><div class="score-label" id="overall-label">&#8211;</div><div class="score-sublabel" id="overall-sub">&#8211;</div></div>
</div>
<div class="score-grid" id="score-grid"></div>
<p class="hint" id="score-hint"></p>
<hr class="divider">
<div class="dl-section"><div class="dl-section-title">Font files</div><div id="test-dl-content"></div></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn-primary" id="add-to-list-btn" onclick="addTestedFont()" style="flex:1;">+ Add to selected fonts</button>
<a id="gf-specimen-link" href="#" target="_blank" rel="noopener" class="btn-primary" style="flex:1;text-decoration:none;">
<svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 2h3v3M10 2L5.5 6.5M2 4h3M2 4v6h6V7"/></svg>
View on Google Fonts
</a>
</div>
</div>
<div id="error-box" style="display:none;padding:.75rem;background:var(--red-bg);border-radius:var(--radius-sm);font-size:.8rem;color:var(--red);margin-top:.75rem;"></div>
</div>
</div>
<div>
<div class="card">
<div class="card-title">How scoring works (Google Fonts)</div>
<div style="display:flex;flex-direction:column;gap:10px;font-size:.78rem;color:var(--text2);line-height:1.55;">
<div><strong style="color:var(--text);">Canvas pixel measurement</strong><br>Renders I and O to a hidden canvas at 120px, scans bounding boxes for §703.5.4 O:I ratio and §703.5.7 stroke weight. Accuracy ~±5%.</div>
<div><strong style="color:var(--text);">Style &amp; form</strong><br>Font name heuristics detect serif, script, and decorative patterns for §703.5.3 compliance.</div>
<div><strong style="color:var(--text);">Upload a TTF instead</strong><br>For higher-accuracy metric analysis using the actual font binary tables, use the <em>Upload TTF / OTF file</em> tab.</div>
</div>
</div>
<div class="card" style="margin-top:10px;">
<div class="card-title">Quick-test suggestions</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;" id="suggestion-btns"></div>
</div>
</div>
</div>
</div>
<!-- UPLOAD TTF / OTF TAB -->
<div class="tab-panel" id="tab-upload">
<div class="two-col">
<div>
<div id="upload-zone-wrap">
<div class="upload-zone" id="upload-zone"
onclick="document.getElementById('ttf-file-input').click()"
ondragover="event.preventDefault();this.classList.add('drag')"
ondragleave="this.classList.remove('drag')"
ondrop="handleDrop(event)">
<input type="file" id="ttf-file-input" accept=".ttf,.otf,.woff,.woff2" onchange="handleFileSelect(event)">
<div class="upload-icon">&#8679;</div>
<div class="upload-title">Drop a TTF, OTF, WOFF or WOFF2 file here</div>
<div class="upload-sub">Or click to browse your files</div>
<button class="upload-btn" onclick="event.stopPropagation();document.getElementById('ttf-file-input').click()">Choose file</button>
</div>
<p style="font-size:.72rem;color:var(--text3);margin-top:.75rem;line-height:1.5;">
Files are processed entirely in your browser — nothing is uploaded to any server. Analysis reads font metric tables directly from the binary file for the most accurate ADA §703.5 measurements available.
</p>
</div>
<div class="ttf-result" id="ttf-result">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem;flex-wrap:wrap;">
<div class="filename-badge" id="ttf-filename-badge">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M2 1h6l3 3v8H2V1z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="M8 1v3h3" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
<span id="ttf-filename">&#8211;</span>
<button class="reset-btn" onclick="resetUpload()" title="Clear and upload another">&#10005;</button>
</div>
</div>
<div class="ttf-preview-zone">
<div class="preview-label">Live character preview</div>
<div class="ttf-preview-large" id="ttf-prev-large">IO Hamburgefons</div>
<div class="ttf-preview-sentence" id="ttf-prev-sentence">The quick brown fox jumps over the lazy dog</div>
<div class="ttf-preview-chars" id="ttf-prev-chars">I O H B D 1 l 0 8 Q W M</div>
</div>
<div class="ttf-preview-zone" style="background:var(--text);border-color:var(--text);">
<div class="preview-label" style="color:#999;">Light-on-dark contrast test</div>
<div class="ttf-preview-large" id="ttf-prev-inv" style="color:#fff;">IO Hamburgefons</div>
</div>
<hr class="divider">
<div class="overall-score" id="ttf-overall-box">
<div class="score-num" id="ttf-overall-num">&#8211;</div>
<div><div class="score-label" id="ttf-overall-label">&#8211;</div><div class="score-sublabel" id="ttf-overall-sub">&#8211;</div></div>
</div>
<div class="score-grid" id="ttf-score-grid"></div>
<p class="hint" id="ttf-score-hint"></p>
<hr class="divider">
<div class="card-title" style="margin-bottom:.6rem;">Raw font metrics from binary tables</div>
<table class="metric-table" id="ttf-metric-table">
<thead><tr><th>Metric</th><th>Value</th><th>Source</th></tr></thead>
<tbody id="ttf-metric-body"></tbody>
</table>
<hr class="divider">
<p class="hint" id="ttf-method-note"></p>
</div>
</div>
<div>
<div class="card">
<div class="card-title">How TTF analysis works</div>
<div style="display:flex;flex-direction:column;gap:10px;font-size:.78rem;color:var(--text2);line-height:1.55;">
<div><strong style="color:var(--text);">Direct table parsing</strong><br>Reads binary font tables using a JavaScript DataView — no server, no library. Measurements from the font's own metric data.</div>
<div><strong style="color:var(--text);">Tables read:</strong>
<ul style="margin:.3rem 0 0 1rem;display:flex;flex-direction:column;gap:3px;">
<li><code>head</code> — unitsPerEm</li>
<li><code>OS/2</code> — sCapHeight, usWeightClass, fsSelection, panose</li>
<li><code>hhea</code> — ascender, lineGap</li>
<li><code>hmtx</code> — advance widths for O and I</li>
<li><code>cmap</code> — glyph index for O and I</li>
<li><code>name</code> — family name, subfamily</li>
</ul>
</div>
<div><strong style="color:var(--text);">Accuracy</strong><br>O:I from hmtx is exact. Stroke from usWeightClass is a strong estimate; canvas provides cross-check. Combined: ±23% vs ±5% canvas-only.</div>
<div><strong style="color:var(--text);">Privacy</strong><br>Font file never leaves your browser. All parsing runs locally in JavaScript.</div>
</div>
</div>
<div class="card" style="margin-top:10px;">
<div class="card-title">ADA §703.5 — what we measure</div>
<div style="display:flex;flex-direction:column;gap:6px;font-size:.75rem;color:var(--text2);">
<div><strong style="color:var(--text);">§703.5.3 Style</strong> — Sans-serif, no italic/script/decorative. Via OS/2 fsSelection, panose bFamilyType, name table.</div>
<div><strong style="color:var(--text);">§703.5.4 O:I proportion</strong> — O width 55110% of I height. From hmtx advance widths / sCapHeight.</div>
<div><strong style="color:var(--text);">§703.5.7 Stroke weight</strong> — I stroke 1030% of height. From usWeightClass + canvas cross-check.</div>
<div><strong style="color:var(--text);">§703.5.8 Spacing</strong> — 1035% of height. Condensed-variant name detection.</div>
<div><strong style="color:var(--text);">§703.5.3 Conventional form</strong> — No unusual letterforms. Via panose + subfamily name.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const IC={
ext:`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 2h3v3M10 2L5.5 6.5M2 4h3M2 4v6h6V7"/></svg>`,
dl:`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v6M3.5 6l2.5 2.5L8.5 6M2 10h8"/></svg>`,
chk:`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 6l3 3 5-5"/></svg>`,
chkW:`<svg viewBox="0 0 12 12" fill="none" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 6l3 3 5-5"/></svg>`
};
function gfSpecimenUrl(gf){return`https://fonts.google.com/specimen/${gf}`;}
function gfDownloadUrl(gf){return`https://fonts.google.com/download?family=${gf.replace(/\+/g,' ')}`;}
const FONTS=[
{name:"Roboto",style:"Humanist",cat:"Neutral workhorse",tags:["transit","best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Google flagship. Stroke 8-22% of height. O:I ~90%. Used in Android and transit apps worldwide.",gf:"Roboto"},
{name:"Noto Sans",style:"Humanist",cat:"Global Unicode coverage",tags:["transit","best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Exceptional Unicode coverage. Designed for legibility at all sizes. O:I ~88%.",gf:"Noto+Sans"},
{name:"Open Sans",style:"Humanist",cat:"Versatile display",tags:["transit","best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Wide apertures, consistent stroke (~14%). O:I ~95%. Excellent at all sizes.",gf:"Open+Sans"},
{name:"Lato",style:"Humanist",cat:"Corporate clarity",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Semi-rounded. O:I ~92%. Clear I/l/1 distinction. Good for corporate signage.",gf:"Lato"},
{name:"Inter",style:"Neo-grotesque",cat:"Screen-optimized",tags:["transit","best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Designed for screen legibility. Tall x-height, open apertures. O:I ~92%.",gf:"Inter"},
{name:"Source Sans 3",style:"Humanist",cat:"Adobe workhorse",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Adobe open-source screen font. Excellent letter-spacing, O:I ~90%.",gf:"Source+Sans+3"},
{name:"Work Sans",style:"Geometric",cat:"Modern signage",tags:["transit","best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Optimized for medium-large display. Open spacing, O:I ~94%. Great for wayfinding.",gf:"Work+Sans"},
{name:"IBM Plex Sans",style:"Humanist",cat:"Technical precision",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Clear I/l/1 disambiguation by design. Stroke ~12-15%. O:I ~88%.",gf:"IBM+Plex+Sans"},
{name:"DM Sans",style:"Geometric",cat:"Clean geometric",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Low contrast, consistent strokes (~13%), O:I ~96%. Excellent for digital displays.",gf:"DM+Sans"},
{name:"Nunito Sans",style:"Humanist",cat:"Friendly rounded",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Well-spaced, slightly rounded terminals. O:I ~92%. Good weight range.",gf:"Nunito+Sans"},
{name:"Figtree",style:"Geometric",cat:"Geometric modern",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"High-quality geometric. Excellent aperture, O:I ~94%, uniform strokes ~12%.",gf:"Figtree"},
{name:"Manrope",style:"Geometric",cat:"Distinctive display",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Wide letter forms, O:I ~98%. Excellent character differentiation. Large x-height.",gf:"Manrope"},
{name:"Lexend",style:"Humanist",cat:"Dyslexia-aware",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Researched for reading fluency. Extra-wide spacing built in. ADA+ for low vision.",gf:"Lexend"},
{name:"Atkinson Hyperlegible",style:"Humanist",cat:"Low-vision optimized",tags:["transit","best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Designed by Braille Institute. Exaggerated character disambiguation. Ideal for ADA.",gf:"Atkinson+Hyperlegible"},
{name:"Urbanist",style:"Geometric",cat:"Clean geometric",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Based on Futura proportions. Wide O:I ~100%, uniform stroke ~11%.",gf:"Urbanist"},
{name:"Plus Jakarta Sans",style:"Humanist",cat:"Contemporary",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Wide forms, O:I ~95%, excellent weight range. Modern professional choice.",gf:"Plus+Jakarta+Sans"},
{name:"Outfit",style:"Geometric",cat:"Geometric display",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Circular letter forms. O:I ~98%, stroke ~10%. Bold weights excellent for signage.",gf:"Outfit"},
{name:"Mulish",style:"Neo-grotesque",cat:"Versatile grotesque",tags:["best"],compliance:{style:true,proportion:true,stroke:true,spacing:true,form:true},notes:"Balanced proportions, O:I ~90%. Clean and minimal for signage.",gf:"Mulish"},
{name:"Josefin Sans",style:"Geometric",cat:"Art-deco display",tags:[],compliance:{style:true,proportion:false,stroke:false,spacing:true,form:true},notes:"Thin stroke (<10%) in light weights fails §703.5.7. Use bold (600+) only.",gf:"Josefin+Sans"},
{name:"Raleway",style:"Geometric",cat:"Display headline",tags:["caution"],compliance:{style:true,proportion:false,stroke:false,spacing:true,form:true},notes:"Light weights fail stroke minimum. M/W ambiguity at thin weights. Use 500+ only.",gf:"Raleway"},
{name:"Montserrat",style:"Geometric",cat:"Geometric bold",tags:["transit"],compliance:{style:true,proportion:true,stroke:false,spacing:true,form:true},notes:"Weights <300 fail stroke minimum. At 400+ meets §703.5.7. Very popular in transit.",gf:"Montserrat"},
{name:"Poppins",style:"Geometric",cat:"Geometric rounded",tags:[],compliance:{style:true,proportion:true,stroke:false,spacing:true,form:true},notes:"Perfectly circular O:I ~100%. Ultralight/thin fail 10% stroke minimum. Use 400+ only.",gf:"Poppins"},
{name:"Quicksand",style:"Geometric",cat:"Rounded display",tags:["caution"],compliance:{style:true,proportion:true,stroke:false,spacing:false,form:true},notes:"Light weight (300) fails stroke minimum. Limited to 700 max. Rounded terminals reduce differentiation.",gf:"Quicksand"},
{name:"Exo 2",style:"Geometric",cat:"Technical/industrial",tags:["caution"],compliance:{style:true,proportion:false,stroke:false,spacing:true,form:false},notes:"Some weights approach proportion limits. Italic-influenced normal forms. Use regular upright only.",gf:"Exo+2"},
{name:"Comfortaa",style:"Geometric",cat:"Rounded playful",tags:["caution"],compliance:{style:true,proportion:true,stroke:false,spacing:false,form:false},notes:"Rounded terminals and compressed spacing fall below ADA minimums. Not recommended.",gf:"Comfortaa"}
];
let activeFilter='all',selected=new Set();
let currentTestFont=null,currentWeight=400,availableWeights=[],currentFontGf='';
const TAB_IDS=['browse','test','upload'];
function switchTab(id){
document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',TAB_IDS[i]===id));
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
document.getElementById('tab-'+id).classList.add('active');
}
function setFilter(f,btn){
activeFilter=f;
document.querySelectorAll('.pill').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');renderGrid();
}
function complianceSummary(f){
const s=Object.values(f.compliance).filter(Boolean).length;
if(s===5)return{cls:'badge-green',txt:'Fully compliant'};
if(s>=3)return{cls:'badge-amber',txt:'Conditionally compliant'};
return{cls:'badge-red',txt:'Use with caution'};
}
function renderGrid(){
const q=(document.getElementById('search').value||'').toLowerCase();
let list=FONTS.filter(f=>{
const mq=!q||f.name.toLowerCase().includes(q)||f.style.toLowerCase().includes(q)||f.cat.toLowerCase().includes(q);
let mf=true;
if(activeFilter==='best')mf=f.tags.includes('best');
if(activeFilter==='transit')mf=f.tags.includes('transit');
if(activeFilter==='caution')mf=f.tags.includes('caution');
return mq&&mf;
});
document.getElementById('count-label').textContent=list.length+' font'+(list.length!==1?'s':'');
const CK=['style','proportion','stroke','spacing','form'];
const CN={style:'Style',proportion:'O:I ratio',stroke:'Stroke wt',spacing:'Spacing',form:'Form'};
document.getElementById('font-grid').innerHTML=list.map(f=>{
const lbl=complianceSummary(f);const sel=selected.has(f.name);
const dots=CK.map(k=>`<div class="criteria-dot-wrap"><div class="dot ${f.compliance[k]?'dot-green':'dot-amber'}"></div><span>${CN[k]}</span></div>`).join('');
const tB=f.tags.includes('transit')?`<span class="badge badge-blue">Transit-tested</span>`:'';
return`<div class="font-card ${sel?'selected':''}" id="fc-${f.gf}">
<div class="check-icon">${IC.chkW}</div>
<div class="font-card-header">
<div><div class="font-card-name">${f.name}</div><div class="font-card-meta">${f.style} &middot; ${f.cat}</div></div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;"><span class="badge ${lbl.cls}">${lbl.txt}</span>${tB}</div>
</div>
<div class="font-preview-text" style="font-family:'${f.name}',sans-serif;">Aa Bb Cc 123</div>
<p class="font-card-notes">${f.notes}</p>
<div class="criteria-dots">${dots}</div>
<div class="font-card-actions">
<button class="action-btn sel-btn ${sel?'active':''}" onclick="toggleFont('${f.name.replace(/'/g,"\\'")}')">
${sel?IC.chk+' Selected':'+ Select'}
</button>
<a class="action-btn" href="${gfSpecimenUrl(f.gf)}" target="_blank" rel="noopener">${IC.ext} Google Fonts</a>
<a class="action-btn" href="${gfDownloadUrl(f.gf)}" target="_blank" rel="noopener">${IC.dl} Download TTF/OTF</a>
</div>
</div>`;
}).join('');
loadPreviewFonts(list);renderSidebar();
}
function loadPreviewFonts(list){
const needed=list.map(f=>f.gf+':wght@400;700').join('&family=');
const id='gf-preview-link';let el=document.getElementById(id);
if(!el){el=document.createElement('link');el.id=id;el.rel='stylesheet';document.head.appendChild(el);}
el.href=`https://fonts.googleapis.com/css2?family=${needed}&display=swap`;
}
function toggleFont(name){if(selected.has(name))selected.delete(name);else selected.add(name);renderGrid();}
function renderSidebar(){
const listEl=document.getElementById('selected-list');
const dlA=document.getElementById('dl-actions');
if(selected.size===0){listEl.innerHTML='<p class="empty-state">Click fonts to select them</p>';dlA.style.display='none';return;}
dlA.style.display='block';
listEl.innerHTML=[...selected].map(n=>{
const f=FONTS.find(x=>x.name===n);
const dl=f?gfDownloadUrl(f.gf):'#';const sp=f?gfSpecimenUrl(f.gf):'#';
return`<div class="selected-item">
<span style="font-weight:500;font-size:.8rem;">${n}</span>
<div class="selected-item-actions">
<a href="${sp}" target="_blank" rel="noopener" style="color:var(--text3);display:flex;align-items:center;padding:2px 3px;">${IC.ext}</a>
<a href="${dl}" target="_blank" rel="noopener" style="color:var(--blue);display:flex;align-items:center;padding:2px 3px;">${IC.dl}</a>
<button onclick="selected.delete('${n.replace(/'/g,"\\'")}');renderGrid();" style="border:none;background:none;color:var(--text3);cursor:pointer;font-size:.85rem;padding:0 2px;">&#10005;</button>
</div>
</div>`;
}).join('');
}
async function downloadAllSelected(){
const btn=document.getElementById('dl-all-btn');btn.textContent='Opening downloads...';btn.disabled=true;
const names=[...selected];
for(let i=0;i<names.length;i++){
const f=FONTS.find(x=>x.name===names[i]);if(!f)continue;
window.open(gfDownloadUrl(f.gf),'_blank');
if(i<names.length-1)await new Promise(r=>setTimeout(r,700));
}
btn.textContent='&#8659; Download all selected (ZIP per family)';btn.disabled=false;
}
async function testFont(){
const raw=document.getElementById('font-input').value.trim();if(!raw)return;
const btn=document.getElementById('test-btn');
btn.innerHTML='<span class="spinner"></span>Loading...';btn.disabled=true;
['error-box','score-section','preview-section','weight-section'].forEach(id=>document.getElementById(id).style.display='none');
try{
const gfName=raw.replace(/ /g,'+');currentFontGf=gfName;
const link=document.createElement('link');link.rel='stylesheet';
link.href=`https://fonts.googleapis.com/css2?family=${gfName}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900&display=swap`;
document.head.appendChild(link);
await new Promise((res,rej)=>{
link.onload=res;
link.onerror=()=>rej(new Error('Font not found. Check spelling — use the exact name from fonts.google.com'));
setTimeout(()=>rej(new Error('Timeout. Check your connection.')),8000);
});
await document.fonts.ready;
availableWeights=[];
for(const w of[100,200,300,400,500,600,700,800,900]){
const loaded=await Promise.race([document.fonts.load(`${w} 20px "${raw}"`),new Promise(r=>setTimeout(()=>r([]),600))]);
if(loaded&&loaded.length>0)availableWeights.push(w);
}
if(availableWeights.length===0)availableWeights=[400];
currentTestFont=raw;currentWeight=availableWeights.includes(400)?400:availableWeights[Math.floor(availableWeights.length/2)];
renderWeightButtons();applyPreview();await scoreGFFont();
document.getElementById('gf-specimen-link').href=gfSpecimenUrl(gfName);
['weight-section','preview-section','score-section'].forEach(id=>document.getElementById(id).style.display='block');
}catch(e){const eb=document.getElementById('error-box');eb.textContent=e.message||'Could not load font.';eb.style.display='block';}
finally{btn.innerHTML='Analyze font';btn.disabled=false;}
}
function renderWeightButtons(){
document.getElementById('weight-selector').innerHTML=[100,200,300,400,500,600,700,800,900].map(w=>
`<button class="weight-btn ${w===currentWeight?'active':''}" ${availableWeights.includes(w)?'':'disabled'} onclick="selectWeight(${w})">${w}</button>`
).join('');
}
function selectWeight(w){currentWeight=w;renderWeightButtons();applyPreview();scoreGFFont();}
function applyPreview(){
if(!currentTestFont)return;
const fs=`font-family:"${currentTestFont}",sans-serif;font-weight:${currentWeight};`;
document.getElementById('prev-large').style.cssText=fs+'font-size:42px;line-height:1.1;margin-bottom:4px;color:var(--text);';
document.getElementById('prev-large-inv').style.cssText=fs+'font-size:42px;line-height:1.1;color:#fff;';
document.getElementById('prev-chars').style.cssText=fs+'font-size:22px;color:var(--text);letter-spacing:0.08em;';
}
async function scoreGFFont(){
if(!currentTestFont)return;
await new Promise(r=>setTimeout(r,200));
const res=canvasMeasure(currentTestFont,currentWeight);
const nm=currentTestFont.toLowerCase();
const isSerif=/serif|times|georgia|garamond|palatino|caslon|bodoni|didot|baskerville|merriweather|playfair|lora|crimson|cormorant/i.test(nm);
const isScript=/script|cursive|dancing|pacifico|satisfy|lobster|cookie|yellowtail|sacramento|allura|kaushan/i.test(nm);
const isDecor=/decorat|symbol|emoji|icon|dingbat|flourish/i.test(nm);
const isCond=/condensed|compressed|narrow|cond/i.test(nm)&&!/expanded|wide/i.test(nm);
const weightLow=currentWeight<400;
const stylePass=!isSerif&&!isScript&&!isDecor;
const propPass=res.oiRatio>=55&&res.oiRatio<=110;
const strokePass=res.strokePct>=10&&res.strokePct<=30;
const spacingPass=!isCond;const formPass=!isScript&&!isDecor;
const total=[stylePass,propPass,strokePass,spacingPass,formPass].filter(Boolean).length;
const full=total===5&&!weightLow;
renderScoreUI('overall-box','overall-num','overall-label','overall-sub','score-grid','score-hint',
{total,full,weightLow,stylePass,propPass,strokePass,spacingPass,formPass,
oiRatio:res.oiRatio,strokePct:res.strokePct,isCond,currentWeight});
const dlUrl=gfDownloadUrl(currentFontGf);
document.getElementById('test-dl-content').innerHTML=
`<a class="dl-link" href="${dlUrl}" target="_blank" rel="noopener">${IC.dl} Download all weights as ZIP (Google Fonts)</a>
<p class="dl-note">ZIP contains TTF and variable font files for all weights. Extract and upload TTF/OTF files to your CMS font library.</p>`;
window._lastTestedFont={name:currentTestFont,weight:currentWeight,score:total,gf:currentFontGf};
}
function addTestedFont(){
if(!window._lastTestedFont)return;
const{name,gf}=window._lastTestedFont;selected.add(name);
if(!FONTS.find(f=>f.name===name))FONTS.unshift({name,style:'(tested)',cat:'User-tested font',tags:[],gf,compliance:{},notes:`Tested at weight ${window._lastTestedFont.weight}. Score: ${window._lastTestedFont.score}/5.`});
renderSidebar();switchTab('browse');renderGrid();
}
function canvasMeasure(fontName,weight){
const canvas=document.getElementById('measure-canvas');
const ctx=canvas.getContext('2d');
canvas.width=800;canvas.height=300;
const H=120;ctx.font=`${weight} ${H}px "${fontName}", sans-serif`;
const oWidth=ctx.measureText('O').width;const iWidth=ctx.measureText('I').width;
ctx.clearRect(0,0,800,300);ctx.fillStyle='#000';ctx.fillText('I',10,180);
const iData=ctx.getImageData(0,0,200,300).data;
let iTop=-1,iBot=-1,iStrokeMin=Infinity;
for(let y=0;y<300;y++){
let hasInk=false,rowInkW=0;
for(let x=0;x<200;x++){if(iData[(y*200+x)*4+3]>30){hasInk=true;rowInkW++;}}
if(hasInk){if(iTop<0)iTop=y;iBot=y;if(rowInkW>0&&rowInkW<iStrokeMin)iStrokeMin=rowInkW;}
}
const iH=iBot-iTop;
return{oiRatio:iWidth>0?Math.round((oWidth/iWidth)*100):0,strokePct:iH>0?Math.round((iStrokeMin/iH)*100):0};
}
function renderScoreUI(boxId,numId,labelId,subId,gridId,hintId,d){
const oCls=d.full?'full':d.total>=3?'cond':'fail';
const oTxt=d.full?'Fully compliant':d.total>=3?'Conditionally compliant':'Does not meet ADA §703.5';
const oSub=d.full?'All ADA §703.5 visual character criteria met':d.total>=3?'Passes most criteria — check notes for restrictions':'Multiple criteria failed — not recommended for ADA signage';
document.getElementById(boxId).className='overall-score '+oCls;
document.getElementById(numId).textContent=d.total+'/5';
document.getElementById(labelId).textContent=oTxt;
document.getElementById(subId).textContent=oSub;
const items=[
{name:'§703.5.3 — Style',val:d.stylePass?'Sans-serif':'Decorative/Script',cls:d.stylePass?'pass':'fail',note:d.stylePass?'Conventional form detected':'ADA requires sans-serif, non-decorative'},
{name:'§703.5.4 — O:I proportion',val:d.oiRatio?`${d.oiRatio}% (target 55-110%)`:'—',cls:d.propPass?'pass':(d.isCond?'warn':'fail'),note:d.isCond?'Condensed — verify at target size':(d.propPass?'Within ADA range':'Outside 55-110%')},
{name:'§703.5.7 — Stroke weight',val:d.strokePct?`~${d.strokePct}% of height (target 10-30%)`:(d.weightClass?`usWeightClass ${d.weightClass}`:'Could not measure'),cls:d.strokePass?'pass':(d.weightLow?'warn':'fail'),note:d.weightLow?`Weight ${d.currentWeight} high-risk for thin strokes`:(d.strokePass?'Within ADA range':'Outside 10-30%')},
{name:'§703.5.8 — Spacing',val:d.spacingPass?'Normal':'Condensed — check spacing',cls:d.spacingPass?'pass':'warn',note:d.spacingPass?'No condensed flag detected':'Condensed fonts risk failing 10% minimum'},
{name:'§703.5.3 — Conventional form',val:d.formPass?'Standard form':'Unusual/script form',cls:d.formPass?'pass':'fail',note:d.formPass?'No script or decorative forms detected':'ADA prohibits script, oblique, or highly decorative'}
];
document.getElementById(gridId).innerHTML=items.map(i=>`<div class="score-item ${i.cls}"><div class="score-item-name">${i.name}</div><div class="score-item-val">${i.val}</div><div class="score-item-note">${i.note}</div></div>`).join('');
let hints=[];
if(d.weightLow)hints.push(`Weight ${d.currentWeight} below recommended 400 minimum.`);
if(d.isCond)hints.push('Condensed variant — verify O:I at your display size.');
if(d.strokePct&&d.strokePct<10&&!d.strokePass)hints.push('Stroke appears thin. Try a heavier weight.');
if(d.full)hints.push('Passes all measurable §703.5 criteria.');
document.getElementById(hintId).textContent=hints.join(' ');
}
function u16(dv,o){return dv.getUint16(o,false);}
function u32(dv,o){return dv.getUint32(o,false);}
function i16(dv,o){return dv.getInt16(o,false);}
function parseTTF(buffer){
const dv=new DataView(buffer);
const result={tables:{},metrics:{},errors:[]};
const numTables=u16(dv,4);
const tableMap={};
for(let i=0;i<numTables;i++){
const base=12+i*16;
const tag=String.fromCharCode(dv.getUint8(base),dv.getUint8(base+1),dv.getUint8(base+2),dv.getUint8(base+3));
tableMap[tag]={offset:u32(dv,base+8),length:u32(dv,base+12)};
}
result.tables=Object.keys(tableMap);
let unitsPerEm=1000;
if(tableMap.head){const h=tableMap.head.offset;unitsPerEm=u16(dv,h+18);result.metrics.unitsPerEm=unitsPerEm;}
let capHeight=0,weightClass=0,fsSelection=0,panose=[];
if(tableMap['OS/2']){
const o=tableMap['OS/2'].offset;const ver=u16(dv,o);
weightClass=u16(dv,o+4);fsSelection=u16(dv,o+62);
panose=[];for(let i=0;i<10;i++)panose.push(dv.getUint8(o+32+i));
if(ver>=2){capHeight=i16(dv,o+88);}
result.metrics.weightClass=weightClass;result.metrics.fsSelection=fsSelection;
result.metrics.panose=panose;result.metrics.capHeight=capHeight;
}
let ascender=0;
if(tableMap.hhea){const h=tableMap.hhea.offset;ascender=i16(dv,h+4);result.metrics.ascender=ascender;result.metrics.lineGap=i16(dv,h+8);}
let familyName='',subfamilyName='';
if(tableMap.name){
const no=tableMap.name.offset;const count=u16(dv,no+2);const strOffset=u16(dv,no+4);
for(let i=0;i<count;i++){
const base=no+6+i*12;const platformID=u16(dv,base);const nameID=u16(dv,base+6);
const len=u16(dv,base+8);const strOff=u16(dv,base+10);const absOff=no+strOffset+strOff;
if(nameID===1||nameID===2){
let str='';
if(platformID===3){for(let j=0;j<len;j+=2){try{str+=String.fromCharCode(u16(dv,absOff+j));}catch(e){}}}
else{for(let j=0;j<len;j++){try{str+=String.fromCharCode(dv.getUint8(absOff+j));}catch(e){}}}
str=str.replace(/\0/g,'').trim();
if(nameID===1&&str.length>0&&!familyName)familyName=str;
if(nameID===2&&str.length>0&&!subfamilyName)subfamilyName=str;
}
}
}
result.metrics.familyName=familyName||'Unknown';result.metrics.subfamilyName=subfamilyName||'Regular';
let glyphI=73,glyphO=79;
if(tableMap.cmap){
const co=tableMap.cmap.offset;const numSub=u16(dv,co+2);let fmt4Off=-1;
for(let i=0;i<numSub;i++){
const base=co+4+i*8;const plat=u16(dv,base);const off=u32(dv,base+4);
const fmt=u16(dv,co+off);
if((plat===3||plat===0)&&fmt===4&&fmt4Off<0)fmt4Off=co+off;
}
if(fmt4Off>=0){
try{
const segCount=u16(dv,fmt4Off+6)/2;
const endCodesOff=fmt4Off+14;const startCodesOff=endCodesOff+2*segCount+2;
const idDeltaOff=startCodesOff+2*segCount;const idRangeOff=idDeltaOff+2*segCount;
const lookupChar=(cp)=>{
for(let s=0;s<segCount;s++){
const end=u16(dv,endCodesOff+s*2);if(cp>end)continue;
const start=u16(dv,startCodesOff+s*2);if(cp<start)return 0;
const idRange=i16(dv,idDeltaOff+s*2);const idRangeOffset=u16(dv,idRangeOff+s*2);
if(idRangeOffset===0)return(cp+idRange)&0xFFFF;
const glyphOff=idRangeOff+s*2+idRangeOffset+(cp-start)*2;
const gid=u16(dv,glyphOff);return gid===0?0:(gid+idRange)&0xFFFF;
}
return 0;
};
glyphI=lookupChar(0x49)||73;glyphO=lookupChar(0x4F)||79;
}catch(e){result.errors.push('cmap: '+e.message);}
}
}
result.metrics.glyphI=glyphI;result.metrics.glyphO=glyphO;
let awI=500,awO=700;
if(tableMap.hmtx&&tableMap.hhea){
const hh=tableMap.hhea.offset;const numLongHorMetrics=u16(dv,hh+34);const hm=tableMap.hmtx.offset;
const getAW=(gid)=>{if(gid<numLongHorMetrics)return u16(dv,hm+gid*4);return u16(dv,hm+(numLongHorMetrics-1)*4);};
awI=getAW(glyphI);awO=getAW(glyphO);
}
result.metrics.awI=awI;result.metrics.awO=awO;
const iHeight=capHeight>0?capHeight:(ascender>0?Math.round(ascender*0.7):Math.round(unitsPerEm*0.7));
result.metrics.oiRatio=iHeight>0?Math.round((awO/iHeight)*100):Math.round((awO/awI)*100);
result.metrics.iHeight=iHeight;
const wcToStroke=(wc)=>{
if(wc<=100)return 4;if(wc<=200)return 7;if(wc<=300)return 9;
if(wc<=400)return 12;if(wc<=500)return 14;if(wc<=600)return 17;
if(wc<=700)return 20;if(wc<=800)return 24;return 28;
};
result.metrics.strokeEst=weightClass>0?wcToStroke(weightClass):0;
const isItalicFlag=!!(fsSelection&0x01);const isBoldFlag=!!(fsSelection&0x20);const isObliqueFlag=!!(fsSelection&0x200);
const panoseFamily=panose[0]||0;const panoseSerifStyle=panose[1]||0;
result.metrics.isItalic=isItalicFlag||isObliqueFlag||/italic|oblique|slanted/i.test(subfamilyName||'');
result.metrics.isSerif=panoseFamily===2&&panoseSerifStyle>=2&&panoseSerifStyle<=10;
result.metrics.isScript=panoseFamily===3;result.metrics.isDecorative=panoseFamily===4||panoseFamily===5;
result.metrics.isCondensed=/condensed|compressed|narrow|cond/i.test(familyName+' '+subfamilyName)&&!/expanded|wide/i.test(familyName+' '+subfamilyName);
result.metrics.isBold=isBoldFlag;
return result;
}
function handleDrop(e){
e.preventDefault();document.getElementById('upload-zone').classList.remove('drag');
const file=e.dataTransfer.files[0];if(file)processFontFile(file);
}
function handleFileSelect(e){const file=e.target.files[0];if(file)processFontFile(file);}
async function processFontFile(file){
const ext=file.name.split('.').pop().toLowerCase();
if(!['ttf','otf','woff','woff2'].includes(ext)){alert('Please upload a TTF, OTF, WOFF, or WOFF2 file.');return;}
document.getElementById('ttf-result').classList.remove('active');
document.getElementById('ttf-filename').textContent=file.name;
const arrayBuffer=await file.arrayBuffer();
const fontFaceName='TTFUpload_'+Date.now();
const blob=new Blob([arrayBuffer],{type:'font/'+ext});
const blobUrl=URL.createObjectURL(blob);
const ff=new FontFace(fontFaceName,`url(${blobUrl})`);
let fontLoaded=false;
try{await ff.load();document.fonts.add(ff);fontLoaded=true;}catch(e){console.warn('FontFace:',e);}
if(fontLoaded){
const ps=`font-family:"${fontFaceName}",sans-serif;`;
['ttf-prev-large','ttf-prev-sentence','ttf-prev-chars','ttf-prev-inv'].forEach((id,i)=>{
document.getElementById(id).style.fontFamily=`"${fontFaceName}",sans-serif`;
});
}
let parsed=null;let parseError=null;
if(ext==='ttf'||ext==='otf'){try{parsed=parseTTF(arrayBuffer);}catch(e){parseError=e.message;}}
else{parseError='WOFF/WOFF2 table parsing not supported — showing canvas measurements only.';}
let canvasRes={oiRatio:0,strokePct:0};
if(fontLoaded){await new Promise(r=>setTimeout(r,150));canvasRes=canvasMeasure(fontFaceName,400);}
const useTTF=parsed&&!parseError;const m=useTTF?parsed.metrics:{};
const oiRatio=useTTF?m.oiRatio:canvasRes.oiRatio;
const strokePct=useTTF?(m.strokeEst||canvasRes.strokePct):canvasRes.strokePct;
const strokePctCanvas=canvasRes.strokePct;
const nm=(useTTF?(m.familyName+' '+m.subfamilyName):(file.name)).toLowerCase();
const isSerif=useTTF?m.isSerif:/serif|times|garamond|palatino/i.test(nm);
const isScript=useTTF?m.isScript:/script|cursive|dancing|pacifico/i.test(nm);
const isDecorative=useTTF?m.isDecorative:/decorat|symbol|emoji|icon/i.test(nm);
const isItalic=useTTF?m.isItalic:/italic|oblique|slanted/i.test(nm);
const isCondensed=useTTF?m.isCondensed:/condensed|compressed|narrow|cond/i.test(nm)&&!/expanded|wide/i.test(nm);
const stylePass=!isSerif&&!isScript&&!isDecorative&&!isItalic;
const propPass=oiRatio>=55&&oiRatio<=110;
const strokePass=strokePct>=10&&strokePct<=30;
const spacingPass=!isCondensed;const formPass=!isScript&&!isDecorative&&!isItalic;
const total=[stylePass,propPass,strokePass,spacingPass,formPass].filter(Boolean).length;
renderScoreUI('ttf-overall-box','ttf-overall-num','ttf-overall-label','ttf-overall-sub','ttf-score-grid','ttf-score-hint',
{total,full:total===5,weightLow:false,stylePass,propPass,strokePass,spacingPass,formPass,
oiRatio,strokePct,strokeEst:m.strokeEst,weightClass:m.weightClass,isCond:isCondensed,currentWeight:m.weightClass||400});
const srcTTF='<span class="source-note source-ttf">TTF table</span>';
const srcCanvas='<span class="source-note source-canvas">canvas</span>';
let rows=useTTF?[
['Family name',m.familyName,srcTTF+' name.nameID=1'],
['Subfamily',m.subfamilyName,srcTTF+' name.nameID=2'],
['Units per em',m.unitsPerEm,srcTTF+' head'],
['Cap height',m.capHeight>0?m.capHeight:'Not in table (ver<2)',m.capHeight>0?srcTTF+' OS/2.sCapHeight':'—'],
['Ascender',m.ascender,srcTTF+' hhea'],
['I height used',m.iHeight,m.capHeight>0?srcTTF+' OS/2.sCapHeight':'derived'],
['Advance width — I',m.awI+' units',srcTTF+' hmtx #'+m.glyphI],
['Advance width — O',m.awO+' units',srcTTF+' hmtx #'+m.glyphO],
['O:I ratio (§703.5.4)',oiRatio+'% (target 55-110%)',srcTTF+' hmtx/sCapHeight'],
['usWeightClass',m.weightClass,srcTTF+' OS/2'],
['Stroke est. from wClass',m.strokeEst+'% of cap height',srcTTF+' → ADA target 10-30%'],
['Stroke (canvas cross-check)',strokePctCanvas+'%',srcCanvas+' pixel scan'],
['fsSelection','italic='+m.isItalic+', bold='+m.isBold,srcTTF+' OS/2'],
['Panose','Family='+m.panose[0]+', Serif='+m.panose[1],srcTTF+' OS/2'],
['Condensed',isCondensed?'Yes':'No',srcTTF+' name tables'],
]:[
['File',file.name,'—'],
['Parse method','Canvas only (WOFF/WOFF2)','—'],
['O:I ratio',oiRatio+'%',srcCanvas],
['Stroke',strokePctCanvas+'%',srcCanvas],
['Note',parseError||'','—'],
];
document.getElementById('ttf-metric-body').innerHTML=rows.map(r=>`<tr><td>${r[0]}</td><td>${r[1]}</td><td style="font-size:.68rem;color:var(--text3);text-align:right;">${r[2]}</td></tr>`).join('');
document.getElementById('ttf-method-note').textContent=useTTF
?`Tables read: ${parsed.tables.join(', ')}. Canvas pixel scan used as cross-check for stroke weight.`
:`${parseError||''} Canvas measurements shown. Upload a TTF or OTF for full metric analysis.`;
document.getElementById('ttf-result').classList.add('active');
}
function resetUpload(){document.getElementById('ttf-result').classList.remove('active');document.getElementById('ttf-file-input').value='';}
const SUGG=["Fira Sans","Space Grotesk","Barlow","Jost","Sora","Albert Sans","Instrument Sans","Onest","Be Vietnam Pro","Hanken Grotesk","Schibsted Grotesk","Geologica","Maven Pro","Encode Sans","Saira"];
document.getElementById('suggestion-btns').innerHTML=SUGG.map(s=>
`<button onclick="document.getElementById('font-input').value='${s}';switchTab('test');testFont()" style="font-size:.7rem;padding:3px 9px;border:1px solid var(--border);border-radius:20px;background:var(--surface2);color:var(--text2);cursor:pointer;">${s}</button>`
).join('');
renderGrid();
</script>
</body>
</html>