/ scripts / convert-fonts.mjs
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 === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "&" ? "&amp;" : 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();