convert-fonts.mjs
1 #!/usr/bin/env node 2 /** 3 * Convert TTF/OTF fonts to DWS SignalOS binary format (.epdfont) 4 * 5 * Usage: 6 * node convert-fonts.mjs my-font -r Regular.ttf -b Bold.ttf -i Italic.ttf 7 * node convert-fonts.mjs my-font -r Regular.ttf --size 16 8 * node convert-fonts.mjs my-font -r Regular.ttf -o /path/to/sdcard/fonts/ 9 * 10 * Requirements: 11 * npm install (installs opentype.js) 12 */ 13 14 import opentype from "opentype.js"; 15 import fs from "node:fs"; 16 import path from "node:path"; 17 import { parseArgs } from "node:util"; 18 import { fileURLToPath } from "node:url"; 19 20 const __filename = fileURLToPath(import.meta.url); 21 const __dirname = path.dirname(__filename); 22 23 const MAGIC = 0x46445045; // "EPDF" in little-endian 24 const VERSION = 1; 25 const DPI = 150; 26 27 // Unicode intervals 28 const INTERVALS_BASE = [ 29 [0x0000, 0x007f], // Basic Latin (ASCII) 30 [0x0080, 0x00ff], // Latin-1 Supplement 31 [0x0100, 0x017f], // Latin Extended-A 32 [0x0180, 0x024f], // Latin Extended-B (Vietnamese Ơ, Ư) 33 [0x1e00, 0x1eff], // Latin Extended Additional (Vietnamese tones) 34 [0x2000, 0x206f], // General Punctuation 35 [0x2010, 0x203a], // Dashes, quotes, prime marks 36 [0x2040, 0x205f], // Misc punctuation 37 [0x20a0, 0x20cf], // Currency symbols 38 [0x0300, 0x036f], // Combining Diacritical Marks 39 [0x0400, 0x04ff], // Cyrillic 40 [0x2200, 0x22ff], // Math operators 41 [0x2190, 0x21ff], // Arrows 42 ]; 43 44 // Thai script intervals 45 const INTERVALS_THAI = [ 46 [0x0e00, 0x0e7f], // Thai 47 ]; 48 49 // CJK intervals for --bin format (includes Vietnamese/Thai) 50 const INTERVALS_BIN_CJK = [ 51 [0x0000, 0x007f], // Basic Latin (ASCII) 52 [0x0080, 0x00ff], // Latin-1 Supplement 53 [0x0100, 0x017f], // Latin Extended-A 54 [0x0180, 0x024f], // Latin Extended-B (Vietnamese) 55 [0x0250, 0x02af], // IPA Extensions 56 [0x0300, 0x036f], // Combining Diacritical Marks 57 [0x0e00, 0x0e7f], // Thai 58 [0x1e00, 0x1eff], // Latin Extended Additional (Vietnamese tones) 59 [0x2000, 0x206f], // General Punctuation 60 [0x2010, 0x203a], // Dashes, quotes, prime marks 61 [0x20a0, 0x20cf], // Currency symbols 62 [0x3000, 0x303f], // CJK Symbols and Punctuation 63 [0x3040, 0x309f], // Hiragana 64 [0x30a0, 0x30ff], // Katakana 65 [0x4e00, 0x9fff], // CJK Unified Ideographs (20,992 chars) 66 [0xff00, 0xff5f], // Fullwidth forms 67 ]; 68 69 /** 70 * Scanline rasterizer for opentype.js paths - renders glyph to 8-bit grayscale with 4x supersampling 71 */ 72 class GlyphRasterizer { 73 constructor(font, fontSize, variations = null) { 74 this.font = font; 75 this.fontSize = fontSize; 76 this.scale = (fontSize * DPI) / (72 * font.unitsPerEm); 77 this.variations = variations; 78 79 if (font.tables.fvar?.axes?.length > 0) { 80 const axisInfo = font.tables.fvar.axes.map(a => `${a.tag}(${a.minValue}-${a.defaultValue}-${a.maxValue})`); 81 console.log(` Variable font axes: ${axisInfo.join(", ")}`); 82 if (variations) { 83 console.log(` Applied variations: ${Object.entries(variations).map(([k, v]) => `${k}=${v}`).join(", ")}`); 84 } 85 } 86 } 87 88 renderGlyph(codePoint) { 89 const glyphIndex = this.font.charToGlyphIndex(String.fromCodePoint(codePoint)); 90 if (glyphIndex === 0 && codePoint !== 0) return null; 91 92 const glyph = this.font.glyphs.get(glyphIndex); 93 if (!glyph) return null; 94 95 const advanceWidth = Math.round((glyph.advanceWidth ?? 0) * this.scale); 96 const bbox = glyph.getBoundingBox(); 97 98 if (!bbox || (bbox.x1 === 0 && bbox.y1 === 0 && bbox.x2 === 0 && bbox.y2 === 0)) { 99 return { width: 0, height: 0, advanceX: advanceWidth, left: 0, top: 0, data: Buffer.alloc(0) }; 100 } 101 102 const x1 = Math.floor(bbox.x1 * this.scale); 103 const x2 = Math.ceil(bbox.x2 * this.scale); 104 const y1 = Math.floor(bbox.y1 * this.scale); 105 const y2 = Math.ceil(bbox.y2 * this.scale); 106 const width = Math.max(1, x2 - x1); 107 const height = Math.max(1, y2 - y1); 108 109 // 4x supersampling 110 const ssScale = 4; 111 const ssWidth = width * ssScale; 112 const ssHeight = height * ssScale; 113 const ssBuffer = new Uint8Array(ssWidth * ssHeight); 114 115 const pathOptions = this.variations ? { variation: this.variations } : {}; 116 const glyphPath = glyph.getPath(0, 0, this.fontSize * DPI / 72, pathOptions); 117 // offsetX shifts glyph left edge to 0; offsetY positions top of glyph at buffer top (with Y-flip) 118 this.rasterizePath(glyphPath, ssBuffer, ssWidth, ssHeight, -x1 * ssScale, y2 * ssScale); 119 120 // Downsample 121 const buffer = new Uint8Array(width * height); 122 for (let y = 0; y < height; y++) { 123 for (let x = 0; x < width; x++) { 124 let sum = 0; 125 for (let sy = 0; sy < ssScale; sy++) { 126 for (let sx = 0; sx < ssScale; sx++) { 127 sum += ssBuffer[(y * ssScale + sy) * ssWidth + (x * ssScale + sx)]; 128 } 129 } 130 buffer[y * width + x] = Math.min(255, Math.round(sum / (ssScale * ssScale))); 131 } 132 } 133 134 return { width, height, advanceX: advanceWidth, left: x1, top: y2, data: Buffer.from(buffer) }; 135 } 136 137 rasterizePath(glyphPath, buffer, width, height, offsetX, offsetY) { 138 const edges = []; 139 let currentX = 0, currentY = 0, startX = 0, startY = 0; 140 const ssScale = 4; // Must match supersampling scale 141 142 for (const cmd of glyphPath.commands) { 143 switch (cmd.type) { 144 case "M": 145 currentX = startX = cmd.x * ssScale + offsetX; 146 currentY = startY = cmd.y * ssScale + offsetY; 147 break; 148 case "L": { 149 const x = cmd.x * ssScale + offsetX, y = cmd.y * ssScale + offsetY; 150 this.addEdge(edges, currentX, currentY, x, y); 151 currentX = x; currentY = y; 152 break; 153 } 154 case "Q": { 155 const x = cmd.x * ssScale + offsetX, y = cmd.y * ssScale + offsetY; 156 this.addCurve(edges, currentX, currentY, cmd.x1 * ssScale + offsetX, cmd.y1 * ssScale + offsetY, x, y, 8); 157 currentX = x; currentY = y; 158 break; 159 } 160 case "C": { 161 const x = cmd.x * ssScale + offsetX, y = cmd.y * ssScale + offsetY; 162 this.addCubicCurve(edges, currentX, currentY, cmd.x1 * ssScale + offsetX, cmd.y1 * ssScale + offsetY, cmd.x2 * ssScale + offsetX, cmd.y2 * ssScale + offsetY, x, y); 163 currentX = x; currentY = y; 164 break; 165 } 166 case "Z": 167 this.addEdge(edges, currentX, currentY, startX, startY); 168 currentX = startX; currentY = startY; 169 break; 170 } 171 } 172 173 edges.sort((a, b) => a.yMin - b.yMin); 174 175 for (let y = 0; y < height; y++) { 176 const scanY = y + 0.5; 177 const intersections = edges 178 .filter(e => e.yMin <= scanY && e.yMax > scanY) 179 .map(e => e.x1 + ((scanY - e.y1) * (e.x2 - e.x1)) / (e.y2 - e.y1)) 180 .sort((a, b) => a - b); 181 182 for (let i = 0; i < intersections.length - 1; i += 2) { 183 const xStart = Math.max(0, Math.floor(intersections[i])); 184 const xEnd = Math.min(width, Math.ceil(intersections[i + 1])); 185 for (let x = xStart; x < xEnd; x++) buffer[y * width + x] = 255; 186 } 187 } 188 } 189 190 addEdge(edges, x1, y1, x2, y2) { 191 if (Math.abs(y2 - y1) < 0.001) return; 192 if (y1 > y2) { [x1, x2] = [x2, x1]; [y1, y2] = [y2, y1]; } 193 edges.push({ x1, y1, x2, y2, yMin: y1, yMax: y2 }); 194 } 195 196 addCurve(edges, x0, y0, x1, y1, x2, y2, steps) { 197 let px = x0, py = y0; 198 for (let i = 1; i <= steps; i++) { 199 const t = i / steps, mt = 1 - t; 200 const x = mt * mt * x0 + 2 * mt * t * x1 + t * t * x2; 201 const y = mt * mt * y0 + 2 * mt * t * y1 + t * t * y2; 202 this.addEdge(edges, px, py, x, y); 203 px = x; py = y; 204 } 205 } 206 207 addCubicCurve(edges, x0, y0, x1, y1, x2, y2, x3, y3) { 208 let px = x0, py = y0; 209 for (let i = 1; i <= 12; i++) { 210 const t = i / 12, mt = 1 - t; 211 const x = mt * mt * mt * x0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t * x3; 212 const y = mt * mt * mt * y0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t * y3; 213 this.addEdge(edges, px, py, x, y); 214 px = x; py = y; 215 } 216 } 217 } 218 219 // Apply sRGB gamma correction for perceptually uniform gray levels on e-ink. 220 // Input: linear coverage 0-255, output: gamma-corrected 0-255. 221 function srgbGamma(value) { 222 const linear = value / 255.0; 223 const corrected = linear <= 0.0031308 224 ? linear * 12.92 225 : 1.055 * Math.pow(linear, 1.0 / 2.4) - 0.055; 226 return Math.round(corrected * 255.0); 227 } 228 229 function renderDownsampled(data, width, height, is2Bit) { 230 const bitsPerPixel = is2Bit ? 2 : 1; 231 const pixelsPerByte = 8 / bitsPerPixel; 232 const pixels = []; 233 let px = 0; 234 235 for (let i = 0; i < width * height; i++) { 236 const corrected = srgbGamma(data[i]); 237 const value = corrected >> 4; // Convert 8-bit to 4-bit 238 const quantized = is2Bit 239 ? (value >= 12 ? 3 : value >= 8 ? 2 : value >= 4 ? 1 : 0) 240 : (value >= 2 ? 1 : 0); 241 px = (px << bitsPerPixel) | quantized; 242 if (i % pixelsPerByte === pixelsPerByte - 1) { pixels.push(px); px = 0; } 243 } 244 245 const remainder = (width * height) % pixelsPerByte; 246 if (remainder !== 0) { 247 px <<= (pixelsPerByte - remainder) * bitsPerPixel; 248 pixels.push(px); 249 } 250 251 return Buffer.from(pixels); 252 } 253 254 function validateIntervals(font, intervals) { 255 const validIntervals = []; 256 for (const [iStart, iEnd] of intervals) { 257 let start = iStart; 258 for (let cp = iStart; cp <= iEnd; cp++) { 259 if (font.charToGlyphIndex(String.fromCodePoint(cp)) === 0 && cp !== 0) { 260 if (start < cp) validIntervals.push([start, cp - 1]); 261 start = cp + 1; 262 } 263 } 264 if (start <= iEnd) validIntervals.push([start, iEnd]); 265 } 266 return validIntervals; 267 } 268 269 /** 270 * Render all glyphs from a font - shared by binary and header output 271 */ 272 function renderAllGlyphs(font, rasterizer, intervals, is2Bit, progressLabel) { 273 const validIntervals = validateIntervals(font, [...intervals].sort((a, b) => a[0] - b[0])); 274 if (!validIntervals.length) return null; 275 276 const totalGlyphs = validIntervals.reduce((sum, [s, e]) => sum + (e - s + 1), 0); 277 const allGlyphs = []; 278 let totalBitmapSize = 0; 279 let processed = 0; 280 let lastPercent = -1; 281 282 for (const [iStart, iEnd] of validIntervals) { 283 for (let codePoint = iStart; codePoint <= iEnd; codePoint++) { 284 const glyph = rasterizer.renderGlyph(codePoint); 285 const width = glyph?.width ?? 0; 286 const height = glyph?.height ?? 0; 287 const pixelData = (width > 0 && height > 0 && glyph?.data) 288 ? renderDownsampled(glyph.data, width, height, is2Bit) 289 : Buffer.alloc(0); 290 291 allGlyphs.push({ 292 width, height, 293 advanceX: glyph?.advanceX ?? 0, 294 left: glyph?.left ?? 0, 295 top: glyph?.top ?? 0, 296 dataLength: pixelData.length, 297 dataOffset: totalBitmapSize, 298 codePoint, 299 pixelData, 300 }); 301 302 totalBitmapSize += pixelData.length; 303 processed++; 304 305 const percent = Math.floor((processed / totalGlyphs) * 100); 306 if (percent !== lastPercent && percent % 10 === 0) { 307 process.stdout.write(`\r ${progressLabel} (${percent}%)`); 308 lastPercent = percent; 309 } 310 } 311 } 312 process.stdout.write("\r" + " ".repeat(80) + "\r"); 313 314 const scale = rasterizer.scale; 315 return { 316 glyphs: allGlyphs, 317 validIntervals, 318 totalBitmapSize, 319 metrics: { 320 advanceY: Math.ceil((font.ascender - font.descender) * scale), 321 ascender: Math.ceil(font.ascender * scale), 322 descender: Math.floor(font.descender * scale), 323 }, 324 }; 325 } 326 327 function convertFont(fontPath, outputPath, size, is2Bit, intervals, variations = null) { 328 if (!fs.existsSync(fontPath)) { 329 console.error(` Warning: Font file not found: ${fontPath}`); 330 return false; 331 } 332 333 const label = `Converting: ${path.basename(fontPath)} -> ${path.basename(outputPath)}`; 334 console.log(` ${label}`); 335 336 try { 337 const font = opentype.loadSync(fontPath); 338 const rasterizer = new GlyphRasterizer(font, size, variations); 339 const result = renderAllGlyphs(font, rasterizer, intervals, is2Bit, label); 340 341 if (!result) { 342 console.error(" Error: No valid glyphs found"); 343 return false; 344 } 345 346 const { glyphs, validIntervals, totalBitmapSize, metrics } = result; 347 348 // Build binary file 349 fs.mkdirSync(path.dirname(outputPath), { recursive: true }); 350 351 const headerSize = 16, metricsSize = 18; 352 const intervalsSize = validIntervals.length * 12; 353 const glyphsSize = glyphs.length * 14; 354 const buffer = Buffer.alloc(headerSize + metricsSize + intervalsSize + glyphsSize + totalBitmapSize); 355 let offset = 0; 356 357 // Header 358 buffer.writeUInt32LE(MAGIC, offset); offset += 4; 359 buffer.writeUInt16LE(VERSION, offset); offset += 2; 360 buffer.writeUInt16LE(is2Bit ? 0x01 : 0x00, offset); offset += 2; 361 offset += 8; // reserved 362 363 // Metrics 364 buffer.writeUInt8(metrics.advanceY & 0xff, offset++); 365 buffer.writeUInt8(0, offset++); // pad 366 buffer.writeInt16LE(metrics.ascender, offset); offset += 2; 367 buffer.writeInt16LE(metrics.descender, offset); offset += 2; 368 buffer.writeUInt32LE(validIntervals.length, offset); offset += 4; 369 buffer.writeUInt32LE(glyphs.length, offset); offset += 4; 370 buffer.writeUInt32LE(totalBitmapSize, offset); offset += 4; 371 372 // Intervals 373 let glyphOffset = 0; 374 for (const [iStart, iEnd] of validIntervals) { 375 buffer.writeUInt32LE(iStart, offset); offset += 4; 376 buffer.writeUInt32LE(iEnd, offset); offset += 4; 377 buffer.writeUInt32LE(glyphOffset, offset); offset += 4; 378 glyphOffset += iEnd - iStart + 1; 379 } 380 381 // Glyphs 382 for (const g of glyphs) { 383 buffer.writeUInt8(g.width, offset++); 384 buffer.writeUInt8(g.height, offset++); 385 buffer.writeUInt8(g.advanceX & 0xff, offset++); 386 buffer.writeUInt8(0, offset++); // pad 387 buffer.writeInt16LE(g.left, offset); offset += 2; 388 buffer.writeInt16LE(g.top, offset); offset += 2; 389 buffer.writeUInt16LE(g.dataLength, offset); offset += 2; 390 buffer.writeUInt32LE(g.dataOffset, offset); offset += 4; 391 } 392 393 // Bitmap data 394 for (const g of glyphs) { 395 g.pixelData.copy(buffer, offset); 396 offset += g.pixelData.length; 397 } 398 399 fs.writeFileSync(outputPath, buffer); 400 console.log(` Created: ${outputPath} (${buffer.length} bytes)`); 401 return true; 402 } catch (error) { 403 console.error(` Error: ${error.message}`); 404 return false; 405 } 406 } 407 408 function convertFontToHeader(fontPath, outputPath, size, is2Bit, intervals, headerName, variations = null) { 409 if (!fs.existsSync(fontPath)) { 410 console.error(` Warning: Font file not found: ${fontPath}`); 411 return false; 412 } 413 414 const label = `Converting: ${path.basename(fontPath)} -> ${path.basename(outputPath)}`; 415 console.log(` ${label}`); 416 417 try { 418 const font = opentype.loadSync(fontPath); 419 const rasterizer = new GlyphRasterizer(font, size, variations); 420 const result = renderAllGlyphs(font, rasterizer, intervals, is2Bit, label); 421 422 if (!result) { 423 console.error(" Error: No valid glyphs found"); 424 return false; 425 } 426 427 const { glyphs, validIntervals, totalBitmapSize, metrics } = result; 428 fs.mkdirSync(path.dirname(outputPath), { recursive: true }); 429 430 const lines = [ 431 "/**", 432 " * generated by convert-fonts.mjs", 433 ` * name: ${headerName}`, 434 ` * size: ${size}`, 435 ` * mode: ${is2Bit ? "2-bit" : "1-bit"}`, 436 " */", 437 "#pragma once", 438 '#include "EpdFontData.h"', 439 "", 440 ]; 441 442 // Bitmap array 443 const combinedBitmap = Buffer.concat(glyphs.map(g => g.pixelData)); 444 lines.push(`static const uint8_t PROGMEM ${headerName}Bitmaps[${totalBitmapSize}] = {`); 445 for (let i = 0; i < combinedBitmap.length; i += 19) { 446 const chunk = combinedBitmap.slice(i, Math.min(i + 19, combinedBitmap.length)); 447 const hex = Array.from(chunk).map(b => `0x${b.toString(16).toUpperCase().padStart(2, "0")}`).join(", "); 448 lines.push(` ${hex}${i + 19 >= combinedBitmap.length ? "" : ","}`); 449 } 450 lines.push("};", ""); 451 452 // Glyphs array 453 const getCharComment = (cp) => { 454 if (cp < 0x20 || cp === 0x5c || cp === 0x2a) return cp === 0x5c ? " // \\\\" : cp === 0x2a ? " // *" : ""; 455 try { 456 const char = String.fromCodePoint(cp); 457 return /[\p{C}\p{Z}]/u.test(char) && cp !== 0x20 ? "" : ` // ${char}`; 458 } catch { return ""; } 459 }; 460 461 lines.push(`static const EpdGlyph PROGMEM ${headerName}Glyphs[] = {`); 462 for (const g of glyphs) { 463 lines.push(` {${g.width}, ${g.height}, ${g.advanceX}, ${g.left}, ${g.top}, ${g.dataLength}, ${g.dataOffset}},${getCharComment(g.codePoint)}`); 464 } 465 lines.push("};", ""); 466 467 // Intervals array 468 lines.push(`static const EpdUnicodeInterval PROGMEM ${headerName}Intervals[] = {`); 469 let glyphOffset = 0; 470 const intervalEntries = validIntervals.map(([s, e]) => { 471 const entry = `{0x${s.toString(16)}, 0x${e.toString(16)}, 0x${glyphOffset.toString(16)}}`; 472 glyphOffset += e - s + 1; 473 return entry; 474 }); 475 for (let i = 0; i < intervalEntries.length; i += 4) { 476 const chunk = intervalEntries.slice(i, Math.min(i + 4, intervalEntries.length)); 477 lines.push(` ${chunk.join(", ")}${i + 4 >= intervalEntries.length ? "" : ","}`); 478 } 479 lines.push("};", ""); 480 481 // Font struct 482 lines.push(`static const EpdFontData ${headerName} = {`); 483 lines.push(` ${headerName}Bitmaps, ${headerName}Glyphs, ${headerName}Intervals, ${validIntervals.length}, ${metrics.advanceY}, ${metrics.ascender}, ${metrics.descender}, ${is2Bit},`); 484 lines.push("};"); 485 486 fs.writeFileSync(outputPath, lines.join("\n") + "\n"); 487 console.log(` Created: ${outputPath} (${totalBitmapSize} bytes bitmap, ${glyphs.length} glyphs)`); 488 return true; 489 } catch (error) { 490 console.error(` Error: ${error.message}`); 491 return false; 492 } 493 } 494 495 // Sample characters for different scripts 496 const SAMPLE_CHARS_LATIN = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,;:!?'\"()-"; 497 const SAMPLE_CHARS_VIETNAMESE = "ÀÁÂÃÈÉÊÌÍÒÓÔÕÙÚÝàáâãèéêìíòóôõùúýĂăĐđĨĩŨũƠơƯưẠạẢảẤấẦầẨẩẪẫẬậẮắẰằẲẳẴẵẶặẸẹẺẻẼẽẾếỀềỂểỄễỆệỈỉỊịỌọỎỏỐốỒồỔổỖỗỘộỚớỜờỞởỠỡỢợỤụỦủỨứỪừỬửỮữỰựỲỳỴỵỶỷỸỹ"; 498 const SAMPLE_CHARS_THAI = "กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรลวศษสหฬอฮฤฦะัาำิีึืุูเแโใไๅๆ็่้๊๋์ํ๎๏๐๑๒๓๔๕๖๗๘๙"; 499 500 function generatePreview(fontPath, outputPath, size, variations = null, extraChars = "") { 501 if (!fs.existsSync(fontPath)) return false; 502 503 try { 504 const font = opentype.loadSync(fontPath); 505 const rasterizer = new GlyphRasterizer(font, size, variations); 506 507 const sampleChars = SAMPLE_CHARS_LATIN + extraChars; 508 const glyphs = [...sampleChars] 509 .map(char => { 510 const glyph = rasterizer.renderGlyph(char.codePointAt(0)); 511 return glyph?.width > 0 ? { char, ...glyph } : null; 512 }) 513 .filter(Boolean); 514 515 const escapeHtml = c => c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : c; 516 517 const html = `<!DOCTYPE html> 518 <html> 519 <head> 520 <meta charset="utf-8"> 521 <title>Font Preview: ${path.basename(fontPath)}</title> 522 <style> 523 body { font-family: monospace; background: #f0f0f0; padding: 20px; } 524 h1 { margin-bottom: 10px; } 525 .info { color: #666; margin-bottom: 20px; } 526 .glyphs { display: flex; flex-wrap: wrap; gap: 8px; background: white; padding: 20px; border-radius: 8px; } 527 .glyph { display: flex; flex-direction: column; align-items: center; padding: 8px; border: 1px solid #ddd; border-radius: 4px; } 528 .glyph canvas { image-rendering: pixelated; } 529 .glyph .char { font-size: 12px; color: #333; margin-top: 4px; } 530 .glyph .metrics { font-size: 10px; color: #999; } 531 </style> 532 </head> 533 <body> 534 <h1>Font Preview: ${path.basename(fontPath)}</h1> 535 <div class="info">Size: ${size}pt | Glyphs: ${glyphs.length}</div> 536 <div class="glyphs"> 537 ${glyphs.map(g => ` <div class="glyph"> 538 <canvas width="${g.width}" height="${g.height}" data-top="${g.top}" data-pixels="data:application/octet-stream;base64,${g.data.toString("base64")}" style="width: ${g.width * 2}px; height: ${g.height * 2}px;"></canvas> 539 <span class="char">${escapeHtml(g.char)}</span> 540 <span class="metrics">${g.width}x${g.height}</span> 541 </div>`).join("\n")} 542 </div> 543 <script> 544 document.querySelectorAll('canvas[data-pixels]').forEach(canvas => { 545 const ctx = canvas.getContext('2d'); 546 const w = canvas.width, h = canvas.height; 547 const data = atob(canvas.dataset.pixels.split(',')[1]); 548 const imgData = ctx.createImageData(w, h); 549 for (let i = 0; i < data.length && i < w * h; i++) { 550 const v = 255 - data.charCodeAt(i); 551 imgData.data[i * 4] = imgData.data[i * 4 + 1] = imgData.data[i * 4 + 2] = v; 552 imgData.data[i * 4 + 3] = 255; 553 } 554 ctx.putImageData(imgData, 0, 0); 555 }); 556 </script> 557 </body> 558 </html>`; 559 560 fs.writeFileSync(outputPath, html); 561 console.log(` Preview: ${outputPath}`); 562 return true; 563 } catch (error) { 564 console.error(` Preview error: ${error.message}`); 565 return false; 566 } 567 } 568 569 /** 570 * Convert font to raw .bin format for ExternalFont (direct Unicode indexing) 571 * Output format: FontName_size_WxH.bin 572 * Each glyph is stored at offset = codepoint * bytesPerChar 573 * Glyph data is 1-bit packed bitmap, MSB first 574 */ 575 function convertFontToBin(fontPath, outputPath, size, intervals, variations = null) { 576 if (!fs.existsSync(fontPath)) { 577 console.error(` Warning: Font file not found: ${fontPath}`); 578 return false; 579 } 580 581 const label = `Converting to .bin: ${path.basename(fontPath)}`; 582 console.log(` ${label}`); 583 584 try { 585 const font = opentype.loadSync(fontPath); 586 const rasterizer = new GlyphRasterizer(font, size, variations); 587 588 // Find max codepoint from intervals 589 const maxCodepoint = Math.max(...intervals.map(([, end]) => end)); 590 console.log(` Max codepoint: U+${maxCodepoint.toString(16).toUpperCase()} (${maxCodepoint})`); 591 592 // Render a sample glyph to determine dimensions 593 let charWidth = 0, charHeight = 0; 594 const sampleChars = [0x4e2d, 0x56fd, 0x6587, 'A'.charCodeAt(0), 'W'.charCodeAt(0)]; // CJK + Latin 595 for (const cp of sampleChars) { 596 const glyph = rasterizer.renderGlyph(cp); 597 if (glyph && glyph.width > 0) { 598 charWidth = Math.max(charWidth, glyph.width + Math.abs(glyph.left)); 599 charHeight = Math.max(charHeight, glyph.height); 600 } 601 } 602 603 // Add padding for consistent cell size 604 charWidth = Math.ceil(charWidth * 1.1); 605 charHeight = Math.ceil(charHeight * 1.1); 606 607 const bytesPerRow = Math.ceil(charWidth / 8); 608 const bytesPerChar = bytesPerRow * charHeight; 609 610 console.log(` Cell size: ${charWidth}x${charHeight}, ${bytesPerChar} bytes/char`); 611 612 // Create output buffer (direct indexed by codepoint) 613 const totalSize = (maxCodepoint + 1) * bytesPerChar; 614 console.log(` Output size: ${(totalSize / 1024 / 1024).toFixed(1)} MB`); 615 616 const buffer = Buffer.alloc(totalSize, 0); 617 618 // Render all glyphs in intervals 619 let rendered = 0, total = 0; 620 for (const [start, end] of intervals) { 621 total += end - start + 1; 622 } 623 624 let lastPercent = -1; 625 for (const [start, end] of intervals) { 626 for (let cp = start; cp <= end; cp++) { 627 const glyph = rasterizer.renderGlyph(cp); 628 const offset = cp * bytesPerChar; 629 630 if (glyph && glyph.width > 0 && glyph.height > 0 && glyph.data) { 631 // Center glyph in cell 632 const xOffset = Math.max(0, Math.floor((charWidth - glyph.width) / 2) + glyph.left); 633 const yOffset = Math.max(0, Math.floor((charHeight - glyph.height) / 2)); 634 635 // Convert 8-bit grayscale to 1-bit packed 636 for (let glyphY = 0; glyphY < glyph.height; glyphY++) { 637 const cellY = yOffset + glyphY; 638 if (cellY >= charHeight) continue; 639 640 for (let glyphX = 0; glyphX < glyph.width; glyphX++) { 641 const cellX = xOffset + glyphX; 642 if (cellX >= charWidth || cellX < 0) continue; 643 644 const srcIdx = glyphY * glyph.width + glyphX; 645 const gray = glyph.data[srcIdx]; 646 647 // Threshold at 128 for 1-bit 648 if (gray >= 128) { 649 const byteIdx = offset + cellY * bytesPerRow + Math.floor(cellX / 8); 650 const bitIdx = 7 - (cellX % 8); 651 buffer[byteIdx] |= (1 << bitIdx); 652 } 653 } 654 } 655 } 656 657 rendered++; 658 const percent = Math.floor((rendered / total) * 100); 659 if (percent !== lastPercent && percent % 10 === 0) { 660 process.stdout.write(`\r ${label} (${percent}%)`); 661 lastPercent = percent; 662 } 663 } 664 } 665 process.stdout.write("\r" + " ".repeat(80) + "\r"); 666 667 // Generate output filename: FontName_size_WxH.bin 668 const fontName = path.basename(fontPath).replace(/\.(ttf|otf|ttc)$/i, "").replace(/[^a-zA-Z0-9]/g, ""); 669 const outputFilename = `${fontName}_${size}_${charWidth}x${charHeight}.bin`; 670 const fullOutputPath = path.join(outputPath, outputFilename); 671 672 fs.mkdirSync(path.dirname(fullOutputPath), { recursive: true }); 673 fs.writeFileSync(fullOutputPath, buffer); 674 console.log(` Created: ${fullOutputPath} (${(buffer.length / 1024 / 1024).toFixed(1)} MB)`); 675 676 return true; 677 } catch (error) { 678 console.error(` Error: ${error.message}`); 679 return false; 680 } 681 } 682 683 function main() { 684 const { values, positionals } = parseArgs({ 685 allowPositionals: true, 686 options: { 687 regular: { type: "string", short: "r" }, 688 bold: { type: "string", short: "b" }, 689 italic: { type: "string", short: "i" }, 690 output: { type: "string", short: "o", default: "." }, 691 size: { type: "string", short: "s", default: "16" }, 692 "2bit": { type: "boolean", default: false }, 693 "all-sizes": { type: "boolean", default: false }, 694 header: { type: "boolean", default: false }, 695 bin: { type: "boolean", default: false }, 696 var: { type: "string", multiple: true }, 697 preview: { type: "boolean", default: false }, 698 thai: { type: "boolean", default: false }, 699 help: { type: "boolean", short: "h", default: false }, 700 }, 701 }); 702 703 if (values.help || positionals.length === 0) { 704 console.log(` 705 Convert TTF/OTF fonts to DWS SignalOS binary format (.epdfont) 706 707 Usage: 708 node convert-fonts.mjs <family-name> -r <regular.ttf> [options] 709 710 Options: 711 -r, --regular Path to regular style TTF/OTF file (required) 712 -b, --bold Path to bold style TTF/OTF file 713 -i, --italic Path to italic style TTF/OTF file 714 -o, --output Output directory (default: current directory) 715 -s, --size Font size in points (default: 16) 716 --2bit Generate 2-bit grayscale (smoother but larger) 717 --all-sizes Generate all reader sizes (14, 16, 18pt) 718 --bin Output raw .bin format for CJK/Thai (streamed from SD card) 719 --header Output C header file instead of binary .epdfont 720 --var Variable font axis value (e.g., --var wght=700 --var wdth=100) 721 --preview Generate HTML preview of rendered glyphs 722 --thai Include Thai script characters (U+0E00-0E7F) 723 -h, --help Show this help message 724 725 Examples: 726 node convert-fonts.mjs my-font -r MyFont-Regular.ttf -b MyFont-Bold.ttf -i MyFont-Italic.ttf 727 node convert-fonts.mjs roboto -r Roboto-VariableFont_wdth,wght.ttf --var wght=400 728 node convert-fonts.mjs noto-sans-thai -r NotoSansThai-Regular.ttf --thai --all-sizes 729 node convert-fonts.mjs noto-sans-cjk -r NotoSansSC-Regular.ttf --bin --size 24 730 `); 731 process.exit(0); 732 } 733 734 const family = positionals[0]; 735 if (!values.regular) { 736 console.error("Error: Regular font (-r) is required"); 737 process.exit(1); 738 } 739 740 // Parse variations 741 let variations = null; 742 if (values.var?.length > 0) { 743 variations = {}; 744 for (const spec of values.var) { 745 const match = spec.match(/^(\w+)=(\d+(?:\.\d+)?)$/); 746 if (!match) { 747 console.error(`Error: Invalid --var format: ${spec} (use axis=value)`); 748 process.exit(1); 749 } 750 variations[match[1]] = parseFloat(match[2]); 751 } 752 } 753 754 const { output: outputBase, "2bit": is2Bit, header: outputHeader, bin: outputBin, preview: doPreview } = values; 755 const baseSize = parseInt(values.size, 10); 756 if (isNaN(baseSize) || baseSize <= 0) { 757 console.error("Error: Invalid font size"); 758 process.exit(1); 759 } 760 761 // Handle --bin mode (direct Unicode indexed format for ExternalFont) 762 if (outputBin) { 763 console.log(`Converting to .bin format: ${family}`); 764 console.log(`Output directory: ${outputBase}`); 765 console.log(`Font size: ${baseSize}pt`); 766 if (variations) console.log(`Variable font: ${Object.entries(variations).map(([k, v]) => `${k}=${v}`).join(", ")}`); 767 console.log(); 768 769 const success = convertFontToBin(values.regular, outputBase, baseSize, INTERVALS_BIN_CJK, variations); 770 771 if (success) { 772 console.log("\nTo use this font:"); 773 console.log("1. Copy the .bin file to /config/fonts/ on your SD card"); 774 console.log("2. Load in app: FONT_MANAGER.loadExternalFont(\"filename.bin\");"); 775 } 776 777 process.exit(success ? 0 : 1); 778 } 779 780 // .epdfont mode - use base Latin character set, optionally with Thai 781 const intervals = [...INTERVALS_BASE]; 782 if (values.thai) { 783 intervals.push(...INTERVALS_THAI); 784 console.log("Including Thai script (U+0E00-0E7F)"); 785 } 786 787 console.log(`Converting font family: ${family}`); 788 console.log(`Output directory: ${outputBase}`); 789 console.log(`Font size: ${baseSize}pt`); 790 if (is2Bit) console.log("Mode: 2-bit grayscale"); 791 if (outputHeader) console.log("Output: C header files"); 792 if (variations) console.log(`Variable font: ${Object.entries(variations).map(([k, v]) => `${k}=${v}`).join(", ")}`); 793 console.log(); 794 795 const styles = [["regular", values.regular], ["bold", values.bold], ["italic", values.italic]]; 796 const sizes = values["all-sizes"] ? [14, 16, 18] : [baseSize]; 797 let successCount = 0, totalCount = 0; 798 799 for (const size of sizes) { 800 const familyDir = values["all-sizes"] ? path.join(outputBase, `${family}-${size}`) : path.join(outputBase, family); 801 if (values["all-sizes"]) console.log(`Size: ${size}pt -> ${path.basename(familyDir)}/`); 802 803 for (const [styleName, fontPath] of styles) { 804 if (!fontPath) continue; 805 totalCount++; 806 807 const outputFile = outputHeader 808 ? path.join(outputBase, `${family.replace(/-/g, "_")}_${styleName}${values["all-sizes"] ? `_${size}` : ""}_2b.h`) 809 : path.join(familyDir, `${styleName}.epdfont`); 810 811 const success = outputHeader 812 ? convertFontToHeader(fontPath, outputFile, size, is2Bit, intervals, path.basename(outputFile, ".h"), variations) 813 : convertFont(fontPath, outputFile, size, is2Bit, intervals, variations); 814 815 if (success) { 816 successCount++; 817 if (doPreview) { 818 // Include extra characters for Thai/Vietnamese fonts 819 let extraChars = ""; 820 if (values.thai) extraChars += SAMPLE_CHARS_THAI; 821 if (family.includes("vn") || family.includes("viet")) extraChars += SAMPLE_CHARS_VIETNAMESE; 822 generatePreview(fontPath, outputFile.replace(/\.(epdfont|h)$/, ".html"), size, variations, extraChars); 823 } 824 } 825 } 826 } 827 828 console.log(); 829 console.log(`Converted ${successCount}/${totalCount} fonts`); 830 831 if (successCount > 0) { 832 console.log("\nTo use this font in your theme, add to your .theme file:\n"); 833 console.log("[fonts]"); 834 for (const [name, sz] of [["small", 14], ["medium", 16], ["large", 18]]) { 835 console.log(`reader_font_${name} = ${values["all-sizes"] ? `${family}-${sz}` : family}`); 836 } 837 console.log("\nThen copy the font folder(s) to /config/fonts/ on your SD card."); 838 } 839 840 process.exit(successCount === totalCount ? 0 : 1); 841 } 842 843 main();