@willwade/aac-processors 0.0.27 → 0.0.29

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.
@@ -12,6 +12,11 @@ declare class GridsetProcessor extends BaseProcessor {
12
12
  private decryptGridsetEntry;
13
13
  private getGridsetPassword;
14
14
  private ensureAlphaChannel;
15
+ /**
16
+ * Calculate appropriate font color (black or white) based on background brightness
17
+ * Uses WCAG relative luminance formula to determine contrast
18
+ */
19
+ private getContrastFontColor;
15
20
  /**
16
21
  * Extract words from Grid3 WordList structure
17
22
  */
@@ -58,6 +58,19 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
58
58
  ensureAlphaChannel(color) {
59
59
  if (!color)
60
60
  return '#FFFFFFFF';
61
+ // Handle rgb() and rgba() formats
62
+ const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
63
+ if (rgbMatch) {
64
+ const r = parseInt(rgbMatch[1]);
65
+ const g = parseInt(rgbMatch[2]);
66
+ const b = parseInt(rgbMatch[3]);
67
+ const a = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1.0;
68
+ const alphaHex = Math.round(a * 255)
69
+ .toString(16)
70
+ .toUpperCase()
71
+ .padStart(2, '0');
72
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${alphaHex}`;
73
+ }
61
74
  // If already 8 digits (with alpha), return as is
62
75
  if (color.match(/^#[0-9A-Fa-f]{8}$/))
63
76
  return color;
@@ -74,6 +87,37 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
74
87
  // Invalid or unknown format, return white
75
88
  return '#FFFFFFFF';
76
89
  }
90
+ /**
91
+ * Calculate appropriate font color (black or white) based on background brightness
92
+ * Uses WCAG relative luminance formula to determine contrast
93
+ */
94
+ getContrastFontColor(backgroundColor) {
95
+ if (!backgroundColor)
96
+ return '#FF000000FF'; // Default to black
97
+ // Parse color from various formats
98
+ let r = 255, g = 255, b = 255;
99
+ // Handle hex colors
100
+ const hexMatch = backgroundColor.match(/#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/);
101
+ if (hexMatch) {
102
+ r = parseInt(hexMatch[1], 16);
103
+ g = parseInt(hexMatch[2], 16);
104
+ b = parseInt(hexMatch[3], 16);
105
+ }
106
+ else {
107
+ // Handle rgb() format
108
+ const rgbMatch = backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
109
+ if (rgbMatch) {
110
+ r = parseInt(rgbMatch[1]);
111
+ g = parseInt(rgbMatch[2]);
112
+ b = parseInt(rgbMatch[3]);
113
+ }
114
+ }
115
+ // Calculate relative luminance using WCAG formula
116
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
117
+ // Use white text for dark backgrounds (luminance < 0.5), black for light backgrounds
118
+ // Return 6-digit hex (ensureAlphaChannel will add FF for alpha)
119
+ return luminance < 0.5 ? '#FFFFFF' : '#000000';
120
+ }
77
121
  /**
78
122
  * Extract words from Grid3 WordList structure
79
123
  */
@@ -1628,7 +1672,8 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1628
1672
  // For "None" surround, just use BackColour for the fill (no TileColour)
1629
1673
  BackColour: this.ensureAlphaChannel(style.backgroundColor),
1630
1674
  BorderColour: this.ensureAlphaChannel(style.borderColor),
1631
- FontColour: this.ensureAlphaChannel(style.fontColor),
1675
+ // Calculate font color based on background if not explicitly set
1676
+ FontColour: this.ensureAlphaChannel(style.fontColor || this.getContrastFontColor(style.backgroundColor)),
1632
1677
  FontName: style.fontFamily || 'Arial',
1633
1678
  FontSize: style.fontSize?.toString() || '16',
1634
1679
  };
@@ -1687,26 +1732,50 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1687
1732
  if (imageMatch) {
1688
1733
  imageExt = imageMatch[1].toLowerCase();
1689
1734
  }
1690
- // Grid3 dynamically constructs image filenames by prepending cell coordinates
1691
- // The XML should only contain the suffix: -0-text-0.{ext}
1692
- // Grid3 automatically adds the X-Y prefix based on the Cell's position
1693
- captionAndImage.Image = `-0-text-0.${imageExt}`;
1694
1735
  // Extract image data from button parameters if available
1695
1736
  // (AstericsGridProcessor stores it there during loadIntoTree)
1737
+ // Also handle data URLs from OBZ conversion
1696
1738
  let imageData = Buffer.alloc(0);
1739
+ let hasImageData = false;
1697
1740
  if (button.parameters &&
1698
1741
  button.parameters.imageData &&
1699
1742
  Buffer.isBuffer(button.parameters.imageData)) {
1700
1743
  imageData = button.parameters.imageData;
1744
+ hasImageData = imageData.length > 0;
1745
+ }
1746
+ else if (button.image &&
1747
+ typeof button.image === 'string' &&
1748
+ button.image.startsWith('data:image')) {
1749
+ // Convert data URL to Buffer (for OBZ → Grid3 conversion)
1750
+ try {
1751
+ const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/);
1752
+ if (matches) {
1753
+ const extension = matches[1]; // e.g., 'png', 'jpeg', 'gif'
1754
+ const base64Data = matches[2];
1755
+ imageData = Buffer.from(base64Data, 'base64');
1756
+ imageExt = extension; // Override the detected extension
1757
+ hasImageData = imageData.length > 0;
1758
+ }
1759
+ }
1760
+ catch (err) {
1761
+ console.warn(`[Grid3] Failed to convert data URL to Buffer for button ${button.id}:`, err);
1762
+ }
1763
+ }
1764
+ // Only add image reference if we have actual image data
1765
+ if (hasImageData) {
1766
+ // Grid3 dynamically constructs image filenames by prepending cell coordinates
1767
+ // The XML should only contain the suffix: -0-text-0.{ext}
1768
+ // Grid3 automatically adds the X-Y prefix based on the Cell's position
1769
+ captionAndImage.Image = `-0-text-0.${imageExt}`;
1770
+ // Store image data for later writing to ZIP
1771
+ buttonImages.set(button.id, {
1772
+ imageData: imageData,
1773
+ ext: imageExt,
1774
+ pageName: page.name || page.id,
1775
+ x: position.x,
1776
+ y: position.y + yOffset,
1777
+ });
1701
1778
  }
1702
- // Store image data for later writing to ZIP
1703
- buttonImages.set(button.id, {
1704
- imageData: imageData,
1705
- ext: imageExt,
1706
- pageName: page.name || page.id,
1707
- x: position.x,
1708
- y: position.y + yOffset,
1709
- });
1710
1779
  }
1711
1780
  const cellData = {
1712
1781
  '@_X': position.x, // Grid3 uses 0-based X coordinates (defaults to 0 when omitted)
@@ -1736,9 +1805,10 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1736
1805
  if (button.style?.borderColor) {
1737
1806
  styleObj.BorderColour = this.ensureAlphaChannel(button.style.borderColor);
1738
1807
  }
1739
- if (button.style?.fontColor) {
1740
- styleObj.FontColour = this.ensureAlphaChannel(button.style.fontColor);
1741
- }
1808
+ // Always add font color inline - either from button style or calculated from background
1809
+ const fontColor = button.style?.fontColor ||
1810
+ this.getContrastFontColor(button.style?.backgroundColor);
1811
+ styleObj.FontColour = this.ensureAlphaChannel(fontColor);
1742
1812
  if (button.style?.fontFamily) {
1743
1813
  styleObj.FontName = button.style.fontFamily;
1744
1814
  }
@@ -3,7 +3,18 @@ import { AACTree } from '../core/treeStructure';
3
3
  import { ValidationResult } from '../validation/validationTypes';
4
4
  import { type ButtonForTranslation, type LLMLTranslationResult } from '../utilities/translation/translationProcessor';
5
5
  declare class ObfProcessor extends BaseProcessor {
6
+ private zipFile?;
7
+ private imageCache;
6
8
  constructor(options?: ProcessorOptions);
9
+ /**
10
+ * Extract an image from the ZIP file as a Buffer
11
+ */
12
+ private extractImageAsBuffer;
13
+ /**
14
+ * Extract an image from the ZIP file and convert to data URL
15
+ */
16
+ private extractImageAsDataUrl;
17
+ private getMimeTypeFromFilename;
7
18
  private processBoard;
8
19
  extractTexts(filePathOrBuffer: string | Buffer): string[];
9
20
  loadIntoTree(filePathOrBuffer: string | Buffer): AACTree;
@@ -26,6 +26,104 @@ function mapObfVisibility(hidden) {
26
26
  class ObfProcessor extends baseProcessor_1.BaseProcessor {
27
27
  constructor(options) {
28
28
  super(options);
29
+ this.imageCache = new Map(); // Cache for data URLs
30
+ }
31
+ /**
32
+ * Extract an image from the ZIP file as a Buffer
33
+ */
34
+ extractImageAsBuffer(imageId, images) {
35
+ if (!this.zipFile || !images) {
36
+ return null;
37
+ }
38
+ // Find the image metadata
39
+ const imageData = images.find((img) => img.id === imageId);
40
+ if (!imageData) {
41
+ return null;
42
+ }
43
+ // Try to get the image file from the ZIP
44
+ const possiblePaths = [
45
+ imageData.path,
46
+ `images/${imageData.filename || imageId}`,
47
+ imageData.id,
48
+ ].filter(Boolean);
49
+ for (const imagePath of possiblePaths) {
50
+ try {
51
+ const entry = this.zipFile.getEntry(imagePath);
52
+ if (entry) {
53
+ return entry.getData(); // Return raw Buffer
54
+ }
55
+ }
56
+ catch (err) {
57
+ continue;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * Extract an image from the ZIP file and convert to data URL
64
+ */
65
+ extractImageAsDataUrl(imageId, images) {
66
+ // Check cache first
67
+ if (this.imageCache.has(imageId)) {
68
+ return this.imageCache.get(imageId) ?? null;
69
+ }
70
+ if (!this.zipFile || !images) {
71
+ return null;
72
+ }
73
+ // Find the image metadata
74
+ const imageData = images.find((img) => img.id === imageId);
75
+ if (!imageData) {
76
+ return null;
77
+ }
78
+ // Try to get the image file from the ZIP
79
+ // Images are typically stored in an 'images' folder or root
80
+ const possiblePaths = [
81
+ imageData.path, // Explicit path if provided
82
+ `images/${imageData.filename || imageId}`, // Standard images folder
83
+ imageData.id, // Just the ID
84
+ ].filter(Boolean);
85
+ for (const imagePath of possiblePaths) {
86
+ try {
87
+ const entry = this.zipFile.getEntry(imagePath);
88
+ if (entry) {
89
+ const buffer = entry.getData();
90
+ const contentType = imageData.content_type ||
91
+ this.getMimeTypeFromFilename(imagePath);
92
+ const dataUrl = `data:${contentType};base64,${buffer.toString('base64')}`;
93
+ this.imageCache.set(imageId, dataUrl);
94
+ return dataUrl;
95
+ }
96
+ }
97
+ catch (err) {
98
+ // Continue to next path
99
+ continue;
100
+ }
101
+ }
102
+ // If image has a URL, use that as fallback
103
+ if (imageData.url) {
104
+ const url = imageData.url;
105
+ this.imageCache.set(imageId, url);
106
+ return url;
107
+ }
108
+ return null;
109
+ }
110
+ getMimeTypeFromFilename(filename) {
111
+ const ext = filename.toLowerCase().split('.').pop();
112
+ switch (ext) {
113
+ case 'png':
114
+ return 'image/png';
115
+ case 'jpg':
116
+ case 'jpeg':
117
+ return 'image/jpeg';
118
+ case 'gif':
119
+ return 'image/gif';
120
+ case 'svg':
121
+ return 'image/svg+xml';
122
+ case 'webp':
123
+ return 'image/webp';
124
+ default:
125
+ return 'image/png';
126
+ }
29
127
  }
30
128
  processBoard(boardData, _boardPath) {
31
129
  const sourceButtons = boardData.buttons || [];
@@ -55,6 +153,22 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
55
153
  message: String(btn?.vocalization || btn?.label || ''),
56
154
  },
57
155
  };
156
+ // Resolve image if image_id is present
157
+ let resolvedImage;
158
+ let imageBuffer;
159
+ if (btn.image_id && boardData.images) {
160
+ resolvedImage = this.extractImageAsDataUrl(btn.image_id, boardData.images) || undefined;
161
+ imageBuffer = this.extractImageAsBuffer(btn.image_id, boardData.images) || undefined;
162
+ }
163
+ // Build parameters object for Grid3 export compatibility
164
+ const buttonParameters = {};
165
+ if (imageBuffer) {
166
+ buttonParameters.imageData = imageBuffer;
167
+ }
168
+ // Store image_id for web viewers to fetch images via API
169
+ if (btn.image_id) {
170
+ buttonParameters.image_id = btn.image_id;
171
+ }
58
172
  return new treeStructure_1.AACButton({
59
173
  // Make button ID unique by combining page ID and button ID
60
174
  id: `${pageId}::${btn?.id || ''}`,
@@ -65,6 +179,9 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
65
179
  backgroundColor: btn.background_color,
66
180
  borderColor: btn.border_color,
67
181
  },
182
+ image: resolvedImage, // Set the resolved image data URL
183
+ resolvedImageEntry: resolvedImage,
184
+ parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
68
185
  semanticAction,
69
186
  targetPageId: btn.load_board?.path,
70
187
  semantic_id: btn.semantic_id, // Extract semantic_id if present
@@ -271,6 +388,9 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
271
388
  console.error('[OBF] Error instantiating AdmZip with input:', err);
272
389
  throw err;
273
390
  }
391
+ // Store the ZIP file reference for image extraction
392
+ this.zipFile = zip;
393
+ this.imageCache.clear(); // Clear cache for new file
274
394
  console.log('[OBF] Detected zip archive, extracting .obf files');
275
395
  zip.getEntries().forEach((entry) => {
276
396
  if (entry.entryName.endsWith('.obf')) {
@@ -47,10 +47,13 @@ export declare class MetricsCalculator {
47
47
  * - Parent button's cumulative effort (to reach the button)
48
48
  * - + Effort to select the word form from its position in predictions grid
49
49
  *
50
+ * If a word exists as both a regular button and a word form, the version
51
+ * with lower effort is kept.
52
+ *
50
53
  * @param tree - The AAC tree
51
54
  * @param buttons - Already calculated button metrics
52
55
  * @param options - Metrics options
53
- * @returns Array of word form button metrics
56
+ * @returns Object containing word form metrics and labels that were replaced
54
57
  */
55
58
  private calculateWordFormMetrics;
56
59
  /**
@@ -83,11 +83,20 @@ class MetricsCalculator {
83
83
  });
84
84
  // Update buttons using dynamic spelling effort if applicable
85
85
  const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort);
86
- // Calculate metrics for word forms (smart grammar predictions)
87
- const wordFormMetrics = this.calculateWordFormMetrics(tree, buttons, options);
88
- buttons.push(...wordFormMetrics);
89
- // Re-sort after adding word forms
90
- buttons.sort((a, b) => a.effort - b.effort);
86
+ // Calculate metrics for word forms (smart grammar predictions) if enabled
87
+ // Default to true if not specified
88
+ const useSmartGrammar = options.useSmartGrammar !== false;
89
+ if (useSmartGrammar) {
90
+ const { wordFormMetrics, replacedLabels } = this.calculateWordFormMetrics(tree, buttons, options);
91
+ // Remove buttons that were replaced by lower-effort word forms
92
+ const filteredButtons = buttons.filter((btn) => !replacedLabels.has(btn.label.toLowerCase()));
93
+ // Add word forms and re-sort
94
+ filteredButtons.push(...wordFormMetrics);
95
+ filteredButtons.sort((a, b) => a.effort - b.effort);
96
+ // Replace the original buttons array
97
+ buttons.length = 0;
98
+ buttons.push(...filteredButtons);
99
+ }
91
100
  // Calculate grid dimensions
92
101
  const grid = this.calculateGridDimensions(tree);
93
102
  // Identify prediction metrics
@@ -573,14 +582,18 @@ class MetricsCalculator {
573
582
  * - Parent button's cumulative effort (to reach the button)
574
583
  * - + Effort to select the word form from its position in predictions grid
575
584
  *
585
+ * If a word exists as both a regular button and a word form, the version
586
+ * with lower effort is kept.
587
+ *
576
588
  * @param tree - The AAC tree
577
589
  * @param buttons - Already calculated button metrics
578
590
  * @param options - Metrics options
579
- * @returns Array of word form button metrics
591
+ * @returns Object containing word form metrics and labels that were replaced
580
592
  */
581
593
  calculateWordFormMetrics(tree, buttons, _options = {}) {
582
594
  const wordFormMetrics = [];
583
- // Track which buttons already exist to avoid duplicates
595
+ const replacedLabels = new Set();
596
+ // Track buttons by label to compare efforts
584
597
  const existingLabels = new Map();
585
598
  buttons.forEach((btn) => existingLabels.set(btn.label.toLowerCase(), btn));
586
599
  // Iterate through all pages to find buttons with predictions
@@ -596,10 +609,6 @@ class MetricsCalculator {
596
609
  // Calculate effort for each word form
597
610
  btn.predictions.forEach((wordForm, index) => {
598
611
  const wordFormLower = wordForm.toLowerCase();
599
- // Skip if this word form already exists as a regular button
600
- if (existingLabels.has(wordFormLower)) {
601
- return;
602
- }
603
612
  // Calculate effort based on position in predictions array
604
613
  // Assume predictions are displayed in a grid layout (e.g., 2 columns)
605
614
  const predictionsGridCols = 2; // Typical predictions layout
@@ -611,7 +620,17 @@ class MetricsCalculator {
611
620
  const predictionSelectionEffort = (0, effort_1.visualScanEffort)(predictionPriorItems);
612
621
  // Word form effort = parent button's cumulative effort + selection effort
613
622
  const wordFormEffort = parentMetrics.effort + predictionSelectionEffort;
614
- // Mark as word form with a special ID pattern
623
+ // Check if this word already exists as a regular button
624
+ const existingBtn = existingLabels.get(wordFormLower);
625
+ // If word exists and has lower or equal effort, skip the word form
626
+ if (existingBtn && existingBtn.effort <= wordFormEffort) {
627
+ return;
628
+ }
629
+ // If word exists but word form has lower effort, mark for replacement
630
+ if (existingBtn && existingBtn.effort > wordFormEffort) {
631
+ replacedLabels.add(wordFormLower);
632
+ }
633
+ // Create word form metric
615
634
  const wordFormBtn = {
616
635
  id: `${btn.id}_wordform_${index}`,
617
636
  label: wordForm,
@@ -631,8 +650,11 @@ class MetricsCalculator {
631
650
  });
632
651
  });
633
652
  });
634
- console.log(`📝 Calculated ${wordFormMetrics.length} word form metrics from predictions`);
635
- return wordFormMetrics;
653
+ console.log(`📝 Calculated ${wordFormMetrics.length} word form metrics` +
654
+ (replacedLabels.size > 0
655
+ ? ` (${replacedLabels.size} replaced higher-effort buttons: ${Array.from(replacedLabels).join(', ')})`
656
+ : ''));
657
+ return { wordFormMetrics, replacedLabels };
636
658
  }
637
659
  /**
638
660
  * Calculate grid dimensions from the tree
@@ -117,6 +117,20 @@ export interface MetricsOptions {
117
117
  * Default is 1.5 (checking 1-2 predictions on average).
118
118
  */
119
119
  predictionSelections?: number;
120
+ /**
121
+ * Whether to include smart grammar word forms in metrics
122
+ *
123
+ * When true (default): Word forms from smart grammar predictions are included
124
+ * in the metrics. If a word exists as both a regular button and a word form,
125
+ * the version with lower effort is used.
126
+ *
127
+ * When false: Smart grammar word forms are excluded from metrics. Only actual
128
+ * buttons in the tree are analyzed.
129
+ *
130
+ * Only applicable to systems that support smart grammar (e.g., Grid 3).
131
+ * Default is true.
132
+ */
133
+ useSmartGrammar?: boolean;
120
134
  }
121
135
  /**
122
136
  * Comparison result between two board sets
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willwade/aac-processors",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
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
  "types": "dist/index.d.ts",