Files
ada-font-analyzer/ada-font-analyzer.html
T

788 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>