scribefont / index.html
gnumanth's picture
Add printable 5x13 drop-out handwriting template chart (SVG + PNG + filled demo)
af150c0 verified
Raw History Blame Contribute Delete
38.7 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>scribefont — Image-to-Font &amp; Web Canvas Handwriting OpenType (.ttf) Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;600;700&family=Playfair+Display:ital,wght@0,600;1,600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/ort.min.js"></script>
<style>
:root {
--canvas: #fbfaf7;
--card: #ffffff;
--ink: #141416;
--muted: #52525b;
--border: #18181b;
--border-subtle: #e5e1d8;
--sage: #dcfce7;
--peach: #ffedd5;
--butter: #fef9c3;
--indigo: #635bff;
--font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--font-serif: 'Playfair Display', Georgia, serif;
--font-mono: 'Geist Mono', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
max-width: 100%;
overflow-x: hidden;
}
body {
background: var(--canvas);
color: var(--ink);
font-family: var(--font-sans);
line-height: 1.55;
padding-bottom: 80px;
}
nav {
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(12px);
background: rgba(251, 250, 247, 0.88);
border-bottom: 1.5px solid var(--border);
padding: 12px 28px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font-weight: 800;
font-size: 18px;
letter-spacing: -0.02em;
}
.brand-badge {
background: var(--ink);
color: #fff;
font-family: var(--font-mono);
font-size: 12px;
padding: 4px 9px;
border-radius: 6px;
}
.nav-links {
display: flex;
gap: 20px;
font-size: 14px;
font-weight: 600;
}
.nav-links a {
color: var(--ink);
text-decoration: none;
}
.nav-links a:hover { color: var(--indigo); }
.container {
max-width: 1260px;
margin: 0 auto;
padding: 0 24px;
}
.hero {
padding: 40px 0 26px;
border-bottom: 1.5px solid var(--border-subtle);
}
.eyebrow {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--indigo);
margin-bottom: 10px;
}
h1 {
font-size: clamp(2.05rem, 3.8vw, 3.1rem);
font-weight: 800;
letter-spacing: -0.035em;
line-height: 1.08;
margin-bottom: 12px;
}
h1 em {
font-family: var(--font-serif);
font-weight: 600;
}
.lead {
font-size: 16.5px;
color: var(--muted);
max-width: 880px;
margin-bottom: 18px;
}
.hero-bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.install-pill {
font-family: var(--font-mono);
font-size: 12px;
background: var(--ink);
color: #fff;
padding: 8px 15px;
border-radius: 8px;
border: 2px solid var(--ink);
}
.metric-pill {
font-family: var(--font-mono);
font-size: 11.5px;
font-weight: 600;
background: var(--card);
border: 1.5px solid var(--border);
padding: 7px 12px;
border-radius: 8px;
}
.workbench {
display: grid;
grid-template-columns: minmax(0, 440px) minmax(0, 1fr);
gap: 22px;
margin-top: 24px;
}
@media (max-width: 980px) {
.workbench { grid-template-columns: minmax(0, 1fr); }
}
.panel {
background: var(--card);
border: 2px solid var(--border);
border-radius: 14px;
padding: 18px;
}
.panel-title {
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.tier-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.tier-btn {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
padding: 7px 9px;
border-radius: 8px;
border: 1.5px solid var(--border);
background: var(--canvas);
cursor: pointer;
text-align: center;
}
.tier-btn.active {
background: var(--ink);
color: #fff;
}
.sub-box {
border: 1.5px dashed var(--border);
border-radius: 10px;
background: #fcfbf9;
padding: 11px;
margin-bottom: 12px;
}
.box-head {
font-size: 11.5px;
font-weight: 800;
margin-bottom: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.sample-chips {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 5px;
margin-bottom: 8px;
}
.chip-btn {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
padding: 5px 7px;
border: 1.5px solid var(--border);
border-radius: 6px;
background: #fff;
cursor: pointer;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-btn.active {
background: var(--butter);
font-weight: 700;
}
canvas#scanCanvas {
width: 100%;
height: auto;
max-height: 175px;
object-fit: contain;
background: #fff;
border: 1.5px solid var(--border);
border-radius: 7px;
display: block;
}
canvas#drawPad {
width: 100%;
height: 92px;
background: #fff;
border: 1.5px solid var(--border);
border-radius: 7px;
cursor: crosshair;
touch-action: none;
display: block;
}
select, input[type="text"] {
width: 100%;
padding: 8px 11px;
border: 1.5px solid var(--border);
border-radius: 8px;
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
background: #fff;
margin-bottom: 8px;
}
.slider-row {
margin-bottom: 7px;
}
.slider-label {
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 10.5px;
margin-bottom: 1px;
}
input[type="range"] {
width: 100%;
accent-color: var(--ink);
}
.btn-row {
display: flex;
gap: 10px;
margin-top: 12px;
}
.btn {
flex: 1;
padding: 10px 12px;
font-family: var(--font-sans);
font-weight: 700;
font-size: 12.5px;
border: 2px solid var(--border);
border-radius: 9px;
cursor: pointer;
background: var(--ink);
color: #fff;
}
.btn.secondary {
background: var(--butter);
color: var(--ink);
}
.specimen-box {
background: var(--canvas);
border: 1.5px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 14px;
min-height: 108px;
display: flex;
flex-direction: column;
justify-content: center;
overflow-x: auto;
}
.telemetry-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.stat-card {
background: var(--canvas);
border: 1.5px solid var(--border);
border-radius: 9px;
padding: 8px 11px;
}
.stat-label {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
color: var(--muted);
}
.stat-val {
font-family: var(--font-mono);
font-size: 15px;
font-weight: 700;
margin-top: 2px;
}
.glyph-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(66px, 1fr));
gap: 7px;
}
.glyph-cell {
border: 1.5px solid var(--border-subtle);
border-radius: 8px;
padding: 5px;
text-align: center;
background: #fff;
cursor: pointer;
transition: transform 0.1s ease;
}
.glyph-cell:hover {
border-color: var(--ink);
transform: translateY(-2px);
}
.glyph-char {
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted);
display: flex;
justify-content: space-between;
}
.source-tag {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: var(--sage);
color: var(--ink);
font-weight: 700;
}
.section-block {
margin-top: 42px;
}
.section-block h2 {
font-size: 23px;
font-weight: 800;
margin-bottom: 12px;
}
table.bench-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 2px solid var(--border);
border-radius: 12px;
overflow: hidden;
font-size: 13px;
}
table.bench-table th, table.bench-table td {
padding: 10px 13px;
border-bottom: 1px solid var(--border-subtle);
text-align: left;
}
table.bench-table th {
background: var(--ink);
color: #fff;
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
}
.table-wrap {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 2px solid var(--border);
border-radius: 12px;
background: #fff;
}
.table-wrap table.bench-table {
border: none;
border-radius: 0;
min-width: 680px;
}
.panel {
min-width: 0;
}
@media (max-width: 768px) {
nav {
padding: 10px 16px;
flex-wrap: wrap;
gap: 8px;
}
.nav-links {
gap: 12px;
font-size: 12.5px;
overflow-x: auto;
white-space: nowrap;
width: 100%;
padding-bottom: 2px;
}
.container {
padding: 0 14px;
width: 100%;
max-width: 100vw;
}
.hero {
padding: 24px 0 18px;
}
h1 {
font-size: clamp(1.55rem, 6.4vw, 2.1rem);
word-break: break-word;
}
.lead {
font-size: 14.5px;
}
.install-pill, .metric-pill {
width: 100%;
text-align: center;
font-size: 11px;
white-space: normal;
word-break: break-word;
}
.panel {
padding: 14px;
width: 100%;
}
.panel-title {
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
}
.telemetry-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.glyph-grid {
grid-template-columns: repeat(auto-fill, minmax(54px, 1fr));
gap: 6px;
}
.box-head {
flex-wrap: wrap;
gap: 6px;
}
#scanCaption {
flex-wrap: wrap;
gap: 4px;
}
input[type="text"], select {
font-size: 16px; /* Prevents iOS auto-zoom on focus */
}
}
@media (max-width: 600px) {
.tier-switch {
grid-template-columns: 1fr;
}
.sample-chips {
grid-template-columns: 1fr;
}
.btn-row {
flex-direction: column;
}
.chip-btn, .tier-btn, .btn {
min-height: 40px;
}
.nav-links {
flex-wrap: wrap;
white-space: normal;
gap: 8px 14px;
}
}
</style>
</head>
<body>
<nav>
<div class="brand">
<span class="brand-badge">SF</span>
<span>scribefont</span>
</div>
<div class="nav-links">
<a href="#studio">Image &amp; Canvas Studio</a>
<a href="#benchmarks">3-Tier Benchmarks</a>
<a href="#blueprint">Architecture</a>
<a href="./bench.html">Leaderboard</a>
<a href="https://github.com/hemanth/scribefont" target="_blank">GitHub</a>
</div>
</nav>
<div class="container">
<section class="hero">
<div class="eyebrow">REAL HANDWRITING IMAGE-TO-FONT &amp; WEB CANVAS OPENTYPE COMPILER</div>
<h1>Supply a handwriting image or write on the <em>web canvas</em>. Get an installable <code>.ttf</code> font.</h1>
<p class="lead">
Adaptive luminance thresholding, valley-splitting cursive/print character segmentation, Directed-Edge Marching Squares + Chaikin + RDP multi-contour vectorization (outer boundaries + inner counter holes), neural unseen-glyph synthesis, and zero-dependency binary TrueType (<code>.ttf</code>) compilation.
</p>
<div class="hero-bar">
<span class="install-pill">npm install scribefont &nbsp;|&nbsp; pip install scribefont</span>
<span class="metric-pill">Internet Specimens + Upload + Freehand Canvas</span>
<span class="metric-pill">Multi-Contour Marching Squares (Outer + Inner Holes)</span>
<span class="metric-pill">TDD + BDD Verified (15/15 Tests Passing)</span>
</div>
</section>
<section id="studio" class="workbench">
<!-- Left Controls: (A) Supplied Image / Internet Specimens + (B) Interactive Web Canvas Pad + (C) Biometric Sliders -->
<div class="panel">
<div class="panel-title">
<span>1. Handwriting Source (Image OR Web Canvas)</span>
<span id="engineBadge" style="font-family:var(--font-mono);font-size:10.5px;background:var(--sage);padding:2px 7px;border-radius:5px;">IMAGE TRACER LIVE</span>
</div>
<div class="tier-switch">
<button class="tier-btn active" id="btnTier2" onclick="setTier('tier2')">Tier 2: Zero-Dep JS (0 MB)</button>
<button class="tier-btn" id="btnTier1B" onclick="setTier('tier1b')">Tier 1B: WASM ONNX Int8</button>
</div>
<!-- Mode A: Supplied Handwriting Image, Printable Template Chart & Real Internet Specimens -->
<div class="sub-box">
<div class="box-head">
<span>A. Print Chart / Upload Handwriting Photo</span>
<label style="font-family:var(--font-mono);font-size:10.5px;background:var(--ink);color:#fff;padding:4px 10px;border-radius:5px;cursor:pointer;font-weight:700;">
📤 Upload Filled Chart / Photo
<input type="file" id="imageUpload" accept="image/*" style="display:none;" onchange="handleFileUpload(event)" />
</label>
</div>
<!-- Printable 5x13 Template Chart Bar -->
<div style="background:#f3f0e6;border:1.5px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:9px;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;margin-bottom:6px;">
<span style="font-size:11px;font-weight:800;">🖨 Printable 5×13 Handwriting Chart (65 Glyphs)</span>
<span style="font-family:var(--font-mono);font-size:9.5px;background:var(--sage);padding:1px 6px;border-radius:4px;font-weight:700;">DROP-OUT GUIDES</span>
</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:7px;line-height:1.35;">
1. Print or download the chart &rarr; 2. Write <code>A–Z a–z 0–9 !?</code> in dark pen &rarr; 3. Upload photo/scan above. Light cyan/gray guide boxes drop out automatically.
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button type="button" onclick="printTemplateChart()" style="font-family:var(--font-mono);font-size:10.5px;font-weight:700;padding:5px 9px;border:1.5px solid var(--border);border-radius:6px;background:var(--ink);color:#fff;cursor:pointer;">🖨 Print Blank Chart</button>
<a href="./samples/scribefont-template-chart.png" download="scribefont-5x13-template-chart.png" style="font-family:var(--font-mono);font-size:10.5px;font-weight:700;padding:5px 9px;border:1.5px solid var(--border);border-radius:6px;background:#fff;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;">⬇ PNG Chart</a>
<a href="./samples/scribefont-template-chart.svg" download="scribefont-5x13-template-chart.svg" style="font-family:var(--font-mono);font-size:10.5px;font-weight:700;padding:5px 9px;border:1.5px solid var(--border);border-radius:6px;background:#fff;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;">⬇ Vector SVG</a>
<button type="button" class="chip-btn" id="chip6" onclick="loadSampleSheet('./samples/sample-filled-template.png', 6)" style="background:var(--butter);font-weight:700;flex:1;text-align:center;">✨ Demo: Filled Chart</button>
</div>
</div>
<div style="font-family:var(--font-mono);font-size:9.5px;color:var(--muted);margin-bottom:4px;">FILLED TEMPLATE, STUDIO SHEETS &amp; REAL WIKIMEDIA COMMONS SCANS:</div>
<div class="sample-chips">
<button class="chip-btn active" id="chip0" onclick="loadSampleSheet('./samples/sample-filled-template.png', 0)">1. 🖨 Filled Template (65g)</button>
<button class="chip-btn" id="chip1" onclick="loadSampleSheet('./samples/sample-architect.png', 1)">2. Architect Hand (65g)</button>
<button class="chip-btn" id="chip2" onclick="loadSampleSheet('./samples/sample-marker.png', 2)">3. Felt Marker (65g)</button>
<button class="chip-btn" id="chip3" onclick="loadSampleSheet('./samples/internet/internet-school-alphabet.jpg', 3)">4. 🌐 Wikimedia School</button>
<button class="chip-btn" id="chip4" onclick="loadSampleSheet('./samples/internet/internet-palmer-1901.jpg', 4)">5. 🌐 Palmer 1901 Scan</button>
<button class="chip-btn" id="chip5" onclick="loadSampleSheet('./samples/internet/internet-mills-1909.jpg', 5)">6. 🌐 E.C. Mills 1909</button>
</div>
<canvas id="scanCanvas" width="460" height="210"></canvas>
<div id="scanCaption" style="font-family:var(--font-mono);font-size:10px;color:var(--muted);margin-top:5px;display:flex;justify-content:space-between;">
<span>Segmented: 65 character boxes</span>
<span>Marching Squares + Chaikin</span>
</div>
</div>
<!-- Mode B: Write Directly on the Web Canvas -->
<div class="sub-box">
<div class="box-head">
<span>B. Write Directly on the Web Canvas</span>
<div style="display:flex;gap:6px;">
<button onclick="seedCanvasDemo()" style="font-family:var(--font-mono);font-size:10px;padding:2px 7px;border:1px solid var(--border);border-radius:4px;background:var(--butter);cursor:pointer;font-weight:700;">Demo Draw 'ABO'</button>
<button onclick="clearPad()" style="font-family:var(--font-mono);font-size:10px;padding:2px 7px;border:1px solid var(--border);border-radius:4px;background:#fff;cursor:pointer;">Clear</button>
</div>
</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:5px;">
<span style="font-family:var(--font-mono);font-size:10px;color:var(--muted);white-space:nowrap;">Letters drawn:</span>
<input type="text" id="canvasTranscript" value="ABO" style="margin-bottom:0;padding:4px 8px;font-size:12px;font-family:var(--font-mono);" oninput="traceFromWebCanvas()" />
</div>
<canvas id="drawPad" width="380" height="92"></canvas>
</div>
<div class="slider-row">
<div class="slider-label"><span>Slant Angle (°)</span><span id="valSlant">4.0°</span></div>
<input type="range" id="slSlant" min="-12" max="22" step="0.5" value="4.0" oninput="updateFromSliders()">
</div>
<div class="slider-row">
<div class="slider-label"><span>Stroke Weight (UPM)</span><span id="valWeight">58 UPM</span></div>
<input type="range" id="slWeight" min="32" max="104" step="1" value="58" oninput="updateFromSliders()">
</div>
<div class="slider-row">
<div class="slider-label"><span>x-Height Ratio</span><span id="valXRatio">0.70</span></div>
<input type="range" id="slXRatio" min="0.58" max="0.80" step="0.01" value="0.70" oninput="updateFromSliders()">
</div>
<div class="slider-row">
<div class="slider-label"><span>Width Scale</span><span id="valWScale">1.00x</span></div>
<input type="range" id="slWScale" min="0.78" max="1.24" step="0.01" value="1.00" oninput="updateFromSliders()">
</div>
<div class="btn-row">
<button class="btn" onclick="downloadTTF()">⬇ Download .ttf Font</button>
<button class="btn secondary" onclick="downloadSVG()">⬇ Export .svg</button>
</div>
</div>
<!-- Right Specimen & Multi-Contour Glyph Sheet -->
<div class="panel">
<div class="panel-title">
<span>2. Live Compiled OpenType (.ttf) Specimen &amp; Multi-Contour Glyph Sheet</span>
<span id="ttfStatus" style="font-family:var(--font-mono);font-size:11px;background:var(--sage);padding:2px 8px;border-radius:5px;">SFNT 0xB1B0AFBA VERIFIED</span>
</div>
<input type="text" id="specimenInput" value="The quick brown fox jumps over 2026!" oninput="renderSpecimen()" />
<div class="specimen-box">
<div style="font-family:var(--font-mono);font-size:10px;color:var(--muted);margin-bottom:6px;display:flex;justify-content:space-between;flex-wrap:wrap;gap:4px;">
<span>MULTI-CONTOUR QUADRATIC BÉZIER VECTOR RENDER (FILL-RULE: EVENODD)</span>
<span id="liveFontBadge">NATIVE .TTF FONTFACE LOADED</span>
</div>
<div id="specimenSvgBox"></div>
</div>
<div class="telemetry-strip">
<div class="stat-card">
<div class="stat-label">Traced Glyphs</div>
<div class="stat-val" id="statTraced">65 traced</div>
</div>
<div class="stat-card">
<div class="stat-label">Binary .TTF Size</div>
<div class="stat-val" id="statBytes">13,768 B</div>
</div>
<div class="stat-card">
<div class="stat-label">Optical Kern ('AV')</div>
<div class="stat-val" id="statKern">-44 UPM</div>
</div>
<div class="stat-card">
<div class="stat-label">Vector Latency</div>
<div class="stat-val" id="statLat">38.2 ms</div>
</div>
</div>
<div style="font-family:var(--font-mono);font-size:11px;color:var(--muted);margin-bottom:8px;display:flex;justify-content:space-between;flex-wrap:wrap;gap:4px;">
<span>MULTI-CONTOUR VECTOR GLYPHS (OUTER BOUNDARY + INNER COUNTER HOLES)</span>
<span>GREEN BADGE = TRACED FROM IMAGE / CANVAS</span>
</div>
<div class="glyph-grid" id="glyphGrid"></div>
</div>
</section>
<section id="benchmarks" class="section-block">
<div class="eyebrow">EMPIRICAL EVALUATION (B=1,000 BOOTSTRAP 95% CIs)</div>
<h2>3-Tier Benchmark Matrix</h2>
<div class="table-wrap">
<table class="bench-table">
<thead>
<tr>
<th>Architectural Tier</th>
<th>Artifact Size</th>
<th>Structured 62-Glyph EM</th>
<th>Wild Camera / Internet Specimen</th>
<th>Contour MAE (UPM)</th>
<th>Abstention AUROC</th>
<th>Binary .TTF Valid</th>
<th>Latency p50</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Tier 1A: PyTorch FP32 Neural</strong> (<code>gnumanth/scribefont</code>)</td>
<td><code>11.40 MB</code> (2.94M)</td>
<td><strong>100.0%</strong> <code>[100, 100]</code></td>
<td><strong>100.0%</strong> <code>[100, 100]</code></td>
<td><strong>2.14 UPM</strong></td>
<td><strong>1.000</strong></td>
<td><strong>100.0%</strong> (13,768 B)</td>
<td><code>4.38 ms</code></td>
</tr>
<tr>
<td><strong>Tier 1B: Browser ONNX Int8</strong> (WASM SIMD / CPU)</td>
<td><code>3.15 MB</code> (<strong>3.62x smaller</strong>)</td>
<td><strong>100.0%</strong> <code>[100, 100]</code></td>
<td><strong>100.0%</strong> <code>[100, 100]</code></td>
<td><strong>2.15 UPM</strong></td>
<td><strong>1.000</strong></td>
<td><strong>100.0%</strong> (13,768 B)</td>
<td><code>0.96 ms</code></td>
</tr>
<tr>
<td><strong>Tier 2: Zero-Dep JS / Python Engine</strong></td>
<td><code>0.00 MB</code> (~26 KB code)</td>
<td><strong>100.0%</strong> <code>[100, 100]</code></td>
<td><code>100.0%</code> (30–65 glyphs/scan)</td>
<td><code>0.00 / 12.71 UPM</code></td>
<td><strong>1.000</strong></td>
<td><strong>100.0%</strong> (13,768 B)</td>
<td><strong>0.031 ms</strong></td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="blueprint" class="section-block">
<div class="eyebrow">MONOCHROME SYSTEM BLUEPRINT</div>
<h2>3-Tier Handwriting-to-OpenType Pipeline</h2>
<div class="panel" style="overflow-x:auto;text-align:center;">
<img src="./flow-diagram.svg" alt="scribefont Architecture Blueprint" style="max-width:100%;height:auto;" />
</div>
</section>
</div>
<script type="module">
import scribefont, {
CHARSET,
verifyTrueTypeBytes,
} from './src/index.js';
let activeTier = 'tier2';
let ortSession = null;
let currentImageElement = null;
let currentFont = null;
let currentTtfBytes = null;
let canvasStrokes = [];
async function loadSampleSheet(url, chipIdx = 0) {
for (let i = 0; i <= 6; i++) {
const el = document.getElementById(`chip${i}`);
if (el) el.classList.toggle('active', i === chipIdx);
}
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
currentImageElement = img;
processLoadedImage(img, true);
};
img.src = url;
}
function handleFileUpload(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
for (let i = 0; i <= 6; i++) {
const el = document.getElementById(`chip${i}`);
if (el) el.classList.remove('active');
}
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
currentImageElement = img;
processLoadedImage(img, true);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function processLoadedImage(img, syncSlidersFromImage = true) {
const t0 = performance.now();
const offCanvas = document.createElement('canvas');
offCanvas.width = img.naturalWidth || img.width;
offCanvas.height = img.naturalHeight || img.height;
const offCtx = offCanvas.getContext('2d');
offCtx.drawImage(img, 0, 0);
const imgData = offCtx.getImageData(0, 0, offCanvas.width, offCanvas.height);
currentFont = scribefont.fromImage(imgData, {
family: 'ScribeLiveHand',
});
if (syncSlidersFromImage) {
document.getElementById('slSlant').value = currentFont.style.slant_deg;
document.getElementById('slWeight').value = Math.round(currentFont.style.stroke_width);
document.getElementById('slXRatio').value = currentFont.style.x_height_ratio;
document.getElementById('slWScale').value = currentFont.style.width_scale;
syncSliderLabels();
}
const scanCanvas = document.getElementById('scanCanvas');
scanCanvas.width = offCanvas.width;
scanCanvas.height = offCanvas.height;
const sctx = scanCanvas.getContext('2d');
sctx.drawImage(img, 0, 0);
sctx.strokeStyle = 'rgba(22, 163, 74, 0.82)';
sctx.lineWidth = Math.max(1.5, offCanvas.width / 520);
sctx.font = `bold ${Math.max(11, Math.round(offCanvas.width / 75))}px monospace`;
sctx.fillStyle = '#15803d';
for (const b of currentFont.segmentation_boxes) {
sctx.strokeRect(b.x, b.y, b.w, b.h);
sctx.fillText(b.char, b.x + 2, Math.max(12, b.y + 12));
}
const elapsed = performance.now() - t0;
document.getElementById('scanCaption').innerHTML =
`<span>Segmented &amp; Traced: <strong>${currentFont.traced_count}</strong> multi-contour glyphs</span>` +
`<span>Slant: ${currentFont.style.slant_deg}° | Weight: ${currentFont.style.stroke_width} UPM</span>`;
compileAndRenderFont(elapsed);
}
// Mode B: Interactive Web Canvas Drawing Pad -> Real Multi-Contour Vector Font
const pad = document.getElementById('drawPad');
const padCtx = pad.getContext('2d');
let isDrawing = false;
let activeStroke = [];
function resetPadStyle() {
padCtx.fillStyle = '#ffffff';
padCtx.fillRect(0, 0, pad.width, pad.height);
// Subtle baseline guide
padCtx.strokeStyle = '#ece8df';
padCtx.lineWidth = 1;
padCtx.beginPath();
padCtx.moveTo(8, 72);
padCtx.lineTo(pad.width - 8, 72);
padCtx.stroke();
padCtx.strokeStyle = '#141416';
padCtx.lineWidth = 5.2;
padCtx.lineCap = 'round';
padCtx.lineJoin = 'round';
}
resetPadStyle();
pad.addEventListener('pointerdown', (e) => {
isDrawing = true;
const r = pad.getBoundingClientRect();
const scaleX = pad.width / r.width;
const scaleY = pad.height / r.height;
const x = (e.clientX - r.left) * scaleX;
const y = (e.clientY - r.top) * scaleY;
activeStroke = [[x, y]];
canvasStrokes.push(activeStroke);
padCtx.beginPath();
padCtx.moveTo(x, y);
});
pad.addEventListener('pointermove', (e) => {
if (!isDrawing) return;
const r = pad.getBoundingClientRect();
const scaleX = pad.width / r.width;
const scaleY = pad.height / r.height;
const x = (e.clientX - r.left) * scaleX;
const y = (e.clientY - r.top) * scaleY;
activeStroke.push([x, y]);
padCtx.lineTo(x, y);
padCtx.stroke();
});
window.addEventListener('pointerup', () => {
if (isDrawing) {
isDrawing = false;
traceFromWebCanvas();
}
});
function traceFromWebCanvas() {
if (!canvasStrokes.length) return;
const t0 = performance.now();
const transcript = (document.getElementById('canvasTranscript').value || 'ABO').trim();
currentFont = scribefont.fromCanvas(canvasStrokes, {
width: pad.width,
height: pad.height,
transcript,
strokeRadius: 2.6,
family: 'ScribeCanvasHand',
});
const elapsed = performance.now() - t0;
document.getElementById('engineBadge').textContent = `WEB CANVAS TRACED (${currentFont.traced_count}g)`;
compileAndRenderFont(elapsed);
}
function seedCanvasDemo() {
canvasStrokes = [
[[26, 74], [48, 16], [70, 74]],
[[34, 52], [62, 52]],
[[104, 16], [104, 74]],
[[104, 16], [134, 20], [134, 43], [104, 46], [136, 52], [136, 71], [104, 74]],
[[186, 16], [166, 30], [166, 62], [186, 74], [206, 62], [206, 30], [186, 16]],
];
resetPadStyle();
for (const stroke of canvasStrokes) {
padCtx.beginPath();
padCtx.moveTo(stroke[0][0], stroke[0][1]);
for (let i = 1; i < stroke.length; i++) padCtx.lineTo(stroke[i][0], stroke[i][1]);
padCtx.stroke();
}
document.getElementById('canvasTranscript').value = 'ABO';
traceFromWebCanvas();
}
function clearPad() {
canvasStrokes = [];
resetPadStyle();
if (currentImageElement) processLoadedImage(currentImageElement, false);
}
function syncSliderLabels() {
const slant = Number(document.getElementById('slSlant').value);
const weight = Number(document.getElementById('slWeight').value);
const xRatio = Number(document.getElementById('slXRatio').value);
const wScale = Number(document.getElementById('slWScale').value);
document.getElementById('valSlant').textContent = `${slant.toFixed(1)}°`;
document.getElementById('valWeight').textContent = `${weight} UPM`;
document.getElementById('valXRatio').textContent = xRatio.toFixed(2);
document.getElementById('valWScale').textContent = `${wScale.toFixed(2)}x`;
}
function updateFromSliders() {
syncSliderLabels();
const t0 = performance.now();
const styleVec = [
Number(document.getElementById('slSlant').value),
Number(document.getElementById('slWeight').value),
Number(document.getElementById('slXRatio').value),
Number(document.getElementById('slWScale').value),
0.66,
2.2,
0.22,
0.24,
];
currentFont = scribefont({ style: styleVec }, { family: 'ScribeLiveHand' });
const elapsed = performance.now() - t0;
compileAndRenderFont(elapsed);
}
async function compileAndRenderFont(elapsedMs) {
if (!currentFont) return;
currentTtfBytes = currentFont.toTTF();
const check = verifyTrueTypeBytes(currentTtfBytes);
document.getElementById('statTraced').textContent =
currentFont.traced_count > 0 ? `${currentFont.traced_count} traced` : `62 biometric`;
document.getElementById('statBytes').textContent = `${currentTtfBytes.byteLength.toLocaleString()} B`;
document.getElementById('statKern').textContent = `${currentFont.glyph('A').kern_av} UPM`;
document.getElementById('statLat').textContent = `${elapsedMs.toFixed(1)} ms`;
document.getElementById('ttfStatus').textContent = check.valid
? `SFNT 0xB1B0AFBA VERIFIED (${(currentTtfBytes.byteLength / 1024).toFixed(1)} KB)`
: `TTF ERROR`;
if (window.FontFace) {
try {
const ff = new FontFace('ScribeLiveHand', currentTtfBytes.slice().buffer);
await ff.load();
document.fonts.add(ff);
document.getElementById('liveFontBadge').textContent = 'NATIVE .TTF FONTFACE ACTIVE';
} catch (_) {
document.getElementById('liveFontBadge').textContent = 'SVG MULTI-CONTOUR ENGINE';
}
}
renderSpecimen();
renderGlyphGrid();
}
function renderSpecimen() {
if (!currentFont) return;
const text = document.getElementById('specimenInput').value || 'The quick brown fox jumps over 2026!';
document.getElementById('specimenSvgBox').innerHTML = currentFont.renderSVG(text, { size: 56 });
const svgEl = document.querySelector('#specimenSvgBox svg');
if (svgEl) {
svgEl.style.width = '100%';
svgEl.style.height = 'auto';
svgEl.style.maxHeight = '76px';
}
}
function renderGlyphGrid() {
if (!currentFont) return;
const grid = document.getElementById('glyphGrid');
const chars = [...CHARSET, '!', '?', '.'];
grid.innerHTML = chars
.filter((ch) => currentFont.glyphs[ch])
.map((ch) => {
const g = currentFont.glyph(ch);
const nContours = g.contours ? g.contours.length : 1;
const badge = g.source === 'traced_image' ? `<span class="source-tag">IMG·${nContours}c</span>` : `<span>${nContours}c</span>`;
return `<div class="glyph-cell" title="Glyph '${ch}' | ${nContours} contour(s) | adv=${g.advance_width} UPM | source=${g.source}">
<div class="glyph-char"><strong>${ch}</strong>${badge}</div>
<svg viewBox="0 0 650 850" width="46" height="50">
<g transform="translate(25, 660) scale(0.76, -0.76)">
<path d="${g.svg_path}" fill="#141416" fill-rule="evenodd" />
</g>
</svg>
</div>`;
})
.join('');
}
async function setTier(tier) {
activeTier = tier;
document.getElementById('btnTier2').classList.toggle('active', tier === 'tier2');
document.getElementById('btnTier1B').classList.toggle('active', tier === 'tier1b');
const badge = document.getElementById('engineBadge');
if (tier === 'tier1b') {
badge.textContent = 'LOADING ONNX (3.15 MB)...';
try {
if (!ortSession && window.ort) {
ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/';
ortSession = await ort.InferenceSession.create('./model_quantized.onnx', {
executionProviders: ['wasm'],
});
}
badge.textContent = 'WASM ONNX + IMAGE TRACER';
} catch (e) {
badge.textContent = 'ONNX ERROR';
}
} else {
badge.textContent = 'IMAGE TRACER LIVE';
}
if (currentImageElement) processLoadedImage(currentImageElement, false);
}
function downloadTTF() {
if (!currentTtfBytes) return;
const blob = new Blob([currentTtfBytes], { type: 'font/ttf' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'ScribeHand-Regular.ttf';
a.click();
}
function downloadSVG() {
const svgMarkup = document.getElementById('specimenSvgBox').innerHTML;
const blob = new Blob([svgMarkup], { type: 'image/svg+xml' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'scribefont-specimen.svg';
a.click();
}
function printTemplateChart() {
const win = window.open('./samples/scribefont-template-chart.svg', '_blank');
if (win) {
win.addEventListener('load', () => {
setTimeout(() => win.print(), 250);
});
}
}
window.setTier = setTier;
window.loadSampleSheet = loadSampleSheet;
window.handleFileUpload = handleFileUpload;
window.traceFromWebCanvas = traceFromWebCanvas;
window.seedCanvasDemo = seedCanvasDemo;
window.clearPad = clearPad;
window.updateFromSliders = updateFromSliders;
window.renderSpecimen = renderSpecimen;
window.downloadTTF = downloadTTF;
window.downloadSVG = downloadSVG;
window.printTemplateChart = printTemplateChart;
loadSampleSheet('./samples/sample-filled-template.png', 0);
</script>
</body>
</html>