|
| 1 | +#!/usr/bin/env tsx |
| 2 | + |
| 3 | +import fs from 'fs'; |
| 4 | +import path from 'path'; |
| 5 | +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; |
| 6 | +import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent } from '../src/core/treeStructure'; |
| 7 | + |
| 8 | +interface Cell { |
| 9 | + text: string; |
| 10 | + row: number; |
| 11 | + col: number; |
| 12 | +} |
| 13 | + |
| 14 | +interface Grid { |
| 15 | + name: string; |
| 16 | + filename: string; |
| 17 | + rows: number; |
| 18 | + cols: number; |
| 19 | + cells: Cell[]; |
| 20 | + categories: string[]; |
| 21 | +} |
| 22 | + |
| 23 | +function parseGridFile(filePath: string, filename: string): Grid { |
| 24 | + const content = fs.readFileSync(filePath, 'utf-8'); |
| 25 | + const lines = content.split('\n'); |
| 26 | + const cells: Cell[] = []; |
| 27 | + const categories: string[] = []; |
| 28 | + let maxRows = 0; |
| 29 | + let maxCols = 0; |
| 30 | + let currentRow = 0; |
| 31 | + |
| 32 | + // Parse each line as a row of cells |
| 33 | + lines.forEach(line => { |
| 34 | + const trimmedLine = line.trim(); |
| 35 | + |
| 36 | + // Skip empty lines completely (OCR sometimes inserts blank lines) |
| 37 | + if (!trimmedLine) { |
| 38 | + return; |
| 39 | + } |
| 40 | + |
| 41 | + // Split by tabs to get individual cells, preserve empty cells between tabs |
| 42 | + const cellTexts = line.split('\t'); |
| 43 | + |
| 44 | + cellTexts.forEach((text, colIndex) => { |
| 45 | + const trimmedText = text.trim(); |
| 46 | + |
| 47 | + // Add cell even if empty (to preserve position) |
| 48 | + if (trimmedText !== '...' && trimmedText !== '') { |
| 49 | + cells.push({ |
| 50 | + text: trimmedText, |
| 51 | + row: currentRow, |
| 52 | + col: colIndex |
| 53 | + }); |
| 54 | + } |
| 55 | + maxCols = Math.max(maxCols, colIndex + 1); |
| 56 | + }); |
| 57 | + |
| 58 | + currentRow += 1; |
| 59 | + maxRows = Math.max(maxRows, currentRow); |
| 60 | + }); |
| 61 | + |
| 62 | + // Identify categories (words that appear in the rightmost columns or are known categories) |
| 63 | + const knownCategories = [ |
| 64 | + 'Mitteilungen', 'Fragen', 'Leute', 'Verben', 'Eigenschaften', 'Gefühle', |
| 65 | + 'Treffen', 'Lob', 'Beschwerde', 'Sprüche', 'Spielen', 'Multimedia', |
| 66 | + 'Essen', 'Trinken', 'Farben/Formen', 'Sport', 'Musik', 'Draußen', |
| 67 | + 'Fahrzeuge', 'Tiere', 'Pflanzen', 'Wetter', 'Buchstaben', 'Zahlen/Geld', |
| 68 | + 'Haus', 'Kleidung', 'Körper', 'Arztbesuch', 'Therapie', 'Tagesplan', |
| 69 | + 'Zeit', 'Schule', 'Buch', 'Basteln/Büro', 'Werken', 'Feste/Religion', |
| 70 | + 'Freizeit', 'Biografie', 'Berufe', 'Geografie', 'Politik', |
| 71 | + 'Dies und das', 'Wortbausteine' |
| 72 | + ]; |
| 73 | + |
| 74 | + cells.forEach(cell => { |
| 75 | + if (knownCategories.includes(cell.text)) { |
| 76 | + categories.push(cell.text); |
| 77 | + } |
| 78 | + }); |
| 79 | + |
| 80 | + // Strip extensions and any parent path prefix (e.g. "Home->Fragen.PNG.txt" -> "Fragen") |
| 81 | + const baseName = filename |
| 82 | + .replace(/\.txt$/i, '') |
| 83 | + .replace(/\.png$/i, ''); |
| 84 | + const parts = baseName.split('->'); |
| 85 | + const pageName = parts[parts.length - 1] || baseName; |
| 86 | + |
| 87 | + return { |
| 88 | + name: pageName, |
| 89 | + filename, |
| 90 | + rows: maxRows, |
| 91 | + cols: maxCols, |
| 92 | + cells, |
| 93 | + categories: [...new Set(categories)] |
| 94 | + }; |
| 95 | +} |
| 96 | + |
| 97 | +function createAACPage(grid: Grid): AACPage { |
| 98 | + const buttons: AACButton[] = []; |
| 99 | + // Build an explicit 2D grid so downstream exporters keep the shape (6×11 etc.) |
| 100 | + const gridLayout: (AACButton | null)[][] = Array.from({ length: grid.rows }, () => |
| 101 | + Array.from({ length: grid.cols }, () => null) |
| 102 | + ); |
| 103 | + |
| 104 | + grid.cells.forEach(cell => { |
| 105 | + const text = cell.text; |
| 106 | + const isCategory = grid.categories.includes(text); |
| 107 | + const isNavigation = text === 'Home' || text === 'weiter' || text === 'zurück'; |
| 108 | + |
| 109 | + // Ensure position is within bounds |
| 110 | + if (cell.row < grid.rows && cell.col < grid.cols) { |
| 111 | + const button: AACButton = { |
| 112 | + id: `${grid.name.toLowerCase().replace(/[^a-z]/g, '_')}_${cell.row}_${cell.col}`, |
| 113 | + label: text, |
| 114 | + style: { |
| 115 | + backgroundColor: isCategory ? '#4CAF50' : isNavigation ? '#2196F3' : '#FFFFFF', |
| 116 | + fontColor: isCategory || isNavigation ? '#FFFFFF' : '#000000', |
| 117 | + borderColor: '#CCCCCC', |
| 118 | + borderWidth: 1, |
| 119 | + }, |
| 120 | + position: { |
| 121 | + row: cell.row, |
| 122 | + col: cell.col, |
| 123 | + width: 1, |
| 124 | + height: 1, |
| 125 | + }, |
| 126 | + semanticAction: isCategory ? { |
| 127 | + category: AACSemanticCategory.NAVIGATION, |
| 128 | + intent: AACSemanticIntent.NAVIGATE_TO, |
| 129 | + targetId: text.toLowerCase().replace(/[^a-z]/g, '_'), |
| 130 | + text: text |
| 131 | + } : isNavigation ? { |
| 132 | + category: AACSemanticCategory.NAVIGATION, |
| 133 | + intent: text === 'Home' ? AACSemanticIntent.GO_HOME : AACSemanticIntent.GO_BACK, |
| 134 | + } : { |
| 135 | + category: AACSemanticCategory.COMMUNICATION, |
| 136 | + intent: AACSemanticIntent.SPEAK_IMMEDIATE, |
| 137 | + text: text |
| 138 | + } |
| 139 | + }; |
| 140 | + |
| 141 | + buttons.push(button); |
| 142 | + gridLayout[cell.row][cell.col] = button; |
| 143 | + } |
| 144 | + }); |
| 145 | + |
| 146 | + return { |
| 147 | + id: grid.name.toLowerCase().replace(/[^a-z]/g, '_'), |
| 148 | + name: grid.name, |
| 149 | + grid: gridLayout, |
| 150 | + buttons, |
| 151 | + style: { |
| 152 | + rows: grid.rows, |
| 153 | + cols: grid.cols, |
| 154 | + backgroundColor: '#F5F5F5', |
| 155 | + gap: 2, |
| 156 | + } |
| 157 | + }; |
| 158 | +} |
| 159 | + |
| 160 | +async function convertTextFilesToGridset() { |
| 161 | + console.log('=== TXT to Gridset Converter ===\n'); |
| 162 | + console.log('This script converts tab-separated text files to Grid3 format.\n'); |
| 163 | + |
| 164 | + // Default to the OCR outputs we generate from screenshots |
| 165 | + const baseDir = path.resolve(__dirname, '../examples/text-conversion'); |
| 166 | + const txtDir = path.join(baseDir, 'ocr-results'); |
| 167 | + if (!fs.existsSync(txtDir)) { |
| 168 | + console.log('Creating txt-files directory...'); |
| 169 | + fs.mkdirSync(txtDir, { recursive: true }); |
| 170 | + console.log('Please put your tab-separated text files in the txt-files directory.'); |
| 171 | + console.log('File format: Home.txt, Home->Fragen.txt, etc.'); |
| 172 | + return; |
| 173 | + } |
| 174 | + |
| 175 | + const files = fs.readdirSync(txtDir).filter(f => f.endsWith('.txt')); |
| 176 | + |
| 177 | + if (files.length === 0) { |
| 178 | + console.log('No .txt files found in txt-files directory.'); |
| 179 | + return; |
| 180 | + } |
| 181 | + |
| 182 | + console.log(`Found ${files.length} text files:\n`); |
| 183 | + |
| 184 | + const tree = new AACTree(); |
| 185 | + |
| 186 | + // Process each text file |
| 187 | + for (const file of files) { |
| 188 | + const filePath = path.join(txtDir, file); |
| 189 | + const grid = parseGridFile(filePath, file); |
| 190 | + const page = createAACPage(grid); |
| 191 | + |
| 192 | + tree.addPage(page); |
| 193 | + |
| 194 | + console.log(`✓ ${file}`); |
| 195 | + console.log(` - Page: ${grid.name}`); |
| 196 | + console.log(` - Grid: ${grid.rows}×${grid.cols}`); |
| 197 | + console.log(` - Cells: ${grid.cells.length}`); |
| 198 | + console.log(` - Categories: ${grid.categories.join(', ') || 'none'}`); |
| 199 | + console.log(); |
| 200 | + } |
| 201 | + |
| 202 | + // Set Home as root page if it exists |
| 203 | + const homePage = Object.values(tree.pages).find(p => p.id === 'home'); |
| 204 | + if (homePage) { |
| 205 | + tree.rootId = 'home'; |
| 206 | + console.log('✓ Set Home as root page\n'); |
| 207 | + } |
| 208 | + |
| 209 | + // Export to Grid3 |
| 210 | + const processor = new GridsetProcessor(); |
| 211 | + const outputPath = path.join(baseDir, 'converted-from-txt.gridset'); |
| 212 | + |
| 213 | + processor.saveFromTree(tree, outputPath); |
| 214 | + |
| 215 | + const stats = fs.statSync(outputPath); |
| 216 | + console.log(`🎉 Conversion complete!`); |
| 217 | + console.log(`📦 Output file: converted-from-txt.gridset`); |
| 218 | + console.log(`📊 File size: ${(stats.size / 1024).toFixed(2)} KB`); |
| 219 | + console.log(`📁 Full path: ${outputPath}\n`); |
| 220 | + |
| 221 | + console.log('You can now import converted-from-txt.gridset into Grid3!'); |
| 222 | +} |
| 223 | + |
| 224 | +// Also create a function to parse one file for testing |
| 225 | +export function parseSingleTextFile(filePath: string): Grid { |
| 226 | + const filename = path.basename(filePath); |
| 227 | + return parseGridFile(filePath, filename); |
| 228 | +} |
| 229 | + |
| 230 | +if (require.main === module) { |
| 231 | + convertTextFilesToGridset(); |
| 232 | +} |
0 commit comments