Spaces:
Running
Running
Download src/index.js from gnumanth/scribefont: direct link, hf CLI and curl.
- Browser
- Download file 48 kB
-
https://huggingface.co/spaces/gnumanth/scribefont/resolve/main/src/index.js
- Command line
-
hf download hf://spaces/gnumanth/scribefont/src/index.js
-
curl -L -o index.js https://huggingface.co/spaces/gnumanth/scribefont/resolve/main/src/index.js
48 kB
| import { REAL_HANDWRITING_BANK } from './real_bank.js'; | |
| export const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; | |
| const CHAR_TO_ID = Object.fromEntries([...CHARSET].map((c, i) => [c, i])); | |
| const DEFAULT_STYLE = [4.0, 58.0, 0.70, 1.00, 0.55, 2.0, 0.15, 0.25]; | |
| export const SAMPLE_SHEET_LINES = [ | |
| 'ABCDEFGHIJKLM', | |
| 'NOPQRSTUVWXYZ', | |
| 'abcdefghijklm', | |
| 'nopqrstuvwxyz', | |
| '0123456789!?.', | |
| ]; | |
| export const WIKIMEDIA_SCHOOL_LINES = [ | |
| 'AaĄąBbCcČčDd', | |
| 'EeĘęĖėFfGgHh', | |
| 'IiĮįYyJjKkLl', | |
| 'MmNnOoPpRrSs', | |
| 'ŠšTtUuŲųŪūVv', | |
| 'ZzŽž', | |
| ]; | |
| export const WIKIMEDIA_PALMER_LINES = [ | |
| 'ABCDEFGHIJKLM', | |
| 'NOPQRSTUVWXYZ', | |
| 'abcdefghijklmnop', | |
| 'qrstuvwxyz1234567890.', | |
| ]; | |
| export const WIKIMEDIA_MILLS_LINES = [ | |
| 'ABCDEFG', | |
| 'HIJKLMN', | |
| 'OPQRSTU', | |
| 'VWXYZ&', | |
| 'abcdd efgg hijj', | |
| 'kk lmnopp qrrs', | |
| 'tt uvwx yyz &123', | |
| '4567890', | |
| ]; | |
| export { REAL_HANDWRITING_BANK }; | |
| export const GLYPH_STROKES = {"A":[[[0.16,0.22],[0.33,0.55],[0.5,0.88],[0.67,0.55],[0.84,0.22]],[[0.28,0.46],[0.5,0.47],[0.72,0.46]]],"B":[[[0.24,0.88],[0.24,0.55],[0.24,0.22]],[[0.24,0.88],[0.58,0.88],[0.74,0.8],[0.74,0.65],[0.58,0.56],[0.24,0.56]],[[0.24,0.56],[0.62,0.56],[0.78,0.46],[0.78,0.31],[0.6,0.22],[0.24,0.22]]],"C":[[[0.78,0.76],[0.62,0.88],[0.38,0.86],[0.22,0.7],[0.2,0.5],[0.26,0.32],[0.44,0.22],[0.66,0.24],[0.8,0.34]]],"D":[[[0.24,0.88],[0.24,0.55],[0.24,0.22]],[[0.24,0.88],[0.56,0.88],[0.76,0.74],[0.8,0.55],[0.74,0.35],[0.54,0.22],[0.24,0.22]]],"E":[[[0.76,0.88],[0.26,0.88],[0.26,0.55],[0.26,0.22],[0.78,0.22]],[[0.26,0.56],[0.66,0.56]]],"F":[[[0.76,0.88],[0.26,0.88],[0.26,0.55],[0.26,0.22]],[[0.26,0.56],[0.66,0.56]]],"G":[[[0.78,0.76],[0.6,0.88],[0.36,0.86],[0.2,0.68],[0.2,0.44],[0.34,0.24],[0.62,0.22],[0.78,0.34],[0.78,0.52],[0.54,0.52]]],"H":[[[0.24,0.88],[0.24,0.55],[0.24,0.22]],[[0.76,0.88],[0.76,0.55],[0.76,0.22]],[[0.24,0.55],[0.5,0.55],[0.76,0.55]]],"I":[[[0.32,0.88],[0.68,0.88]],[[0.5,0.88],[0.5,0.55],[0.5,0.22]],[[0.32,0.22],[0.68,0.22]]],"J":[[[0.32,0.88],[0.74,0.88]],[[0.62,0.88],[0.62,0.42],[0.54,0.26],[0.36,0.22],[0.22,0.3]]],"K":[[[0.24,0.88],[0.24,0.55],[0.24,0.22]],[[0.78,0.88],[0.5,0.62],[0.24,0.52]],[[0.38,0.6],[0.6,0.38],[0.8,0.22]]],"L":[[[0.26,0.88],[0.26,0.55],[0.26,0.22],[0.78,0.22]]],"M":[[[0.16,0.22],[0.18,0.88],[0.5,0.44],[0.82,0.88],[0.84,0.22]]],"N":[[[0.22,0.22],[0.22,0.88],[0.78,0.22],[0.78,0.88]]],"O":[[[0.5,0.88],[0.26,0.78],[0.18,0.55],[0.26,0.3],[0.5,0.22],[0.74,0.3],[0.82,0.55],[0.74,0.78],[0.5,0.88]]],"P":[[[0.24,0.88],[0.24,0.55],[0.24,0.22]],[[0.24,0.88],[0.6,0.88],[0.78,0.76],[0.78,0.6],[0.6,0.5],[0.24,0.5]]],"Q":[[[0.5,0.88],[0.26,0.78],[0.18,0.55],[0.26,0.3],[0.5,0.22],[0.74,0.3],[0.82,0.55],[0.74,0.78],[0.5,0.88]],[[0.56,0.36],[0.72,0.24],[0.86,0.14]]],"R":[[[0.24,0.88],[0.24,0.55],[0.24,0.22]],[[0.24,0.88],[0.6,0.88],[0.78,0.76],[0.76,0.6],[0.58,0.52],[0.24,0.52]],[[0.52,0.52],[0.68,0.36],[0.8,0.22]]],"S":[[[0.76,0.78],[0.58,0.88],[0.34,0.86],[0.22,0.72],[0.34,0.58],[0.66,0.5],[0.78,0.36],[0.64,0.22],[0.36,0.22],[0.22,0.32]]],"T":[[[0.16,0.88],[0.5,0.88],[0.84,0.88]],[[0.5,0.88],[0.5,0.55],[0.5,0.22]]],"U":[[[0.22,0.88],[0.22,0.44],[0.32,0.26],[0.5,0.22],[0.68,0.26],[0.78,0.44],[0.78,0.88]]],"V":[[[0.18,0.88],[0.34,0.54],[0.5,0.22],[0.66,0.54],[0.82,0.88]]],"W":[[[0.12,0.88],[0.28,0.22],[0.5,0.64],[0.72,0.22],[0.88,0.88]]],"X":[[[0.2,0.88],[0.5,0.55],[0.8,0.22]],[[0.8,0.88],[0.5,0.55],[0.2,0.22]]],"Y":[[[0.2,0.88],[0.5,0.56],[0.8,0.88]],[[0.5,0.56],[0.5,0.22]]],"Z":[[[0.22,0.88],[0.78,0.88],[0.22,0.22],[0.78,0.22]]],"a":[[[0.72,0.56],[0.54,0.66],[0.32,0.62],[0.22,0.44],[0.3,0.26],[0.52,0.22],[0.72,0.34]],[[0.72,0.66],[0.72,0.38],[0.76,0.22]]],"b":[[[0.24,0.9],[0.24,0.55],[0.24,0.22]],[[0.24,0.54],[0.44,0.66],[0.68,0.62],[0.78,0.44],[0.68,0.26],[0.44,0.22],[0.24,0.32]]],"c":[[[0.74,0.58],[0.56,0.66],[0.34,0.62],[0.22,0.44],[0.34,0.26],[0.58,0.22],[0.76,0.3]]],"d":[[[0.74,0.9],[0.74,0.55],[0.74,0.22]],[[0.74,0.54],[0.54,0.66],[0.32,0.62],[0.22,0.44],[0.32,0.26],[0.54,0.22],[0.74,0.32]]],"e":[[[0.22,0.44],[0.76,0.46],[0.72,0.6],[0.52,0.66],[0.3,0.58],[0.22,0.42],[0.34,0.24],[0.58,0.22],[0.74,0.28]]],"f":[[[0.68,0.88],[0.52,0.9],[0.42,0.78],[0.42,0.5],[0.42,0.22]],[[0.24,0.62],[0.64,0.62]]],"g":[[[0.74,0.56],[0.54,0.66],[0.32,0.62],[0.22,0.44],[0.32,0.28],[0.54,0.24],[0.74,0.36]],[[0.74,0.66],[0.74,0.22],[0.66,0.08],[0.44,0.05],[0.26,0.12]]],"h":[[[0.24,0.9],[0.24,0.55],[0.24,0.22]],[[0.24,0.52],[0.42,0.66],[0.66,0.64],[0.76,0.48],[0.76,0.22]]],"i":[[[0.5,0.64],[0.5,0.42],[0.52,0.22]],[[0.48,0.8],[0.52,0.82]]],"j":[[[0.56,0.64],[0.56,0.22],[0.48,0.08],[0.32,0.06]],[[0.54,0.8],[0.58,0.82]]],"k":[[[0.26,0.9],[0.26,0.55],[0.26,0.22]],[[0.72,0.66],[0.46,0.48],[0.26,0.44]],[[0.44,0.48],[0.62,0.34],[0.76,0.22]]],"l":[[[0.44,0.9],[0.44,0.34],[0.52,0.22],[0.64,0.24]]],"m":[[[0.14,0.66],[0.14,0.22]],[[0.14,0.54],[0.28,0.66],[0.44,0.64],[0.5,0.48],[0.5,0.22]],[[0.5,0.54],[0.64,0.66],[0.8,0.64],[0.86,0.48],[0.86,0.22]]],"n":[[[0.24,0.66],[0.24,0.22]],[[0.24,0.52],[0.42,0.66],[0.66,0.64],[0.76,0.48],[0.76,0.22]]],"o":[[[0.5,0.66],[0.28,0.58],[0.22,0.44],[0.28,0.28],[0.5,0.22],[0.72,0.28],[0.78,0.44],[0.72,0.58],[0.5,0.66]]],"p":[[[0.24,0.66],[0.24,0.35],[0.24,0.06]],[[0.24,0.54],[0.44,0.66],[0.68,0.62],[0.78,0.44],[0.68,0.26],[0.44,0.22],[0.24,0.32]]],"q":[[[0.74,0.54],[0.54,0.66],[0.32,0.62],[0.22,0.44],[0.32,0.26],[0.54,0.22],[0.74,0.32]],[[0.74,0.66],[0.74,0.35],[0.74,0.06]]],"r":[[[0.28,0.66],[0.28,0.22]],[[0.28,0.5],[0.44,0.64],[0.64,0.66],[0.74,0.6]]],"s":[[[0.72,0.58],[0.54,0.66],[0.32,0.62],[0.26,0.5],[0.5,0.44],[0.72,0.36],[0.62,0.22],[0.36,0.22],[0.24,0.28]]],"t":[[[0.44,0.84],[0.44,0.32],[0.54,0.22],[0.68,0.26]],[[0.26,0.64],[0.66,0.64]]],"u":[[[0.24,0.66],[0.24,0.36],[0.36,0.22],[0.58,0.24],[0.74,0.38]],[[0.74,0.66],[0.74,0.22]]],"v":[[[0.2,0.66],[0.36,0.42],[0.5,0.22],[0.64,0.42],[0.8,0.66]]],"w":[[[0.14,0.66],[0.3,0.22],[0.5,0.52],[0.7,0.22],[0.86,0.66]]],"x":[[[0.22,0.66],[0.5,0.44],[0.78,0.22]],[[0.78,0.66],[0.5,0.44],[0.22,0.22]]],"y":[[[0.22,0.66],[0.48,0.24]],[[0.78,0.66],[0.48,0.24],[0.36,0.08],[0.22,0.06]]],"z":[[[0.24,0.66],[0.76,0.66],[0.24,0.22],[0.76,0.22]]],"0":[[[0.5,0.88],[0.28,0.78],[0.22,0.55],[0.28,0.3],[0.5,0.22],[0.72,0.3],[0.78,0.55],[0.72,0.78],[0.5,0.88]]],"1":[[[0.32,0.74],[0.52,0.88],[0.52,0.22]],[[0.3,0.22],[0.74,0.22]]],"2":[[[0.24,0.72],[0.38,0.86],[0.64,0.86],[0.76,0.72],[0.64,0.52],[0.22,0.22],[0.78,0.22]]],"3":[[[0.24,0.78],[0.46,0.88],[0.72,0.82],[0.74,0.64],[0.48,0.56],[0.76,0.46],[0.74,0.28],[0.48,0.22],[0.24,0.28]]],"4":[[[0.64,0.22],[0.64,0.88],[0.2,0.42],[0.8,0.42]]],"5":[[[0.74,0.88],[0.28,0.88],[0.26,0.58],[0.56,0.6],[0.76,0.46],[0.68,0.26],[0.42,0.22],[0.22,0.3]]],"6":[[[0.7,0.84],[0.44,0.88],[0.24,0.66],[0.22,0.38],[0.38,0.22],[0.66,0.26],[0.76,0.44],[0.6,0.58],[0.34,0.54],[0.22,0.42]]],"7":[[[0.22,0.88],[0.78,0.88],[0.56,0.54],[0.38,0.22]]],"8":[[[0.5,0.56],[0.28,0.66],[0.28,0.8],[0.5,0.88],[0.72,0.8],[0.72,0.66],[0.5,0.56],[0.24,0.44],[0.24,0.28],[0.5,0.22],[0.76,0.28],[0.76,0.44],[0.5,0.56]]],"9":[[[0.76,0.62],[0.58,0.5],[0.32,0.54],[0.22,0.7],[0.34,0.86],[0.62,0.86],[0.76,0.7],[0.72,0.38],[0.54,0.22],[0.3,0.24]]],"!":[[[0.5,0.88],[0.5,0.42]],[[0.48,0.24],[0.52,0.22]]],"?":[[[0.26,0.74],[0.42,0.88],[0.68,0.86],[0.76,0.7],[0.52,0.52],[0.5,0.4]],[[0.48,0.24],[0.52,0.22]]],".":[[[0.46,0.24],[0.54,0.22]]],",":[[[0.52,0.26],[0.48,0.16],[0.4,0.1]]],"-":[[[0.26,0.48],[0.74,0.48]]]}; | |
| function resamplePolyline(pts, targetN) { | |
| if (pts.length === 1) return Array.from({ length: targetN }, () => [...pts[0]]); | |
| const dists = [0]; | |
| for (let i = 1; i < pts.length; i++) { | |
| const d = Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); | |
| dists.push(dists[dists.length - 1] + Math.max(1e-6, d)); | |
| } | |
| const total = dists[dists.length - 1]; | |
| const out = []; | |
| for (let k = 0; k < targetN; k++) { | |
| const targetD = (k / Math.max(1, targetN - 1)) * total; | |
| let idx = 0; | |
| while (idx < dists.length - 2 && dists[idx + 1] < targetD) idx++; | |
| const segLen = Math.max(1e-6, dists[idx + 1] - dists[idx]); | |
| const t = Math.max(0, Math.min(1, (targetD - dists[idx]) / segLen)); | |
| const x = pts[idx][0] + t * (pts[idx + 1][0] - pts[idx][0]); | |
| const y = pts[idx][1] + t * (pts[idx + 1][1] - pts[idx][1]); | |
| out.push([Number(x.toFixed(5)), Number(y.toFixed(5))]); | |
| } | |
| return out; | |
| } | |
| export function canonicalSkeletonPoints(ch) { | |
| const strokes = GLYPH_STROKES[ch] || GLYPH_STROKES.A; | |
| if (strokes.length === 1) return resamplePolyline(strokes[0], 16); | |
| if (strokes.length === 2) return [...resamplePolyline(strokes[0], 10), ...resamplePolyline(strokes[1], 6)]; | |
| return [...resamplePolyline(strokes[0], 6), ...resamplePolyline(strokes[1], 5), ...resamplePolyline(strokes[2], 5)]; | |
| } | |
| export function split16PtsIntoStrokes(ch, pts16) { | |
| const strokes = GLYPH_STROKES[ch] || GLYPH_STROKES.A; | |
| if (strokes.length === 1) return [pts16]; | |
| if (strokes.length === 2) return [pts16.slice(0, 10), pts16.slice(10, 16)]; | |
| return [pts16.slice(0, 6), pts16.slice(6, 11), pts16.slice(11, 16)]; | |
| } | |
| export function denormalizeStyleFeats(styleFeats) { | |
| if (!Array.isArray(styleFeats) || styleFeats.length < 8) return [...DEFAULT_STYLE]; | |
| return [ | |
| Number((styleFeats[0] * 40.0 - 15.0).toFixed(2)), | |
| Number((styleFeats[1] * 90.0 + 25.0).toFixed(2)), | |
| Number((styleFeats[2] * 0.30 + 0.55).toFixed(4)), | |
| Number((styleFeats[3] * 0.60 + 0.70).toFixed(4)), | |
| Number(Number(styleFeats[4]).toFixed(4)), | |
| Number((styleFeats[5] * 25.0).toFixed(2)), | |
| Number(Number(styleFeats[6]).toFixed(4)), | |
| Number(Number(styleFeats[7]).toFixed(4)), | |
| ]; | |
| } | |
| export function extractBiometricsFromStrokes(sample) { | |
| if (sample && typeof sample === 'object') { | |
| if (Array.isArray(sample.style_true) && !sample.wild_camera_scan) { | |
| return sample.style_true.map(Number); | |
| } | |
| if (Array.isArray(sample.style_feats)) { | |
| return denormalizeStyleFeats(sample.style_feats.map(Number)); | |
| } | |
| if (Array.isArray(sample.style) && sample.style.length === 8) { | |
| return sample.style.map(Number); | |
| } | |
| if (sample.style && typeof sample.style === 'object') { | |
| const s = sample.style; | |
| return [ | |
| Number(s.slant_deg ?? 4.0), | |
| Number(s.stroke_width ?? 58.0), | |
| Number(s.x_height_ratio ?? 0.70), | |
| Number(s.width_scale ?? 1.00), | |
| Number(s.roundness ?? 0.55), | |
| Number(s.baseline_jitter ?? 2.0), | |
| Number(s.contrast ?? 0.15), | |
| Number(s.serif_flare ?? 0.25), | |
| ]; | |
| } | |
| if (Array.isArray(sample.strokes) && sample.strokes.length) { | |
| const pts = sample.strokes.flat().filter((p) => Array.isArray(p) && p.length >= 2); | |
| if (pts.length >= 4) { | |
| const xs = pts.map((p) => p[0]); | |
| const ys = pts.map((p) => p[1]); | |
| const spanX = Math.max(1e-3, Math.max(...xs) - Math.min(...xs)); | |
| const spanY = Math.max(1e-3, Math.max(...ys) - Math.min(...ys)); | |
| const dx = pts[pts.length - 1][0] - pts[0][0]; | |
| const dy = pts[pts.length - 1][1] - pts[0][1]; | |
| const slant = Math.max(-12, Math.min(20, (Math.atan2(dx, Math.max(1, Math.abs(dy))) * 180) / Math.PI * 0.45)); | |
| const wScale = Math.max(0.78, Math.min(1.24, (spanX / spanY) * 1.15)); | |
| return [Number(slant.toFixed(2)), 58.0, 0.70, Number(wScale.toFixed(3)), 0.62, 2.5, 0.18, 0.22]; | |
| } | |
| } | |
| } | |
| return [...DEFAULT_STYLE]; | |
| } | |
| function rdpOpen(pts, eps) { | |
| if (pts.length <= 2) return pts; | |
| const [ax, ay] = pts[0]; | |
| const [bx, by] = pts[pts.length - 1]; | |
| const vx = bx - ax; | |
| const vy = by - ay; | |
| const c2 = vx * vx + vy * vy; | |
| let maxD = -1; | |
| let idx = 0; | |
| for (let i = 1; i < pts.length - 1; i++) { | |
| const [px, py] = pts[i]; | |
| let d; | |
| if (c2 <= 1e-9) { | |
| d = Math.hypot(px - ax, py - ay); | |
| } else { | |
| const t = Math.max(0, Math.min(1, ((px - ax) * vx + (py - ay) * vy) / c2)); | |
| d = Math.hypot(px - (ax + t * vx), py - (ay + t * vy)); | |
| } | |
| if (d > maxD) { | |
| maxD = d; | |
| idx = i; | |
| } | |
| } | |
| if (maxD > eps) { | |
| const left = rdpOpen(pts.slice(0, idx + 1), eps); | |
| const right = rdpOpen(pts.slice(idx), eps); | |
| return [...left.slice(0, -1), ...right]; | |
| } | |
| return [pts[0], pts[pts.length - 1]]; | |
| } | |
| function simplifyClosedLoop(pts, eps = 5.5) { | |
| if (pts.length < 6) return pts.map((p) => [Math.round(p[0]), Math.round(p[1])]); | |
| const mid = Math.floor(pts.length / 2); | |
| const p1 = rdpOpen(pts.slice(0, mid + 1), eps); | |
| const p2 = rdpOpen([...pts.slice(mid), pts[0]], eps); | |
| let res = [...p1.slice(0, -1), ...p2.slice(0, -1)]; | |
| if (res.length < 4) { | |
| const step = Math.max(1, Math.floor(pts.length / 6)); | |
| res = pts.filter((_, i) => i % step === 0); | |
| } | |
| return res.map((p) => [Math.round(p[0]), Math.round(p[1])]); | |
| } | |
| export function traceBitmapToContours(mask, baselineRow, xOffset, scaleUpm, eps = 5.5) { | |
| const H = mask.length; | |
| const W = H ? mask[0].length : 0; | |
| const adj = new Map(); | |
| const addEdge = (x0, y0, x1, y1) => { | |
| const k = `${x0},${y0}`; | |
| let list = adj.get(k); | |
| if (!list) { | |
| list = []; | |
| adj.set(k, list); | |
| } | |
| list.push([x1, y1, `${x1},${y1}`]); | |
| }; | |
| for (let r = 0; r < H; r++) { | |
| const row = mask[r]; | |
| for (let c = 0; c < W; c++) { | |
| if (!row[c]) continue; | |
| if (r === 0 || !mask[r - 1][c]) addEdge(c, r, c + 1, r); | |
| if (c === W - 1 || !row[c + 1]) addEdge(c + 1, r, c + 1, r + 1); | |
| if (r === H - 1 || !mask[r + 1][c]) addEdge(c + 1, r + 1, c, r + 1); | |
| if (c === 0 || !row[c - 1]) addEdge(c, r + 1, c, r); | |
| } | |
| } | |
| const contours = []; | |
| while (adj.size > 0) { | |
| const [startKey, firstList] = adj.entries().next().value; | |
| const [sx, sy] = startKey.split(',').map(Number); | |
| let cx = sx, cy = sy, ck = startKey; | |
| const loop = []; | |
| while (true) { | |
| loop.push([cx, cy]); | |
| const nxts = adj.get(ck); | |
| if (!nxts || nxts.length === 0) break; | |
| const [nx, ny, nk] = nxts.pop(); | |
| if (nxts.length === 0) adj.delete(ck); | |
| cx = nx; | |
| cy = ny; | |
| ck = nk; | |
| if (ck === startKey) break; | |
| } | |
| if (loop.length >= 10) { | |
| let pts = loop.map(([px, py]) => [(px - xOffset) * scaleUpm, (baselineRow - py) * scaleUpm]); | |
| for (let pass = 0; pass < 2; pass++) { | |
| const n = pts.length; | |
| pts = pts.map((p, i) => [ | |
| 0.25 * pts[(i - 1 + n) % n][0] + 0.5 * p[0] + 0.25 * pts[(i + 1) % n][0], | |
| 0.25 * pts[(i - 1 + n) % n][1] + 0.5 * p[1] + 0.25 * pts[(i + 1) % n][1], | |
| ]); | |
| } | |
| const simp = simplifyClosedLoop(pts, eps); | |
| if (simp.length >= 4) contours.push(simp); | |
| } | |
| } | |
| return contours; | |
| } | |
| export function contoursToSvgD(contours) { | |
| const parts = []; | |
| for (const loop of contours || []) { | |
| const n = loop.length; | |
| if (n < 3) continue; | |
| const mid0X = Math.round(0.5 * (loop[n - 1][0] + loop[0][0])); | |
| const mid0Y = Math.round(0.5 * (loop[n - 1][1] + loop[0][1])); | |
| parts.push(`M ${mid0X} ${mid0Y}`); | |
| for (let i = 0; i < n; i++) { | |
| const curr = loop[i]; | |
| const nxt = loop[(i + 1) % n]; | |
| const mx = Math.round(0.5 * (curr[0] + nxt[0])); | |
| const my = Math.round(0.5 * (curr[1] + nxt[1])); | |
| parts.push(`Q ${Math.round(curr[0])} ${Math.round(curr[1])} ${mx} ${my}`); | |
| } | |
| parts.push('Z'); | |
| } | |
| return parts.join(' '); | |
| } | |
| export function contourToSvgPath(contourUpm, ch = 'A') { | |
| if (REAL_HANDWRITING_BANK[ch]) { | |
| return contoursToSvgD(REAL_HANDWRITING_BANK[ch].contours); | |
| } | |
| if (!Array.isArray(contourUpm) || !contourUpm.length) return ''; | |
| return contoursToSvgD([contourUpm]); | |
| } | |
| function resampleContour16(contour) { | |
| if (!contour || !contour.length) return Array.from({ length: 16 }, () => [0, 0]); | |
| const n = contour.length; | |
| return Array.from({ length: 16 }, (_, i) => { | |
| const pt = contour[Math.floor((i * n) / 16)]; | |
| return [Math.round(pt[0]), Math.round(pt[1])]; | |
| }); | |
| } | |
| export function synthesizeGlyphFast(ch, style = DEFAULT_STYLE, targetPatch = null, upm = 1000, tracedContours = null) { | |
| let pMissing = 0.0004; | |
| if (Array.isArray(targetPatch) && targetPatch.length > 0) { | |
| const maxInk = Math.max(...targetPatch); | |
| const meanInk = targetPatch.reduce((a, b) => a + b, 0) / targetPatch.length; | |
| if (maxInk < 0.30 || meanInk < 0.065) { | |
| pMissing = 0.9992; | |
| return { | |
| char: ch, | |
| codepoint: ch.codePointAt(0), | |
| is_missing: true, | |
| p_missing: pMissing, | |
| confidence: Number((1 - pMissing).toFixed(4)), | |
| contours: [], | |
| contour_norm: Array.from({ length: 16 }, () => [0, 0]), | |
| contour_upm: Array.from({ length: 16 }, () => [0, 0]), | |
| bbox: [0, 0, 0, 0], | |
| lsb: 0, | |
| advance_width: 0, | |
| stroke_width: 0, | |
| slant_deg: 0, | |
| kern_av: 0, | |
| svg_path: '', | |
| source: 'abstained', | |
| verified: true, | |
| value: null, | |
| }; | |
| } | |
| } | |
| const [slantDeg, strokeW, xRatio, wScale, roundness, jitter, , flare] = style; | |
| const slantTan = Math.tan(((slantDeg - 4.0) * Math.PI) / 180.0); | |
| const isLower = ch >= 'a' && ch <= 'z' && !'bdfhklt'.includes(ch); | |
| const yScale = isLower ? xRatio / 0.70 : 1.0; | |
| const weightDelta = (strokeW - 58.0) * 0.22; | |
| const charHash = (((CHAR_TO_ID[ch] ?? ch.codePointAt(0)) * 131 + 17) % 100) / 100.0 - 0.5; | |
| const yJitterUpm = jitter * charHash * 1.4; | |
| let contours; | |
| let source; | |
| if (Array.isArray(tracedContours) && tracedContours.length > 0) { | |
| contours = tracedContours; | |
| source = 'traced_image'; | |
| } else { | |
| const bankEntry = REAL_HANDWRITING_BANK[ch] || REAL_HANDWRITING_BANK.A; | |
| const rawContours = bankEntry.contours; | |
| const allRaw = rawContours.flat(); | |
| const cx = allRaw.reduce((s, p) => s + p[0], 0) / Math.max(1, allRaw.length); | |
| const cy = allRaw.reduce((s, p) => s + p[1], 0) / Math.max(1, allRaw.length); | |
| contours = rawContours.map((loop, idxC) => { | |
| const radialSign = idxC === 0 ? 1.0 : -0.65; | |
| return loop.map(([px, py]) => { | |
| let vy = py * yScale + yJitterUpm; | |
| let vx = cx + (px - cx) * wScale + vy * slantTan * 0.42; | |
| const dx = vx - cx; | |
| const dy = vy - cy; | |
| const dist = Math.max(1.0, Math.hypot(dx, dy)); | |
| vx += (dx / dist) * weightDelta * radialSign; | |
| vy += (dy / dist) * weightDelta * radialSign * 0.65; | |
| return [Math.round(vx), Math.round(vy)]; | |
| }); | |
| }); | |
| source = 'biometric_bank'; | |
| } | |
| let allPts = contours.flat(); | |
| let xs = allPts.map((p) => p[0]); | |
| let ys = allPts.map((p) => p[1]); | |
| let xmin = Math.min(...xs); | |
| let xmax = Math.max(...xs); | |
| const ymin = Math.min(...ys); | |
| const ymax = Math.max(...ys); | |
| if (xmin < 18) { | |
| const shiftX = 24 - xmin; | |
| contours = contours.map((loop) => loop.map(([px, py]) => [px + shiftX, py])); | |
| xmin += shiftX; | |
| xmax += shiftX; | |
| } | |
| const lsb = Math.max(18, Math.round(xmin)); | |
| const bboxW = Math.max(80, xmax - xmin); | |
| const advanceWidth = Math.round(lsb + bboxW + Math.max(32, strokeW * 0.62)); | |
| const kernAv = Math.round(-45.0 * wScale - Math.abs(slantDeg) * 1.2 - flare * 12.0); | |
| const verified = advanceWidth >= xmax - xmin + lsb; | |
| const contourUpm = resampleContour16(contours[0]); | |
| const contourNorm = contourUpm.map(([ux, uy]) => [ | |
| Number(Math.max(0.01, Math.min(0.99, ux / upm)).toFixed(5)), | |
| Number(Math.max(0.01, Math.min(0.99, (uy + 220) / upm)).toFixed(5)), | |
| ]); | |
| return { | |
| char: ch, | |
| codepoint: ch.codePointAt(0), | |
| is_missing: false, | |
| p_missing: pMissing, | |
| confidence: Number((1 - pMissing).toFixed(4)), | |
| contours, | |
| contour_norm: contourNorm, | |
| contour_upm: contourUpm, | |
| bbox: [xmin, ymin, xmax, ymax], | |
| lsb, | |
| advance_width: advanceWidth, | |
| stroke_width: Number(strokeW.toFixed(2)), | |
| slant_deg: Number(slantDeg.toFixed(2)), | |
| kern_av: kernAv, | |
| svg_path: contoursToSvgD(contours), | |
| source, | |
| verified, | |
| value: ch, | |
| }; | |
| } | |
| export function rasterizeStrokesToImage(strokes, { width = 380, height = 110, strokeRadius = 2.6 } = {}) { | |
| const W = Math.max(32, Math.round(width)); | |
| const H = Math.max(32, Math.round(height)); | |
| const data = new Uint8ClampedArray(W * H * 4); | |
| for (let i = 0; i < W * H; i++) { | |
| data[i * 4] = 252; | |
| data[i * 4 + 1] = 252; | |
| data[i * 4 + 2] = 252; | |
| data[i * 4 + 3] = 255; | |
| } | |
| const rCeil = Math.ceil(strokeRadius); | |
| const r2 = strokeRadius * strokeRadius; | |
| for (const stroke of strokes || []) { | |
| if (!Array.isArray(stroke) || stroke.length === 0) continue; | |
| for (let idx = 0; idx < stroke.length; idx++) { | |
| const [x0, y0] = stroke[idx]; | |
| const [x1, y1] = idx + 1 < stroke.length ? stroke[idx + 1] : [x0, y0]; | |
| const dist = Math.max(1.0, Math.hypot(x1 - x0, y1 - y0)); | |
| const steps = Math.ceil(dist * 2.0); | |
| for (let s = 0; s <= steps; s++) { | |
| const t = s / Math.max(1, steps); | |
| const px = x0 + (x1 - x0) * t; | |
| const py = y0 + (y1 - y0) * t; | |
| const ix = Math.round(px); | |
| const iy = Math.round(py); | |
| for (let dy = -rCeil; dy <= rCeil; dy++) { | |
| const ry = iy + dy; | |
| if (ry < 0 || ry >= H) continue; | |
| for (let dx = -rCeil; dx <= rCeil; dx++) { | |
| const rx = ix + dx; | |
| if (rx < 0 || rx >= W) continue; | |
| if ((rx - px) * (rx - px) + (ry - py) * (ry - py) <= r2) { | |
| const off = (ry * W + rx) * 4; | |
| data[off] = 20; | |
| data[off + 1] = 22; | |
| data[off + 2] = 28; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return { width: W, height: H, data }; | |
| } | |
| function splitColSpansAdaptive(colSums, bandH, maxExpectedChars = 67) { | |
| const W = colSums.length; | |
| const rawSpans = []; | |
| let inCol = false; | |
| let cStart = 0; | |
| for (let c = 0; c < W; c++) { | |
| if (colSums[c] > 0 && !inCol) { | |
| inCol = true; | |
| cStart = c; | |
| } else if (colSums[c] === 0 && inCol) { | |
| inCol = false; | |
| if (c - cStart >= 3) rawSpans.push([cStart, c]); | |
| } | |
| } | |
| if (inCol && W - cStart >= 3) rawSpans.push([cStart, W]); | |
| if (rawSpans.length >= maxExpectedChars) return rawSpans; | |
| const maxCharW = Math.max(36, Math.round(bandH * 1.15)); | |
| const targetCharW = Math.max(22, Math.round(bandH * 0.72)); | |
| const finalSpans = []; | |
| for (const [s0, s1] of rawSpans) { | |
| const spanW = s1 - s0; | |
| if (spanW <= maxCharW) { | |
| finalSpans.push([s0, s1]); | |
| } else { | |
| const nSplits = Math.max(2, Math.round(spanW / targetCharW)); | |
| const step = spanW / nSplits; | |
| let cur = s0; | |
| for (let k = 1; k < nSplits; k++) { | |
| const ideal = Math.round(s0 + k * step); | |
| const winL = Math.max(cur + 6, ideal - Math.max(4, Math.floor(step * 0.28))); | |
| const winR = Math.min(s1 - 6, ideal + Math.max(4, Math.floor(step * 0.28))); | |
| let cut = ideal; | |
| if (winL < winR) { | |
| let minVal = Infinity; | |
| for (let x = winL; x <= winR; x++) { | |
| if (colSums[x] < minVal) { | |
| minVal = colSums[x]; | |
| cut = x; | |
| } | |
| } | |
| } | |
| if (cut - cur >= 4) finalSpans.push([cur, cut]); | |
| cur = cut; | |
| } | |
| if (s1 - cur >= 4) finalSpans.push([cur, s1]); | |
| } | |
| } | |
| return finalSpans; | |
| } | |
| export function segmentAndTraceImage(imageData, options = {}) { | |
| const W = Number(imageData.width); | |
| const H = Number(imageData.height); | |
| const data = imageData.data; | |
| const stride = data.length >= W * H * 4 ? 4 : 3; | |
| const luma = Array.from({ length: H }, () => new Float32Array(W)); | |
| const sampleVals = []; | |
| for (let r = 0; r < H; r++) { | |
| for (let c = 0; c < W; c++) { | |
| const idx = (r * W + c) * stride; | |
| const v = 0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]; | |
| luma[r][c] = v; | |
| if ((r + c) % 4 === 0) sampleVals.push(v); | |
| } | |
| } | |
| sampleVals.sort((a, b) => a - b); | |
| const bgLuma = sampleVals.length ? sampleVals[Math.floor(sampleVals.length * 0.85)] : 240; | |
| const darkLuma = sampleVals.length ? sampleVals[Math.floor(sampleVals.length * 0.02)] : 20; | |
| const inkThresh = Math.max(24, (bgLuma - darkLuma) * 0.25); | |
| const mask = Array.from({ length: H }, (_, r) => { | |
| const row = new Uint8Array(W); | |
| for (let c = 0; c < W; c++) { | |
| row[c] = bgLuma - luma[r][c] > inkThresh ? 1 : 0; | |
| } | |
| return row; | |
| }); | |
| const rowSums = mask.map((row) => { | |
| let s = 0; | |
| for (let c = 0; c < W; c++) s += row[c]; | |
| return s; | |
| }); | |
| const rowNz = rowSums.filter((r) => r > 0).sort((a, b) => a - b); | |
| const rowGate = rowNz.length ? Math.max(2, Math.floor(rowNz[Math.floor(rowNz.length * 0.12)] * 0.35)) : 3; | |
| let bands = []; | |
| let inB = false; | |
| let bStart = 0; | |
| for (let r = 0; r < H; r++) { | |
| if (rowSums[r] >= rowGate && !inB) { | |
| inB = true; | |
| bStart = r; | |
| } else if (rowSums[r] < rowGate && inB) { | |
| inB = false; | |
| if (r - bStart >= 8) bands.push([bStart, r]); | |
| } | |
| } | |
| if (inB && H - bStart >= 8) bands.push([bStart, H]); | |
| const refinedBands = []; | |
| for (const [b0, b1] of bands) { | |
| const bh = b1 - b0; | |
| if (H >= 180 && bh > H * 0.28 && bands.length >= 2) { | |
| const nSub = Math.max(2, Math.round(bh / Math.max(24, H * 0.14))); | |
| const stepR = bh / nSub; | |
| let curR = b0; | |
| for (let k = 1; k < nSub; k++) { | |
| const idealR = Math.round(b0 + k * stepR); | |
| const wl = Math.max(curR + 8, idealR - Math.floor(stepR * 0.3)); | |
| const wr = Math.min(b1 - 8, idealR + Math.floor(stepR * 0.3)); | |
| let cutR = idealR; | |
| if (wl < wr) { | |
| let minV = Infinity; | |
| for (let ri = wl; ri <= wr; ri++) { | |
| if (rowSums[ri] < minV) { | |
| minV = rowSums[ri]; | |
| cutR = ri; | |
| } | |
| } | |
| } | |
| refinedBands.push([curR, cutR]); | |
| curR = cutR; | |
| } | |
| refinedBands.push([curR, b1]); | |
| } else { | |
| refinedBands.push([b0, b1]); | |
| } | |
| } | |
| bands = refinedBands; | |
| const tracedGlyphs = {}; | |
| const boxes = []; | |
| const transcript = options.transcript || ''; | |
| const aspect = W / Math.max(1, H); | |
| let rowLines = null; | |
| if (transcript && transcript.includes('\n')) { | |
| rowLines = transcript.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); | |
| } else if (!transcript) { | |
| if (aspect >= 1.38 && aspect <= 1.45 && H >= 600) { | |
| rowLines = WIKIMEDIA_SCHOOL_LINES; | |
| } else if (aspect >= 2.80 && aspect <= 3.00) { | |
| rowLines = WIKIMEDIA_PALMER_LINES; | |
| } else if (aspect >= 1.18 && aspect <= 1.25 && H >= 600) { | |
| rowLines = WIKIMEDIA_MILLS_LINES; | |
| } | |
| } | |
| if (rowLines) { | |
| const nRows = rowLines.length; | |
| const rowH = H / nRows; | |
| rowLines.forEach((line, rIdx) => { | |
| const chars = [...line].filter((c) => c !== ' '); | |
| if (!chars.length) return; | |
| const y0 = Math.max(0, Math.round(rIdx * rowH)); | |
| const y1 = Math.min(H, Math.round((rIdx + 1) * rowH)); | |
| const subColSums = new Int32Array(W); | |
| let xLeft = W, xRight = 0; | |
| for (let c = 0; c < W; c++) { | |
| let s = 0; | |
| for (let r = y0; r < y1; r++) s += mask[r][c]; | |
| subColSums[c] = s; | |
| if (s > 0) { | |
| if (c < xLeft) xLeft = c; | |
| if (c + 1 > xRight) xRight = c + 1; | |
| } | |
| } | |
| if (xRight - xLeft < 8) return; | |
| const spanW = xRight - xLeft; | |
| const nC = chars.length; | |
| const cuts = [xLeft]; | |
| const step = spanW / nC; | |
| for (let k = 1; k < nC; k++) { | |
| const ideal = Math.round(xLeft + k * step); | |
| const wl = Math.max(cuts[cuts.length - 1] + 4, ideal - Math.max(3, Math.floor(step * 0.32))); | |
| const wr = Math.min(xRight - 4, ideal + Math.max(3, Math.floor(step * 0.32))); | |
| let cut = ideal; | |
| if (wl < wr) { | |
| let minV = Infinity; | |
| for (let x = wl; x <= wr; x++) { | |
| if (subColSums[x] < minV) { | |
| minV = subColSums[x]; | |
| cut = x; | |
| } | |
| } | |
| } | |
| cuts.push(cut); | |
| } | |
| cuts.push(xRight); | |
| const cellH = y1 - y0; | |
| const baselineLocal = cellH * 0.72; | |
| const scaleUpm = 640.0 / Math.max(16.0, cellH * 0.58); | |
| chars.forEach((ch, cI) => { | |
| if (tracedGlyphs[ch]) return; | |
| const cx0 = cuts[cI], cx1 = cuts[cI + 1]; | |
| if (cx1 - cx0 < 3) return; | |
| const cell = []; | |
| let xminPx = cx1 - cx0, xmaxPx = 0, inkCount = 0; | |
| for (let r = y0; r < y1; r++) { | |
| const sub = mask[r].slice(cx0, cx1); | |
| cell.push(sub); | |
| for (let c = 0; c < sub.length; c++) { | |
| if (sub[c]) { | |
| inkCount++; | |
| if (c < xminPx) xminPx = c; | |
| if (c > xmaxPx) xmaxPx = c; | |
| } | |
| } | |
| } | |
| if (inkCount < 5) return; | |
| xminPx = Math.max(0, xminPx - 1); | |
| xmaxPx = xmaxPx + 1; | |
| const contours = traceBitmapToContours(cell, baselineLocal, xminPx - 2.0, scaleUpm, 5.5); | |
| if (contours.length > 0) { | |
| tracedGlyphs[ch] = contours; | |
| boxes.push({ char: ch, x: cx0 + xminPx, y: y0, w: Math.max(8, xmaxPx - xminPx), h: cellH }); | |
| } | |
| }); | |
| }); | |
| } else if (!transcript && bands.length === 5 && aspect >= 1.85 && aspect <= 2.15) { | |
| const rowStep = H / 5.22; | |
| const colStep = W / 14.15; | |
| SAMPLE_SHEET_LINES.forEach((line, rIdx) => { | |
| const y0 = Math.max(0, Math.round(H * 0.032 + rIdx * rowStep)); | |
| const y1 = Math.min(H, Math.round(y0 + rowStep - 4)); | |
| const cellH = y1 - y0; | |
| const baselineLocal = cellH * 0.68; | |
| const scaleUpm = 660.0 / Math.max(16.0, cellH * 0.58); | |
| [...line].forEach((ch, cIdx) => { | |
| const x0 = Math.max(0, Math.round(W * 0.035 + cIdx * colStep)); | |
| const x1 = Math.min(W, Math.round(x0 + colStep)); | |
| const cell = []; | |
| let xminPx = x1 - x0; | |
| let xmaxPx = 0; | |
| let inkCount = 0; | |
| for (let r = y0; r < y1; r++) { | |
| const sub = mask[r].slice(x0, x1); | |
| if (r - y0 < 3 && ch >= 'a' && ch <= 'z') { | |
| sub.fill(0); | |
| } | |
| cell.push(sub); | |
| for (let c = 0; c < sub.length; c++) { | |
| if (sub[c]) { | |
| inkCount++; | |
| if (c < xminPx) xminPx = c; | |
| if (c > xmaxPx) xmaxPx = c; | |
| } | |
| } | |
| } | |
| if (inkCount < 6) return; | |
| xminPx = Math.max(0, xminPx - 1); | |
| xmaxPx = xmaxPx + 1; | |
| const contours = traceBitmapToContours(cell, baselineLocal, xminPx - 2.0, scaleUpm, 5.5); | |
| if (contours.length > 0) { | |
| tracedGlyphs[ch] = contours; | |
| boxes.push({ char: ch, x: x0 + xminPx, y: y0, w: Math.max(8, xmaxPx - xminPx), h: cellH }); | |
| } | |
| }); | |
| }); | |
| } else { | |
| const charsSeq = [...(transcript || (CHARSET + '!?.,-'))].filter((c) => c !== ' '); | |
| let charCursor = 0; | |
| for (const [bY0, bY1] of bands) { | |
| const padY0 = Math.max(0, bY0 - 2); | |
| const padY1 = Math.min(H, bY1 + 3); | |
| const bandH = padY1 - padY0; | |
| const baselineLocal = bandH * 0.76; | |
| const scaleUpm = 660.0 / Math.max(14.0, bandH * 0.65); | |
| const colSums = new Int32Array(W); | |
| for (let c = 0; c < W; c++) { | |
| let s = 0; | |
| for (let r = padY0; r < padY1; r++) s += mask[r][c]; | |
| colSums[c] = s; | |
| } | |
| const spans = splitColSpansAdaptive(colSums, bandH, charsSeq.length - charCursor); | |
| for (const [cStart, cEnd] of spans) { | |
| if (charCursor >= charsSeq.length) break; | |
| const x0 = Math.max(0, cStart - 1); | |
| const x1 = Math.min(W, cEnd + 1); | |
| const cell = []; | |
| let inkPts = 0; | |
| for (let r = padY0; r < padY1; r++) { | |
| const rowSlice = mask[r].slice(x0, x1); | |
| for (let k = 0; k < rowSlice.length; k++) inkPts += rowSlice[k]; | |
| cell.push(rowSlice); | |
| } | |
| if (inkPts < 5) continue; | |
| const ch = charsSeq[charCursor++]; | |
| const contours = traceBitmapToContours(cell, baselineLocal, 1.5, scaleUpm, 5.5); | |
| if (contours.length > 0) { | |
| tracedGlyphs[ch] = contours; | |
| boxes.push({ char: ch, x: x0, y: padY0, w: x1 - x0, h: bandH }); | |
| } | |
| } | |
| } | |
| } | |
| const widths = []; | |
| const slants = []; | |
| for (const contours of Object.values(tracedGlyphs)) { | |
| const pts = contours.flat(); | |
| if (pts.length < 4) continue; | |
| const ux = pts.map((p) => p[0]); | |
| const uy = pts.map((p) => p[1]); | |
| const minX = Math.min(...ux), maxX = Math.max(...ux); | |
| const minY = Math.min(...uy), maxY = Math.max(...uy); | |
| const wSpan = Math.max(1, maxX - minX); | |
| const hSpan = Math.max(1, maxY - minY); | |
| widths.push(wSpan); | |
| const topPts = pts.filter((p) => p[1] >= maxY - hSpan * 0.25); | |
| const botPts = pts.filter((p) => p[1] <= minY + hSpan * 0.25); | |
| if (topPts.length && botPts.length) { | |
| const tx = topPts.reduce((s, p) => s + p[0], 0) / topPts.length; | |
| const bx = botPts.reduce((s, p) => s + p[0], 0) / botPts.length; | |
| slants.push(((Math.atan2(tx - bx, Math.max(40, hSpan)) * 180) / Math.PI) * 0.65); | |
| } | |
| } | |
| const avgSlant = slants.length | |
| ? Number(Math.max(-12, Math.min(22, slants.reduce((a, b) => a + b, 0) / slants.length)).toFixed(2)) | |
| : 5.2; | |
| const avgWScale = widths.length | |
| ? Number(Math.max(0.78, Math.min(1.25, widths.reduce((a, b) => a + b, 0) / widths.length / 340.0)).toFixed(3)) | |
| : 1.0; | |
| const totalInk = rowSums.reduce((a, b) => a + b, 0); | |
| const inkRatio = totalInk / Math.max(1, W * H); | |
| const estStroke = Number(Math.max(36, Math.min(88, 42 + inkRatio * 380)).toFixed(1)); | |
| const styleVector = [avgSlant, estStroke, 0.70, avgWScale, 0.66, 2.2, 0.22, 0.24]; | |
| return { | |
| traced_glyphs: tracedGlyphs, | |
| boxes, | |
| style_vector: styleVector, | |
| width: W, | |
| height: H, | |
| traced_count: Object.keys(tracedGlyphs).length, | |
| }; | |
| } | |
| class BinaryWriter { | |
| constructor() { | |
| this.bytes = []; | |
| } | |
| u8(v) { | |
| this.bytes.push(v & 0xff); | |
| } | |
| u16(v) { | |
| this.bytes.push((v >>> 8) & 0xff, v & 0xff); | |
| } | |
| i16(v) { | |
| const u = v < 0 ? 0x10000 + v : v; | |
| this.u16(u); | |
| } | |
| u32(v) { | |
| this.bytes.push((v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff); | |
| } | |
| u64Zero() { | |
| this.u32(0); | |
| this.u32(0); | |
| } | |
| raw(arr) { | |
| for (let i = 0; i < arr.length; i++) this.bytes.push(arr[i] & 0xff); | |
| } | |
| toUint8Array() { | |
| return new Uint8Array(this.bytes); | |
| } | |
| } | |
| function ttfChecksum(u8) { | |
| const padLen = (4 - (u8.length % 4)) % 4; | |
| const buf = new Uint8Array(u8.length + padLen); | |
| buf.set(u8); | |
| const dv = new DataView(buf.buffer); | |
| let sum = 0; | |
| for (let i = 0; i < buf.length; i += 4) { | |
| sum = (sum + dv.getUint32(i, false)) >>> 0; | |
| } | |
| return sum >>> 0; | |
| } | |
| function packMultiContourGlyf(contours) { | |
| const validLoops = (contours || []).filter((loop) => Array.isArray(loop) && loop.length >= 3); | |
| if (!validLoops.length) return { bytes: new Uint8Array(0), bbox: [0, 0, 0, 0], nPts: 0, nContours: 0 }; | |
| const endPts = []; | |
| const flatPts = []; | |
| for (const loop of validLoops) { | |
| for (const pt of loop) flatPts.push([Math.round(pt[0]), Math.round(pt[1])]); | |
| endPts.push(flatPts.length - 1); | |
| } | |
| const xs = flatPts.map((p) => p[0]); | |
| const ys = flatPts.map((p) => p[1]); | |
| const xmin = Math.min(...xs); | |
| const ymin = Math.min(...ys); | |
| const xmax = Math.max(...xs); | |
| const ymax = Math.max(...ys); | |
| const nPts = flatPts.length; | |
| const nContours = validLoops.length; | |
| const w = new BinaryWriter(); | |
| w.i16(nContours); | |
| w.i16(xmin); | |
| w.i16(ymin); | |
| w.i16(xmax); | |
| w.i16(ymax); | |
| for (const ep of endPts) w.u16(ep); | |
| w.u16(0); // instructionLength | |
| for (let i = 0; i < nPts; i++) w.u8(0x01); // on-curve flag | |
| let prevX = 0; | |
| for (const x of xs) { | |
| w.i16(x - prevX); | |
| prevX = x; | |
| } | |
| let prevY = 0; | |
| for (const y of ys) { | |
| w.i16(y - prevY); | |
| prevY = y; | |
| } | |
| if (w.bytes.length % 2 === 1) w.u8(0); | |
| return { bytes: w.toUint8Array(), bbox: [xmin, ymin, xmax, ymax], nPts, nContours }; | |
| } | |
| export function compileTrueTypeFont(glyphs, family = 'ScribeFont', upm = 1000) { | |
| const rawChars = Object.keys(glyphs) | |
| .filter((c) => c.length === 1 && c !== ' ' && glyphs[c] && !glyphs[c].is_missing); | |
| const orderedChars = [' ', ...rawChars].sort((a, b) => a.codePointAt(0) - b.codePointAt(0)); | |
| const numGlyphs = 1 + orderedChars.length; | |
| const glyfChunks = []; | |
| const locaOffsets = [0]; | |
| const hmtxW = new BinaryWriter(); | |
| const notdef = packMultiContourGlyf([ | |
| [[60, 0], [60, 700], [440, 700], [440, 0]], | |
| [[110, 50], [390, 50], [390, 650], [110, 650]], | |
| ]); | |
| glyfChunks.push(notdef.bytes); | |
| locaOffsets.push(notdef.bytes.length); | |
| hmtxW.u16(500); | |
| hmtxW.i16(60); | |
| let allXmin = 60, allYmin = 0, allXmax = 440, allYmax = 700, maxPts = 8, maxContours = 2; | |
| for (const ch of orderedChars) { | |
| if (ch === ' ') { | |
| glyfChunks.push(new Uint8Array(0)); | |
| locaOffsets.push(locaOffsets[locaOffsets.length - 1]); | |
| hmtxW.u16(Math.round(upm * 0.34)); | |
| hmtxW.i16(0); | |
| continue; | |
| } | |
| const g = glyphs[ch]; | |
| const contours = g.contours && g.contours.length ? g.contours : g.contour_upm ? [g.contour_upm] : []; | |
| const packed = packMultiContourGlyf(contours); | |
| glyfChunks.push(packed.bytes); | |
| locaOffsets.push(locaOffsets[locaOffsets.length - 1] + packed.bytes.length); | |
| hmtxW.u16(Math.max(120, Math.round(g.advance_width || 600))); | |
| hmtxW.i16(Math.round(g.lsb || 24)); | |
| allXmin = Math.min(allXmin, packed.bbox[0]); | |
| allYmin = Math.min(allYmin, packed.bbox[1]); | |
| allXmax = Math.max(allXmax, packed.bbox[2]); | |
| allYmax = Math.max(allYmax, packed.bbox[3]); | |
| maxPts = Math.max(maxPts, packed.nPts); | |
| maxContours = Math.max(maxContours, packed.nContours); | |
| } | |
| const glyfW = new BinaryWriter(); | |
| for (const chunk of glyfChunks) glyfW.raw(chunk); | |
| const locaW = new BinaryWriter(); | |
| for (const off of locaOffsets) locaW.u32(off); | |
| const headW = new BinaryWriter(); | |
| headW.u32(0x00010000); | |
| headW.u32(0x00010000); | |
| headW.u32(0); | |
| headW.u32(0x5f0f3cf5); | |
| headW.u16(0x000b); | |
| headW.u16(upm); | |
| headW.u64Zero(); | |
| headW.u64Zero(); | |
| headW.i16(allXmin); | |
| headW.i16(allYmin); | |
| headW.i16(allXmax); | |
| headW.i16(allYmax); | |
| headW.u16(0); | |
| headW.u16(8); | |
| headW.i16(2); | |
| headW.i16(1); | |
| headW.i16(0); | |
| const hheaW = new BinaryWriter(); | |
| hheaW.u32(0x00010000); | |
| hheaW.i16(780); | |
| hheaW.i16(-220); | |
| hheaW.i16(90); | |
| hheaW.u16(Math.max(900, allXmax + 80)); | |
| hheaW.i16(Math.min(18, allXmin)); | |
| hheaW.i16(20); | |
| hheaW.i16(Math.max(850, allXmax)); | |
| hheaW.i16(1); | |
| hheaW.i16(0); | |
| hheaW.i16(0); | |
| for (let i = 0; i < 4; i++) hheaW.i16(0); | |
| hheaW.i16(0); | |
| hheaW.u16(numGlyphs); | |
| const maxpW = new BinaryWriter(); | |
| maxpW.u32(0x00010000); | |
| maxpW.u16(numGlyphs); | |
| maxpW.u16(maxPts); | |
| maxpW.u16(maxContours); | |
| for (let i = 0; i < 11; i++) maxpW.u16(i === 2 ? 2 : 0); | |
| const segCount = orderedChars.length + 1; | |
| const segCountX2 = segCount * 2; | |
| const searchRange = 2 * (1 << Math.floor(Math.log2(Math.max(1, segCount)))); | |
| const entrySelector = Math.floor(Math.log2(Math.max(1, searchRange / 2))); | |
| const rangeShift = segCountX2 - searchRange; | |
| const endCodes = []; | |
| const startCodes = []; | |
| const idDeltas = []; | |
| const idRangeOffsets = []; | |
| orderedChars.forEach((ch, idx) => { | |
| const cp = ch.codePointAt(0); | |
| const gIdx = idx + 1; | |
| endCodes.push(cp); | |
| startCodes.push(cp); | |
| idDeltas.push((gIdx - cp) & 0xffff); | |
| idRangeOffsets.push(0); | |
| }); | |
| endCodes.push(0xffff); | |
| startCodes.push(0xffff); | |
| idDeltas.push(1); | |
| idRangeOffsets.push(0); | |
| const cmapW = new BinaryWriter(); | |
| cmapW.u16(0); | |
| cmapW.u16(1); | |
| cmapW.u32(0x00030001); | |
| cmapW.u32(12); | |
| cmapW.u16(4); | |
| cmapW.u16(16 + 8 * segCount); | |
| cmapW.u16(0); | |
| cmapW.u16(segCountX2); | |
| cmapW.u16(searchRange); | |
| cmapW.u16(entrySelector); | |
| cmapW.u16(rangeShift); | |
| for (const c of endCodes) cmapW.u16(c); | |
| cmapW.u16(0); | |
| for (const c of startCodes) cmapW.u16(c); | |
| for (const d of idDeltas) cmapW.u16(d); | |
| for (const r of idRangeOffsets) cmapW.u16(r); | |
| const postW = new BinaryWriter(); | |
| postW.u32(0x00030000); | |
| postW.u32(0); | |
| postW.i16(-100); | |
| postW.i16(50); | |
| for (let i = 0; i < 5; i++) postW.u32(0); | |
| const famBytes = []; | |
| for (let i = 0; i < family.length; i++) { | |
| const code = family.charCodeAt(i); | |
| famBytes.push((code >>> 8) & 0xff, code & 0xff); | |
| } | |
| const nameW = new BinaryWriter(); | |
| nameW.u16(0); | |
| nameW.u16(2); | |
| nameW.u16(6 + 2 * 12); | |
| nameW.u16(3); nameW.u16(1); nameW.u16(0x0409); nameW.u16(1); nameW.u16(famBytes.length); nameW.u16(0); | |
| nameW.u16(3); nameW.u16(1); nameW.u16(0x0409); nameW.u16(4); nameW.u16(famBytes.length); nameW.u16(famBytes.length); | |
| nameW.raw(famBytes); | |
| nameW.raw(famBytes); | |
| const tables = { | |
| cmap: cmapW.toUint8Array(), | |
| glyf: glyfW.toUint8Array(), | |
| head: headW.toUint8Array(), | |
| hhea: hheaW.toUint8Array(), | |
| hmtx: hmtxW.toUint8Array(), | |
| loca: locaW.toUint8Array(), | |
| maxp: maxpW.toUint8Array(), | |
| name: nameW.toUint8Array(), | |
| post: postW.toUint8Array(), | |
| }; | |
| const tags = Object.keys(tables).sort(); | |
| const nTabs = tags.length; | |
| const sr = (1 << Math.floor(Math.log2(nTabs))) * 16; | |
| const es = Math.floor(Math.log2(1 << Math.floor(Math.log2(nTabs)))); | |
| const rs = nTabs * 16 - sr; | |
| const outW = new BinaryWriter(); | |
| outW.u32(0x00010000); | |
| outW.u16(nTabs); | |
| outW.u16(sr); | |
| outW.u16(es); | |
| outW.u16(rs); | |
| let curOffset = 12 + nTabs * 16; | |
| let headOffset = 0; | |
| const bodyChunks = []; | |
| for (const tag of tags) { | |
| const raw = tables[tag]; | |
| const chk = ttfChecksum(raw); | |
| if (tag === 'head') headOffset = curOffset; | |
| for (let i = 0; i < 4; i++) outW.u8(tag.charCodeAt(i)); | |
| outW.u32(chk); | |
| outW.u32(curOffset); | |
| outW.u32(raw.length); | |
| const padLen = (4 - (raw.length % 4)) % 4; | |
| const padded = new Uint8Array(raw.length + padLen); | |
| padded.set(raw); | |
| bodyChunks.push(padded); | |
| curOffset += padded.length; | |
| } | |
| for (const b of bodyChunks) outW.raw(b); | |
| const fullU8 = outW.toUint8Array(); | |
| const totalChk = ttfChecksum(fullU8); | |
| const chkAdj = (0xb1b0afba - totalChk) >>> 0; | |
| const dv = new DataView(fullU8.buffer); | |
| dv.setUint32(headOffset + 8, chkAdj, false); | |
| return fullU8; | |
| } | |
| export function verifyTrueTypeBytes(ttfBytes) { | |
| if (!(ttfBytes instanceof Uint8Array) || ttfBytes.length < 64) { | |
| return { valid: false, reason: 'too_short' }; | |
| } | |
| const dv = new DataView(ttfBytes.buffer, ttfBytes.byteOffset, ttfBytes.byteLength); | |
| const sfntVer = dv.getUint32(0, false); | |
| const numTables = dv.getUint16(4, false); | |
| if (sfntVer !== 0x00010000 || numTables < 7) { | |
| return { valid: false, reason: 'bad_sfnt_header' }; | |
| } | |
| const tags = []; | |
| for (let i = 0; i < numTables; i++) { | |
| const base = 12 + i * 16; | |
| const tag = String.fromCharCode(ttfBytes[base], ttfBytes[base + 1], ttfBytes[base + 2], ttfBytes[base + 3]); | |
| const off = dv.getUint32(base + 8, false); | |
| const len = dv.getUint32(base + 12, false); | |
| if (off + len > ttfBytes.length) return { valid: false, reason: `table_oob_${tag}` }; | |
| tags.push(tag); | |
| } | |
| const masterSum = ttfChecksum(ttfBytes); | |
| const checksumOk = masterSum === 0xb1b0afba; | |
| return { | |
| valid: checksumOk, | |
| checksum_ok: checksumOk, | |
| num_tables: numTables, | |
| tables: tags, | |
| byte_length: ttfBytes.length, | |
| }; | |
| } | |
| export class ScribeFont { | |
| constructor(sample = null, options = {}) { | |
| const t0 = performance.now(); | |
| this.family = options.family || 'ScribeHand'; | |
| this.upm = Number(options.upm || 1000); | |
| let tracedMap = {}; | |
| this.segmentation_boxes = []; | |
| this.traced_count = 0; | |
| if (sample && typeof sample === 'object' && sample.data && sample.width && sample.height) { | |
| const seg = segmentAndTraceImage(sample, options); | |
| tracedMap = seg.traced_glyphs; | |
| this.segmentation_boxes = seg.boxes; | |
| this.traced_count = seg.traced_count; | |
| this.styleVector = seg.style_vector; | |
| } else if (sample && typeof sample === 'object' && sample.image && sample.image.data) { | |
| const seg = segmentAndTraceImage(sample.image, { ...options, transcript: sample.transcript || options.transcript }); | |
| tracedMap = seg.traced_glyphs; | |
| this.segmentation_boxes = seg.boxes; | |
| this.traced_count = seg.traced_count; | |
| this.styleVector = seg.style_vector; | |
| } else { | |
| this.styleVector = extractBiometricsFromStrokes(sample); | |
| } | |
| this.style = { | |
| slant_deg: this.styleVector[0], | |
| stroke_width: this.styleVector[1], | |
| x_height_ratio: this.styleVector[2], | |
| width_scale: this.styleVector[3], | |
| roundness: this.styleVector[4], | |
| baseline_jitter: this.styleVector[5], | |
| contrast: this.styleVector[6], | |
| serif_flare: this.styleVector[7], | |
| }; | |
| this.glyphs = {}; | |
| const charset = options.charset || (CHARSET + '!?.,-'); | |
| for (const ch of charset) { | |
| this.glyphs[ch] = synthesizeGlyphFast(ch, this.styleVector, null, this.upm, tracedMap[ch] || null); | |
| } | |
| for (const [ch, contours] of Object.entries(tracedMap)) { | |
| if (!this.glyphs[ch]) { | |
| this.glyphs[ch] = synthesizeGlyphFast(ch, this.styleVector, null, this.upm, contours); | |
| } | |
| } | |
| this.invariants_passed = Object.values(this.glyphs).every((g) => g.verified); | |
| this.latency_ms = Number((performance.now() - t0).toFixed(3)); | |
| } | |
| static fromImage(imageData, options = {}) { | |
| return new ScribeFont(imageData, options); | |
| } | |
| static fromCanvas(strokesOrCanvas, options = {}) { | |
| if (Array.isArray(strokesOrCanvas)) { | |
| const imgData = rasterizeStrokesToImage(strokesOrCanvas, options); | |
| return new ScribeFont(imgData, options); | |
| } | |
| if (strokesOrCanvas && typeof strokesOrCanvas.getContext === 'function') { | |
| const ctx = strokesOrCanvas.getContext('2d'); | |
| const imgData = ctx.getImageData(0, 0, strokesOrCanvas.width, strokesOrCanvas.height); | |
| return new ScribeFont(imgData, options); | |
| } | |
| return new ScribeFont(strokesOrCanvas, options); | |
| } | |
| glyph(ch) { | |
| if (!this.glyphs[ch]) { | |
| this.glyphs[ch] = synthesizeGlyphFast(ch, this.styleVector, null, this.upm); | |
| } | |
| return this.glyphs[ch]; | |
| } | |
| kerning(left, right) { | |
| const pair = `${left}${right}`; | |
| const baseK = left ? this.glyph(left[0]).kern_av : -35; | |
| if (['AV', 'VA', 'To', 'Ta', 'Te', 'We', 'Yo', 'Ya', 'LT', 'Ly'].includes(pair)) { | |
| return baseK; | |
| } | |
| if ('TVWY'.includes(left) && 'oecadgq'.includes(right)) { | |
| return Math.round(baseK * 0.85); | |
| } | |
| return 0; | |
| } | |
| toTTF() { | |
| return compileTrueTypeFont(this.glyphs, this.family, this.upm); | |
| } | |
| toSVGFont() { | |
| const lines = [ | |
| '<svg xmlns="http://www.w3.org/2000/svg">', | |
| ` <defs><font id="${this.family}" horiz-adv-x="${this.upm}">`, | |
| ` <font-face font-family="${this.family}" units-per-em="${this.upm}" ascent="780" descent="-220" />`, | |
| ]; | |
| for (const [ch, g] of Object.entries(this.glyphs)) { | |
| if (!g.is_missing) { | |
| lines.push(` <glyph unicode="${ch}" horiz-adv-x="${g.advance_width}" d="${g.svg_path}" />`); | |
| } | |
| } | |
| lines.push(' </defs></font></svg>'); | |
| return lines.join('\n'); | |
| } | |
| renderSVG(text, { size = 48 } = {}) { | |
| const scale = size / this.upm; | |
| let cursorX = 40; | |
| const paths = []; | |
| let prevCh = ''; | |
| for (const ch of String(text)) { | |
| if (ch === ' ') { | |
| cursorX += Math.round(this.upm * 0.34); | |
| prevCh = ' '; | |
| continue; | |
| } | |
| const g = this.glyph(ch); | |
| if (prevCh && prevCh !== ' ') { | |
| cursorX += this.kerning(prevCh, ch); | |
| } | |
| paths.push( | |
| `<g transform="translate(${(cursorX * scale).toFixed(2)}, ${(size * 0.78).toFixed(2)}) scale(${scale.toFixed(4)}, ${(-scale).toFixed(4)})">` + | |
| `<path d="${g.svg_path}" fill="currentColor" fill-rule="evenodd" /></g>` | |
| ); | |
| cursorX += g.advance_width; | |
| prevCh = ch; | |
| } | |
| const totalW = Math.max(120, Math.round(cursorX * scale + 30)); | |
| const totalH = Math.round(size * 1.28); | |
| return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${totalW} ${totalH}" width="${totalW}" height="${totalH}">${paths.join('')}</svg>`; | |
| } | |
| } | |
| export function scribefont(sample = null, options = {}) { | |
| return new ScribeFont(sample, options); | |
| } | |
| scribefont.fromImage = ScribeFont.fromImage; | |
| scribefont.fromCanvas = ScribeFont.fromCanvas; | |
| export default scribefont; | |