@willwade/aac-processors 0.0.29 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +52 -852
  2. package/dist/browser/core/baseProcessor.js +241 -0
  3. package/dist/browser/core/stringCasing.js +179 -0
  4. package/dist/browser/core/treeStructure.js +255 -0
  5. package/dist/browser/index.browser.js +73 -0
  6. package/dist/browser/processors/applePanelsProcessor.js +582 -0
  7. package/dist/browser/processors/astericsGridProcessor.js +1509 -0
  8. package/dist/browser/processors/dotProcessor.js +221 -0
  9. package/dist/browser/processors/gridset/commands.js +962 -0
  10. package/dist/browser/processors/gridset/crypto.js +53 -0
  11. package/dist/browser/processors/gridset/password.js +43 -0
  12. package/dist/browser/processors/gridset/pluginTypes.js +277 -0
  13. package/dist/browser/processors/gridset/resolver.js +137 -0
  14. package/dist/browser/processors/gridset/symbolAlignment.js +276 -0
  15. package/dist/browser/processors/gridset/symbols.js +421 -0
  16. package/dist/browser/processors/gridsetProcessor.js +2002 -0
  17. package/dist/browser/processors/obfProcessor.js +705 -0
  18. package/dist/browser/processors/opmlProcessor.js +274 -0
  19. package/dist/browser/types/aac.js +38 -0
  20. package/dist/browser/utilities/analytics/utils/idGenerator.js +89 -0
  21. package/dist/browser/utilities/translation/translationProcessor.js +200 -0
  22. package/dist/browser/utils/io.js +95 -0
  23. package/dist/browser/validation/baseValidator.js +156 -0
  24. package/dist/browser/validation/gridsetValidator.js +355 -0
  25. package/dist/browser/validation/obfValidator.js +500 -0
  26. package/dist/browser/validation/validationTypes.js +46 -0
  27. package/dist/cli/index.js +5 -5
  28. package/dist/core/analyze.d.ts +2 -2
  29. package/dist/core/analyze.js +2 -2
  30. package/dist/core/baseProcessor.d.ts +5 -4
  31. package/dist/core/baseProcessor.js +22 -27
  32. package/dist/core/treeStructure.d.ts +5 -5
  33. package/dist/core/treeStructure.js +1 -4
  34. package/dist/index.browser.d.ts +37 -0
  35. package/dist/index.browser.js +99 -0
  36. package/dist/index.d.ts +1 -48
  37. package/dist/index.js +1 -136
  38. package/dist/index.node.d.ts +48 -0
  39. package/dist/index.node.js +152 -0
  40. package/dist/processors/applePanelsProcessor.d.ts +5 -4
  41. package/dist/processors/applePanelsProcessor.js +58 -62
  42. package/dist/processors/astericsGridProcessor.d.ts +7 -6
  43. package/dist/processors/astericsGridProcessor.js +31 -42
  44. package/dist/processors/dotProcessor.d.ts +5 -4
  45. package/dist/processors/dotProcessor.js +25 -33
  46. package/dist/processors/excelProcessor.d.ts +4 -3
  47. package/dist/processors/excelProcessor.js +6 -3
  48. package/dist/processors/gridset/crypto.d.ts +18 -0
  49. package/dist/processors/gridset/crypto.js +57 -0
  50. package/dist/processors/gridset/helpers.d.ts +1 -1
  51. package/dist/processors/gridset/helpers.js +18 -8
  52. package/dist/processors/gridset/password.d.ts +20 -3
  53. package/dist/processors/gridset/password.js +17 -3
  54. package/dist/processors/gridset/wordlistHelpers.d.ts +3 -3
  55. package/dist/processors/gridset/wordlistHelpers.js +21 -20
  56. package/dist/processors/gridsetProcessor.d.ts +7 -12
  57. package/dist/processors/gridsetProcessor.js +118 -77
  58. package/dist/processors/obfProcessor.d.ts +9 -7
  59. package/dist/processors/obfProcessor.js +131 -56
  60. package/dist/processors/obfsetProcessor.d.ts +5 -4
  61. package/dist/processors/obfsetProcessor.js +10 -16
  62. package/dist/processors/opmlProcessor.d.ts +5 -4
  63. package/dist/processors/opmlProcessor.js +27 -34
  64. package/dist/processors/snapProcessor.d.ts +8 -7
  65. package/dist/processors/snapProcessor.js +15 -12
  66. package/dist/processors/touchchatProcessor.d.ts +8 -7
  67. package/dist/processors/touchchatProcessor.js +22 -17
  68. package/dist/types/aac.d.ts +0 -2
  69. package/dist/types/aac.js +2 -0
  70. package/dist/utils/io.d.ts +12 -0
  71. package/dist/utils/io.js +107 -0
  72. package/dist/validation/gridsetValidator.js +7 -7
  73. package/dist/validation/snapValidator.js +28 -35
  74. package/docs/BROWSER_USAGE.md +618 -0
  75. package/examples/README.md +77 -0
  76. package/examples/browser-test-server.js +81 -0
  77. package/examples/browser-test.html +331 -0
  78. package/examples/vitedemo/QUICKSTART.md +74 -0
  79. package/examples/vitedemo/README.md +157 -0
  80. package/examples/vitedemo/index.html +376 -0
  81. package/examples/vitedemo/package-lock.json +1221 -0
  82. package/examples/vitedemo/package.json +18 -0
  83. package/examples/vitedemo/src/main.ts +519 -0
  84. package/examples/vitedemo/test-files/example.dot +14 -0
  85. package/examples/vitedemo/test-files/example.grd +1 -0
  86. package/examples/vitedemo/test-files/example.gridset +0 -0
  87. package/examples/vitedemo/test-files/example.obz +0 -0
  88. package/examples/vitedemo/test-files/example.opml +18 -0
  89. package/examples/vitedemo/test-files/simple.obf +53 -0
  90. package/examples/vitedemo/tsconfig.json +24 -0
  91. package/examples/vitedemo/vite.config.ts +34 -0
  92. package/package.json +20 -4
@@ -0,0 +1,705 @@
1
+ import { BaseProcessor, } from '../core/baseProcessor';
2
+ import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, } from '../core/treeStructure';
3
+ import { generateCloneId } from '../utilities/analytics/utils/idGenerator';
4
+ import { extractAllButtonsForTranslation, validateTranslationResults, } from '../utilities/translation/translationProcessor';
5
+ import { readBinaryFromInput, readTextFromInput, writeTextToPath, encodeBase64, } from '../utils/io';
6
+ let JSZipModuleObf;
7
+ async function getJSZipObf() {
8
+ if (!JSZipModuleObf) {
9
+ try {
10
+ // Try ES module import first (browser/Vite)
11
+ const module = await import('jszip');
12
+ JSZipModuleObf = module.default || module;
13
+ }
14
+ catch (error) {
15
+ // Fall back to CommonJS require (Node.js)
16
+ try {
17
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
18
+ const module = require('jszip');
19
+ JSZipModuleObf = module.default || module;
20
+ }
21
+ catch (err2) {
22
+ throw new Error('Zip handling requires JSZip in this environment.');
23
+ }
24
+ }
25
+ }
26
+ if (!JSZipModuleObf) {
27
+ throw new Error('Zip handling requires JSZip in this environment.');
28
+ }
29
+ return JSZipModuleObf;
30
+ }
31
+ const OBF_FORMAT_VERSION = 'open-board-0.1';
32
+ /**
33
+ * Map OBF hidden value to AAC standard visibility
34
+ * OBF: true = hidden, false/undefined = visible
35
+ * Maps to: 'Hidden' | 'Visible' | undefined
36
+ */
37
+ function mapObfVisibility(hidden) {
38
+ if (hidden === undefined) {
39
+ return undefined; // Default to visible
40
+ }
41
+ return hidden ? 'Hidden' : 'Visible';
42
+ }
43
+ class ObfProcessor extends BaseProcessor {
44
+ constructor(options) {
45
+ super(options);
46
+ this.imageCache = new Map(); // Cache for data URLs
47
+ }
48
+ /**
49
+ * Extract an image from the ZIP file as a Buffer
50
+ */
51
+ async extractImageAsBuffer(imageId, images) {
52
+ if (!this.zipFile || !images) {
53
+ return null;
54
+ }
55
+ // Find the image metadata
56
+ const imageData = images.find((img) => img.id === imageId);
57
+ if (!imageData) {
58
+ return null;
59
+ }
60
+ // Try to get the image file from the ZIP
61
+ const possiblePaths = [
62
+ imageData.path,
63
+ `images/${imageData.filename || imageId}`,
64
+ imageData.id,
65
+ ].filter(Boolean);
66
+ for (const imagePath of possiblePaths) {
67
+ try {
68
+ const file = this.zipFile.file(imagePath);
69
+ if (file) {
70
+ const buffer = await file.async('nodebuffer');
71
+ return buffer;
72
+ }
73
+ }
74
+ catch (err) {
75
+ continue;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * Extract an image from the ZIP file and convert to data URL
82
+ */
83
+ async extractImageAsDataUrl(imageId, images) {
84
+ // Check cache first
85
+ if (this.imageCache.has(imageId)) {
86
+ return this.imageCache.get(imageId) ?? null;
87
+ }
88
+ if (!this.zipFile || !images) {
89
+ return null;
90
+ }
91
+ // Find the image metadata
92
+ const imageData = images.find((img) => img.id === imageId);
93
+ if (!imageData) {
94
+ return null;
95
+ }
96
+ // Try to get the image file from the ZIP
97
+ // Images are typically stored in an 'images' folder or root
98
+ const possiblePaths = [
99
+ imageData.path, // Explicit path if provided
100
+ `images/${imageData.filename || imageId}`, // Standard images folder
101
+ imageData.id, // Just the ID
102
+ ].filter(Boolean);
103
+ for (const imagePath of possiblePaths) {
104
+ try {
105
+ const file = this.zipFile.file(imagePath);
106
+ if (file) {
107
+ const buffer = await file.async('uint8array');
108
+ const contentType = imageData.content_type ||
109
+ this.getMimeTypeFromFilename(imagePath);
110
+ const dataUrl = `data:${contentType};base64,${encodeBase64(buffer)}`;
111
+ this.imageCache.set(imageId, dataUrl);
112
+ return dataUrl;
113
+ }
114
+ }
115
+ catch (err) {
116
+ // Continue to next path
117
+ continue;
118
+ }
119
+ }
120
+ // If image has a URL, use that as fallback
121
+ if (imageData.url) {
122
+ const url = imageData.url;
123
+ this.imageCache.set(imageId, url);
124
+ return url;
125
+ }
126
+ return null;
127
+ }
128
+ getMimeTypeFromFilename(filename) {
129
+ const ext = filename.toLowerCase().split('.').pop();
130
+ switch (ext) {
131
+ case 'png':
132
+ return 'image/png';
133
+ case 'jpg':
134
+ case 'jpeg':
135
+ return 'image/jpeg';
136
+ case 'gif':
137
+ return 'image/gif';
138
+ case 'svg':
139
+ return 'image/svg+xml';
140
+ case 'webp':
141
+ return 'image/webp';
142
+ default:
143
+ return 'image/png';
144
+ }
145
+ }
146
+ async processBoard(boardData, _boardPath) {
147
+ const sourceButtons = boardData.buttons || [];
148
+ // Calculate page ID first (used to make button IDs unique)
149
+ const pageId = _boardPath && _boardPath.endsWith('.obf') && !_boardPath.includes('/')
150
+ ? _boardPath // Zip entry - use filename to match navigation paths
151
+ : boardData?.id
152
+ ? String(boardData.id)
153
+ : _boardPath?.split('/').pop() || '';
154
+ const buttons = await Promise.all(sourceButtons.map(async (btn) => {
155
+ const semanticAction = btn.load_board
156
+ ? {
157
+ category: AACSemanticCategory.NAVIGATION,
158
+ intent: AACSemanticIntent.NAVIGATE_TO,
159
+ targetId: btn.load_board.path,
160
+ fallback: {
161
+ type: 'NAVIGATE',
162
+ targetPageId: btn.load_board.path,
163
+ },
164
+ }
165
+ : {
166
+ category: AACSemanticCategory.COMMUNICATION,
167
+ intent: AACSemanticIntent.SPEAK_TEXT,
168
+ text: String(btn?.vocalization || btn?.label || ''),
169
+ fallback: {
170
+ type: 'SPEAK',
171
+ message: String(btn?.vocalization || btn?.label || ''),
172
+ },
173
+ };
174
+ // Resolve image if image_id is present
175
+ let resolvedImage;
176
+ let imageBuffer;
177
+ if (btn.image_id && boardData.images) {
178
+ resolvedImage =
179
+ (await this.extractImageAsDataUrl(btn.image_id, boardData.images)) || undefined;
180
+ imageBuffer =
181
+ (await this.extractImageAsBuffer(btn.image_id, boardData.images)) || undefined;
182
+ }
183
+ // Build parameters object for Grid3 export compatibility
184
+ const buttonParameters = {};
185
+ if (imageBuffer) {
186
+ buttonParameters.imageData = imageBuffer;
187
+ }
188
+ // Store image_id for web viewers to fetch images via API
189
+ if (btn.image_id) {
190
+ buttonParameters.image_id = btn.image_id;
191
+ }
192
+ return new AACButton({
193
+ // Make button ID unique by combining page ID and button ID
194
+ id: `${pageId}::${btn?.id || ''}`,
195
+ label: String(btn?.label || ''),
196
+ message: String(btn?.vocalization || btn?.label || ''),
197
+ visibility: mapObfVisibility(btn.hidden),
198
+ style: {
199
+ backgroundColor: btn.background_color,
200
+ borderColor: btn.border_color,
201
+ },
202
+ image: resolvedImage, // Set the resolved image data URL
203
+ resolvedImageEntry: resolvedImage,
204
+ parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
205
+ semanticAction,
206
+ targetPageId: btn.load_board?.path,
207
+ semantic_id: btn.semantic_id, // Extract semantic_id if present
208
+ });
209
+ }));
210
+ const buttonMap = new Map(buttons.map((btn) => [btn.id, btn]));
211
+ const page = new AACPage({
212
+ id: pageId, // Use the page ID we calculated earlier
213
+ name: String(boardData?.name || ''),
214
+ grid: [],
215
+ buttons,
216
+ parentId: null,
217
+ locale: boardData.locale,
218
+ descriptionHtml: boardData.description_html,
219
+ images: boardData.images,
220
+ sounds: boardData.sounds,
221
+ });
222
+ // Process grid layout if available
223
+ if (boardData.grid) {
224
+ const rows = typeof boardData.grid.rows === 'number'
225
+ ? boardData.grid.rows
226
+ : boardData.grid.order?.length || 0;
227
+ const cols = typeof boardData.grid.columns === 'number'
228
+ ? boardData.grid.columns
229
+ : boardData.grid.order
230
+ ? boardData.grid.order.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0)
231
+ : 0;
232
+ if (rows > 0 && cols > 0) {
233
+ const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => null));
234
+ if (Array.isArray(boardData.grid.order) && boardData.grid.order.length) {
235
+ boardData.grid.order.forEach((orderRow, rowIndex) => {
236
+ if (!Array.isArray(orderRow))
237
+ return;
238
+ orderRow.forEach((cellId, colIndex) => {
239
+ if (cellId === null || cellId === undefined)
240
+ return;
241
+ if (rowIndex >= rows || colIndex >= cols)
242
+ return;
243
+ const aacBtn = buttonMap.get(`${pageId}::${cellId}`);
244
+ if (aacBtn) {
245
+ grid[rowIndex][colIndex] = aacBtn;
246
+ }
247
+ });
248
+ });
249
+ }
250
+ else {
251
+ for (const btn of sourceButtons) {
252
+ if (typeof btn.box_id === 'number') {
253
+ const row = Math.floor(btn.box_id / cols);
254
+ const col = btn.box_id % cols;
255
+ if (row < rows && col < cols) {
256
+ const aacBtn = buttonMap.get(`${pageId}::${btn.id}`);
257
+ if (aacBtn) {
258
+ grid[row][col] = aacBtn;
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ page.grid = grid;
265
+ // Generate clone_id for buttons in the grid
266
+ const semanticIds = [];
267
+ const cloneIds = [];
268
+ grid.forEach((row, rowIndex) => {
269
+ row.forEach((btn, colIndex) => {
270
+ if (btn) {
271
+ // Generate clone_id based on position and label
272
+ btn.clone_id = generateCloneId(rows, cols, rowIndex, colIndex, btn.label);
273
+ cloneIds.push(btn.clone_id);
274
+ // Track semantic_id if present
275
+ if (btn.semantic_id) {
276
+ semanticIds.push(btn.semantic_id);
277
+ }
278
+ }
279
+ });
280
+ });
281
+ // Track IDs on the page
282
+ if (semanticIds.length > 0) {
283
+ page.semantic_ids = semanticIds;
284
+ }
285
+ if (cloneIds.length > 0) {
286
+ page.clone_ids = cloneIds;
287
+ }
288
+ }
289
+ }
290
+ return page;
291
+ }
292
+ async extractTexts(filePathOrBuffer) {
293
+ const tree = await this.loadIntoTree(filePathOrBuffer);
294
+ const texts = [];
295
+ for (const pageId in tree.pages) {
296
+ const page = tree.pages[pageId];
297
+ if (page.name)
298
+ texts.push(page.name);
299
+ page.buttons.forEach((btn) => {
300
+ if (typeof btn.label === 'string')
301
+ texts.push(btn.label);
302
+ if (typeof btn.message === 'string' && btn.message !== btn.label)
303
+ texts.push(btn.message);
304
+ });
305
+ }
306
+ return texts;
307
+ }
308
+ async loadIntoTree(filePathOrBuffer) {
309
+ // Detailed logging for debugging input
310
+ const bufferLength = typeof filePathOrBuffer === 'string'
311
+ ? null
312
+ : readBinaryFromInput(filePathOrBuffer).byteLength;
313
+ console.log('[OBF] loadIntoTree called with:', {
314
+ type: typeof filePathOrBuffer,
315
+ isBuffer: typeof Buffer !== 'undefined' && Buffer.isBuffer(filePathOrBuffer),
316
+ value: typeof filePathOrBuffer === 'string'
317
+ ? filePathOrBuffer
318
+ : `[Buffer of length ${bufferLength ?? 0}]`,
319
+ });
320
+ const tree = new AACTree();
321
+ // Helper: try to parse JSON OBF
322
+ function tryParseObfJson(data) {
323
+ try {
324
+ const str = typeof data === 'string' ? data : readTextFromInput(data);
325
+ // Check for empty or whitespace-only content
326
+ if (!str.trim()) {
327
+ return null;
328
+ }
329
+ const obj = JSON.parse(str);
330
+ if (obj && typeof obj === 'object' && 'id' in obj && 'buttons' in obj) {
331
+ // Validate buttons is an array
332
+ if (!Array.isArray(obj.buttons)) {
333
+ throw new Error('Invalid OBF: buttons must be an array');
334
+ }
335
+ return obj;
336
+ }
337
+ }
338
+ catch (error) {
339
+ // Log parsing errors for debugging but don't throw
340
+ }
341
+ return null;
342
+ }
343
+ // If input is a string path and ends with .obf, treat as JSON
344
+ if (typeof filePathOrBuffer === 'string' && filePathOrBuffer.endsWith('.obf')) {
345
+ try {
346
+ const content = readTextFromInput(filePathOrBuffer);
347
+ const boardData = tryParseObfJson(content);
348
+ if (boardData) {
349
+ console.log('[OBF] Detected .obf file, parsed as JSON');
350
+ const page = await this.processBoard(boardData, filePathOrBuffer);
351
+ tree.addPage(page);
352
+ // Set metadata from root board
353
+ tree.metadata.format = 'obf';
354
+ tree.metadata.name = boardData.name;
355
+ tree.metadata.description = boardData.description_html;
356
+ tree.metadata.locale = boardData.locale;
357
+ tree.metadata.id = boardData.id;
358
+ if (boardData.url)
359
+ tree.metadata.url = boardData.url;
360
+ if (boardData.locale)
361
+ tree.metadata.languages = [boardData.locale];
362
+ tree.rootId = page.id;
363
+ return tree;
364
+ }
365
+ else {
366
+ throw new Error('Invalid OBF JSON content');
367
+ }
368
+ }
369
+ catch (err) {
370
+ console.error('[OBF] Error reading .obf file:', err);
371
+ throw err;
372
+ }
373
+ }
374
+ // If input is a buffer or string that parses as OBF JSON
375
+ const asJson = tryParseObfJson(filePathOrBuffer);
376
+ if (asJson) {
377
+ console.log('[OBF] Detected buffer/string as OBF JSON');
378
+ const page = await this.processBoard(asJson, '[bufferOrString]');
379
+ tree.addPage(page);
380
+ // Set metadata from root board
381
+ tree.metadata.format = 'obf';
382
+ tree.metadata.name = asJson.name;
383
+ tree.metadata.description = asJson.description_html;
384
+ tree.metadata.locale = asJson.locale;
385
+ tree.metadata.id = asJson.id;
386
+ if (asJson.url)
387
+ tree.metadata.url = asJson.url;
388
+ if (asJson.locale) {
389
+ tree.metadata.languages = [asJson.locale];
390
+ }
391
+ tree.rootId = page.id;
392
+ return tree;
393
+ }
394
+ // Otherwise, try as ZIP (.obz). Detect likely zip signature first; throw if neither JSON nor ZIP
395
+ function isLikelyZip(input) {
396
+ if (typeof input === 'string')
397
+ return input.endsWith('.zip') || input.endsWith('.obz');
398
+ const bytes = readBinaryFromInput(input);
399
+ return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
400
+ }
401
+ if (!isLikelyZip(filePathOrBuffer)) {
402
+ throw new Error('Invalid OBF content: not JSON and not ZIP');
403
+ }
404
+ const JSZip = await getJSZipObf();
405
+ let zip;
406
+ try {
407
+ const zipInput = readBinaryFromInput(filePathOrBuffer);
408
+ zip = await JSZip.loadAsync(zipInput);
409
+ }
410
+ catch (err) {
411
+ console.error('[OBF] Error loading ZIP with JSZip:', err);
412
+ throw err;
413
+ }
414
+ // Store the ZIP file reference for image extraction
415
+ this.zipFile = zip;
416
+ this.imageCache.clear(); // Clear cache for new file
417
+ console.log('[OBF] Detected zip archive, extracting .obf files');
418
+ // Collect all .obf entries
419
+ const obfEntries = [];
420
+ zip.forEach((relativePath, file) => {
421
+ if (file.dir)
422
+ return;
423
+ if (relativePath.endsWith('.obf')) {
424
+ obfEntries.push({ name: relativePath, file });
425
+ }
426
+ });
427
+ // Process each .obf entry
428
+ for (const entry of obfEntries) {
429
+ try {
430
+ const content = await entry.file.async('string');
431
+ const boardData = tryParseObfJson(content);
432
+ if (boardData) {
433
+ const page = await this.processBoard(boardData, entry.name);
434
+ tree.addPage(page);
435
+ // Set metadata if not already set (use first board as reference)
436
+ if (!tree.metadata.format) {
437
+ tree.metadata.format = 'obf';
438
+ tree.metadata.name = boardData.name;
439
+ tree.metadata.description = boardData.description_html;
440
+ tree.metadata.locale = boardData.locale;
441
+ tree.metadata.id = boardData.id;
442
+ if (boardData.url)
443
+ tree.metadata.url = boardData.url;
444
+ if (boardData.locale)
445
+ tree.metadata.languages = [boardData.locale];
446
+ tree.rootId = page.id;
447
+ }
448
+ }
449
+ else {
450
+ console.warn('[OBF] Skipped entry (not valid OBF JSON):', entry.name);
451
+ }
452
+ }
453
+ catch (err) {
454
+ console.warn('[OBF] Error processing entry:', entry.name, err);
455
+ }
456
+ }
457
+ return tree;
458
+ }
459
+ buildGridMetadata(page) {
460
+ const buttonPositions = new Map();
461
+ const totalRows = Array.isArray(page.grid) ? page.grid.length : 0;
462
+ const totalColumns = totalRows > 0
463
+ ? page.grid.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0)
464
+ : 0;
465
+ if (totalRows === 0 || totalColumns === 0) {
466
+ if (!page.buttons.length) {
467
+ return { rows: 0, columns: 0, order: [], buttonPositions };
468
+ }
469
+ const fallbackRow = page.buttons.map((button, index) => {
470
+ const id = String(button.id ?? '');
471
+ buttonPositions.set(id, index);
472
+ return id;
473
+ });
474
+ return {
475
+ rows: 1,
476
+ columns: fallbackRow.length,
477
+ order: [fallbackRow],
478
+ buttonPositions,
479
+ };
480
+ }
481
+ const order = [];
482
+ for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
483
+ const sourceRow = page.grid[rowIndex] || [];
484
+ const orderRow = [];
485
+ for (let colIndex = 0; colIndex < totalColumns; colIndex++) {
486
+ const cell = sourceRow[colIndex] || null;
487
+ if (cell) {
488
+ const id = String(cell.id ?? '');
489
+ orderRow.push(id);
490
+ buttonPositions.set(id, rowIndex * totalColumns + colIndex);
491
+ }
492
+ else {
493
+ orderRow.push(null);
494
+ }
495
+ }
496
+ order.push(orderRow);
497
+ }
498
+ return { rows: totalRows, columns: totalColumns, order, buttonPositions };
499
+ }
500
+ createObfBoardFromPage(page, fallbackName, metadata) {
501
+ const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page);
502
+ const boardName = metadata?.name && page.id === metadata?.defaultHomePageId
503
+ ? metadata.name
504
+ : page.name || fallbackName;
505
+ return {
506
+ format: OBF_FORMAT_VERSION,
507
+ id: page.id,
508
+ url: metadata?.url,
509
+ locale: metadata?.locale || page.locale || 'en',
510
+ name: boardName,
511
+ description_html: metadata?.description && page.id === metadata?.defaultHomePageId
512
+ ? metadata.description
513
+ : page.descriptionHtml || boardName,
514
+ grid: {
515
+ rows,
516
+ columns,
517
+ order,
518
+ },
519
+ buttons: page.buttons.map((button) => ({
520
+ id: button.id,
521
+ label: button.label,
522
+ vocalization: button.message || button.label,
523
+ load_board: button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && button.targetPageId
524
+ ? {
525
+ path: button.targetPageId,
526
+ }
527
+ : undefined,
528
+ background_color: button.style?.backgroundColor,
529
+ border_color: button.style?.borderColor,
530
+ box_id: buttonPositions.get(String(button.id ?? '')),
531
+ })),
532
+ images: Array.isArray(page.images) ? page.images : [],
533
+ sounds: Array.isArray(page.sounds) ? page.sounds : [],
534
+ };
535
+ }
536
+ async processTexts(filePathOrBuffer, translations, outputPath) {
537
+ // Load the tree, apply translations, and save to new file
538
+ const tree = await this.loadIntoTree(filePathOrBuffer);
539
+ // Apply translations to all text content
540
+ Object.values(tree.pages).forEach((page) => {
541
+ // Translate page names
542
+ if (page.name && translations.has(page.name)) {
543
+ const translatedName = translations.get(page.name);
544
+ if (translatedName !== undefined) {
545
+ page.name = translatedName;
546
+ }
547
+ }
548
+ // Translate button labels and messages
549
+ page.buttons.forEach((button) => {
550
+ if (button.label && translations.has(button.label)) {
551
+ const translatedLabel = translations.get(button.label);
552
+ if (translatedLabel !== undefined) {
553
+ button.label = translatedLabel;
554
+ }
555
+ }
556
+ if (button.message && translations.has(button.message)) {
557
+ const translatedMessage = translations.get(button.message);
558
+ if (translatedMessage !== undefined) {
559
+ button.message = translatedMessage;
560
+ }
561
+ }
562
+ });
563
+ });
564
+ // Save the translated tree and return its content
565
+ await this.saveFromTree(tree, outputPath);
566
+ return readBinaryFromInput(outputPath);
567
+ }
568
+ async saveFromTree(tree, outputPath) {
569
+ if (outputPath.endsWith('.obf')) {
570
+ // Save as single OBF JSON file
571
+ const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0];
572
+ if (!rootPage) {
573
+ throw new Error('No pages to save');
574
+ }
575
+ const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board', tree.metadata);
576
+ writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2));
577
+ }
578
+ else {
579
+ // Save as OBZ (zip with multiple OBF files)
580
+ const JSZip = await getJSZipObf();
581
+ const zip = new JSZip();
582
+ Object.values(tree.pages).forEach((page) => {
583
+ const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
584
+ const obfContent = JSON.stringify(obfBoard, null, 2);
585
+ zip.file(`${page.id}.obf`, obfContent);
586
+ });
587
+ const zipBuffer = await zip.generateAsync({ type: 'uint8array' });
588
+ const { writeBinaryToPath } = await import('../utils/io');
589
+ writeBinaryToPath(outputPath, zipBuffer);
590
+ }
591
+ }
592
+ /**
593
+ * Extract strings with metadata for aac-tools-platform compatibility
594
+ * Uses the generic implementation from BaseProcessor
595
+ */
596
+ async extractStringsWithMetadata(filePath) {
597
+ return this.extractStringsWithMetadataGeneric(filePath);
598
+ }
599
+ /**
600
+ * Generate translated download for aac-tools-platform compatibility
601
+ * Uses the generic implementation from BaseProcessor
602
+ */
603
+ async generateTranslatedDownload(filePath, translatedStrings, sourceStrings) {
604
+ return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings);
605
+ }
606
+ /**
607
+ * Validate OBF/OBZ file format
608
+ * @param filePath - Path to the file to validate
609
+ * @returns Promise with validation result
610
+ */
611
+ async validate(filePath) {
612
+ const ObfValidator = this.getObfValidator();
613
+ return ObfValidator.validateFile(filePath);
614
+ }
615
+ /**
616
+ * Extract symbol information from an OBF/OBZ file for LLM-based translation.
617
+ * Returns a structured format showing which buttons have symbols and their context.
618
+ *
619
+ * This method uses shared translation utilities that work across all AAC formats.
620
+ *
621
+ * @param filePathOrBuffer - Path to OBF/OBZ file or buffer
622
+ * @returns Array of symbol information for LLM processing
623
+ */
624
+ async extractSymbolsForLLM(filePathOrBuffer) {
625
+ const tree = await this.loadIntoTree(filePathOrBuffer);
626
+ // Collect all buttons from all pages
627
+ const allButtons = [];
628
+ Object.values(tree.pages).forEach((page) => {
629
+ page.buttons.forEach((button) => {
630
+ // Add page context to each button
631
+ button.pageId = page.id;
632
+ button.pageName = page.name || page.id;
633
+ allButtons.push(button);
634
+ });
635
+ });
636
+ // Use shared utility to extract buttons with translation context
637
+ return extractAllButtonsForTranslation(allButtons, (button) => ({
638
+ pageId: button.pageId,
639
+ pageName: button.pageName,
640
+ }));
641
+ }
642
+ /**
643
+ * Apply LLM translations with symbol information.
644
+ * The LLM should provide translations with symbol attachments in the correct positions.
645
+ *
646
+ * This method uses shared translation utilities that work across all AAC formats.
647
+ *
648
+ * @param filePathOrBuffer - Path to OBF/OBZ file or buffer
649
+ * @param llmTranslations - Array of LLM translations with symbol info
650
+ * @param outputPath - Where to save the translated OBF/OBZ file
651
+ * @param options - Translation options (e.g., allowPartial for testing)
652
+ * @returns Buffer of the translated OBF/OBZ file
653
+ */
654
+ async processLLMTranslations(filePathOrBuffer, llmTranslations, outputPath, options) {
655
+ const tree = await this.loadIntoTree(filePathOrBuffer);
656
+ // Validate translations using shared utility
657
+ const buttonIds = Object.values(tree.pages).flatMap((page) => page.buttons.map((b) => b.id));
658
+ validateTranslationResults(llmTranslations, buttonIds, options);
659
+ // Create a map for quick lookup
660
+ const translationMap = new Map(llmTranslations.map((t) => [t.buttonId, t]));
661
+ // Apply translations
662
+ Object.values(tree.pages).forEach((page) => {
663
+ page.buttons.forEach((button) => {
664
+ const translation = translationMap.get(button.id);
665
+ if (!translation)
666
+ return;
667
+ // Apply label translation
668
+ if (translation.translatedLabel) {
669
+ button.label = translation.translatedLabel;
670
+ }
671
+ // Apply message translation (vocalization in OBF)
672
+ if (translation.translatedMessage) {
673
+ button.message = translation.translatedMessage;
674
+ // Update semantic action if symbols provided
675
+ if (translation.symbols && translation.symbols.length > 0) {
676
+ if (!button.semanticAction) {
677
+ button.semanticAction = {
678
+ category: AACSemanticCategory.COMMUNICATION,
679
+ intent: AACSemanticIntent.SPEAK_TEXT,
680
+ text: translation.translatedMessage,
681
+ };
682
+ }
683
+ button.semanticAction.richText = {
684
+ text: translation.translatedMessage,
685
+ symbols: translation.symbols,
686
+ };
687
+ }
688
+ }
689
+ });
690
+ });
691
+ // Save and return
692
+ await this.saveFromTree(tree, outputPath);
693
+ return readBinaryFromInput(outputPath);
694
+ }
695
+ getObfValidator() {
696
+ try {
697
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-return
698
+ return require('../validation/obfValidator').ObfValidator;
699
+ }
700
+ catch (error) {
701
+ throw new Error('Validation utilities are not available in this environment.');
702
+ }
703
+ }
704
+ }
705
+ export { ObfProcessor };