@willwade/aac-processors 0.2.9 → 0.2.11

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 CHANGED
@@ -13,6 +13,7 @@ npm install @willwade/aac-processors
13
13
  ## Dual Build Targets
14
14
 
15
15
  ### Node.js (default)
16
+
16
17
  Full feature set, including filesystem access, SQLite-backed formats, and
17
18
  ZIP/encrypted formats.
18
19
 
@@ -27,6 +28,7 @@ const texts = await snap.extractTexts('board.sps');
27
28
  ```
28
29
 
29
30
  ### Browser
31
+
30
32
  Browser-safe entry that avoids Node-only dependencies. It expects `Buffer`,
31
33
  `Uint8Array`, or `ArrayBuffer` inputs rather than file paths.
32
34
 
@@ -82,7 +84,8 @@ const translations = new Map([
82
84
 
83
85
  await processor.processTexts('board.dot', translations, 'board-es.dot');
84
86
  ```
85
- NB: Please use [https://aactools.co.uk](https://aactools.co.uk) for a far more comphrensive translation logic - where we do far far more than this...
87
+
88
+ NB: Please use [https://aactools.co.uk](https://aactools.co.uk) for a far more comphrensive translation logic - where we do far far more than this...
86
89
 
87
90
  ## Documentation
88
91
 
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Grid3 Cell Helpers
3
+ *
4
+ * Utilities for working with Grid 3 cells, including finding button positions
5
+ * and calculating cell spans in grid layouts.
6
+ */
7
+ /**
8
+ * Find button position with span information
9
+ *
10
+ * Searches the page's grid layout for a button and calculates its position
11
+ * and span (how many columns/rows it occupies).
12
+ *
13
+ * @param page - The AAC page containing the button
14
+ * @param button - The button to locate
15
+ * @param fallbackIndex - Index to use if button not found in grid
16
+ * @returns Position and span information for the button
17
+ *
18
+ * @example
19
+ * const position = findButtonPosition(page, button, 0);
20
+ * console.log(`Button at ${position.x},${position.y} spans ${position.columnSpan}x${position.rowSpan}`);
21
+ */
22
+ export function findButtonPosition(page, button, fallbackIndex) {
23
+ if (page.grid && page.grid.length > 0) {
24
+ // Search for button in grid layout and calculate span
25
+ for (let y = 0; y < page.grid.length; y++) {
26
+ for (let x = 0; x < page.grid[y].length; x++) {
27
+ const current = page.grid[y][x];
28
+ if (current && current.id === button.id) {
29
+ // Calculate span by checking how far the same button extends
30
+ let columnSpan = 1;
31
+ let rowSpan = 1;
32
+ // Check column span (rightward)
33
+ while (x + columnSpan < page.grid[y].length) {
34
+ const right = page.grid[y][x + columnSpan];
35
+ if (right && right.id === button.id) {
36
+ columnSpan++;
37
+ }
38
+ else {
39
+ break;
40
+ }
41
+ }
42
+ // Check row span (downward)
43
+ while (y + rowSpan < page.grid.length) {
44
+ const below = page.grid[y + rowSpan][x];
45
+ if (below && below.id === button.id) {
46
+ rowSpan++;
47
+ }
48
+ else {
49
+ break;
50
+ }
51
+ }
52
+ return { x, y, columnSpan, rowSpan };
53
+ }
54
+ }
55
+ }
56
+ }
57
+ // Fallback positioning
58
+ const gridCols = page.grid?.[0]?.length || Math.ceil(Math.sqrt(page.buttons.length));
59
+ return {
60
+ x: fallbackIndex % gridCols,
61
+ y: Math.floor(fallbackIndex / gridCols),
62
+ columnSpan: 1,
63
+ rowSpan: 1,
64
+ };
65
+ }
66
+ /**
67
+ * Calculate cell position key for Maps and Sets
68
+ *
69
+ * Creates a string key from X and Y coordinates for use as a Map key or Set entry.
70
+ *
71
+ * @param x - X coordinate
72
+ * @param y - Y coordinate
73
+ * @returns String key in format "x,y"
74
+ *
75
+ * @example
76
+ * const key = cellPositionKey(5, 3);
77
+ * console.log(key); // "5,3"
78
+ */
79
+ export function cellPositionKey(x, y) {
80
+ return `${x},${y}`;
81
+ }
82
+ /**
83
+ * Parse cell position key
84
+ *
85
+ * Extracts X and Y coordinates from a position key string.
86
+ *
87
+ * @param key - Position key in format "x,y"
88
+ * @returns Object with x and y properties
89
+ *
90
+ * @example
91
+ * const pos = parseCellPositionKey("5,3");
92
+ * console.log(pos); // { x: 5, y: 3 }
93
+ */
94
+ export function parseCellPositionKey(key) {
95
+ const [x, y] = key.split(',').map(Number);
96
+ return { x, y };
97
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Grid3 Grid Calculations
3
+ *
4
+ * Utilities for calculating grid dimensions and definitions
5
+ * based on page layout and button count.
6
+ */
7
+ /**
8
+ * Calculate column definitions based on page layout
9
+ *
10
+ * Analyzes the page's grid structure to determine the number of columns.
11
+ * If no grid exists, estimates from button count.
12
+ *
13
+ * @param page - The AAC page to analyze
14
+ * @returns Column definitions object for Grid 3 XML
15
+ *
16
+ * @example
17
+ * const columns = calculateColumnDefinitions(page);
18
+ * // Returns: { ColumnDefinition: [{}, {}, {}, {}] } for 4 columns
19
+ */
20
+ export function calculateColumnDefinitions(page) {
21
+ let maxCols = 4; // Default minimum
22
+ if (page.grid && page.grid.length > 0) {
23
+ maxCols = Math.max(maxCols, page.grid[0]?.length || 0);
24
+ }
25
+ else {
26
+ // Fallback: estimate from button count
27
+ maxCols = Math.max(4, Math.ceil(Math.sqrt(page.buttons.length)));
28
+ }
29
+ return {
30
+ ColumnDefinition: Array(maxCols).fill({}),
31
+ };
32
+ }
33
+ /**
34
+ * Calculate row definitions based on page layout
35
+ *
36
+ * Analyzes the page's grid structure to determine the number of rows.
37
+ * If no grid exists, estimates from button count.
38
+ *
39
+ * @param page - The AAC page to analyze
40
+ * @param addWorkspaceOffset - Whether to add 1 row for workspace (default: false)
41
+ * @returns Row definitions object for Grid 3 XML
42
+ *
43
+ * @example
44
+ * const rows = calculateRowDefinitions(page, false);
45
+ * // Returns: { RowDefinition: [{}, {}, {}, {}] } for 4 rows
46
+ */
47
+ export function calculateRowDefinitions(page, addWorkspaceOffset = false) {
48
+ let maxRows = 4; // Default minimum
49
+ const offset = addWorkspaceOffset ? 1 : 0;
50
+ if (page.grid && page.grid.length > 0) {
51
+ maxRows = Math.max(maxRows, page.grid.length + offset);
52
+ }
53
+ else {
54
+ // Fallback: estimate from button count
55
+ const estimatedCols = Math.ceil(Math.sqrt(page.buttons.length));
56
+ maxRows = Math.max(4, Math.ceil(page.buttons.length / estimatedCols)) + offset;
57
+ }
58
+ return {
59
+ RowDefinition: Array(maxRows).fill({}),
60
+ };
61
+ }
@@ -149,7 +149,10 @@ export function getCommonDocumentsPath() {
149
149
  // Query registry for Common Documents path
150
150
  const child_process = getNodeRequire()('child_process');
151
151
  const command = 'REG.EXE QUERY "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders" /V "Common Documents"';
152
- const output = child_process.execSync(command, { encoding: 'utf-8', windowsHide: true });
152
+ const output = child_process.execSync(command, {
153
+ encoding: 'utf-8',
154
+ windowsHide: true,
155
+ });
153
156
  // Parse the output to extract the path
154
157
  const match = output.match(/Common Documents\s+REG_SZ\s+(.+)/);
155
158
  if (match && match[1]) {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Grid3 XML Formatter
3
+ *
4
+ * Utilities for formatting XML to match Grid 3's specific requirements.
5
+ * Grid 3 has strict formatting requirements including line endings, self-closing
6
+ * tag spacing, and specific tag expansion rules.
7
+ */
8
+ /**
9
+ * Tags that Grid 3 requires in full opening/closing format instead of self-closing
10
+ * Grid 3 cannot parse <AudioDescription /> - it requires <AudioDescription></AudioDescription>
11
+ */
12
+ const TAGS_NEEDING_EXPANSION = ['AudioDescription', 'VideoDescription'];
13
+ /**
14
+ * Format XML string to match Grid 3's requirements
15
+ *
16
+ * Grid 3 requires specific formatting:
17
+ * - Windows line endings (\r\n)
18
+ * - Space before /> in self-closing tags: <Element /> not <Element/>
19
+ * - Plain apostrophes instead of &apos;
20
+ * - Specific tags expanded to full opening/closing format
21
+ * - CDATA for empty/whitespace captions and <r> tags
22
+ *
23
+ * @param xml - The XML string to format
24
+ * @returns Formatted XML string compatible with Grid 3
25
+ *
26
+ * @example
27
+ * const formatted = formatGrid3Xml('<Grid><Cell X="0"/></Grid>');
28
+ * // Returns: '<Grid>\r\n<Cell X="0" />\r\n</Grid>'
29
+ */
30
+ export function formatGrid3Xml(xml) {
31
+ let formatted = xml;
32
+ // Convert Unix line endings to Windows (\r\n) for Grid 3 compatibility
33
+ formatted = formatted.replace(/\n/g, '\r\n');
34
+ // Add space before /> in self-closing tags to match Grid 3's expected format
35
+ // Grid 3 original files use <Element /> not <Element/>
36
+ formatted = formatted.replace(/<(\w+)([^>]*)\/>/g, '<$1$2 />');
37
+ // Decode XML entities back to plain text to match Grid 3's expected format
38
+ // Grid 3 expects plain apostrophes, not &apos;
39
+ formatted = formatted.replace(/&apos;/g, "'");
40
+ formatted = formatted.replace(/&quot;/g, '"');
41
+ formatted = formatted.replace(/&lt;/g, '<');
42
+ formatted = formatted.replace(/&gt;/g, '>');
43
+ // Expand only specific self-closing tags that Grid 3 requires in full opening/closing format
44
+ // This must be done AFTER adding spaces, so we need to match the format with spaces
45
+ for (const tag of TAGS_NEEDING_EXPANSION) {
46
+ formatted = formatted.replace(new RegExp(`<${tag}(\\s+[^>]*)? />`, 'g'), `<${tag}$1></${tag}>`);
47
+ }
48
+ return formatted;
49
+ }
50
+ /**
51
+ * Format empty/whitespace captions with CDATA for Grid 3 compatibility
52
+ *
53
+ * Grid 3 requires <![CDATA[ ]]> for empty captions, not plain text.
54
+ * Also handles <r> tags which need CDATA for spaces to prevent stripping.
55
+ *
56
+ * @param xml - The XML string to format
57
+ * @returns XML string with CDATA-wrapped empty content
58
+ */
59
+ export function formatEmptyCaptionsWithCdata(xml) {
60
+ let formatted = xml;
61
+ // Convert empty/whitespace captions to CDATA format for Grid 3 compatibility
62
+ // Grid 3 requires <![CDATA[ ]]> for empty captions, not plain text
63
+ formatted = formatted.replace(/<Caption><\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
64
+ formatted = formatted.replace(/<Caption> <\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
65
+ formatted = formatted.replace(/<Caption> {2}<\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
66
+ // Preserve CDATA in <r> tags for text parameters
67
+ // Spaces in <r> tags must use CDATA or they get stripped during rendering
68
+ // e.g., <r> </r> becomes <r><![CDATA[ ]]></r>
69
+ formatted = formatted.replace(/<r> <\/r>/g, '<r><![CDATA[ ]]></r>');
70
+ formatted = formatted.replace(/<r> {2}<\/r>/g, '<r><![CDATA[ ]]></r>');
71
+ return formatted;
72
+ }
73
+ /**
74
+ * Complete XML formatting for Grid 3 compatibility
75
+ * Combines all Grid 3 XML formatting requirements
76
+ *
77
+ * @param xml - The XML string to format
78
+ * @returns Fully formatted XML string compatible with Grid 3
79
+ */
80
+ export function formatGrid3XmlComplete(xml) {
81
+ let formatted = formatGrid3Xml(xml);
82
+ formatted = formatEmptyCaptionsWithCdata(formatted);
83
+ return formatted;
84
+ }
@@ -5,6 +5,9 @@ import { resolveGrid3CellImage } from './gridset/resolver';
5
5
  import { extractAllButtonsForTranslation, validateTranslationResults, } from '../utilities/translation/translationProcessor';
6
6
  import { getZipEntriesFromAdapter, resolveGridsetPassword, } from './gridset/password';
7
7
  import { decryptGridsetEntry } from './gridset/crypto';
8
+ import { formatGrid3XmlComplete } from './gridset/xmlFormatter';
9
+ import { calculateColumnDefinitions as calcColumnDefs, calculateRowDefinitions as calcRowDefs, } from './gridset/gridCalculations';
10
+ import { findButtonPosition as findButtonPos } from './gridset/cellHelpers';
8
11
  import { GridsetValidator } from '../validation/gridsetValidator';
9
12
  // New imports for enhanced Grid 3 support
10
13
  import { detectPluginCellType, Grid3CellType } from './gridset/pluginTypes';
@@ -2195,33 +2198,11 @@ class GridsetProcessor extends BaseProcessor {
2195
2198
  }
2196
2199
  // Helper method to calculate column definitions based on page layout
2197
2200
  calculateColumnDefinitions(page) {
2198
- let maxCols = 4; // Default minimum
2199
- if (page.grid && page.grid.length > 0) {
2200
- maxCols = Math.max(maxCols, page.grid[0]?.length || 0);
2201
- }
2202
- else {
2203
- // Fallback: estimate from button count
2204
- maxCols = Math.max(4, Math.ceil(Math.sqrt(page.buttons.length)));
2205
- }
2206
- return {
2207
- ColumnDefinition: Array(maxCols).fill({}),
2208
- };
2201
+ return calcColumnDefs(page);
2209
2202
  }
2210
2203
  // Helper method to calculate row definitions based on page layout
2211
2204
  calculateRowDefinitions(page, addWorkspaceOffset = false) {
2212
- let maxRows = 4; // Default minimum
2213
- const offset = addWorkspaceOffset ? 1 : 0;
2214
- if (page.grid && page.grid.length > 0) {
2215
- maxRows = Math.max(maxRows, page.grid.length + offset);
2216
- }
2217
- else {
2218
- // Fallback: estimate from button count
2219
- const estimatedCols = Math.ceil(Math.sqrt(page.buttons.length));
2220
- maxRows = Math.max(4, Math.ceil(page.buttons.length / estimatedCols)) + offset;
2221
- }
2222
- return {
2223
- RowDefinition: Array(maxRows).fill({}),
2224
- };
2205
+ return calcRowDefs(page, addWorkspaceOffset);
2225
2206
  }
2226
2207
  /**
2227
2208
  * Save a modified tree while preserving all original files (settings, images, assets)
@@ -2367,6 +2348,9 @@ class GridsetProcessor extends BaseProcessor {
2367
2348
  }
2368
2349
  }
2369
2350
  }
2351
+ // DO NOT create new cells - the system should only modify existing content
2352
+ // Personalized vocabulary is added to WordList cells via the WordList.Items array
2353
+ // Creating new cells would corrupt the grid structure
2370
2354
  // Update the page's WordList with new words from modified buttons
2371
2355
  // Collect all modified buttons that should be added to the WordList
2372
2356
  const newWordListItems = [];
@@ -2379,9 +2363,18 @@ class GridsetProcessor extends BaseProcessor {
2379
2363
  ? [originalGrid.Grid.Cells.Cell]
2380
2364
  : [];
2381
2365
  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;
2366
+ const cellY = parseInt(String(c['@_Y'] || c['@_Row'] || '0'), 10);
2367
+ // Check Y position first
2368
+ if (cellY !== pos.y) {
2369
+ return false;
2370
+ }
2371
+ const cellX = c['@_X'] !== undefined ? parseInt(String(c['@_X']), 10) : undefined;
2372
+ // If cell has no X attribute (full-width cell), it matches any button at this Y
2373
+ if (cellX === undefined) {
2374
+ return true;
2375
+ }
2376
+ // Otherwise, check exact X match
2377
+ return cellX === pos.x;
2385
2378
  });
2386
2379
  if (cell) {
2387
2380
  const contentType = cell.Content?.ContentType || cell.Content?.contentType;
@@ -2390,11 +2383,14 @@ class GridsetProcessor extends BaseProcessor {
2390
2383
  // Note: Prediction cells are already skipped earlier, so they won't reach here
2391
2384
  if (isWordListCell) {
2392
2385
  // Add this button to the WordList with proper Grid 3 format
2393
- // Format: <Text><s><r>label</r></s></Text>
2386
+ // Format: <Text><p><s><r>label</r></s></p></Text>
2387
+ // Note: <p> wrapper is required by Grid 3's WordList format
2394
2388
  newWordListItems.push({
2395
2389
  Text: {
2396
- s: {
2397
- r: button.label,
2390
+ p: {
2391
+ s: {
2392
+ r: button.label,
2393
+ },
2398
2394
  },
2399
2395
  },
2400
2396
  Image: '', // No image for user-added words
@@ -2421,23 +2417,9 @@ class GridsetProcessor extends BaseProcessor {
2421
2417
  originalGrid.Grid.WordList.Items.WordListItem = allItems;
2422
2418
  }
2423
2419
  }
2424
- // Build the updated grid XML and convert to Windows line endings
2420
+ // Build the updated grid XML and format for Grid 3 compatibility
2425
2421
  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>');
2422
+ builtXml = formatGrid3XmlComplete(builtXml);
2441
2423
  newGridFiles.set(gridPath, builtXml);
2442
2424
  }
2443
2425
  // Copy all files from original zip, replacing modified grid files
@@ -2506,57 +2488,14 @@ class GridsetProcessor extends BaseProcessor {
2506
2488
  // Preserve Grid 3 XML formatting requirements
2507
2489
  suppressBooleanAttributes: false,
2508
2490
  });
2509
- // Build the grid XML and convert to Windows line endings for Grid 3 compatibility
2491
+ // Build the grid XML and format for Grid 3 compatibility
2510
2492
  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>');
2493
+ builtXml = formatGrid3XmlComplete(builtXml);
2514
2494
  return builtXml;
2515
2495
  }
2516
2496
  // Helper method to find button position with span information
2517
2497
  findButtonPosition(page, button, fallbackIndex) {
2518
- if (page.grid && page.grid.length > 0) {
2519
- // Search for button in grid layout and calculate span
2520
- for (let y = 0; y < page.grid.length; y++) {
2521
- for (let x = 0; x < page.grid[y].length; x++) {
2522
- const current = page.grid[y][x];
2523
- if (current && current.id === button.id) {
2524
- // Calculate span by checking how far the same button extends
2525
- let columnSpan = 1;
2526
- let rowSpan = 1;
2527
- // Check column span (rightward)
2528
- while (x + columnSpan < page.grid[y].length) {
2529
- const right = page.grid[y][x + columnSpan];
2530
- if (right && right.id === button.id) {
2531
- columnSpan++;
2532
- }
2533
- else {
2534
- break;
2535
- }
2536
- }
2537
- // Check row span (downward)
2538
- while (y + rowSpan < page.grid.length) {
2539
- const below = page.grid[y + rowSpan][x];
2540
- if (below && below.id === button.id) {
2541
- rowSpan++;
2542
- }
2543
- else {
2544
- break;
2545
- }
2546
- }
2547
- return { x, y, columnSpan, rowSpan };
2548
- }
2549
- }
2550
- }
2551
- }
2552
- // Fallback positioning
2553
- const gridCols = page.grid?.[0]?.length || Math.ceil(Math.sqrt(page.buttons.length));
2554
- return {
2555
- x: fallbackIndex % gridCols,
2556
- y: Math.floor(fallbackIndex / gridCols),
2557
- columnSpan: 1,
2558
- rowSpan: 1,
2559
- };
2498
+ return findButtonPos(page, button, fallbackIndex);
2560
2499
  }
2561
2500
  /**
2562
2501
  * Extract strings with metadata for aac-tools-platform compatibility