feat: add ADA Font Analyzer v1.0 — three-tab browser tool with TTF binary parser
This commit is contained in:
@@ -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()">⇓ 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>/ → HTML Block</strong> or an <code><iframe></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 55–110% (§703.5.4)</span></div>
|
||||
<div class="criteria-dot-wrap"><div class="dot dot-green"></div><span style="color:var(--text2);">Stroke weight 10–30% 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 10–35% (§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 & 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">–</div>
|
||||
<div><div class="score-label" id="overall-label">–</div><div class="score-sublabel" id="overall-sub">–</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 & 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">⇧</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">–</span>
|
||||
<button class="reset-btn" onclick="resetUpload()" title="Clear and upload another">✕</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">–</div>
|
||||
<div><div class="score-label" id="ttf-overall-label">–</div><div class="score-sublabel" id="ttf-overall-sub">–</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: ±2–3% 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 55–110% of I height. From hmtx advance widths / sCapHeight.</div>
|
||||
<div><strong style="color:var(--text);">§703.5.7 Stroke weight</strong> — I stroke 10–30% of height. From usWeightClass + canvas cross-check.</div>
|
||||
<div><strong style="color:var(--text);">§703.5.8 Spacing</strong> — 10–35% 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} · ${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;">✕</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='⇓ 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>
|
||||
Reference in New Issue
Block a user