@willwade/aac-processors 0.0.30 → 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 +116 -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,2002 @@
1
+ import { BaseProcessor, } from '../core/baseProcessor';
2
+ import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, } from '../core/treeStructure';
3
+ import { XMLParser, XMLBuilder } from 'fast-xml-parser';
4
+ import { resolveGrid3CellImage } from './gridset/resolver';
5
+ import { extractAllButtonsForTranslation, validateTranslationResults, } from '../utilities/translation/translationProcessor';
6
+ import { getZipEntriesWithPassword, resolveGridsetPassword } from './gridset/password';
7
+ import { decryptGridsetEntry } from './gridset/crypto';
8
+ import { GridsetValidator } from '../validation/gridsetValidator';
9
+ // New imports for enhanced Grid 3 support
10
+ import { detectPluginCellType, Grid3CellType } from './gridset/pluginTypes';
11
+ import { detectCommand } from './gridset/commands';
12
+ import { parseSymbolReference } from './gridset/symbols';
13
+ import { isSymbolLibraryReference } from './gridset/resolver';
14
+ import { generateCloneId } from '../utilities/analytics/utils/idGenerator';
15
+ import { translateWithSymbols, extractSymbolsFromButton } from './gridset/symbolAlignment';
16
+ import { readBinaryFromInput, decodeText } from '../utils/io';
17
+ let JSZipModule;
18
+ async function getJSZip() {
19
+ if (!JSZipModule) {
20
+ try {
21
+ // Try ES module import first (browser/Vite)
22
+ const module = await import('jszip');
23
+ JSZipModule = module.default || module;
24
+ }
25
+ catch (error) {
26
+ // Fall back to CommonJS require (Node.js)
27
+ try {
28
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
29
+ const module = require('jszip');
30
+ JSZipModule = module.default || module;
31
+ }
32
+ catch (err2) {
33
+ throw new Error('Zip handling requires JSZip in this environment.');
34
+ }
35
+ }
36
+ }
37
+ if (!JSZipModule) {
38
+ throw new Error('Zip handling requires JSZip in this environment.');
39
+ }
40
+ return JSZipModule;
41
+ }
42
+ class GridsetProcessor extends BaseProcessor {
43
+ constructor(options) {
44
+ super(options);
45
+ }
46
+ // Determine password to use when opening encrypted gridset archives (.gridsetx)
47
+ getGridsetPassword(source) {
48
+ return resolveGridsetPassword(this.options, source);
49
+ }
50
+ // Helper function to ensure color has alpha channel (Grid3 format)
51
+ ensureAlphaChannel(color) {
52
+ if (!color)
53
+ return '#FFFFFFFF';
54
+ // Handle rgb() and rgba() formats
55
+ const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
56
+ if (rgbMatch) {
57
+ const r = parseInt(rgbMatch[1]);
58
+ const g = parseInt(rgbMatch[2]);
59
+ const b = parseInt(rgbMatch[3]);
60
+ const a = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1.0;
61
+ const alphaHex = Math.round(a * 255)
62
+ .toString(16)
63
+ .toUpperCase()
64
+ .padStart(2, '0');
65
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${alphaHex}`;
66
+ }
67
+ // If already 8 digits (with alpha), return as is
68
+ if (color.match(/^#[0-9A-Fa-f]{8}$/))
69
+ return color;
70
+ // If 6 digits (no alpha), add FF for fully opaque
71
+ if (color.match(/^#[0-9A-Fa-f]{6}$/))
72
+ return color + 'FF';
73
+ // If 3 digits (shorthand), expand to 8
74
+ if (color.match(/^#[0-9A-Fa-f]{3}$/)) {
75
+ const r = color[1];
76
+ const g = color[2];
77
+ const b = color[3];
78
+ return `#${r}${r}${g}${g}${b}${b}FF`;
79
+ }
80
+ // Invalid or unknown format, return white
81
+ return '#FFFFFFFF';
82
+ }
83
+ /**
84
+ * Calculate appropriate font color (black or white) based on background brightness
85
+ * Uses WCAG relative luminance formula to determine contrast
86
+ */
87
+ getContrastFontColor(backgroundColor) {
88
+ if (!backgroundColor)
89
+ return '#FF000000FF'; // Default to black
90
+ // Parse color from various formats
91
+ let r = 255, g = 255, b = 255;
92
+ // Handle hex colors
93
+ const hexMatch = backgroundColor.match(/#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/);
94
+ if (hexMatch) {
95
+ r = parseInt(hexMatch[1], 16);
96
+ g = parseInt(hexMatch[2], 16);
97
+ b = parseInt(hexMatch[3], 16);
98
+ }
99
+ else {
100
+ // Handle rgb() format
101
+ const rgbMatch = backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
102
+ if (rgbMatch) {
103
+ r = parseInt(rgbMatch[1]);
104
+ g = parseInt(rgbMatch[2]);
105
+ b = parseInt(rgbMatch[3]);
106
+ }
107
+ }
108
+ // Calculate relative luminance using WCAG formula
109
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
110
+ // Use white text for dark backgrounds (luminance < 0.5), black for light backgrounds
111
+ // Return 6-digit hex (ensureAlphaChannel will add FF for alpha)
112
+ return luminance < 0.5 ? '#FFFFFF' : '#000000';
113
+ }
114
+ /**
115
+ * Extract words from Grid3 WordList structure
116
+ */
117
+ _extractWordsFromWordList(param) {
118
+ if (!param)
119
+ return [];
120
+ // Sometimes the param itself is the WordList, sometimes it has a WordList property
121
+ const wordList = param.WordList || param.wordlist || (param.Items || param.items ? param : undefined);
122
+ if (!wordList || !(wordList.Items || wordList.items))
123
+ return [];
124
+ const items = wordList.Items?.WordListItem || wordList.items?.wordlistitem || [];
125
+ const itemArr = Array.isArray(items) ? items : [items];
126
+ const words = [];
127
+ for (const item of itemArr) {
128
+ const text = item.Text || item.text;
129
+ if (text) {
130
+ const val = this.textOf(text);
131
+ if (val)
132
+ words.push(val);
133
+ }
134
+ else if (item['#text'] !== undefined) {
135
+ words.push(String(item['#text']));
136
+ }
137
+ else if (typeof item === 'string') {
138
+ words.push(item);
139
+ }
140
+ }
141
+ return words;
142
+ }
143
+ // Helper function to generate Grid3 commands from semantic actions
144
+ generateCommandsFromSemanticAction(button, tree) {
145
+ const semanticAction = button.semanticAction;
146
+ if (!semanticAction) {
147
+ // Default to insert text action with structured XML format
148
+ // Use two <s> elements: one for the word, one for the space (CDATA preserves whitespace)
149
+ let text = button.message || button.label || '';
150
+ // Remove trailing space from message if present (we'll add it as separate segment)
151
+ if (text.endsWith(' ')) {
152
+ text = text.slice(0, -1);
153
+ }
154
+ return {
155
+ Command: {
156
+ '@_ID': 'Action.InsertText',
157
+ Parameter: {
158
+ '@_Key': 'text',
159
+ p: {
160
+ s: [
161
+ {
162
+ r: text,
163
+ },
164
+ {
165
+ r: { __cdata: ' ' },
166
+ },
167
+ ],
168
+ },
169
+ },
170
+ },
171
+ };
172
+ }
173
+ // Use platform-specific Grid3 data if available
174
+ if (semanticAction.platformData?.grid3) {
175
+ const grid3Data = semanticAction.platformData.grid3;
176
+ const params = Object.entries(grid3Data.parameters || {}).map(([key, value]) => ({
177
+ '@_Key': key,
178
+ '#text': String(value),
179
+ }));
180
+ return {
181
+ Command: {
182
+ '@_ID': grid3Data.commandId,
183
+ ...(params.length > 0 ? { Parameter: params } : {}),
184
+ },
185
+ };
186
+ }
187
+ // Convert semantic actions to Grid3 commands
188
+ const intentStr = String(semanticAction.intent);
189
+ switch (intentStr) {
190
+ case 'NAVIGATE_TO': {
191
+ // For Grid3, we need to use the grid name, not the ID
192
+ let targetGridName = semanticAction.targetId || '';
193
+ if (tree && semanticAction.targetId) {
194
+ const targetPage = tree.getPage(semanticAction.targetId);
195
+ if (targetPage) {
196
+ targetGridName = targetPage.name || targetPage.id;
197
+ }
198
+ }
199
+ return {
200
+ Command: {
201
+ '@_ID': 'Jump.To',
202
+ Parameter: {
203
+ '@_Key': 'grid',
204
+ '#text': targetGridName,
205
+ },
206
+ },
207
+ };
208
+ }
209
+ case 'GO_BACK':
210
+ return {
211
+ Command: {
212
+ '@_ID': 'Jump.Back',
213
+ },
214
+ };
215
+ case 'GO_HOME':
216
+ return {
217
+ Command: {
218
+ '@_ID': 'Jump.Home',
219
+ },
220
+ };
221
+ case 'DELETE_WORD':
222
+ return {
223
+ Command: {
224
+ '@_ID': 'Action.DeleteWord',
225
+ },
226
+ };
227
+ case 'DELETE_CHARACTER':
228
+ return {
229
+ Command: {
230
+ '@_ID': 'Action.DeleteLetter',
231
+ },
232
+ };
233
+ case 'CLEAR_TEXT':
234
+ return {
235
+ Command: {
236
+ '@_ID': 'Action.Clear',
237
+ },
238
+ };
239
+ case 'SPEAK_TEXT':
240
+ case 'SPEAK_IMMEDIATE': {
241
+ // Users can speak the complete sentence with a dedicated Speak button // Use two <s> elements: one for the word, one for the space (CDATA preserves whitespace) // Grid3 requires explicit trailing space for automatic word spacing // For communication buttons, insert text into message bar (sentence building)
242
+ let text = semanticAction.text || button.message || button.label || '';
243
+ // Remove trailing space from message if present (we'll add it as separate segment)
244
+ if (text.endsWith(' ')) {
245
+ text = text.slice(0, -1);
246
+ }
247
+ return {
248
+ Command: {
249
+ '@_ID': 'Action.InsertText',
250
+ Parameter: {
251
+ '@_Key': 'text',
252
+ p: {
253
+ s: [
254
+ {
255
+ r: text,
256
+ },
257
+ {
258
+ r: { __cdata: ' ' },
259
+ },
260
+ ],
261
+ },
262
+ },
263
+ },
264
+ };
265
+ }
266
+ case 'INSERT_TEXT': {
267
+ // Use two <s> elements: one for the word, one for the space (CDATA preserves whitespace) // Add trailing space for word buttons to enable sentence building
268
+ let text = semanticAction.text || button.message || button.label || '';
269
+ // Remove trailing space from message if present (we'll add it as separate segment)
270
+ if (text.endsWith(' ')) {
271
+ text = text.slice(0, -1);
272
+ }
273
+ return {
274
+ Command: {
275
+ '@_ID': 'Action.InsertText',
276
+ Parameter: {
277
+ '@_Key': 'text',
278
+ p: {
279
+ s: [
280
+ {
281
+ r: text,
282
+ },
283
+ {
284
+ r: { __cdata: ' ' },
285
+ },
286
+ ],
287
+ },
288
+ },
289
+ },
290
+ };
291
+ }
292
+ default: {
293
+ // Use two <s> elements: one for the word, one for the space (CDATA preserves whitespace)
294
+ // Fallback to insert text with structured XML format
295
+ let text = semanticAction.text || button.message || button.label || '';
296
+ // Remove trailing space from message if present (we'll add it as separate segment)
297
+ if (text.endsWith(' ')) {
298
+ text = text.slice(0, -1);
299
+ }
300
+ return {
301
+ Command: {
302
+ '@_ID': 'Action.InsertText',
303
+ Parameter: {
304
+ '@_Key': 'text',
305
+ p: {
306
+ s: [
307
+ {
308
+ r: text,
309
+ },
310
+ {
311
+ r: { __cdata: ' ' },
312
+ },
313
+ ],
314
+ },
315
+ },
316
+ },
317
+ };
318
+ }
319
+ }
320
+ }
321
+ // Helper function to convert Grid 3 style to AACStyle
322
+ convertGrid3StyleToAACStyle(grid3Style) {
323
+ if (!grid3Style)
324
+ return {};
325
+ return {
326
+ backgroundColor: grid3Style.BackColour || grid3Style.TileColour,
327
+ borderColor: grid3Style.BorderColour,
328
+ fontColor: grid3Style.FontColour,
329
+ fontFamily: grid3Style.FontName,
330
+ fontSize: grid3Style.FontSize ? parseInt(String(grid3Style.FontSize)) : undefined,
331
+ backgroundShape: grid3Style.BackgroundShape !== undefined
332
+ ? parseInt(String(grid3Style.BackgroundShape))
333
+ : undefined,
334
+ };
335
+ }
336
+ // Helper function to get style by ID or return default
337
+ getStyleById(styles, styleId) {
338
+ if (!styleId || !styles.has(styleId)) {
339
+ return {};
340
+ }
341
+ return this.convertGrid3StyleToAACStyle(styles.get(styleId));
342
+ }
343
+ // Helper to safely extract text from XML parser values
344
+ textOf(val) {
345
+ if (!val)
346
+ return undefined;
347
+ if (typeof val === 'string')
348
+ return val;
349
+ if (typeof val === 'number')
350
+ return String(val);
351
+ if (typeof val === 'object') {
352
+ if ('#text' in val)
353
+ return String(val['#text']);
354
+ // Handle Grid3 structured format <p><s><r>text</r></s></p>
355
+ // Can start at p, s, or r level
356
+ const parts = [];
357
+ const processS = (s) => {
358
+ if (!s)
359
+ return;
360
+ if (s.r !== undefined) {
361
+ const rElements = Array.isArray(s.r) ? s.r : [s.r];
362
+ for (const r of rElements) {
363
+ if (typeof r === 'object' && r !== null && '#text' in r) {
364
+ parts.push(String(r['#text']));
365
+ }
366
+ else {
367
+ parts.push(String(r));
368
+ }
369
+ }
370
+ }
371
+ };
372
+ if (val.p) {
373
+ const p = val.p;
374
+ const sElements = Array.isArray(p.s) ? p.s : p.s ? [p.s] : [];
375
+ sElements.forEach(processS);
376
+ }
377
+ else if (val.s) {
378
+ const sElements = Array.isArray(val.s) ? val.s : [val.s];
379
+ sElements.forEach(processS);
380
+ }
381
+ else if (val.r !== undefined) {
382
+ processS(val);
383
+ }
384
+ if (parts.length > 0) {
385
+ return parts.join('').trim();
386
+ }
387
+ }
388
+ return undefined;
389
+ }
390
+ async extractTexts(filePathOrBuffer) {
391
+ const tree = await this.loadIntoTree(filePathOrBuffer);
392
+ const texts = [];
393
+ for (const pageId in tree.pages) {
394
+ const page = tree.pages[pageId];
395
+ if (page.name)
396
+ texts.push(page.name);
397
+ page.buttons.forEach((btn) => {
398
+ if (btn.label)
399
+ texts.push(btn.label);
400
+ if (btn.message && btn.message !== btn.label)
401
+ texts.push(btn.message);
402
+ });
403
+ }
404
+ return texts;
405
+ }
406
+ async loadIntoTree(filePathOrBuffer) {
407
+ const tree = new AACTree();
408
+ let zip;
409
+ try {
410
+ const JSZip = await getJSZip();
411
+ const zipInput = readBinaryFromInput(filePathOrBuffer);
412
+ zip = await JSZip.loadAsync(zipInput);
413
+ }
414
+ catch (error) {
415
+ throw new Error(`Invalid ZIP file format: ${error.message}`);
416
+ }
417
+ const password = this.getGridsetPassword(filePathOrBuffer);
418
+ const entries = getZipEntriesWithPassword(zip, password);
419
+ const parser = new XMLParser({ ignoreAttributes: false });
420
+ const isEncryptedArchive = typeof filePathOrBuffer === 'string' && filePathOrBuffer.toLowerCase().endsWith('.gridsetx');
421
+ const encryptedContentPassword = this.getGridsetPassword(filePathOrBuffer);
422
+ // Initialize metadata
423
+ const metadata = {
424
+ format: 'gridset',
425
+ isSmartBox: isEncryptedArchive, // SmartBox files are .gridsetx encrypted archives
426
+ passwordProtected: !!password,
427
+ };
428
+ const readEntryBuffer = async (entry) => {
429
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument
430
+ const raw = await entry.getData();
431
+ if (!isEncryptedArchive) {
432
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
433
+ return raw;
434
+ }
435
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return
436
+ return decryptGridsetEntry(raw, encryptedContentPassword);
437
+ };
438
+ // Parse FileMap.xml if present to index dynamic files per grid
439
+ const fileMapIndex = new Map();
440
+ try {
441
+ const fmEntry = entries.find((e) => e.entryName.endsWith('FileMap.xml'));
442
+ if (fmEntry) {
443
+ const fmXml = decodeText(await readEntryBuffer(fmEntry));
444
+ const fmData = parser.parse(fmXml);
445
+ const entries = fmData?.FileMap?.Entries?.Entry || fmData?.fileMap?.entries?.entry;
446
+ if (entries) {
447
+ const arr = Array.isArray(entries) ? entries : [entries];
448
+ for (const ent of arr) {
449
+ const rawStaticFile = ent['@_StaticFile'] || ent.StaticFile || ent.staticFile;
450
+ const staticFile = typeof rawStaticFile === 'string' ? rawStaticFile.replace(/\\/g, '/') : '';
451
+ if (!staticFile)
452
+ continue;
453
+ const df = ent.DynamicFiles || ent.dynamicFiles;
454
+ const candidates = df?.File || df?.file || df?.Files || df?.files;
455
+ const list = Array.isArray(candidates) ? candidates : candidates ? [candidates] : [];
456
+ const files = [];
457
+ for (const v of list) {
458
+ if (!v)
459
+ continue;
460
+ if (typeof v === 'string')
461
+ files.push(v.replace(/\\/g, '/'));
462
+ else if (typeof v === 'object' && '#text' in v)
463
+ files.push(String(v['#text']).replace(/\\/g, '/'));
464
+ }
465
+ fileMapIndex.set(staticFile, files);
466
+ }
467
+ }
468
+ }
469
+ }
470
+ catch (e) {
471
+ /* ignore: optional FileMap.xml may be missing or malformed */
472
+ }
473
+ // First, load styles from Settings0/Styles/styles.xml (Grid3 format)
474
+ const styles = new Map();
475
+ const styleEntry = entries.find((entry) => entry.entryName.endsWith('styles.xml') || entry.entryName.endsWith('style.xml'));
476
+ if (styleEntry) {
477
+ try {
478
+ const styleXmlContent = decodeText(await readEntryBuffer(styleEntry));
479
+ const styleData = parser.parse(styleXmlContent);
480
+ // Parse styles and store them in the map
481
+ // Grid3 uses StyleData.Styles.Style with Key attribute
482
+ if (styleData.StyleData?.Styles?.Style) {
483
+ const styleArray = Array.isArray(styleData.StyleData.Styles.Style)
484
+ ? styleData.StyleData.Styles.Style
485
+ : [styleData.StyleData.Styles.Style];
486
+ styleArray.forEach((style) => {
487
+ if (style['@_Key']) {
488
+ styles.set(String(style['@_Key']), style);
489
+ }
490
+ });
491
+ }
492
+ // Also handle legacy format with @_ID
493
+ else if (styleData.Styles?.Style) {
494
+ const styleArray = Array.isArray(styleData.Styles.Style)
495
+ ? styleData.Styles.Style
496
+ : [styleData.Styles.Style];
497
+ styleArray.forEach((style) => {
498
+ if (style['@_ID']) {
499
+ styles.set(String(style['@_ID']), style);
500
+ }
501
+ });
502
+ }
503
+ }
504
+ catch (e) {
505
+ console.warn('Failed to parse styles.xml:', e);
506
+ }
507
+ }
508
+ // Debug: log all entry names
509
+ console.log('[Gridset] Total zip entries:', entries.length);
510
+ const gridEntries = entries.filter((e) => e.entryName.startsWith('Grids/') && e.entryName.endsWith('grid.xml'));
511
+ console.log('[Gridset] Grid XML entries found:', gridEntries.length);
512
+ if (gridEntries.length > 0) {
513
+ console.log('[Gridset] First few grid entries:', gridEntries.slice(0, 3).map((e) => e.entryName));
514
+ }
515
+ // First pass: collect all grid names and IDs for navigation resolution
516
+ const gridNameToIdMap = new Map();
517
+ const gridIdToNameMap = new Map();
518
+ for (const entry of entries) {
519
+ if (entry.entryName.startsWith('Grids/') && entry.entryName.endsWith('grid.xml')) {
520
+ try {
521
+ const xmlContent = decodeText(await readEntryBuffer(entry));
522
+ const data = parser.parse(xmlContent);
523
+ const grid = data.Grid || data.grid;
524
+ if (!grid)
525
+ continue;
526
+ const gridId = this.textOf(grid.GridGuid || grid.gridGuid || grid.id);
527
+ const gridName = this.textOf(grid.Name) || this.textOf(grid.name) || this.textOf(grid['@_Name']);
528
+ const folderMatch = entry.entryName.match(/^Grids\/([^/]+)\//);
529
+ const folderName = folderMatch ? folderMatch[1] : undefined;
530
+ if (gridId) {
531
+ if (gridName) {
532
+ gridNameToIdMap.set(gridName, gridId);
533
+ gridIdToNameMap.set(gridId, gridName);
534
+ }
535
+ if (folderName) {
536
+ // Folder name is often used as the grid name in Jump.To commands
537
+ gridNameToIdMap.set(folderName, gridId);
538
+ if (!gridName) {
539
+ gridIdToNameMap.set(gridId, folderName);
540
+ }
541
+ }
542
+ }
543
+ }
544
+ catch (e) {
545
+ // Skip errors in first pass
546
+ }
547
+ }
548
+ }
549
+ // Second pass: process each grid file in the gridset
550
+ for (const entry of entries) {
551
+ // Only process files named grid.xml under Grids/ (any subdir)
552
+ if (entry.entryName.startsWith('Grids/') && entry.entryName.endsWith('grid.xml')) {
553
+ let xmlContent;
554
+ try {
555
+ const buffer = await readEntryBuffer(entry);
556
+ xmlContent = decodeText(buffer);
557
+ console.log(`[Gridset] Raw XML content (first 200 chars) for ${entry.entryName}:`, xmlContent.substring(0, 200));
558
+ }
559
+ catch (e) {
560
+ // Skip unreadable files
561
+ continue;
562
+ }
563
+ let data;
564
+ try {
565
+ data = parser.parse(xmlContent);
566
+ console.log(`[Gridset] Parsed ${entry.entryName}, root keys:`, Object.keys(data));
567
+ }
568
+ catch (error) {
569
+ // Skip malformed XML but log the specific error
570
+ console.warn(`Malformed XML in ${entry.entryName}: ${error.message}`);
571
+ continue;
572
+ }
573
+ // Grid3 XML: <Grid> root
574
+ const grid = data.Grid || data.grid;
575
+ if (!grid) {
576
+ console.warn(`[Gridset] No Grid/grid found in ${entry.entryName}`);
577
+ continue;
578
+ }
579
+ // Defensive: GridGuid and Name required
580
+ const gridId = this.textOf(grid.GridGuid || grid.gridGuid || grid.id);
581
+ let gridName = this.textOf(grid.Name) || this.textOf(grid.name) || this.textOf(grid['@_Name']);
582
+ if (!gridName) {
583
+ // Fallback: get folder name from entry path
584
+ const match = entry.entryName.match(/^Grids\/([^/]+)\//);
585
+ if (match)
586
+ gridName = match[1];
587
+ }
588
+ if (!gridId || !gridName) {
589
+ continue;
590
+ }
591
+ const page = new AACPage({
592
+ id: String(gridId),
593
+ name: String(gridName),
594
+ grid: [],
595
+ buttons: [],
596
+ parentId: null,
597
+ style: {
598
+ backgroundColor: grid.BackgroundColour || grid.backgroundColour,
599
+ },
600
+ });
601
+ // Calculate grid dimensions from ColumnDefinitions and RowDefinitions
602
+ const columnDefs = grid.ColumnDefinitions?.ColumnDefinition || [];
603
+ const rowDefs = grid.RowDefinitions?.RowDefinition || [];
604
+ const maxCols = Array.isArray(columnDefs) ? columnDefs.length : columnDefs ? 1 : 5;
605
+ const maxRows = Array.isArray(rowDefs) ? rowDefs.length : rowDefs ? 1 : 4;
606
+ // Process buttons: <Cells><Cell>
607
+ const cells = grid.Cells?.Cell || grid.cells?.cell;
608
+ if (cells) {
609
+ // Cells may be array or single object
610
+ const cellArr = Array.isArray(cells) ? cells : [cells];
611
+ // Create a 2D grid to track button positions
612
+ const gridLayout = [];
613
+ for (let r = 0; r < maxRows; r++) {
614
+ gridLayout[r] = new Array(maxCols).fill(null);
615
+ }
616
+ // Track grid-level prediction wordlists so we can attach them to AutoContent
617
+ const gridPredictionWords = [];
618
+ let predictionCellCounter = 0;
619
+ // Extract words from grid-level AutoContentCommands (e.g., Prediction Bar)
620
+ if (grid.AutoContentCommands) {
621
+ const collections = grid.AutoContentCommands.AutoContentCommandCollection;
622
+ const collectionArr = Array.isArray(collections)
623
+ ? collections
624
+ : collections
625
+ ? [collections]
626
+ : [];
627
+ collectionArr.forEach((collection) => {
628
+ const commands = collection.Commands?.Command;
629
+ const commandArr = Array.isArray(commands) ? commands : commands ? [commands] : [];
630
+ commandArr.forEach((command) => {
631
+ const commandId = command['@_ID'] || command.ID || command.id;
632
+ if (commandId === 'Prediction.PredictThis') {
633
+ const params = command.Parameter;
634
+ const paramArr = Array.isArray(params) ? params : params ? [params] : [];
635
+ const wordListParam = paramArr.find((p) => (p['@_Key'] || p.Key || p.key) === 'wordlist');
636
+ if (wordListParam) {
637
+ const words = this._extractWordsFromWordList(wordListParam);
638
+ gridPredictionWords.push(...words);
639
+ }
640
+ }
641
+ });
642
+ });
643
+ }
644
+ cellArr.forEach((cell, idx) => {
645
+ if (!cell || !cell.Content)
646
+ return;
647
+ // Extract position information from cell attributes
648
+ // Grid3 uses 1-based coordinates, convert to 0-based for internal use
649
+ const cellX = Math.max(0, parseInt(String(cell['@_X'] || '1'), 10) - 1);
650
+ const cellY = Math.max(0, parseInt(String(cell['@_Y'] || '1'), 10) - 1);
651
+ const colSpan = parseInt(String(cell['@_ColumnSpan'] || '1'), 10);
652
+ const rowSpan = parseInt(String(cell['@_RowSpan'] || '1'), 10);
653
+ // Extract scan block number (1-8) for block scanning support
654
+ const scanBlock = parseInt(String(cell['@_ScanBlock'] || '1'), 10);
655
+ // Extract visibility from Grid 3's <Visibility> child element
656
+ // Grid 3 stores visibility as a child element, not an attribute
657
+ // Valid values: Visible, Hidden, Disabled, PointerAndTouchOnly, TouchOnly, PointerOnly
658
+ const grid3Visibility = cell.Visibility || cell.visibility;
659
+ // Map Grid 3 visibility values to AAC standard values
660
+ // Grid 3 can have additional values like TouchOnly, PointerOnly that map to PointerAndTouchOnly
661
+ let cellVisibility;
662
+ if (grid3Visibility) {
663
+ const vis = String(grid3Visibility);
664
+ // Direct mapping for standard values
665
+ if (vis === 'Visible' ||
666
+ vis === 'Hidden' ||
667
+ vis === 'Disabled' ||
668
+ vis === 'PointerAndTouchOnly') {
669
+ cellVisibility = vis;
670
+ }
671
+ // Map Grid 3 specific values to AAC standard
672
+ else if (vis === 'TouchOnly' || vis === 'PointerOnly') {
673
+ cellVisibility = 'PointerAndTouchOnly';
674
+ }
675
+ // Grid 3 may use 'Empty' for cells that exist but have no content
676
+ else if (vis === 'Empty') {
677
+ cellVisibility = 'Empty';
678
+ }
679
+ // Unknown visibility - default to Visible
680
+ else {
681
+ cellVisibility = undefined; // Let it default
682
+ }
683
+ }
684
+ // Extract label from CaptionAndImage/Caption
685
+ const content = cell.Content;
686
+ const captionAndImage = content.CaptionAndImage || content.captionAndImage;
687
+ let label = this.textOf(captionAndImage?.Caption || captionAndImage?.caption) || '';
688
+ // Check if cell has an image/symbol (needed to decide if we should keep it)
689
+ const hasImageCandidate = !!(captionAndImage?.Image ||
690
+ captionAndImage?.image ||
691
+ captionAndImage?.ImageName ||
692
+ captionAndImage?.imageName ||
693
+ captionAndImage?.Symbol ||
694
+ captionAndImage?.symbol);
695
+ // If no caption, try other sources or create a placeholder
696
+ if (!label) {
697
+ // For cells without captions, check if they have images/symbols before skipping
698
+ if (content.ContentType === 'AutoContent') {
699
+ label = `AutoContent_${idx}`;
700
+ }
701
+ else if (hasImageCandidate ||
702
+ content.ContentType === 'Workspace' ||
703
+ content.ContentType === 'LiveCell') {
704
+ // Keep cells with images/symbols even if no caption
705
+ label = `Cell_${idx}`;
706
+ }
707
+ else {
708
+ return; // Skip cells without labels AND without images/symbols
709
+ }
710
+ }
711
+ const message = label; // Use caption as message
712
+ // Detect plugin cell type (Workspace, LiveCell, AutoContent)
713
+ const pluginMetadata = detectPluginCellType(content);
714
+ // Friendly labels for workspace/prediction cells when captions are missing
715
+ if (pluginMetadata.cellType === Grid3CellType.Workspace) {
716
+ if (!label || label.startsWith('Cell_')) {
717
+ label =
718
+ pluginMetadata.displayName ||
719
+ pluginMetadata.subType ||
720
+ pluginMetadata.pluginId ||
721
+ 'Workspace';
722
+ }
723
+ }
724
+ if (pluginMetadata.cellType === Grid3CellType.AutoContent &&
725
+ pluginMetadata.autoContentType === 'Prediction') {
726
+ predictionCellCounter += 1;
727
+ // Always surface a friendly label for predictions even if a placeholder exists
728
+ label = `Prediction ${predictionCellCounter}`;
729
+ }
730
+ // Parse all command types from Grid3 and create semantic actions
731
+ let semanticAction;
732
+ let legacyAction = null;
733
+ // infer action type implicitly from commands; no explicit enum needed
734
+ let navigationTarget;
735
+ let detectedCommands = []; // Store detected command metadata
736
+ const commands = content.Commands?.Command || content.commands?.command;
737
+ let predictionWords;
738
+ // Resolve image for this cell using FileMap and coordinate heuristics
739
+ const imageCandidate = captionAndImage?.Image ||
740
+ captionAndImage?.image ||
741
+ captionAndImage?.ImageName ||
742
+ captionAndImage?.imageName ||
743
+ captionAndImage?.Symbol ||
744
+ captionAndImage?.symbol;
745
+ const declaredImageName = imageCandidate ? this.textOf(imageCandidate) : undefined;
746
+ const gridEntryPath = entry.entryName.replace(/\\/g, '/');
747
+ const baseDir = gridEntryPath.replace(/\/grid\.xml$/, '/');
748
+ const dynamicFiles = fileMapIndex.get(gridEntryPath) || [];
749
+ const resolvedImageEntry = resolveGrid3CellImage(zip, {
750
+ baseDir,
751
+ imageName: declaredImageName,
752
+ x: cellX + 1,
753
+ y: cellY + 1,
754
+ dynamicFiles,
755
+ }, entries) || undefined;
756
+ // Check if image is a symbol library reference
757
+ let symbolLibraryRef = null;
758
+ if (declaredImageName && isSymbolLibraryReference(declaredImageName)) {
759
+ symbolLibraryRef = parseSymbolReference(declaredImageName);
760
+ }
761
+ if (commands) {
762
+ const commandArr = Array.isArray(commands) ? commands : [commands];
763
+ detectedCommands = commandArr.map((cmd) => detectCommand(cmd));
764
+ // Scan all commands for vocabulary (predictions) before identifying primary action
765
+ commandArr.forEach((cmd) => {
766
+ const id = cmd['@_ID'] || cmd.ID || cmd.id;
767
+ if (id === 'Prediction.PredictThis') {
768
+ const params = cmd.Parameter || cmd.parameter;
769
+ const pArr = params ? (Array.isArray(params) ? params : [params]) : [];
770
+ let wlP;
771
+ for (const p of pArr) {
772
+ if (p['@_Key'] === 'wordlist' || p.Key === 'wordlist' || p.key === 'wordlist') {
773
+ wlP = p;
774
+ break;
775
+ }
776
+ }
777
+ if (wlP) {
778
+ const words = this._extractWordsFromWordList(wlP);
779
+ if (words.length > 0) {
780
+ predictionWords = words;
781
+ }
782
+ }
783
+ }
784
+ });
785
+ for (const command of commandArr) {
786
+ const commandId = command['@_ID'] || command.ID || command.id;
787
+ const parameters = command.Parameter || command.parameter;
788
+ const paramArr = parameters
789
+ ? Array.isArray(parameters)
790
+ ? parameters
791
+ : [parameters]
792
+ : [];
793
+ // Helper to get raw parameter object
794
+ const getRawParam = (key) => {
795
+ for (const param of paramArr) {
796
+ if (param['@_Key'] === key || param.Key === key || param.key === key) {
797
+ return param;
798
+ }
799
+ }
800
+ return undefined;
801
+ };
802
+ // Helper to get parameter value
803
+ const getParam = (key) => {
804
+ const param = getRawParam(key);
805
+ if (param === undefined)
806
+ return undefined;
807
+ const simpleValue = param['#text'] ?? param.text ?? param.value;
808
+ if (typeof simpleValue === 'string')
809
+ return simpleValue;
810
+ if (typeof simpleValue === 'number')
811
+ return String(simpleValue);
812
+ const structuredValue = this.textOf(param);
813
+ if (structuredValue !== undefined)
814
+ return structuredValue;
815
+ if (typeof param === 'string')
816
+ return param;
817
+ return undefined;
818
+ };
819
+ // Skip PredictThis in primary action loop as it was handled in pre-pass
820
+ // unless we need a primary action and nothing else exists
821
+ if (commandId === 'Prediction.PredictThis') {
822
+ const wlParam = getRawParam('wordlist');
823
+ const words = wlParam ? this._extractWordsFromWordList(wlParam) : [];
824
+ if (words.length > 0) {
825
+ predictionWords = words;
826
+ }
827
+ if (!semanticAction && words.length > 0) {
828
+ semanticAction = {
829
+ category: AACSemanticCategory.COMMUNICATION,
830
+ intent: AACSemanticIntent.PLATFORM_SPECIFIC,
831
+ text: words.slice(0, 3).join(', '),
832
+ platformData: {
833
+ grid3: { commandId, parameters: { wordlist: words } },
834
+ },
835
+ fallback: { type: 'ACTION', message: 'Predict words' },
836
+ };
837
+ }
838
+ continue;
839
+ }
840
+ switch (commandId) {
841
+ case 'Jump.To': {
842
+ const gridTarget = getParam('grid');
843
+ if (gridTarget) {
844
+ // Resolve grid name to grid ID for navigation
845
+ const targetGridId = gridNameToIdMap.get(gridTarget) || gridTarget;
846
+ navigationTarget = targetGridId;
847
+ // navigate action
848
+ semanticAction = {
849
+ category: AACSemanticCategory.NAVIGATION,
850
+ intent: AACSemanticIntent.NAVIGATE_TO,
851
+ targetId: targetGridId,
852
+ platformData: {
853
+ grid3: {
854
+ commandId,
855
+ parameters: { grid: gridTarget },
856
+ },
857
+ },
858
+ fallback: {
859
+ type: 'NAVIGATE',
860
+ targetPageId: targetGridId,
861
+ },
862
+ };
863
+ legacyAction = {
864
+ type: 'NAVIGATE',
865
+ targetPageId: targetGridId,
866
+ };
867
+ }
868
+ break;
869
+ }
870
+ case 'Jump.Back':
871
+ // action
872
+ semanticAction = {
873
+ category: AACSemanticCategory.NAVIGATION,
874
+ intent: AACSemanticIntent.GO_BACK,
875
+ platformData: {
876
+ grid3: {
877
+ commandId,
878
+ parameters: {},
879
+ },
880
+ },
881
+ fallback: {
882
+ type: 'ACTION',
883
+ message: 'Go back',
884
+ },
885
+ };
886
+ legacyAction = {
887
+ type: 'GO_BACK',
888
+ };
889
+ break;
890
+ case 'Jump.Home':
891
+ case 'Jump.SetHome':
892
+ // action
893
+ navigationTarget = tree.rootId || undefined;
894
+ semanticAction = {
895
+ category: AACSemanticCategory.NAVIGATION,
896
+ intent: AACSemanticIntent.GO_HOME,
897
+ targetId: tree.rootId || undefined,
898
+ platformData: {
899
+ grid3: {
900
+ commandId,
901
+ parameters: {},
902
+ },
903
+ },
904
+ fallback: {
905
+ type: 'ACTION',
906
+ message: 'Go home',
907
+ },
908
+ };
909
+ legacyAction = {
910
+ type: 'GO_HOME',
911
+ };
912
+ break;
913
+ case 'Jump.ToKeyboard': {
914
+ // Navigate to the set keyboard if we found one in settings
915
+ const keyboardGridName = tree.keyboardGridName;
916
+ const keyboardPageId = gridNameToIdMap.get(keyboardGridName);
917
+ if (keyboardPageId) {
918
+ navigationTarget = keyboardPageId;
919
+ }
920
+ semanticAction = {
921
+ category: AACSemanticCategory.NAVIGATION,
922
+ intent: AACSemanticIntent.GO_HOME, // Close enough to 'navigation to keyboard'
923
+ targetId: keyboardPageId,
924
+ platformData: {
925
+ grid3: {
926
+ commandId,
927
+ parameters: {},
928
+ },
929
+ },
930
+ fallback: {
931
+ type: 'NAVIGATE',
932
+ targetPageId: keyboardPageId,
933
+ },
934
+ };
935
+ break;
936
+ }
937
+ case 'Action.InsertTextAndSpeak': {
938
+ const insertText = getParam('text');
939
+ semanticAction = {
940
+ category: AACSemanticCategory.COMMUNICATION,
941
+ intent: AACSemanticIntent.SPEAK_IMMEDIATE,
942
+ text: insertText,
943
+ platformData: {
944
+ grid3: {
945
+ commandId,
946
+ parameters: { text: insertText },
947
+ },
948
+ },
949
+ fallback: {
950
+ type: 'SPEAK',
951
+ message: insertText,
952
+ },
953
+ };
954
+ break;
955
+ }
956
+ case 'Prediction.PredictThis': {
957
+ const wlParam = getRawParam('wordlist');
958
+ const words = wlParam ? this._extractWordsFromWordList(wlParam) : [];
959
+ if (words.length > 0) {
960
+ predictionWords = words;
961
+ if (!semanticAction) {
962
+ semanticAction = {
963
+ category: AACSemanticCategory.COMMUNICATION,
964
+ intent: AACSemanticIntent.PLATFORM_SPECIFIC,
965
+ text: words.slice(0, 3).join(', '), // Provide first few as preview
966
+ platformData: {
967
+ grid3: {
968
+ commandId,
969
+ parameters: { wordlist: words },
970
+ },
971
+ },
972
+ fallback: {
973
+ type: 'ACTION',
974
+ message: 'Predict words',
975
+ },
976
+ };
977
+ }
978
+ }
979
+ // Continue to check other commands (e.g. Action.InsertText)
980
+ continue;
981
+ }
982
+ case 'Action.Speak': {
983
+ // speak
984
+ const speakUnit = getParam('unit');
985
+ const moveCaret = getParam('movecaret');
986
+ semanticAction = {
987
+ category: AACSemanticCategory.COMMUNICATION,
988
+ intent: AACSemanticIntent.SPEAK_TEXT,
989
+ platformData: {
990
+ grid3: {
991
+ commandId,
992
+ parameters: {
993
+ unit: speakUnit,
994
+ movecaret: moveCaret,
995
+ },
996
+ },
997
+ },
998
+ fallback: {
999
+ type: 'SPEAK',
1000
+ message: 'Speak text',
1001
+ },
1002
+ };
1003
+ legacyAction = {
1004
+ type: 'SPEAK',
1005
+ unit: speakUnit,
1006
+ moveCaret: moveCaret ? parseInt(String(moveCaret)) : undefined,
1007
+ };
1008
+ break;
1009
+ }
1010
+ case 'Action.InsertText': {
1011
+ // speak
1012
+ const insertText = getParam('text');
1013
+ semanticAction = {
1014
+ category: AACSemanticCategory.COMMUNICATION,
1015
+ intent: AACSemanticIntent.INSERT_TEXT,
1016
+ text: insertText,
1017
+ platformData: {
1018
+ grid3: {
1019
+ commandId,
1020
+ parameters: { text: insertText },
1021
+ },
1022
+ },
1023
+ fallback: {
1024
+ type: 'SPEAK',
1025
+ message: insertText,
1026
+ },
1027
+ };
1028
+ legacyAction = {
1029
+ type: 'INSERT_TEXT',
1030
+ text: insertText,
1031
+ };
1032
+ break;
1033
+ }
1034
+ case 'Action.DeleteWord':
1035
+ // action
1036
+ semanticAction = {
1037
+ category: AACSemanticCategory.TEXT_EDITING,
1038
+ intent: AACSemanticIntent.DELETE_WORD,
1039
+ platformData: {
1040
+ grid3: {
1041
+ commandId,
1042
+ parameters: {},
1043
+ },
1044
+ },
1045
+ fallback: {
1046
+ type: 'ACTION',
1047
+ message: 'Delete word',
1048
+ },
1049
+ };
1050
+ legacyAction = {
1051
+ type: 'DELETE_WORD',
1052
+ };
1053
+ break;
1054
+ case 'Action.DeleteLetter':
1055
+ // action
1056
+ semanticAction = {
1057
+ category: AACSemanticCategory.TEXT_EDITING,
1058
+ intent: AACSemanticIntent.DELETE_CHARACTER,
1059
+ platformData: {
1060
+ grid3: {
1061
+ commandId,
1062
+ parameters: {},
1063
+ },
1064
+ },
1065
+ fallback: {
1066
+ type: 'ACTION',
1067
+ message: 'Delete character',
1068
+ },
1069
+ };
1070
+ legacyAction = {
1071
+ type: 'DELETE_CHARACTER',
1072
+ };
1073
+ break;
1074
+ case 'Action.Clear':
1075
+ // action
1076
+ semanticAction = {
1077
+ category: AACSemanticCategory.TEXT_EDITING,
1078
+ intent: AACSemanticIntent.CLEAR_TEXT,
1079
+ platformData: {
1080
+ grid3: {
1081
+ commandId,
1082
+ parameters: {},
1083
+ },
1084
+ },
1085
+ fallback: {
1086
+ type: 'ACTION',
1087
+ message: 'Clear text',
1088
+ },
1089
+ };
1090
+ legacyAction = {
1091
+ type: 'CLEAR_TEXT',
1092
+ };
1093
+ break;
1094
+ case 'Action.Letter': {
1095
+ // action
1096
+ const letter = getParam('letter');
1097
+ semanticAction = {
1098
+ category: AACSemanticCategory.TEXT_EDITING,
1099
+ intent: AACSemanticIntent.INSERT_TEXT,
1100
+ text: letter,
1101
+ platformData: {
1102
+ grid3: {
1103
+ commandId,
1104
+ parameters: { letter },
1105
+ },
1106
+ },
1107
+ fallback: {
1108
+ type: 'ACTION',
1109
+ message: letter,
1110
+ },
1111
+ };
1112
+ legacyAction = {
1113
+ type: 'INSERT_LETTER',
1114
+ letter,
1115
+ };
1116
+ break;
1117
+ }
1118
+ case 'Settings.RestAll':
1119
+ // action
1120
+ semanticAction = {
1121
+ category: AACSemanticCategory.CUSTOM,
1122
+ intent: AACSemanticIntent.PLATFORM_SPECIFIC,
1123
+ platformData: {
1124
+ grid3: {
1125
+ commandId,
1126
+ parameters: {
1127
+ indicatorenabled: getParam('indicatorenabled'),
1128
+ action: getParam('action'),
1129
+ },
1130
+ },
1131
+ },
1132
+ fallback: {
1133
+ type: 'ACTION',
1134
+ message: 'Settings action',
1135
+ },
1136
+ };
1137
+ legacyAction = {
1138
+ type: 'SETTINGS',
1139
+ indicatorEnabled: getParam('indicatorenabled') === '1',
1140
+ settingsAction: getParam('action'),
1141
+ };
1142
+ break;
1143
+ case 'AutoContent.Activate':
1144
+ // action
1145
+ semanticAction = {
1146
+ category: AACSemanticCategory.CUSTOM,
1147
+ intent: AACSemanticIntent.PLATFORM_SPECIFIC,
1148
+ platformData: {
1149
+ grid3: {
1150
+ commandId,
1151
+ parameters: {
1152
+ autocontenttype: getParam('autocontenttype'),
1153
+ },
1154
+ },
1155
+ },
1156
+ fallback: {
1157
+ type: 'ACTION',
1158
+ message: 'Auto content',
1159
+ },
1160
+ };
1161
+ legacyAction = {
1162
+ type: 'AUTO_CONTENT',
1163
+ autoContentType: getParam('autocontenttype'),
1164
+ };
1165
+ break;
1166
+ default:
1167
+ // Unknown command - preserve as generic action
1168
+ if (commandId) {
1169
+ // action
1170
+ const allParams = Object.fromEntries(paramArr.map((p) => [p.Key || p.key, p['#text']]));
1171
+ semanticAction = {
1172
+ category: AACSemanticCategory.CUSTOM,
1173
+ intent: AACSemanticIntent.PLATFORM_SPECIFIC,
1174
+ platformData: {
1175
+ grid3: {
1176
+ commandId,
1177
+ parameters: allParams,
1178
+ },
1179
+ },
1180
+ fallback: {
1181
+ type: 'ACTION',
1182
+ message: 'Unknown command',
1183
+ },
1184
+ };
1185
+ legacyAction = {
1186
+ type: 'SPEAK',
1187
+ parameters: { commandId, ...allParams },
1188
+ };
1189
+ }
1190
+ break;
1191
+ }
1192
+ // Use first recognized command
1193
+ if (semanticAction || legacyAction)
1194
+ break;
1195
+ }
1196
+ }
1197
+ // Create default semantic action if none was created from commands
1198
+ if (!semanticAction) {
1199
+ semanticAction = {
1200
+ category: AACSemanticCategory.COMMUNICATION,
1201
+ intent: AACSemanticIntent.SPEAK_TEXT,
1202
+ text: String(message),
1203
+ fallback: {
1204
+ type: 'SPEAK',
1205
+ message: String(message),
1206
+ },
1207
+ };
1208
+ }
1209
+ // Get style information from cell attributes and Content.Style
1210
+ let cellStyleId = cell['@_StyleID'] || cell['@_styleid'];
1211
+ // Grid3 format: check Content.Style.BasedOnStyle
1212
+ if (!cellStyleId && content.Style?.BasedOnStyle) {
1213
+ cellStyleId = content.Style.BasedOnStyle;
1214
+ }
1215
+ const cellStyle = this.getStyleById(styles, cellStyleId ? String(cellStyleId) : undefined);
1216
+ // Also check for inline style overrides
1217
+ const inlineStyle = {};
1218
+ if (cell['@_BackColour'])
1219
+ inlineStyle.backgroundColor = cell['@_BackColour'];
1220
+ if (cell['@_FontColour'])
1221
+ inlineStyle.fontColor = cell['@_FontColour'];
1222
+ if (cell['@_BorderColour'])
1223
+ inlineStyle.borderColor = cell['@_BorderColour'];
1224
+ // Grid3 inline styles from Content.Style
1225
+ if (content.Style) {
1226
+ if (content.Style.BackColour)
1227
+ inlineStyle.backgroundColor = content.Style.BackColour;
1228
+ if (content.Style.FontColour)
1229
+ inlineStyle.fontColor = content.Style.FontColour;
1230
+ if (content.Style.BorderColour)
1231
+ inlineStyle.borderColor = content.Style.BorderColour;
1232
+ if (content.Style.FontName)
1233
+ inlineStyle.fontFamily = content.Style.FontName;
1234
+ if (content.Style.FontSize)
1235
+ inlineStyle.fontSize = parseInt(String(content.Style.FontSize));
1236
+ }
1237
+ // Extract grammar tags from commands (Smart Grammar)
1238
+ const grammar = {};
1239
+ detectedCommands.forEach((cmd) => {
1240
+ if (cmd.parameters.pos)
1241
+ grammar.pos = cmd.parameters.pos;
1242
+ if (cmd.parameters.person)
1243
+ grammar.person = cmd.parameters.person;
1244
+ if (cmd.parameters.number)
1245
+ grammar.number = cmd.parameters.number;
1246
+ if (cmd.parameters.feature)
1247
+ grammar.feature = cmd.parameters.feature;
1248
+ });
1249
+ const isSmartGrammarCell = Object.keys(grammar).length > 0;
1250
+ const button = new AACButton({
1251
+ id: `${gridId}_btn_${idx}`,
1252
+ label: String(label),
1253
+ message: String(message),
1254
+ targetPageId: navigationTarget ? String(navigationTarget) : undefined,
1255
+ semanticAction: semanticAction,
1256
+ semantic_id: cell.semantic_id || cell.SemanticId || undefined, // Extract semantic_id if present
1257
+ image: declaredImageName,
1258
+ resolvedImageEntry: resolvedImageEntry,
1259
+ x: cellX,
1260
+ y: cellY,
1261
+ columnSpan: colSpan,
1262
+ rowSpan: rowSpan,
1263
+ scanBlock: scanBlock, // Add scan block number for block scanning metrics
1264
+ contentType: pluginMetadata.cellType === Grid3CellType.Regular
1265
+ ? 'Normal'
1266
+ : pluginMetadata.cellType === Grid3CellType.Workspace
1267
+ ? 'Workspace'
1268
+ : pluginMetadata.cellType === Grid3CellType.LiveCell
1269
+ ? 'LiveCell'
1270
+ : 'AutoContent',
1271
+ contentSubType: pluginMetadata.subType ||
1272
+ pluginMetadata.liveCellType ||
1273
+ pluginMetadata.autoContentType,
1274
+ symbolLibrary: symbolLibraryRef?.library || undefined,
1275
+ symbolPath: symbolLibraryRef?.path || undefined,
1276
+ visibility: cellVisibility,
1277
+ style: {
1278
+ ...cellStyle,
1279
+ ...inlineStyle, // Inline styles override referenced styles
1280
+ },
1281
+ // Store predictions directly on button for easy access
1282
+ predictions: predictionWords?.length
1283
+ ? [...predictionWords]
1284
+ : gridPredictionWords.length > 0
1285
+ ? [...gridPredictionWords]
1286
+ : undefined,
1287
+ parameters: {
1288
+ pluginMetadata: pluginMetadata, // Store full plugin metadata for future use
1289
+ grid3Commands: detectedCommands, // Store detected command metadata
1290
+ symbolLibraryRef: symbolLibraryRef, // Store full symbol reference
1291
+ grammar: isSmartGrammarCell ? grammar : undefined,
1292
+ isSmartGrammarCell: isSmartGrammarCell,
1293
+ predictions: predictionWords?.length
1294
+ ? [...predictionWords]
1295
+ : gridPredictionWords.length > 0
1296
+ ? [...gridPredictionWords]
1297
+ : undefined,
1298
+ predictionSlot: pluginMetadata.cellType === Grid3CellType.AutoContent &&
1299
+ pluginMetadata.autoContentType === 'Prediction'
1300
+ ? predictionCellCounter
1301
+ : undefined,
1302
+ // Store page name for Grid3 image lookup
1303
+ gridPageName: gridName,
1304
+ },
1305
+ });
1306
+ // Add button to page
1307
+ page.addButton(button);
1308
+ // Place button in grid layout (handle colspan/rowspan)
1309
+ for (let r = cellY; r < cellY + rowSpan && r < maxRows; r++) {
1310
+ for (let c = cellX; c < cellX + colSpan && c < maxCols; c++) {
1311
+ if (gridLayout[r] && gridLayout[r][c] === null) {
1312
+ gridLayout[r][c] = button;
1313
+ }
1314
+ }
1315
+ }
1316
+ });
1317
+ // Set the page's grid layout
1318
+ page.grid = gridLayout;
1319
+ // Generate clone_id for each button in the grid
1320
+ const semanticIds = [];
1321
+ const cloneIds = [];
1322
+ gridLayout.forEach((row, rowIndex) => {
1323
+ row.forEach((btn, colIndex) => {
1324
+ if (btn) {
1325
+ // Generate clone_id based on position and label
1326
+ btn.clone_id = generateCloneId(maxRows, maxCols, rowIndex, colIndex, btn.label);
1327
+ cloneIds.push(btn.clone_id);
1328
+ // Track semantic_id if present
1329
+ if (btn.semantic_id) {
1330
+ semanticIds.push(btn.semantic_id);
1331
+ }
1332
+ }
1333
+ });
1334
+ });
1335
+ // Track IDs on the page
1336
+ if (semanticIds.length > 0) {
1337
+ page.semantic_ids = semanticIds;
1338
+ }
1339
+ if (cloneIds.length > 0) {
1340
+ page.clone_ids = cloneIds;
1341
+ }
1342
+ }
1343
+ tree.addPage(page);
1344
+ }
1345
+ }
1346
+ // After all pages are loaded, set parentId for navigation targets
1347
+ for (const pageId in tree.pages) {
1348
+ const page = tree.pages[pageId];
1349
+ page.buttons.forEach((btn) => {
1350
+ if (btn.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && btn.targetPageId) {
1351
+ const targetPage = tree.getPage(btn.targetPageId);
1352
+ if (targetPage) {
1353
+ targetPage.parentId = page.id;
1354
+ }
1355
+ }
1356
+ });
1357
+ }
1358
+ // Read settings.xml to get the StartGrid (home page)
1359
+ try {
1360
+ const settingsEntry = entries.find((e) => e.entryName.endsWith('settings.xml'));
1361
+ if (settingsEntry) {
1362
+ const settingsXml = decodeText(await readEntryBuffer(settingsEntry));
1363
+ const settingsData = parser.parse(settingsXml);
1364
+ const gsName = settingsData?.GridSetSettings?.Name ||
1365
+ settingsData?.gridSetSettings?.name ||
1366
+ settingsData?.GridsetSettings?.Name;
1367
+ if (gsName)
1368
+ metadata.name = gsName;
1369
+ const gsDesc = settingsData?.GridSetSettings?.Description ||
1370
+ settingsData?.gridSetSettings?.description ||
1371
+ settingsData?.GridsetSettings?.Description;
1372
+ if (gsDesc)
1373
+ metadata.description = gsDesc;
1374
+ const gsLang = settingsData?.GridSetSettings?.PrimaryLanguage ||
1375
+ settingsData?.gridSetSettings?.primaryLanguage ||
1376
+ settingsData?.GridsetSettings?.PrimaryLanguage;
1377
+ if (gsLang && typeof gsLang === 'string') {
1378
+ metadata.locale = gsLang;
1379
+ metadata.languages = [gsLang];
1380
+ }
1381
+ const gsAuthor = settingsData?.GridSetSettings?.Author ||
1382
+ settingsData?.gridSetSettings?.author ||
1383
+ settingsData?.GridsetSettings?.Author;
1384
+ if (gsAuthor)
1385
+ metadata.author = gsAuthor;
1386
+ const docUrl = settingsData?.GridSetSettings?.DocumentationUrl ||
1387
+ settingsData?.gridSetSettings?.documentationUrl ||
1388
+ settingsData?.GridsetSettings?.DocumentationUrl;
1389
+ if (docUrl) {
1390
+ metadata.homepageUrl = docUrl;
1391
+ metadata.documentationUrl = docUrl;
1392
+ }
1393
+ const docSlug = settingsData?.GridSetSettings?.DocumentationSlug ||
1394
+ settingsData?.gridSetSettings?.documentationSlug ||
1395
+ settingsData?.GridsetSettings?.DocumentationSlug;
1396
+ if (docSlug)
1397
+ metadata.documentationSlug = docSlug;
1398
+ const thumbnail = settingsData?.GridSetSettings?.Thumbnail ||
1399
+ settingsData?.gridSetSettings?.thumbnail ||
1400
+ settingsData?.GridsetSettings?.Thumbnail;
1401
+ if (thumbnail)
1402
+ metadata.thumbnail = thumbnail;
1403
+ const thumbBg = settingsData?.GridSetSettings?.ThumbnailBackground ||
1404
+ settingsData?.gridSetSettings?.thumbnailBackground ||
1405
+ settingsData?.GridsetSettings?.ThumbnailBackground;
1406
+ if (thumbBg)
1407
+ metadata.thumbnailBackground = thumbBg;
1408
+ const picSearchKeys = settingsData?.GridSetSettings?.PictureSearch?.PictureSearchKeys?.PictureSearchKey ||
1409
+ settingsData?.gridSetSettings?.pictureSearch?.pictureSearchKeys?.pictureSearchKey ||
1410
+ settingsData?.GridsetSettings?.PictureSearch?.PictureSearchKeys?.PictureSearchKey;
1411
+ if (picSearchKeys) {
1412
+ metadata.pictureSearchKeys = Array.isArray(picSearchKeys)
1413
+ ? picSearchKeys
1414
+ : [picSearchKeys];
1415
+ }
1416
+ const appearance = settingsData?.GridSetSettings?.Appearance ||
1417
+ settingsData?.gridSetSettings?.appearance ||
1418
+ settingsData?.GridsetSettings?.Appearance;
1419
+ if (appearance) {
1420
+ metadata.appearance = {
1421
+ textAtTop: appearance.TextAtTop === '1' ||
1422
+ appearance.textAtTop === '1' ||
1423
+ appearance.TextAtTop === 1,
1424
+ computerControlCellSize: appearance.ComputerControlCellSize
1425
+ ? parseFloat(String(appearance.ComputerControlCellSize))
1426
+ : undefined,
1427
+ };
1428
+ }
1429
+ const startGridName = settingsData?.GridSetSettings?.StartGrid ||
1430
+ settingsData?.gridSetSettings?.startGrid ||
1431
+ settingsData?.GridsetSettings?.StartGrid;
1432
+ if (startGridName && typeof startGridName === 'string') {
1433
+ // Resolve the grid name to grid ID
1434
+ const homeGridId = gridNameToIdMap.get(startGridName);
1435
+ if (homeGridId) {
1436
+ metadata.defaultHomePageId = homeGridId;
1437
+ // Also set tree.rootId so BoardViewer knows which page to show first
1438
+ tree.rootId = homeGridId;
1439
+ }
1440
+ }
1441
+ const keyboardGridName = settingsData?.GridSetSettings?.KeyboardGrid ||
1442
+ settingsData?.gridSetSettings?.keyboardGrid ||
1443
+ settingsData?.GridsetSettings?.KeyboardGrid;
1444
+ if (keyboardGridName && typeof keyboardGridName === 'string') {
1445
+ metadata.defaultKeyboardPageId = gridNameToIdMap.get(keyboardGridName);
1446
+ }
1447
+ }
1448
+ }
1449
+ catch (e) {
1450
+ // If settings.xml parsing fails, tree.rootId will default to first page
1451
+ }
1452
+ // Set metadata on tree
1453
+ tree.metadata = metadata;
1454
+ return tree;
1455
+ }
1456
+ async processTexts(filePathOrBuffer, translations, outputPath) {
1457
+ // Load the tree, apply translations, and save to new file
1458
+ const tree = await this.loadIntoTree(filePathOrBuffer);
1459
+ // Apply translations to all text content
1460
+ Object.values(tree.pages).forEach((page) => {
1461
+ // Translate page names
1462
+ if (page.name && translations.has(page.name)) {
1463
+ const tPage = translations.get(page.name);
1464
+ if (tPage)
1465
+ page.name = tPage;
1466
+ }
1467
+ // Translate button labels and messages, preserving symbol positions
1468
+ page.buttons.forEach((button) => {
1469
+ // Translate label
1470
+ if (button.label && translations.has(button.label)) {
1471
+ const tLabel = translations.get(button.label);
1472
+ if (tLabel)
1473
+ button.label = tLabel;
1474
+ }
1475
+ // Translate message with symbol preservation
1476
+ if (button.message && translations.has(button.message)) {
1477
+ const originalMessage = button.message;
1478
+ const translatedText = translations.get(originalMessage);
1479
+ if (translatedText) {
1480
+ // Extract symbols from the button (from richText or image fields)
1481
+ const symbols = extractSymbolsFromButton(button);
1482
+ if (symbols && symbols.length > 0) {
1483
+ // Use symbol-aware translation to preserve symbol positions
1484
+ const result = translateWithSymbols(originalMessage, translatedText, symbols);
1485
+ // Update the message
1486
+ button.message = result.text;
1487
+ // Update the rich text structure if it exists
1488
+ if (button.semanticAction?.richText) {
1489
+ button.semanticAction.richText.text = result.text;
1490
+ button.semanticAction.richText.symbols = result.richTextSymbols;
1491
+ }
1492
+ else if (result.richTextSymbols.length > 0) {
1493
+ // Create rich text structure if it doesn't exist but we have symbols
1494
+ if (!button.semanticAction) {
1495
+ button.semanticAction = {
1496
+ category: AACSemanticCategory.COMMUNICATION,
1497
+ intent: AACSemanticIntent.SPEAK_TEXT,
1498
+ text: result.text,
1499
+ };
1500
+ }
1501
+ button.semanticAction.richText = {
1502
+ text: result.text,
1503
+ symbols: result.richTextSymbols,
1504
+ };
1505
+ }
1506
+ }
1507
+ else {
1508
+ // No symbols to preserve, simple translation
1509
+ button.message = translatedText;
1510
+ }
1511
+ }
1512
+ }
1513
+ });
1514
+ });
1515
+ // Save the translated tree and return its content
1516
+ await this.saveFromTree(tree, outputPath);
1517
+ return readBinaryFromInput(outputPath);
1518
+ }
1519
+ /**
1520
+ * Extract symbol information from a gridset for LLM-based translation.
1521
+ * Returns a structured format showing which buttons have symbols and their context.
1522
+ *
1523
+ * This method uses shared translation utilities that work across all AAC formats.
1524
+ *
1525
+ * @param filePathOrBuffer - Path to gridset file or buffer
1526
+ * @returns Array of symbol information for LLM processing
1527
+ */
1528
+ async extractSymbolsForLLM(filePathOrBuffer) {
1529
+ const tree = await this.loadIntoTree(filePathOrBuffer);
1530
+ // Collect all buttons from all pages
1531
+ const allButtons = [];
1532
+ Object.values(tree.pages).forEach((page) => {
1533
+ page.buttons.forEach((button) => {
1534
+ // Add page context to each button
1535
+ button.pageId = page.id;
1536
+ button.pageName = page.name || page.id;
1537
+ allButtons.push(button);
1538
+ });
1539
+ });
1540
+ // Use shared utility to extract buttons with translation context
1541
+ return extractAllButtonsForTranslation(allButtons, (button) => ({
1542
+ pageId: button.pageId,
1543
+ pageName: button.pageName,
1544
+ }));
1545
+ }
1546
+ /**
1547
+ * Apply LLM translations with symbol information.
1548
+ * The LLM should provide translations with symbol attachments in the correct positions.
1549
+ *
1550
+ * This method uses shared translation utilities that work across all AAC formats.
1551
+ *
1552
+ * @param filePathOrBuffer - Path to gridset file or buffer
1553
+ * @param llmTranslations - Array of LLM translations with symbol info
1554
+ * @param outputPath - Where to save the translated gridset
1555
+ * @param options - Translation options (e.g., allowPartial for testing)
1556
+ * @returns Buffer of the translated gridset
1557
+ */
1558
+ async processLLMTranslations(filePathOrBuffer, llmTranslations, outputPath, options) {
1559
+ const tree = await this.loadIntoTree(filePathOrBuffer);
1560
+ // Validate translations using shared utility
1561
+ const buttonIds = Object.values(tree.pages).flatMap((page) => page.buttons.map((b) => b.id));
1562
+ validateTranslationResults(llmTranslations, buttonIds, options);
1563
+ // Create a map for quick lookup
1564
+ const translationMap = new Map(llmTranslations.map((t) => [t.buttonId, t]));
1565
+ // Apply translations
1566
+ Object.values(tree.pages).forEach((page) => {
1567
+ page.buttons.forEach((button) => {
1568
+ const translation = translationMap.get(button.id);
1569
+ if (!translation)
1570
+ return;
1571
+ // Apply label translation
1572
+ if (translation.translatedLabel) {
1573
+ button.label = translation.translatedLabel;
1574
+ }
1575
+ // Apply message translation
1576
+ if (translation.translatedMessage) {
1577
+ button.message = translation.translatedMessage;
1578
+ // Update rich text if symbols provided
1579
+ if (translation.symbols && translation.symbols.length > 0) {
1580
+ if (!button.semanticAction) {
1581
+ button.semanticAction = {
1582
+ category: AACSemanticCategory.COMMUNICATION,
1583
+ intent: AACSemanticIntent.SPEAK_TEXT,
1584
+ text: translation.translatedMessage,
1585
+ };
1586
+ }
1587
+ button.semanticAction.richText = {
1588
+ text: translation.translatedMessage,
1589
+ symbols: translation.symbols,
1590
+ };
1591
+ }
1592
+ }
1593
+ });
1594
+ });
1595
+ // Save and return
1596
+ await this.saveFromTree(tree, outputPath);
1597
+ return readBinaryFromInput(outputPath);
1598
+ }
1599
+ async saveFromTree(tree, outputPath) {
1600
+ const JSZip = await getJSZip();
1601
+ const zip = new JSZip();
1602
+ if (Object.keys(tree.pages).length === 0) {
1603
+ // Create empty zip for empty tree
1604
+ const zipBuffer = await zip.generateAsync({ type: 'uint8array' });
1605
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1606
+ require('fs').writeFileSync(outputPath, zipBuffer);
1607
+ return;
1608
+ }
1609
+ // Collect all unique styles from pages and buttons
1610
+ const uniqueStyles = new Map();
1611
+ let styleIdCounter = 1;
1612
+ // Track images that need to be written to the ZIP
1613
+ // Maps button ID to image data for buttons with images
1614
+ const buttonImages = new Map();
1615
+ // Helper function to add style and return its ID
1616
+ const addStyle = (style) => {
1617
+ if (!style)
1618
+ return '';
1619
+ const normalizedStyle = { ...style };
1620
+ const styleKey = JSON.stringify(normalizedStyle);
1621
+ const existing = uniqueStyles.get(styleKey);
1622
+ if (existing)
1623
+ return existing.id;
1624
+ const styleId = `Style${styleIdCounter++}`;
1625
+ uniqueStyles.set(styleKey, { id: styleId, style: normalizedStyle });
1626
+ return styleId;
1627
+ };
1628
+ // Collect styles from all pages and buttons
1629
+ Object.values(tree.pages).forEach((page) => {
1630
+ addStyle(page.style);
1631
+ page.buttons.forEach((button) => {
1632
+ addStyle(button.style);
1633
+ });
1634
+ });
1635
+ // Get the home/start grid from tree.rootId, fallback to first page
1636
+ const pages = Object.values(tree.pages);
1637
+ let startGrid = '';
1638
+ if (tree.rootId) {
1639
+ const homePage = tree.getPage(tree.rootId);
1640
+ if (homePage) {
1641
+ startGrid = homePage.name || homePage.id;
1642
+ }
1643
+ }
1644
+ // Fallback to first page if no rootId or page not found
1645
+ if (!startGrid && pages.length > 0) {
1646
+ startGrid = pages[0].name || pages[0].id;
1647
+ }
1648
+ // Create Settings0/settings.xml with proper Grid3 structure
1649
+ const settingsData = {
1650
+ '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' },
1651
+ GridSetSettings: {
1652
+ '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
1653
+ Name: tree.metadata?.name || '',
1654
+ Description: tree.metadata?.description || '',
1655
+ Author: tree.metadata?.author || '',
1656
+ PrimaryLanguage: tree.metadata?.locale || 'en-US',
1657
+ StartGrid: startGrid,
1658
+ // Add other common Grid3 settings
1659
+ Thumbnail: tree.metadata?.thumbnail || '',
1660
+ ThumbnailBackground: tree.metadata?.thumbnailBackground || '',
1661
+ DocumentationUrl: tree.metadata?.homepageUrl || tree.metadata?.url || '',
1662
+ DocumentationSlug: tree.metadata?.documentationSlug || '',
1663
+ ScanEnabled: 'false',
1664
+ ScanTimeoutMs: '2000',
1665
+ HoverEnabled: 'false',
1666
+ HoverTimeoutMs: '1000',
1667
+ MouseclickEnabled: 'true',
1668
+ Language: tree.metadata?.locale || 'en-US',
1669
+ },
1670
+ };
1671
+ const settingsBuilder = new XMLBuilder({
1672
+ ignoreAttributes: false,
1673
+ format: true,
1674
+ indentBy: ' ',
1675
+ suppressEmptyNode: true,
1676
+ });
1677
+ const settingsXmlContent = settingsBuilder.build(settingsData);
1678
+ zip.file('Settings0/settings.xml', settingsXmlContent, { binary: false });
1679
+ // Create Settings0/Styles/style.xml if there are styles
1680
+ if (uniqueStyles.size > 0) {
1681
+ const stylesArray = Array.from(uniqueStyles.values()).map(({ id, style }) => {
1682
+ const styleObj = {
1683
+ '@_Key': id,
1684
+ // When TileColour is present, BackColour is the surround (outer area)
1685
+ // For "None" surround, just use BackColour for the fill (no TileColour)
1686
+ BackColour: this.ensureAlphaChannel(style.backgroundColor),
1687
+ BorderColour: this.ensureAlphaChannel(style.borderColor),
1688
+ // Calculate font color based on background if not explicitly set
1689
+ FontColour: this.ensureAlphaChannel(style.fontColor || this.getContrastFontColor(style.backgroundColor)),
1690
+ FontName: style.fontFamily || 'Arial',
1691
+ FontSize: style.fontSize?.toString() || '16',
1692
+ };
1693
+ // Don't add TileColour - just use BackColour as the fill color
1694
+ return styleObj;
1695
+ });
1696
+ const styleData = {
1697
+ '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' },
1698
+ StyleData: {
1699
+ '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
1700
+ Styles: {
1701
+ Style: stylesArray,
1702
+ },
1703
+ },
1704
+ };
1705
+ const styleBuilder = new XMLBuilder({
1706
+ ignoreAttributes: false,
1707
+ format: true,
1708
+ indentBy: ' ',
1709
+ });
1710
+ const styleXmlContent = styleBuilder.build(styleData);
1711
+ zip.file('Settings0/Styles/styles.xml', styleXmlContent, { binary: false });
1712
+ }
1713
+ // Collect grid file paths for FileMap.xml
1714
+ const gridFilePaths = [];
1715
+ // Create a grid for each page
1716
+ Object.values(tree.pages).forEach((page) => {
1717
+ const gridData = {
1718
+ Grid: {
1719
+ '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
1720
+ GridGuid: page.id,
1721
+ // Calculate grid dimensions based on actual layout
1722
+ ColumnDefinitions: this.calculateColumnDefinitions(page),
1723
+ RowDefinitions: this.calculateRowDefinitions(page, false), // No automatic workspace row injection
1724
+ AutoContentCommands: '',
1725
+ Cells: page.buttons.length > 0
1726
+ ? {
1727
+ Cell: [
1728
+ // Regular button cells
1729
+ ...this.filterPageButtons(page.buttons).map((button, btnIndex) => {
1730
+ const buttonStyleId = button.style ? addStyle(button.style) : '';
1731
+ // Find button position in grid layout
1732
+ const position = this.findButtonPosition(page, button, btnIndex);
1733
+ // Use position directly from tree
1734
+ const yOffset = 0;
1735
+ // Build CaptionAndImage object
1736
+ const captionAndImage = {
1737
+ Caption: button.label || '',
1738
+ };
1739
+ // Add image reference if button has an image
1740
+ // Grid3 uses coordinate-based naming: {x}-{y}-0-text-0.{ext}
1741
+ if (button.image) {
1742
+ // Try to determine file extension from image name or default to PNG
1743
+ let imageExt = 'png';
1744
+ const imageMatch = button.image.match(/\.(png|jpg|jpeg|gif|svg)$/i);
1745
+ if (imageMatch) {
1746
+ imageExt = imageMatch[1].toLowerCase();
1747
+ }
1748
+ // Extract image data from button parameters if available
1749
+ // (AstericsGridProcessor stores it there during loadIntoTree)
1750
+ // Also handle data URLs from OBZ conversion
1751
+ let imageData = Buffer.alloc(0);
1752
+ let hasImageData = false;
1753
+ if (button.parameters &&
1754
+ button.parameters.imageData &&
1755
+ Buffer.isBuffer(button.parameters.imageData)) {
1756
+ imageData = button.parameters.imageData;
1757
+ hasImageData = imageData.length > 0;
1758
+ }
1759
+ else if (button.image &&
1760
+ typeof button.image === 'string' &&
1761
+ button.image.startsWith('data:image')) {
1762
+ // Convert data URL to Buffer (for OBZ → Grid3 conversion)
1763
+ try {
1764
+ const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/);
1765
+ if (matches) {
1766
+ const extension = matches[1]; // e.g., 'png', 'jpeg', 'gif'
1767
+ const base64Data = matches[2];
1768
+ imageData = Buffer.from(base64Data, 'base64');
1769
+ imageExt = extension; // Override the detected extension
1770
+ hasImageData = imageData.length > 0;
1771
+ }
1772
+ }
1773
+ catch (err) {
1774
+ console.warn(`[Grid3] Failed to convert data URL to Buffer for button ${button.id}:`, err);
1775
+ }
1776
+ }
1777
+ // Only add image reference if we have actual image data
1778
+ if (hasImageData) {
1779
+ // Grid3 dynamically constructs image filenames by prepending cell coordinates
1780
+ // The XML should only contain the suffix: -0-text-0.{ext}
1781
+ // Grid3 automatically adds the X-Y prefix based on the Cell's position
1782
+ captionAndImage.Image = `-0-text-0.${imageExt}`;
1783
+ // Store image data for later writing to ZIP
1784
+ buttonImages.set(button.id, {
1785
+ imageData: imageData,
1786
+ ext: imageExt,
1787
+ pageName: page.name || page.id,
1788
+ x: position.x,
1789
+ y: position.y + yOffset,
1790
+ });
1791
+ }
1792
+ }
1793
+ const cellData = {
1794
+ '@_X': position.x + 1, // Grid3 uses 1-based X coordinates
1795
+ '@_Y': position.y + yOffset + 1, // Grid3 uses 1-based Y coordinates with workspace offset
1796
+ '@_ColumnSpan': position.columnSpan,
1797
+ '@_RowSpan': position.rowSpan,
1798
+ Content: {
1799
+ ContentType: button.contentType === 'Normal' ? undefined : button.contentType,
1800
+ ContentSubType: button.contentSubType,
1801
+ Commands: this.generateCommandsFromSemanticAction(button, tree),
1802
+ CaptionAndImage: captionAndImage,
1803
+ },
1804
+ };
1805
+ // Add style reference and inline color overrides if available
1806
+ // Some Grid3 versions need inline colors in addition to style references
1807
+ if (buttonStyleId || button.style) {
1808
+ const styleObj = {};
1809
+ // Add style reference if we have one
1810
+ if (buttonStyleId) {
1811
+ styleObj.BasedOnStyle = buttonStyleId;
1812
+ }
1813
+ // Add inline color overrides for better Grid3 compatibility
1814
+ if (button.style?.backgroundColor) {
1815
+ // Use BackColour for fill (no TileColour means no surround, just the fill)
1816
+ styleObj.BackColour = this.ensureAlphaChannel(button.style.backgroundColor);
1817
+ }
1818
+ if (button.style?.borderColor) {
1819
+ styleObj.BorderColour = this.ensureAlphaChannel(button.style.borderColor);
1820
+ }
1821
+ // Always add font color inline - either from button style or calculated from background
1822
+ const fontColor = button.style?.fontColor ||
1823
+ this.getContrastFontColor(button.style?.backgroundColor);
1824
+ styleObj.FontColour = this.ensureAlphaChannel(fontColor);
1825
+ if (button.style?.fontFamily) {
1826
+ styleObj.FontName = button.style.fontFamily;
1827
+ }
1828
+ if (button.style?.fontSize) {
1829
+ styleObj.FontSize = button.style.fontSize;
1830
+ }
1831
+ cellData.Content.Style = styleObj;
1832
+ }
1833
+ return cellData;
1834
+ }),
1835
+ ],
1836
+ }
1837
+ : { Cell: [] },
1838
+ },
1839
+ };
1840
+ // Convert to XML
1841
+ const builder = new XMLBuilder({
1842
+ ignoreAttributes: false,
1843
+ format: true,
1844
+ indentBy: ' ',
1845
+ suppressEmptyNode: true,
1846
+ cdataPropName: '__cdata',
1847
+ });
1848
+ const xmlContent = builder.build(gridData);
1849
+ // Add to zip in Grids folder with proper Grid3 naming
1850
+ const gridPath = `Grids/${page.name || page.id}/grid.xml`;
1851
+ gridFilePaths.push(gridPath);
1852
+ zip.file(gridPath, xmlContent, { binary: false });
1853
+ });
1854
+ // Write image files to ZIP
1855
+ buttonImages.forEach((imgData) => {
1856
+ if (imgData.imageData && imgData.imageData.length > 0) {
1857
+ // Create image path in the grid's directory
1858
+ const imagePath = `Grids/${imgData.pageName}/${imgData.x}-${imgData.y}-0-text-0.${imgData.ext}`;
1859
+ zip.file(imagePath, imgData.imageData);
1860
+ }
1861
+ });
1862
+ // Create FileMap.xml to map all grid files with their dynamic image files
1863
+ const fileMapData = {
1864
+ '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' },
1865
+ FileMap: {
1866
+ '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
1867
+ Entries: {
1868
+ Entry: gridFilePaths.map((gridPath) => {
1869
+ // Find all image files for this grid
1870
+ const gridName = gridPath.match(/Grids\/([^/]+)\/grid\.xml$/)?.[1] || '';
1871
+ const imageFiles = [];
1872
+ // Collect image filenames for buttons on this page
1873
+ // IMPORTANT: FileMap.xml requires full paths like "Grids/PageName/1-5-0-text-0.png"
1874
+ buttonImages.forEach((imgData) => {
1875
+ if (imgData.pageName === gridName && imgData.imageData.length > 0) {
1876
+ const imagePath = `Grids/${gridName}/${imgData.x}-${imgData.y}-0-text-0.${imgData.ext}`;
1877
+ imageFiles.push(imagePath);
1878
+ }
1879
+ });
1880
+ return {
1881
+ '@_StaticFile': gridPath,
1882
+ DynamicFiles: imageFiles.length > 0
1883
+ ? {
1884
+ File: imageFiles,
1885
+ }
1886
+ : {},
1887
+ };
1888
+ }),
1889
+ },
1890
+ },
1891
+ };
1892
+ const fileMapBuilder = new XMLBuilder({
1893
+ ignoreAttributes: false,
1894
+ format: true,
1895
+ indentBy: ' ',
1896
+ });
1897
+ const fileMapXmlContent = fileMapBuilder.build(fileMapData);
1898
+ zip.file('FileMap.xml', fileMapXmlContent, { binary: false });
1899
+ // Write the zip file
1900
+ const zipBuffer = await zip.generateAsync({ type: 'uint8array' });
1901
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1902
+ require('fs').writeFileSync(outputPath, zipBuffer);
1903
+ }
1904
+ // Helper method to calculate column definitions based on page layout
1905
+ calculateColumnDefinitions(page) {
1906
+ let maxCols = 4; // Default minimum
1907
+ if (page.grid && page.grid.length > 0) {
1908
+ maxCols = Math.max(maxCols, page.grid[0]?.length || 0);
1909
+ }
1910
+ else {
1911
+ // Fallback: estimate from button count
1912
+ maxCols = Math.max(4, Math.ceil(Math.sqrt(page.buttons.length)));
1913
+ }
1914
+ return {
1915
+ ColumnDefinition: Array(maxCols).fill({}),
1916
+ };
1917
+ }
1918
+ // Helper method to calculate row definitions based on page layout
1919
+ calculateRowDefinitions(page, addWorkspaceOffset = false) {
1920
+ let maxRows = 4; // Default minimum
1921
+ const offset = addWorkspaceOffset ? 1 : 0;
1922
+ if (page.grid && page.grid.length > 0) {
1923
+ maxRows = Math.max(maxRows, page.grid.length + offset);
1924
+ }
1925
+ else {
1926
+ // Fallback: estimate from button count
1927
+ const estimatedCols = Math.ceil(Math.sqrt(page.buttons.length));
1928
+ maxRows = Math.max(4, Math.ceil(page.buttons.length / estimatedCols)) + offset;
1929
+ }
1930
+ return {
1931
+ RowDefinition: Array(maxRows).fill({}),
1932
+ };
1933
+ }
1934
+ // Helper method to find button position with span information
1935
+ findButtonPosition(page, button, fallbackIndex) {
1936
+ if (page.grid && page.grid.length > 0) {
1937
+ // Search for button in grid layout and calculate span
1938
+ for (let y = 0; y < page.grid.length; y++) {
1939
+ for (let x = 0; x < page.grid[y].length; x++) {
1940
+ const current = page.grid[y][x];
1941
+ if (current && current.id === button.id) {
1942
+ // Calculate span by checking how far the same button extends
1943
+ let columnSpan = 1;
1944
+ let rowSpan = 1;
1945
+ // Check column span (rightward)
1946
+ while (x + columnSpan < page.grid[y].length) {
1947
+ const right = page.grid[y][x + columnSpan];
1948
+ if (right && right.id === button.id) {
1949
+ columnSpan++;
1950
+ }
1951
+ else {
1952
+ break;
1953
+ }
1954
+ }
1955
+ // Check row span (downward)
1956
+ while (y + rowSpan < page.grid.length) {
1957
+ const below = page.grid[y + rowSpan][x];
1958
+ if (below && below.id === button.id) {
1959
+ rowSpan++;
1960
+ }
1961
+ else {
1962
+ break;
1963
+ }
1964
+ }
1965
+ return { x, y, columnSpan, rowSpan };
1966
+ }
1967
+ }
1968
+ }
1969
+ }
1970
+ // Fallback positioning
1971
+ const gridCols = page.grid?.[0]?.length || Math.ceil(Math.sqrt(page.buttons.length));
1972
+ return {
1973
+ x: fallbackIndex % gridCols,
1974
+ y: Math.floor(fallbackIndex / gridCols),
1975
+ columnSpan: 1,
1976
+ rowSpan: 1,
1977
+ };
1978
+ }
1979
+ /**
1980
+ * Extract strings with metadata for aac-tools-platform compatibility
1981
+ * Uses the generic implementation from BaseProcessor
1982
+ */
1983
+ extractStringsWithMetadata(filePath) {
1984
+ return this.extractStringsWithMetadataGeneric(filePath);
1985
+ }
1986
+ /**
1987
+ * Generate translated download for aac-tools-platform compatibility
1988
+ * Uses the generic implementation from BaseProcessor
1989
+ */
1990
+ generateTranslatedDownload(filePath, translatedStrings, sourceStrings) {
1991
+ return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings);
1992
+ }
1993
+ /**
1994
+ * Validate Gridset file format
1995
+ * @param filePath - Path to the file to validate
1996
+ * @returns Promise with validation result
1997
+ */
1998
+ async validate(filePath) {
1999
+ return GridsetValidator.validateFile(filePath);
2000
+ }
2001
+ }
2002
+ export { GridsetProcessor };