@willwade/aac-processors 0.2.8 → 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.
@@ -2226,6 +2226,7 @@ class GridsetProcessor extends BaseProcessor {
2226
2226
  /**
2227
2227
  * Save a modified tree while preserving all original files (settings, images, assets)
2228
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.
2229
2230
  *
2230
2231
  * @param originalPath - Path to the original gridset file
2231
2232
  * @param tree - Modified AACTree with pages to save
@@ -2242,91 +2243,202 @@ class GridsetProcessor extends BaseProcessor {
2242
2243
  const AdmZip = (await import('adm-zip')).default;
2243
2244
  const originalZip = new AdmZip(originalPath);
2244
2245
  const outputZip = new AdmZip();
2245
- // Collect styles from the tree for grid.xml files
2246
- const uniqueStyles = new Map();
2247
- let styleIdCounter = 1;
2248
- const addStyle = (style) => {
2249
- if (!style)
2250
- return '';
2251
- const normalizedStyle = { ...style };
2252
- const styleKey = JSON.stringify(normalizedStyle);
2253
- const existing = uniqueStyles.get(styleKey);
2254
- if (existing)
2255
- return existing.id;
2256
- const styleId = `Style${styleIdCounter++}`;
2257
- uniqueStyles.set(styleKey, { id: styleId, style: normalizedStyle });
2258
- return styleId;
2259
- };
2260
- // Collect all styles from pages and buttons
2261
- Object.values(tree.pages).forEach((page) => {
2262
- addStyle(page.style);
2263
- page.buttons.forEach((button) => {
2264
- addStyle(button.style);
2265
- });
2266
- });
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
+ }
2267
2251
  // Track which grid files we're modifying
2268
2252
  const modifiedGridFiles = new Set();
2269
- // Generate grid.xml files for pages in the tree
2253
+ // Generate updated grid.xml files for pages in the tree
2270
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
+ });
2271
2268
  for (const page of Object.values(tree.pages)) {
2272
2269
  const gridPath = `Grids/${page.name}/grid.xml`;
2273
2270
  modifiedGridFiles.add(gridPath);
2274
- // Build the grid XML content
2275
- const gridData = {
2276
- Grid: {
2277
- '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
2278
- GridGuid: page.id,
2279
- ColumnDefinitions: this.calculateColumnDefinitions(page),
2280
- RowDefinitions: this.calculateRowDefinitions(page, false),
2281
- AutoContentCommands: '',
2282
- Cells: page.buttons.length > 0
2283
- ? {
2284
- Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => {
2285
- const buttonStyleId = button.style ? addStyle(button.style) : '';
2286
- const position = this.findButtonPosition(page, button, btnIndex);
2287
- const captionAndImage = {
2288
- Caption: button.label || '',
2289
- };
2290
- // Handle image references
2291
- if (button.image) {
2292
- captionAndImage.Image = `${button.image}`;
2293
- }
2294
- const cell = {
2295
- '@_Column': position.x,
2296
- '@_Row': position.y,
2297
- captionAndImage,
2298
- };
2299
- if (position.columnSpan > 1) {
2300
- cell['@_ColumnSpan'] = position.columnSpan;
2301
- }
2302
- if (position.rowSpan > 1) {
2303
- cell['@_RowSpan'] = position.rowSpan;
2304
- }
2305
- if (buttonStyleId) {
2306
- cell.CellStyle = buttonStyleId;
2307
- }
2308
- if (button.message && button.message !== button.label) {
2309
- // Use spoken message if different from label
2310
- const spoken = button.message;
2311
- const cellContent = {
2312
- spoken,
2313
- type: 'text',
2314
- };
2315
- 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'];
2316
2343
  }
2317
- return cell;
2318
- }),
2344
+ }
2319
2345
  }
2320
- : undefined,
2321
- },
2322
- };
2323
- const gridBuilder = new XMLBuilder({
2324
- ignoreAttributes: false,
2325
- format: true,
2326
- indentBy: ' ',
2327
- suppressEmptyNode: true,
2328
- });
2329
- 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);
2330
2442
  }
2331
2443
  // Copy all files from original zip, replacing modified grid files
2332
2444
  for (const entry of originalZip.getEntries()) {
@@ -2347,6 +2459,60 @@ class GridsetProcessor extends BaseProcessor {
2347
2459
  const outputBuffer = outputZip.toBuffer();
2348
2460
  await writeBinaryToPath(outputPath, outputBuffer);
2349
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
+ }
2350
2516
  // Helper method to find button position with span information
2351
2517
  findButtonPosition(page, button, fallbackIndex) {
2352
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
@@ -2252,6 +2252,7 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2252
2252
  /**
2253
2253
  * Save a modified tree while preserving all original files (settings, images, assets)
2254
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.
2255
2256
  *
2256
2257
  * @param originalPath - Path to the original gridset file
2257
2258
  * @param tree - Modified AACTree with pages to save
@@ -2268,91 +2269,202 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2268
2269
  const AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
2269
2270
  const originalZip = new AdmZip(originalPath);
2270
2271
  const outputZip = new AdmZip();
2271
- // Collect styles from the tree for grid.xml files
2272
- const uniqueStyles = new Map();
2273
- let styleIdCounter = 1;
2274
- const addStyle = (style) => {
2275
- if (!style)
2276
- return '';
2277
- const normalizedStyle = { ...style };
2278
- const styleKey = JSON.stringify(normalizedStyle);
2279
- const existing = uniqueStyles.get(styleKey);
2280
- if (existing)
2281
- return existing.id;
2282
- const styleId = `Style${styleIdCounter++}`;
2283
- uniqueStyles.set(styleKey, { id: styleId, style: normalizedStyle });
2284
- return styleId;
2285
- };
2286
- // Collect all styles from pages and buttons
2287
- Object.values(tree.pages).forEach((page) => {
2288
- addStyle(page.style);
2289
- page.buttons.forEach((button) => {
2290
- addStyle(button.style);
2291
- });
2292
- });
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
+ }
2293
2277
  // Track which grid files we're modifying
2294
2278
  const modifiedGridFiles = new Set();
2295
- // Generate grid.xml files for pages in the tree
2279
+ // Generate updated grid.xml files for pages in the tree
2296
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
+ });
2297
2294
  for (const page of Object.values(tree.pages)) {
2298
2295
  const gridPath = `Grids/${page.name}/grid.xml`;
2299
2296
  modifiedGridFiles.add(gridPath);
2300
- // Build the grid XML content
2301
- const gridData = {
2302
- Grid: {
2303
- '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
2304
- GridGuid: page.id,
2305
- ColumnDefinitions: this.calculateColumnDefinitions(page),
2306
- RowDefinitions: this.calculateRowDefinitions(page, false),
2307
- AutoContentCommands: '',
2308
- Cells: page.buttons.length > 0
2309
- ? {
2310
- Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => {
2311
- const buttonStyleId = button.style ? addStyle(button.style) : '';
2312
- const position = this.findButtonPosition(page, button, btnIndex);
2313
- const captionAndImage = {
2314
- Caption: button.label || '',
2315
- };
2316
- // Handle image references
2317
- if (button.image) {
2318
- captionAndImage.Image = `${button.image}`;
2319
- }
2320
- const cell = {
2321
- '@_Column': position.x,
2322
- '@_Row': position.y,
2323
- captionAndImage,
2324
- };
2325
- if (position.columnSpan > 1) {
2326
- cell['@_ColumnSpan'] = position.columnSpan;
2327
- }
2328
- if (position.rowSpan > 1) {
2329
- cell['@_RowSpan'] = position.rowSpan;
2330
- }
2331
- if (buttonStyleId) {
2332
- cell.CellStyle = buttonStyleId;
2333
- }
2334
- if (button.message && button.message !== button.label) {
2335
- // Use spoken message if different from label
2336
- const spoken = button.message;
2337
- const cellContent = {
2338
- spoken,
2339
- type: 'text',
2340
- };
2341
- 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'];
2342
2369
  }
2343
- return cell;
2344
- }),
2370
+ }
2345
2371
  }
2346
- : undefined,
2347
- },
2348
- };
2349
- const gridBuilder = new fast_xml_parser_1.XMLBuilder({
2350
- ignoreAttributes: false,
2351
- format: true,
2352
- indentBy: ' ',
2353
- suppressEmptyNode: true,
2354
- });
2355
- 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);
2356
2468
  }
2357
2469
  // Copy all files from original zip, replacing modified grid files
2358
2470
  for (const entry of originalZip.getEntries()) {
@@ -2373,6 +2485,60 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2373
2485
  const outputBuffer = outputZip.toBuffer();
2374
2486
  await writeBinaryToPath(outputPath, outputBuffer);
2375
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
+ }
2376
2542
  // Helper method to find button position with span information
2377
2543
  findButtonPosition(page, button, fallbackIndex) {
2378
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.8",
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",