/ scripts / generate-font-samples.mjs
generate-font-samples.mjs
  1  #!/usr/bin/env node
  2  /**
  3   * Generate PNG sample images for converted fonts - e-reader style
  4   */
  5  
  6  import fs from "node:fs";
  7  import path from "node:path";
  8  import sharp from "sharp";
  9  
 10  const SAMPLE_WIDTH = 480;
 11  const SAMPLE_HEIGHT = 800;
 12  const LINE_HEIGHT = 32;
 13  const MARGIN_X = 40;
 14  const MARGIN_Y = 50;
 15  const TEXT_WIDTH = SAMPLE_WIDTH - 2 * MARGIN_X;
 16  
 17  const SAMPLE_TEXTS = {
 18    latin: {
 19      title: "Sample Text",
 20      text: `The quick brown fox jumps over the lazy dog. This sentence contains every letter of the alphabet and is commonly used to test fonts and keyboards.
 21  
 22  Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
 23  
 24  Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
 25  
 26  Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris.`
 27    },
 28    thai: {
 29      title: "ตัวอย่างข้อความ",
 30      text: `ภาษาไทยเป็นภาษาที่มีวรรณยุกต์ มีเสียงวรรณยุกต์ห้าเสียง และมีสระมากมาย การอ่านภาษาไทยต้องอาศัยการผสมพยัญชนะ สระ และวรรณยุกต์เข้าด้วยกัน
 31  
 32  นิทานเรื่องกระต่ายกับเต่า กาลครั้งหนึ่งนานมาแล้ว มีกระต่ายตัวหนึ่งชอบอวดตัวว่าวิ่งเร็ว วันหนึ่งมันท้าเต่าแข่งวิ่ง เต่าก็รับคำท้า
 33  
 34  กระต่ายวิ่งนำหน้าไปไกล แต่เพราะความประมาท มันจึงหยุดนอนพักผ่อน ส่วนเต่าค่อยๆ เดินไปเรื่อยๆ จนแซงกระต่ายไป
 35  
 36  เมื่อกระต่ายตื่นขึ้นมา มันพบว่าเต่าถึงเส้นชัยไปแล้ว นิทานเรื่องนี้สอนให้รู้ว่า ความพยายามอยู่ที่ไหน ความสำเร็จอยู่ที่นั่น`
 37    },
 38    vietnamese: {
 39      title: "Văn bản mẫu",
 40      text: `Tiếng Việt là ngôn ngữ chính thức của Việt Nam. Đây là ngôn ngữ có thanh điệu với sáu thanh khác nhau. Chữ viết tiếng Việt sử dụng bảng chữ cái Latinh với các dấu phụ.
 41  
 42  Truyện Kiều là tác phẩm văn học nổi tiếng nhất của Việt Nam, được viết bởi đại thi hào Nguyễn Du. Truyện kể về cuộc đời đầy bi kịch của nàng Thúy Kiều.
 43  
 44  Văn hóa Việt Nam rất phong phú và đa dạng, với nhiều lễ hội truyền thống được tổ chức quanh năm. Tết Nguyên Đán là ngày lễ quan trọng nhất trong năm.
 45  
 46  Ẩm thực Việt Nam nổi tiếng với phở, bánh mì, và bún chả. Các món ăn thường có sự kết hợp hài hòa giữa các vị chua, cay, mặn, ngọt.`
 47    }
 48  };
 49  
 50  function getSampleText(fontName) {
 51    if (fontName.includes('thai')) return SAMPLE_TEXTS.thai;
 52    if (fontName.includes('vn') || fontName.includes('viet')) return SAMPLE_TEXTS.vietnamese;
 53    return SAMPLE_TEXTS.latin;
 54  }
 55  
 56  async function loadGlyphs(folder) {
 57    const glyphs = {};
 58  
 59    for (const style of ['regular', 'bold', 'italic']) {
 60      const htmlPath = path.join(folder, `${style}.html`);
 61      if (!fs.existsSync(htmlPath)) continue;
 62  
 63      const html = fs.readFileSync(htmlPath, 'utf8');
 64      const glyphMatches = html.matchAll(/<canvas width="(\d+)" height="(\d+)" data-top="(-?\d+)" data-pixels="([^"]+)"[^>]*><\/canvas>\s*<span class="char">([^<]+)<\/span>/g);
 65  
 66      for (const match of glyphMatches) {
 67        const [, width, height, top, pixels, char] = match;
 68        glyphs[`${style}_${char}`] = {
 69          width: parseInt(width),
 70          height: parseInt(height),
 71          top: parseInt(top),
 72          pixels: pixels
 73        };
 74      }
 75    }
 76  
 77    return glyphs;
 78  }
 79  
 80  function decodeGlyph(glyph) {
 81    return Buffer.from(glyph.pixels.split(',')[1], 'base64');
 82  }
 83  
 84  function getGlyph(glyphs, char, style) {
 85    return glyphs[`${style}_${char}`] || glyphs[`regular_${char}`];
 86  }
 87  
 88  function measureWord(glyphs, word, style) {
 89    let width = 0;
 90    for (const char of word) {
 91      const g = getGlyph(glyphs, char, style);
 92      width += g ? g.width + 1 : 7;
 93    }
 94    return width;
 95  }
 96  
 97  const BASELINE_OFFSET = 24; // Baseline position within line height
 98  
 99  function renderGlyph(buffer, g, x, y) {
100    if (!g) return 7;
101  
102    const glyphData = decodeGlyph(g);
103    // Use baseline alignment: top metric indicates distance from baseline to glyph top
104    const yOffset = BASELINE_OFFSET - (g.top || 0);
105  
106    for (let gy = 0; gy < g.height; gy++) {
107      const destY = y + yOffset + gy;
108      if (destY < 0 || destY >= SAMPLE_HEIGHT) continue;
109  
110      for (let gx = 0; gx < g.width; gx++) {
111        const destX = Math.round(x) + gx;
112        if (destX < 0 || destX >= SAMPLE_WIDTH) continue;
113  
114        const srcIdx = gy * g.width + gx;
115        const dstIdx = destY * SAMPLE_WIDTH + destX;
116  
117        if (srcIdx < glyphData.length && dstIdx < buffer.length) {
118          const alpha = glyphData[srcIdx];
119          if (alpha > 0) {
120            buffer[dstIdx] = Math.round(buffer[dstIdx] * (1 - alpha / 255));
121          }
122        }
123      }
124    }
125  
126    return g.width + 1;
127  }
128  
129  async function renderSample(folder, outputPath, fontName) {
130    const glyphs = await loadGlyphs(folder);
131    if (Object.keys(glyphs).length === 0) {
132      console.log(`  No glyphs found for ${fontName}`);
133      return false;
134    }
135  
136    const buffer = Buffer.alloc(SAMPLE_WIDTH * SAMPLE_HEIGHT);
137    buffer.fill(255);
138  
139    // Get sample text based on font type
140    const { title, text } = getSampleText(fontName);
141  
142    // Get space width
143    const spaceG = getGlyph(glyphs, ' ', 'regular');
144    const spaceWidth = spaceG ? spaceG.width + 1 : 7;
145  
146    let y = MARGIN_Y;
147  
148    // Render title centered (use regular style if bold not available)
149    const titleStyle = glyphs['bold_A'] ? 'bold' : 'regular';
150    const titleWidth = measureWord(glyphs, title, titleStyle);
151    let x = MARGIN_X + (TEXT_WIDTH - titleWidth) / 2;
152    for (const char of title) {
153      const g = getGlyph(glyphs, char, titleStyle);
154      x += renderGlyph(buffer, g, x, y);
155    }
156    y += LINE_HEIGHT * 1.5;
157  
158    // Render paragraphs
159    const paragraphs = text.split('\n\n');
160  
161    for (const para of paragraphs) {
162      if (y > SAMPLE_HEIGHT - MARGIN_Y - LINE_HEIGHT) break;
163  
164      const words = para.trim().split(/\s+/);
165  
166      // Build lines
167      const lines = [];
168      let lineWords = [];
169      let lineWidth = 0;
170  
171      for (const word of words) {
172        const wordWidth = measureWord(glyphs, word, 'regular');
173  
174        if (lineWords.length > 0 && lineWidth + spaceWidth + wordWidth > TEXT_WIDTH) {
175          lines.push({ words: lineWords, width: lineWidth });
176          lineWords = [word];
177          lineWidth = wordWidth;
178        } else {
179          if (lineWords.length > 0) lineWidth += spaceWidth;
180          lineWords.push(word);
181          lineWidth += wordWidth;
182        }
183      }
184      if (lineWords.length > 0) {
185        lines.push({ words: lineWords, width: lineWidth, last: true });
186      }
187  
188      // Render lines with justification
189      for (const line of lines) {
190        if (y > SAMPLE_HEIGHT - MARGIN_Y - LINE_HEIGHT) break;
191  
192        x = MARGIN_X;
193  
194        // Calculate word spacing for justification
195        let wordGap = spaceWidth;
196        if (!line.last && line.words.length > 1) {
197          const totalWordWidth = line.width - (line.words.length - 1) * spaceWidth;
198          wordGap = (TEXT_WIDTH - totalWordWidth) / (line.words.length - 1);
199        }
200  
201        for (let i = 0; i < line.words.length; i++) {
202          for (const char of line.words[i]) {
203            const g = getGlyph(glyphs, char, 'regular');
204            x += renderGlyph(buffer, g, x, y);
205          }
206          if (i < line.words.length - 1) {
207            x += wordGap;
208          }
209        }
210  
211        y += LINE_HEIGHT;
212      }
213  
214      y += LINE_HEIGHT * 0.5; // Paragraph gap
215    }
216  
217    await sharp(buffer, { raw: { width: SAMPLE_WIDTH, height: SAMPLE_HEIGHT, channels: 1 } })
218      .png()
219      .toFile(outputPath);
220  
221    console.log(`  Created: ${outputPath}`);
222    return true;
223  }
224  
225  async function main() {
226    const samplesDir = path.resolve(process.argv[2] || 'theme_font_samples');
227  
228    if (!fs.existsSync(samplesDir)) {
229      console.error(`Directory not found: ${samplesDir}`);
230      process.exit(1);
231    }
232  
233    const fonts = fs.readdirSync(samplesDir)
234      .filter(f => fs.statSync(path.join(samplesDir, f)).isDirectory() && f.endsWith('-16'));
235  
236    console.log(`Generating samples for ${fonts.length} fonts...`);
237  
238    for (const fontDir of fonts) {
239      const fontName = fontDir.replace('-16', '');
240      console.log(`\n${fontName}:`);
241  
242      await renderSample(path.join(samplesDir, fontDir), path.join(samplesDir, `${fontName}-sample.png`), fontName);
243    }
244  
245    console.log('\nDone!');
246  }
247  
248  main().catch(console.error);