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);