@willwade/aac-processors 0.2.7 → 0.2.9

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.
@@ -1137,9 +1137,11 @@ class GridsetProcessor extends BaseProcessor {
1137
1137
  }
1138
1138
  break;
1139
1139
  case 'Jump.ToKeyboard': {
1140
- // Navigate to the set keyboard if we found one in settings
1140
+ // Prefer explicit keyboard page metadata when available.
1141
+ // Some Gridsets resolve the keyboard page in metadata
1142
+ // without preserving tree.keyboardGridName during parse.
1141
1143
  const keyboardGridName = tree.keyboardGridName;
1142
- const keyboardPageId = gridNameToIdMap.get(keyboardGridName);
1144
+ const keyboardPageId = tree.metadata?.defaultKeyboardPageId || gridNameToIdMap.get(keyboardGridName);
1143
1145
  if (keyboardPageId && !navigationTarget) {
1144
1146
  navigationTarget = keyboardPageId;
1145
1147
  }
@@ -1699,6 +1701,7 @@ class GridsetProcessor extends BaseProcessor {
1699
1701
  settingsData?.gridSetSettings?.keyboardGrid ||
1700
1702
  settingsData?.GridsetSettings?.KeyboardGrid;
1701
1703
  if (keyboardGridName && typeof keyboardGridName === 'string') {
1704
+ tree.keyboardGridName = keyboardGridName;
1702
1705
  metadata.defaultKeyboardPageId = gridNameToIdMap.get(keyboardGridName);
1703
1706
  }
1704
1707
  }
@@ -1708,6 +1711,22 @@ class GridsetProcessor extends BaseProcessor {
1708
1711
  }
1709
1712
  // Set metadata on tree
1710
1713
  tree.metadata = metadata;
1714
+ if (metadata.defaultKeyboardPageId) {
1715
+ Object.values(tree.pages).forEach((page) => {
1716
+ page.buttons.forEach((button) => {
1717
+ if (button?.semanticAction?.platformData?.grid3?.commandId === 'Jump.ToKeyboard' &&
1718
+ !button.targetPageId) {
1719
+ button.targetPageId = metadata.defaultKeyboardPageId;
1720
+ if (button.semanticAction) {
1721
+ button.semanticAction.targetId = metadata.defaultKeyboardPageId;
1722
+ if (button.semanticAction.fallback?.type === 'NAVIGATE') {
1723
+ button.semanticAction.fallback.targetPageId = metadata.defaultKeyboardPageId;
1724
+ }
1725
+ }
1726
+ }
1727
+ });
1728
+ });
1729
+ }
1711
1730
  return tree;
1712
1731
  }
1713
1732
  async processTexts(filePathOrBuffer, translations, outputPath) {
@@ -2207,6 +2226,7 @@ class GridsetProcessor extends BaseProcessor {
2207
2226
  /**
2208
2227
  * Save a modified tree while preserving all original files (settings, images, assets)
2209
2228
  * This method only updates the grid.xml files for pages in the tree, keeping everything else intact.
2229
+ * It preserves the original grid structure and only updates button labels and messages.
2210
2230
  *
2211
2231
  * @param originalPath - Path to the original gridset file
2212
2232
  * @param tree - Modified AACTree with pages to save
@@ -2223,91 +2243,202 @@ class GridsetProcessor extends BaseProcessor {
2223
2243
  const AdmZip = (await import('adm-zip')).default;
2224
2244
  const originalZip = new AdmZip(originalPath);
2225
2245
  const outputZip = new AdmZip();
2226
- // Collect styles from the tree for grid.xml files
2227
- const uniqueStyles = new Map();
2228
- let styleIdCounter = 1;
2229
- const addStyle = (style) => {
2230
- if (!style)
2231
- return '';
2232
- const normalizedStyle = { ...style };
2233
- const styleKey = JSON.stringify(normalizedStyle);
2234
- const existing = uniqueStyles.get(styleKey);
2235
- if (existing)
2236
- return existing.id;
2237
- const styleId = `Style${styleIdCounter++}`;
2238
- uniqueStyles.set(styleKey, { id: styleId, style: normalizedStyle });
2239
- return styleId;
2240
- };
2241
- // Collect all styles from pages and buttons
2242
- Object.values(tree.pages).forEach((page) => {
2243
- addStyle(page.style);
2244
- page.buttons.forEach((button) => {
2245
- addStyle(button.style);
2246
- });
2247
- });
2246
+ // Create a map of pages by name for easy lookup
2247
+ const pagesByName = new Map();
2248
+ for (const page of Object.values(tree.pages)) {
2249
+ pagesByName.set(page.name, page);
2250
+ }
2248
2251
  // Track which grid files we're modifying
2249
2252
  const modifiedGridFiles = new Set();
2250
- // Generate grid.xml files for pages in the tree
2253
+ // Generate updated grid.xml files for pages in the tree
2251
2254
  const newGridFiles = new Map();
2255
+ // Create XML parser and builder
2256
+ const parser = new XMLParser({
2257
+ ignoreAttributes: false,
2258
+ attributeNamePrefix: '@_',
2259
+ });
2260
+ const gridBuilder = new XMLBuilder({
2261
+ ignoreAttributes: false,
2262
+ format: true,
2263
+ indentBy: ' ',
2264
+ suppressEmptyNode: true,
2265
+ // Preserve Grid 3 XML formatting requirements
2266
+ suppressBooleanAttributes: false,
2267
+ });
2252
2268
  for (const page of Object.values(tree.pages)) {
2253
2269
  const gridPath = `Grids/${page.name}/grid.xml`;
2254
2270
  modifiedGridFiles.add(gridPath);
2255
- // Build the grid XML content
2256
- const gridData = {
2257
- Grid: {
2258
- '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
2259
- GridGuid: page.id,
2260
- ColumnDefinitions: this.calculateColumnDefinitions(page),
2261
- RowDefinitions: this.calculateRowDefinitions(page, false),
2262
- AutoContentCommands: '',
2263
- Cells: page.buttons.length > 0
2264
- ? {
2265
- Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => {
2266
- const buttonStyleId = button.style ? addStyle(button.style) : '';
2267
- const position = this.findButtonPosition(page, button, btnIndex);
2268
- const captionAndImage = {
2269
- Caption: button.label || '',
2270
- };
2271
- // Handle image references
2272
- if (button.image) {
2273
- captionAndImage.Image = `${button.image}`;
2274
- }
2275
- const cell = {
2276
- '@_Column': position.x,
2277
- '@_Row': position.y,
2278
- captionAndImage,
2279
- };
2280
- if (position.columnSpan > 1) {
2281
- cell['@_ColumnSpan'] = position.columnSpan;
2282
- }
2283
- if (position.rowSpan > 1) {
2284
- cell['@_RowSpan'] = position.rowSpan;
2285
- }
2286
- if (buttonStyleId) {
2287
- cell.CellStyle = buttonStyleId;
2288
- }
2289
- if (button.message && button.message !== button.label) {
2290
- // Use spoken message if different from label
2291
- const spoken = button.message;
2292
- const cellContent = {
2293
- spoken,
2294
- type: 'text',
2295
- };
2296
- cell['ContentCell'] = cellContent;
2271
+ // Try to get the original grid.xml file
2272
+ const originalEntry = originalZip.getEntry(gridPath);
2273
+ if (!originalEntry) {
2274
+ // If original doesn't exist, create a new basic grid
2275
+ const basicGrid = this.createBasicGridXml(page);
2276
+ newGridFiles.set(gridPath, basicGrid);
2277
+ continue;
2278
+ }
2279
+ // Parse the original grid XML
2280
+ const originalContent = originalEntry.getData().toString('utf-8');
2281
+ const originalGrid = parser.parse(originalContent);
2282
+ if (!originalGrid.Grid) {
2283
+ // Invalid grid structure, create a basic one
2284
+ const basicGrid = this.createBasicGridXml(page);
2285
+ newGridFiles.set(gridPath, basicGrid);
2286
+ continue;
2287
+ }
2288
+ // Create a map of buttons by their position for easy lookup
2289
+ const buttonsByPosition = new Map();
2290
+ for (const button of page.buttons) {
2291
+ const pos = this.findButtonPosition(page, button, 0);
2292
+ const key = `${pos.x},${pos.y}`;
2293
+ buttonsByPosition.set(key, button);
2294
+ }
2295
+ // Update cells in the original grid
2296
+ const originalCells = originalGrid.Grid.Cells?.Cell;
2297
+ if (originalCells) {
2298
+ const cellArray = Array.isArray(originalCells) ? originalCells : [originalCells];
2299
+ for (const cell of cellArray) {
2300
+ if (!cell.Content)
2301
+ continue;
2302
+ // Get cell position
2303
+ const x = parseInt(String(cell['@_X'] || cell['@_Column'] || '0'), 10);
2304
+ const y = parseInt(String(cell['@_Y'] || cell['@_Row'] || '0'), 10);
2305
+ const key = `${x},${y}`;
2306
+ // Check if there's a modified button for this position
2307
+ const modifiedButton = buttonsByPosition.get(key);
2308
+ if (modifiedButton) {
2309
+ // Check if this is an AutoContent/WordList cell
2310
+ const contentType = cell.Content.ContentType || cell.Content.contentType;
2311
+ const contentSubType = cell.Content.ContentSubType || cell.Content.contentsubtype;
2312
+ const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList';
2313
+ const isPredictionCell = contentType === 'AutoContent' && contentSubType === 'Prediction';
2314
+ if (isWordListCell) {
2315
+ // For WordList cells, we need to add the word to the page's WordList
2316
+ // instead of modifying the cell directly. The cell will automatically
2317
+ // populate from the WordList.
2318
+ // Note: WordList updates are handled by collecting all new words
2319
+ // and adding them to the WordList.Items array later.
2320
+ continue; // Skip cell modification for WordList cells
2321
+ }
2322
+ if (isPredictionCell) {
2323
+ // Prediction cells are populated dynamically by Grid 3's prediction system.
2324
+ // They should remain as <CaptionAndImage xsi:nil="true" /> and not be modified.
2325
+ continue; // Skip cell modification for Prediction cells
2326
+ }
2327
+ // For regular cells, update the caption directly
2328
+ // CDATA wrapping for empty captions will be done in post-processing
2329
+ if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) {
2330
+ const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage;
2331
+ // Check if the label is a placeholder (generated during extraction)
2332
+ const isPlaceholderLabel = !modifiedButton.label ||
2333
+ modifiedButton.label.startsWith('Cell_') ||
2334
+ modifiedButton.label.startsWith('AutoContent_') ||
2335
+ modifiedButton.label.startsWith('Prediction ');
2336
+ if (!isPlaceholderLabel) {
2337
+ // Only update caption with real content, not placeholders
2338
+ captionAndImage.Caption = modifiedButton.label;
2339
+ // Remove xsi:nil attribute when adding content
2340
+ if (captionAndImage['@_xsi:nil'] || captionAndImage['xsi:nil']) {
2341
+ delete captionAndImage['@_xsi:nil'];
2342
+ delete captionAndImage['xsi:nil'];
2297
2343
  }
2298
- return cell;
2299
- }),
2344
+ }
2300
2345
  }
2301
- : undefined,
2302
- },
2303
- };
2304
- const gridBuilder = new XMLBuilder({
2305
- ignoreAttributes: false,
2306
- format: true,
2307
- indentBy: ' ',
2308
- suppressEmptyNode: true,
2309
- });
2310
- newGridFiles.set(gridPath, gridBuilder.build(gridData));
2346
+ // Update the message if different from label
2347
+ // But skip placeholder labels
2348
+ const isPlaceholderMessage = !modifiedButton.message ||
2349
+ modifiedButton.message.startsWith('Cell_') ||
2350
+ modifiedButton.message.startsWith('AutoContent_') ||
2351
+ modifiedButton.message.startsWith('Prediction ');
2352
+ if (!isPlaceholderMessage &&
2353
+ modifiedButton.message &&
2354
+ modifiedButton.message !== modifiedButton.label) {
2355
+ // For simple text content
2356
+ if (!cell.Content.Commands) {
2357
+ cell.Content['#text'] = modifiedButton.message;
2358
+ }
2359
+ }
2360
+ // Update image if present
2361
+ if (modifiedButton.image) {
2362
+ if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) {
2363
+ const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage;
2364
+ captionAndImage.Image = modifiedButton.image;
2365
+ }
2366
+ }
2367
+ }
2368
+ }
2369
+ }
2370
+ // Update the page's WordList with new words from modified buttons
2371
+ // Collect all modified buttons that should be added to the WordList
2372
+ const newWordListItems = [];
2373
+ for (const button of page.buttons) {
2374
+ const pos = this.findButtonPosition(page, button, 0);
2375
+ // Check if this button corresponds to a WordList cell
2376
+ const cellArray = Array.isArray(originalGrid.Grid.Cells?.Cell)
2377
+ ? originalGrid.Grid.Cells.Cell
2378
+ : originalGrid.Grid.Cells?.Cell
2379
+ ? [originalGrid.Grid.Cells.Cell]
2380
+ : [];
2381
+ const cell = cellArray.find((c) => {
2382
+ const cellX = parseInt(String(c['@_X'] || '0'), 10);
2383
+ const cellY = parseInt(String(c['@_Y'] || '0'), 10);
2384
+ return cellX === pos.x && cellY === pos.y;
2385
+ });
2386
+ if (cell) {
2387
+ const contentType = cell.Content?.ContentType || cell.Content?.contentType;
2388
+ const contentSubType = cell.Content?.ContentSubType || cell.Content?.contentsubtype;
2389
+ const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList';
2390
+ // Note: Prediction cells are already skipped earlier, so they won't reach here
2391
+ if (isWordListCell) {
2392
+ // Add this button to the WordList with proper Grid 3 format
2393
+ // Format: <Text><s><r>label</r></s></Text>
2394
+ newWordListItems.push({
2395
+ Text: {
2396
+ s: {
2397
+ r: button.label,
2398
+ },
2399
+ },
2400
+ Image: '', // No image for user-added words
2401
+ PartOfSpeech: 'Unknown',
2402
+ });
2403
+ }
2404
+ }
2405
+ }
2406
+ // Add new items to the existing WordList
2407
+ if (newWordListItems.length > 0) {
2408
+ const existingWordList = originalGrid.Grid.WordList;
2409
+ if (existingWordList && existingWordList.Items) {
2410
+ const existingItems = existingWordList.Items.WordListItem || existingWordList.Items.wordlistitem || [];
2411
+ const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
2412
+ // Merge existing and new items
2413
+ const allItems = [...itemsArray, ...newWordListItems];
2414
+ // Update the WordList
2415
+ if (!originalGrid.Grid.WordList) {
2416
+ originalGrid.Grid.WordList = {};
2417
+ }
2418
+ if (!originalGrid.Grid.WordList.Items) {
2419
+ originalGrid.Grid.WordList.Items = {};
2420
+ }
2421
+ originalGrid.Grid.WordList.Items.WordListItem = allItems;
2422
+ }
2423
+ }
2424
+ // Build the updated grid XML and convert to Windows line endings
2425
+ let builtXml = gridBuilder.build(originalGrid);
2426
+ // Convert Unix line endings to Windows (\r\n) for Grid 3 compatibility
2427
+ builtXml = builtXml.replace(/\n/g, '\r\n');
2428
+ // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility
2429
+ // Grid 3 cannot parse <AudioDescription /> - it requires <AudioDescription></AudioDescription>
2430
+ builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2></$1>');
2431
+ // Convert empty/whitespace captions to CDATA format for Grid 3 compatibility
2432
+ // Grid 3 requires <![CDATA[ ]]> for empty captions, not plain text
2433
+ builtXml = builtXml.replace(/<Caption><\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
2434
+ builtXml = builtXml.replace(/<Caption> <\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
2435
+ builtXml = builtXml.replace(/<Caption> {2}<\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
2436
+ // Preserve CDATA in <r> tags for text parameters
2437
+ // Spaces in <r> tags must use CDATA or they get stripped during rendering
2438
+ // e.g., <r> </r> becomes <r><![CDATA[ ]]></r>
2439
+ builtXml = builtXml.replace(/<r> <\/r>/g, '<r><![CDATA[ ]]></r>');
2440
+ builtXml = builtXml.replace(/<r> {2}<\/r>/g, '<r><![CDATA[ ]]></r>');
2441
+ newGridFiles.set(gridPath, builtXml);
2311
2442
  }
2312
2443
  // Copy all files from original zip, replacing modified grid files
2313
2444
  for (const entry of originalZip.getEntries()) {
@@ -2328,6 +2459,60 @@ class GridsetProcessor extends BaseProcessor {
2328
2459
  const outputBuffer = outputZip.toBuffer();
2329
2460
  await writeBinaryToPath(outputPath, outputBuffer);
2330
2461
  }
2462
+ /**
2463
+ * Create a basic grid XML for a page when original doesn't exist
2464
+ */
2465
+ createBasicGridXml(page) {
2466
+ const gridData = {
2467
+ Grid: {
2468
+ '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
2469
+ GridGuid: page.id,
2470
+ ColumnDefinitions: this.calculateColumnDefinitions(page),
2471
+ RowDefinitions: this.calculateRowDefinitions(page, false),
2472
+ AutoContentCommands: '',
2473
+ Cells: page.buttons.length > 0
2474
+ ? {
2475
+ Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => {
2476
+ const position = this.findButtonPosition(page, button, btnIndex);
2477
+ const cell = {
2478
+ '@_X': position.x,
2479
+ '@_Y': position.y,
2480
+ Content: {
2481
+ CaptionAndImage: {
2482
+ Caption: button.label || '',
2483
+ },
2484
+ },
2485
+ };
2486
+ if (button.image) {
2487
+ cell.Content.CaptionAndImage.Image = button.image;
2488
+ }
2489
+ if (position.columnSpan > 1) {
2490
+ cell['@_ColumnSpan'] = position.columnSpan;
2491
+ }
2492
+ if (position.rowSpan > 1) {
2493
+ cell['@_RowSpan'] = position.rowSpan;
2494
+ }
2495
+ return cell;
2496
+ }),
2497
+ }
2498
+ : undefined,
2499
+ },
2500
+ };
2501
+ const gridBuilder = new XMLBuilder({
2502
+ ignoreAttributes: false,
2503
+ format: true,
2504
+ indentBy: ' ',
2505
+ suppressEmptyNode: true,
2506
+ // Preserve Grid 3 XML formatting requirements
2507
+ suppressBooleanAttributes: false,
2508
+ });
2509
+ // Build the grid XML and convert to Windows line endings for Grid 3 compatibility
2510
+ let builtXml = gridBuilder.build(gridData);
2511
+ builtXml = builtXml.replace(/\n/g, '\r\n');
2512
+ // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility
2513
+ builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2></$1>');
2514
+ return builtXml;
2515
+ }
2331
2516
  // Helper method to find button position with span information
2332
2517
  findButtonPosition(page, button, fallbackIndex) {
2333
2518
  if (page.grid && page.grid.length > 0) {
@@ -54,12 +54,17 @@ declare class GridsetProcessor extends BaseProcessor {
54
54
  /**
55
55
  * Save a modified tree while preserving all original files (settings, images, assets)
56
56
  * This method only updates the grid.xml files for pages in the tree, keeping everything else intact.
57
+ * It preserves the original grid structure and only updates button labels and messages.
57
58
  *
58
59
  * @param originalPath - Path to the original gridset file
59
60
  * @param tree - Modified AACTree with pages to save
60
61
  * @param outputPath - Path where the modified gridset should be saved
61
62
  */
62
63
  saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise<void>;
64
+ /**
65
+ * Create a basic grid XML for a page when original doesn't exist
66
+ */
67
+ private createBasicGridXml;
63
68
  private findButtonPosition;
64
69
  /**
65
70
  * Extract strings with metadata for aac-tools-platform compatibility
@@ -1163,9 +1163,11 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1163
1163
  }
1164
1164
  break;
1165
1165
  case 'Jump.ToKeyboard': {
1166
- // Navigate to the set keyboard if we found one in settings
1166
+ // Prefer explicit keyboard page metadata when available.
1167
+ // Some Gridsets resolve the keyboard page in metadata
1168
+ // without preserving tree.keyboardGridName during parse.
1167
1169
  const keyboardGridName = tree.keyboardGridName;
1168
- const keyboardPageId = gridNameToIdMap.get(keyboardGridName);
1170
+ const keyboardPageId = tree.metadata?.defaultKeyboardPageId || gridNameToIdMap.get(keyboardGridName);
1169
1171
  if (keyboardPageId && !navigationTarget) {
1170
1172
  navigationTarget = keyboardPageId;
1171
1173
  }
@@ -1725,6 +1727,7 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1725
1727
  settingsData?.gridSetSettings?.keyboardGrid ||
1726
1728
  settingsData?.GridsetSettings?.KeyboardGrid;
1727
1729
  if (keyboardGridName && typeof keyboardGridName === 'string') {
1730
+ tree.keyboardGridName = keyboardGridName;
1728
1731
  metadata.defaultKeyboardPageId = gridNameToIdMap.get(keyboardGridName);
1729
1732
  }
1730
1733
  }
@@ -1734,6 +1737,22 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1734
1737
  }
1735
1738
  // Set metadata on tree
1736
1739
  tree.metadata = metadata;
1740
+ if (metadata.defaultKeyboardPageId) {
1741
+ Object.values(tree.pages).forEach((page) => {
1742
+ page.buttons.forEach((button) => {
1743
+ if (button?.semanticAction?.platformData?.grid3?.commandId === 'Jump.ToKeyboard' &&
1744
+ !button.targetPageId) {
1745
+ button.targetPageId = metadata.defaultKeyboardPageId;
1746
+ if (button.semanticAction) {
1747
+ button.semanticAction.targetId = metadata.defaultKeyboardPageId;
1748
+ if (button.semanticAction.fallback?.type === 'NAVIGATE') {
1749
+ button.semanticAction.fallback.targetPageId = metadata.defaultKeyboardPageId;
1750
+ }
1751
+ }
1752
+ }
1753
+ });
1754
+ });
1755
+ }
1737
1756
  return tree;
1738
1757
  }
1739
1758
  async processTexts(filePathOrBuffer, translations, outputPath) {
@@ -2233,6 +2252,7 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2233
2252
  /**
2234
2253
  * Save a modified tree while preserving all original files (settings, images, assets)
2235
2254
  * This method only updates the grid.xml files for pages in the tree, keeping everything else intact.
2255
+ * It preserves the original grid structure and only updates button labels and messages.
2236
2256
  *
2237
2257
  * @param originalPath - Path to the original gridset file
2238
2258
  * @param tree - Modified AACTree with pages to save
@@ -2249,91 +2269,202 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2249
2269
  const AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
2250
2270
  const originalZip = new AdmZip(originalPath);
2251
2271
  const outputZip = new AdmZip();
2252
- // Collect styles from the tree for grid.xml files
2253
- const uniqueStyles = new Map();
2254
- let styleIdCounter = 1;
2255
- const addStyle = (style) => {
2256
- if (!style)
2257
- return '';
2258
- const normalizedStyle = { ...style };
2259
- const styleKey = JSON.stringify(normalizedStyle);
2260
- const existing = uniqueStyles.get(styleKey);
2261
- if (existing)
2262
- return existing.id;
2263
- const styleId = `Style${styleIdCounter++}`;
2264
- uniqueStyles.set(styleKey, { id: styleId, style: normalizedStyle });
2265
- return styleId;
2266
- };
2267
- // Collect all styles from pages and buttons
2268
- Object.values(tree.pages).forEach((page) => {
2269
- addStyle(page.style);
2270
- page.buttons.forEach((button) => {
2271
- addStyle(button.style);
2272
- });
2273
- });
2272
+ // Create a map of pages by name for easy lookup
2273
+ const pagesByName = new Map();
2274
+ for (const page of Object.values(tree.pages)) {
2275
+ pagesByName.set(page.name, page);
2276
+ }
2274
2277
  // Track which grid files we're modifying
2275
2278
  const modifiedGridFiles = new Set();
2276
- // Generate grid.xml files for pages in the tree
2279
+ // Generate updated grid.xml files for pages in the tree
2277
2280
  const newGridFiles = new Map();
2281
+ // Create XML parser and builder
2282
+ const parser = new fast_xml_parser_1.XMLParser({
2283
+ ignoreAttributes: false,
2284
+ attributeNamePrefix: '@_',
2285
+ });
2286
+ const gridBuilder = new fast_xml_parser_1.XMLBuilder({
2287
+ ignoreAttributes: false,
2288
+ format: true,
2289
+ indentBy: ' ',
2290
+ suppressEmptyNode: true,
2291
+ // Preserve Grid 3 XML formatting requirements
2292
+ suppressBooleanAttributes: false,
2293
+ });
2278
2294
  for (const page of Object.values(tree.pages)) {
2279
2295
  const gridPath = `Grids/${page.name}/grid.xml`;
2280
2296
  modifiedGridFiles.add(gridPath);
2281
- // Build the grid XML content
2282
- const gridData = {
2283
- Grid: {
2284
- '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
2285
- GridGuid: page.id,
2286
- ColumnDefinitions: this.calculateColumnDefinitions(page),
2287
- RowDefinitions: this.calculateRowDefinitions(page, false),
2288
- AutoContentCommands: '',
2289
- Cells: page.buttons.length > 0
2290
- ? {
2291
- Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => {
2292
- const buttonStyleId = button.style ? addStyle(button.style) : '';
2293
- const position = this.findButtonPosition(page, button, btnIndex);
2294
- const captionAndImage = {
2295
- Caption: button.label || '',
2296
- };
2297
- // Handle image references
2298
- if (button.image) {
2299
- captionAndImage.Image = `${button.image}`;
2300
- }
2301
- const cell = {
2302
- '@_Column': position.x,
2303
- '@_Row': position.y,
2304
- captionAndImage,
2305
- };
2306
- if (position.columnSpan > 1) {
2307
- cell['@_ColumnSpan'] = position.columnSpan;
2308
- }
2309
- if (position.rowSpan > 1) {
2310
- cell['@_RowSpan'] = position.rowSpan;
2311
- }
2312
- if (buttonStyleId) {
2313
- cell.CellStyle = buttonStyleId;
2314
- }
2315
- if (button.message && button.message !== button.label) {
2316
- // Use spoken message if different from label
2317
- const spoken = button.message;
2318
- const cellContent = {
2319
- spoken,
2320
- type: 'text',
2321
- };
2322
- cell['ContentCell'] = cellContent;
2297
+ // Try to get the original grid.xml file
2298
+ const originalEntry = originalZip.getEntry(gridPath);
2299
+ if (!originalEntry) {
2300
+ // If original doesn't exist, create a new basic grid
2301
+ const basicGrid = this.createBasicGridXml(page);
2302
+ newGridFiles.set(gridPath, basicGrid);
2303
+ continue;
2304
+ }
2305
+ // Parse the original grid XML
2306
+ const originalContent = originalEntry.getData().toString('utf-8');
2307
+ const originalGrid = parser.parse(originalContent);
2308
+ if (!originalGrid.Grid) {
2309
+ // Invalid grid structure, create a basic one
2310
+ const basicGrid = this.createBasicGridXml(page);
2311
+ newGridFiles.set(gridPath, basicGrid);
2312
+ continue;
2313
+ }
2314
+ // Create a map of buttons by their position for easy lookup
2315
+ const buttonsByPosition = new Map();
2316
+ for (const button of page.buttons) {
2317
+ const pos = this.findButtonPosition(page, button, 0);
2318
+ const key = `${pos.x},${pos.y}`;
2319
+ buttonsByPosition.set(key, button);
2320
+ }
2321
+ // Update cells in the original grid
2322
+ const originalCells = originalGrid.Grid.Cells?.Cell;
2323
+ if (originalCells) {
2324
+ const cellArray = Array.isArray(originalCells) ? originalCells : [originalCells];
2325
+ for (const cell of cellArray) {
2326
+ if (!cell.Content)
2327
+ continue;
2328
+ // Get cell position
2329
+ const x = parseInt(String(cell['@_X'] || cell['@_Column'] || '0'), 10);
2330
+ const y = parseInt(String(cell['@_Y'] || cell['@_Row'] || '0'), 10);
2331
+ const key = `${x},${y}`;
2332
+ // Check if there's a modified button for this position
2333
+ const modifiedButton = buttonsByPosition.get(key);
2334
+ if (modifiedButton) {
2335
+ // Check if this is an AutoContent/WordList cell
2336
+ const contentType = cell.Content.ContentType || cell.Content.contentType;
2337
+ const contentSubType = cell.Content.ContentSubType || cell.Content.contentsubtype;
2338
+ const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList';
2339
+ const isPredictionCell = contentType === 'AutoContent' && contentSubType === 'Prediction';
2340
+ if (isWordListCell) {
2341
+ // For WordList cells, we need to add the word to the page's WordList
2342
+ // instead of modifying the cell directly. The cell will automatically
2343
+ // populate from the WordList.
2344
+ // Note: WordList updates are handled by collecting all new words
2345
+ // and adding them to the WordList.Items array later.
2346
+ continue; // Skip cell modification for WordList cells
2347
+ }
2348
+ if (isPredictionCell) {
2349
+ // Prediction cells are populated dynamically by Grid 3's prediction system.
2350
+ // They should remain as <CaptionAndImage xsi:nil="true" /> and not be modified.
2351
+ continue; // Skip cell modification for Prediction cells
2352
+ }
2353
+ // For regular cells, update the caption directly
2354
+ // CDATA wrapping for empty captions will be done in post-processing
2355
+ if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) {
2356
+ const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage;
2357
+ // Check if the label is a placeholder (generated during extraction)
2358
+ const isPlaceholderLabel = !modifiedButton.label ||
2359
+ modifiedButton.label.startsWith('Cell_') ||
2360
+ modifiedButton.label.startsWith('AutoContent_') ||
2361
+ modifiedButton.label.startsWith('Prediction ');
2362
+ if (!isPlaceholderLabel) {
2363
+ // Only update caption with real content, not placeholders
2364
+ captionAndImage.Caption = modifiedButton.label;
2365
+ // Remove xsi:nil attribute when adding content
2366
+ if (captionAndImage['@_xsi:nil'] || captionAndImage['xsi:nil']) {
2367
+ delete captionAndImage['@_xsi:nil'];
2368
+ delete captionAndImage['xsi:nil'];
2323
2369
  }
2324
- return cell;
2325
- }),
2370
+ }
2326
2371
  }
2327
- : undefined,
2328
- },
2329
- };
2330
- const gridBuilder = new fast_xml_parser_1.XMLBuilder({
2331
- ignoreAttributes: false,
2332
- format: true,
2333
- indentBy: ' ',
2334
- suppressEmptyNode: true,
2335
- });
2336
- newGridFiles.set(gridPath, gridBuilder.build(gridData));
2372
+ // Update the message if different from label
2373
+ // But skip placeholder labels
2374
+ const isPlaceholderMessage = !modifiedButton.message ||
2375
+ modifiedButton.message.startsWith('Cell_') ||
2376
+ modifiedButton.message.startsWith('AutoContent_') ||
2377
+ modifiedButton.message.startsWith('Prediction ');
2378
+ if (!isPlaceholderMessage &&
2379
+ modifiedButton.message &&
2380
+ modifiedButton.message !== modifiedButton.label) {
2381
+ // For simple text content
2382
+ if (!cell.Content.Commands) {
2383
+ cell.Content['#text'] = modifiedButton.message;
2384
+ }
2385
+ }
2386
+ // Update image if present
2387
+ if (modifiedButton.image) {
2388
+ if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) {
2389
+ const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage;
2390
+ captionAndImage.Image = modifiedButton.image;
2391
+ }
2392
+ }
2393
+ }
2394
+ }
2395
+ }
2396
+ // Update the page's WordList with new words from modified buttons
2397
+ // Collect all modified buttons that should be added to the WordList
2398
+ const newWordListItems = [];
2399
+ for (const button of page.buttons) {
2400
+ const pos = this.findButtonPosition(page, button, 0);
2401
+ // Check if this button corresponds to a WordList cell
2402
+ const cellArray = Array.isArray(originalGrid.Grid.Cells?.Cell)
2403
+ ? originalGrid.Grid.Cells.Cell
2404
+ : originalGrid.Grid.Cells?.Cell
2405
+ ? [originalGrid.Grid.Cells.Cell]
2406
+ : [];
2407
+ const cell = cellArray.find((c) => {
2408
+ const cellX = parseInt(String(c['@_X'] || '0'), 10);
2409
+ const cellY = parseInt(String(c['@_Y'] || '0'), 10);
2410
+ return cellX === pos.x && cellY === pos.y;
2411
+ });
2412
+ if (cell) {
2413
+ const contentType = cell.Content?.ContentType || cell.Content?.contentType;
2414
+ const contentSubType = cell.Content?.ContentSubType || cell.Content?.contentsubtype;
2415
+ const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList';
2416
+ // Note: Prediction cells are already skipped earlier, so they won't reach here
2417
+ if (isWordListCell) {
2418
+ // Add this button to the WordList with proper Grid 3 format
2419
+ // Format: <Text><s><r>label</r></s></Text>
2420
+ newWordListItems.push({
2421
+ Text: {
2422
+ s: {
2423
+ r: button.label,
2424
+ },
2425
+ },
2426
+ Image: '', // No image for user-added words
2427
+ PartOfSpeech: 'Unknown',
2428
+ });
2429
+ }
2430
+ }
2431
+ }
2432
+ // Add new items to the existing WordList
2433
+ if (newWordListItems.length > 0) {
2434
+ const existingWordList = originalGrid.Grid.WordList;
2435
+ if (existingWordList && existingWordList.Items) {
2436
+ const existingItems = existingWordList.Items.WordListItem || existingWordList.Items.wordlistitem || [];
2437
+ const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
2438
+ // Merge existing and new items
2439
+ const allItems = [...itemsArray, ...newWordListItems];
2440
+ // Update the WordList
2441
+ if (!originalGrid.Grid.WordList) {
2442
+ originalGrid.Grid.WordList = {};
2443
+ }
2444
+ if (!originalGrid.Grid.WordList.Items) {
2445
+ originalGrid.Grid.WordList.Items = {};
2446
+ }
2447
+ originalGrid.Grid.WordList.Items.WordListItem = allItems;
2448
+ }
2449
+ }
2450
+ // Build the updated grid XML and convert to Windows line endings
2451
+ let builtXml = gridBuilder.build(originalGrid);
2452
+ // Convert Unix line endings to Windows (\r\n) for Grid 3 compatibility
2453
+ builtXml = builtXml.replace(/\n/g, '\r\n');
2454
+ // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility
2455
+ // Grid 3 cannot parse <AudioDescription /> - it requires <AudioDescription></AudioDescription>
2456
+ builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2></$1>');
2457
+ // Convert empty/whitespace captions to CDATA format for Grid 3 compatibility
2458
+ // Grid 3 requires <![CDATA[ ]]> for empty captions, not plain text
2459
+ builtXml = builtXml.replace(/<Caption><\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
2460
+ builtXml = builtXml.replace(/<Caption> <\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
2461
+ builtXml = builtXml.replace(/<Caption> {2}<\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
2462
+ // Preserve CDATA in <r> tags for text parameters
2463
+ // Spaces in <r> tags must use CDATA or they get stripped during rendering
2464
+ // e.g., <r> </r> becomes <r><![CDATA[ ]]></r>
2465
+ builtXml = builtXml.replace(/<r> <\/r>/g, '<r><![CDATA[ ]]></r>');
2466
+ builtXml = builtXml.replace(/<r> {2}<\/r>/g, '<r><![CDATA[ ]]></r>');
2467
+ newGridFiles.set(gridPath, builtXml);
2337
2468
  }
2338
2469
  // Copy all files from original zip, replacing modified grid files
2339
2470
  for (const entry of originalZip.getEntries()) {
@@ -2354,6 +2485,60 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2354
2485
  const outputBuffer = outputZip.toBuffer();
2355
2486
  await writeBinaryToPath(outputPath, outputBuffer);
2356
2487
  }
2488
+ /**
2489
+ * Create a basic grid XML for a page when original doesn't exist
2490
+ */
2491
+ createBasicGridXml(page) {
2492
+ const gridData = {
2493
+ Grid: {
2494
+ '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
2495
+ GridGuid: page.id,
2496
+ ColumnDefinitions: this.calculateColumnDefinitions(page),
2497
+ RowDefinitions: this.calculateRowDefinitions(page, false),
2498
+ AutoContentCommands: '',
2499
+ Cells: page.buttons.length > 0
2500
+ ? {
2501
+ Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => {
2502
+ const position = this.findButtonPosition(page, button, btnIndex);
2503
+ const cell = {
2504
+ '@_X': position.x,
2505
+ '@_Y': position.y,
2506
+ Content: {
2507
+ CaptionAndImage: {
2508
+ Caption: button.label || '',
2509
+ },
2510
+ },
2511
+ };
2512
+ if (button.image) {
2513
+ cell.Content.CaptionAndImage.Image = button.image;
2514
+ }
2515
+ if (position.columnSpan > 1) {
2516
+ cell['@_ColumnSpan'] = position.columnSpan;
2517
+ }
2518
+ if (position.rowSpan > 1) {
2519
+ cell['@_RowSpan'] = position.rowSpan;
2520
+ }
2521
+ return cell;
2522
+ }),
2523
+ }
2524
+ : undefined,
2525
+ },
2526
+ };
2527
+ const gridBuilder = new fast_xml_parser_1.XMLBuilder({
2528
+ ignoreAttributes: false,
2529
+ format: true,
2530
+ indentBy: ' ',
2531
+ suppressEmptyNode: true,
2532
+ // Preserve Grid 3 XML formatting requirements
2533
+ suppressBooleanAttributes: false,
2534
+ });
2535
+ // Build the grid XML and convert to Windows line endings for Grid 3 compatibility
2536
+ let builtXml = gridBuilder.build(gridData);
2537
+ builtXml = builtXml.replace(/\n/g, '\r\n');
2538
+ // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility
2539
+ builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2></$1>');
2540
+ return builtXml;
2541
+ }
2357
2542
  // Helper method to find button position with span information
2358
2543
  findButtonPosition(page, button, fallbackIndex) {
2359
2544
  if (page.grid && page.grid.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willwade/aac-processors",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "A comprehensive TypeScript library for processing AAC (Augmentative and Alternative Communication) file formats with translation support",
5
5
  "main": "dist/index.js",
6
6
  "browser": "dist/browser/index.browser.js",