@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.
- package/README.md +52 -852
- package/dist/browser/core/baseProcessor.js +241 -0
- package/dist/browser/core/stringCasing.js +179 -0
- package/dist/browser/core/treeStructure.js +255 -0
- package/dist/browser/index.browser.js +73 -0
- package/dist/browser/processors/applePanelsProcessor.js +582 -0
- package/dist/browser/processors/astericsGridProcessor.js +1509 -0
- package/dist/browser/processors/dotProcessor.js +221 -0
- package/dist/browser/processors/gridset/commands.js +962 -0
- package/dist/browser/processors/gridset/crypto.js +53 -0
- package/dist/browser/processors/gridset/password.js +43 -0
- package/dist/browser/processors/gridset/pluginTypes.js +277 -0
- package/dist/browser/processors/gridset/resolver.js +137 -0
- package/dist/browser/processors/gridset/symbolAlignment.js +276 -0
- package/dist/browser/processors/gridset/symbols.js +421 -0
- package/dist/browser/processors/gridsetProcessor.js +2002 -0
- package/dist/browser/processors/obfProcessor.js +705 -0
- package/dist/browser/processors/opmlProcessor.js +274 -0
- package/dist/browser/types/aac.js +38 -0
- package/dist/browser/utilities/analytics/utils/idGenerator.js +89 -0
- package/dist/browser/utilities/translation/translationProcessor.js +200 -0
- package/dist/browser/utils/io.js +95 -0
- package/dist/browser/validation/baseValidator.js +156 -0
- package/dist/browser/validation/gridsetValidator.js +355 -0
- package/dist/browser/validation/obfValidator.js +500 -0
- package/dist/browser/validation/validationTypes.js +46 -0
- package/dist/cli/index.js +5 -5
- package/dist/core/analyze.d.ts +2 -2
- package/dist/core/analyze.js +2 -2
- package/dist/core/baseProcessor.d.ts +5 -4
- package/dist/core/baseProcessor.js +22 -27
- package/dist/core/treeStructure.d.ts +5 -5
- package/dist/core/treeStructure.js +1 -4
- package/dist/index.browser.d.ts +37 -0
- package/dist/index.browser.js +99 -0
- package/dist/index.d.ts +1 -48
- package/dist/index.js +1 -136
- package/dist/index.node.d.ts +48 -0
- package/dist/index.node.js +152 -0
- package/dist/processors/applePanelsProcessor.d.ts +5 -4
- package/dist/processors/applePanelsProcessor.js +58 -62
- package/dist/processors/astericsGridProcessor.d.ts +7 -6
- package/dist/processors/astericsGridProcessor.js +31 -42
- package/dist/processors/dotProcessor.d.ts +5 -4
- package/dist/processors/dotProcessor.js +25 -33
- package/dist/processors/excelProcessor.d.ts +4 -3
- package/dist/processors/excelProcessor.js +6 -3
- package/dist/processors/gridset/crypto.d.ts +18 -0
- package/dist/processors/gridset/crypto.js +57 -0
- package/dist/processors/gridset/helpers.d.ts +1 -1
- package/dist/processors/gridset/helpers.js +18 -8
- package/dist/processors/gridset/password.d.ts +20 -3
- package/dist/processors/gridset/password.js +17 -3
- package/dist/processors/gridset/wordlistHelpers.d.ts +3 -3
- package/dist/processors/gridset/wordlistHelpers.js +21 -20
- package/dist/processors/gridsetProcessor.d.ts +7 -12
- package/dist/processors/gridsetProcessor.js +118 -77
- package/dist/processors/obfProcessor.d.ts +9 -7
- package/dist/processors/obfProcessor.js +131 -56
- package/dist/processors/obfsetProcessor.d.ts +5 -4
- package/dist/processors/obfsetProcessor.js +10 -16
- package/dist/processors/opmlProcessor.d.ts +5 -4
- package/dist/processors/opmlProcessor.js +27 -34
- package/dist/processors/snapProcessor.d.ts +8 -7
- package/dist/processors/snapProcessor.js +15 -12
- package/dist/processors/touchchatProcessor.d.ts +8 -7
- package/dist/processors/touchchatProcessor.js +22 -17
- package/dist/types/aac.d.ts +0 -2
- package/dist/types/aac.js +2 -0
- package/dist/utils/io.d.ts +12 -0
- package/dist/utils/io.js +107 -0
- package/dist/validation/gridsetValidator.js +7 -7
- package/dist/validation/snapValidator.js +28 -35
- package/docs/BROWSER_USAGE.md +618 -0
- package/examples/README.md +77 -0
- package/examples/browser-test-server.js +81 -0
- package/examples/browser-test.html +331 -0
- package/examples/vitedemo/QUICKSTART.md +74 -0
- package/examples/vitedemo/README.md +157 -0
- package/examples/vitedemo/index.html +376 -0
- package/examples/vitedemo/package-lock.json +1221 -0
- package/examples/vitedemo/package.json +18 -0
- package/examples/vitedemo/src/main.ts +519 -0
- package/examples/vitedemo/test-files/example.dot +14 -0
- package/examples/vitedemo/test-files/example.grd +1 -0
- package/examples/vitedemo/test-files/example.gridset +0 -0
- package/examples/vitedemo/test-files/example.obz +0 -0
- package/examples/vitedemo/test-files/example.opml +18 -0
- package/examples/vitedemo/test-files/simple.obf +53 -0
- package/examples/vitedemo/tsconfig.json +24 -0
- package/examples/vitedemo/vite.config.ts +34 -0
- 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 };
|