@willwade/aac-processors 0.0.26 → 0.0.28

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,18 @@ 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
+ }
58
168
  return new treeStructure_1.AACButton({
59
169
  // Make button ID unique by combining page ID and button ID
60
170
  id: `${pageId}::${btn?.id || ''}`,
@@ -65,6 +175,9 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
65
175
  backgroundColor: btn.background_color,
66
176
  borderColor: btn.border_color,
67
177
  },
178
+ image: resolvedImage, // Set the resolved image data URL
179
+ resolvedImageEntry: resolvedImage,
180
+ parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
68
181
  semanticAction,
69
182
  targetPageId: btn.load_board?.path,
70
183
  semantic_id: btn.semantic_id, // Extract semantic_id if present
@@ -271,6 +384,9 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
271
384
  console.error('[OBF] Error instantiating AdmZip with input:', err);
272
385
  throw err;
273
386
  }
387
+ // Store the ZIP file reference for image extraction
388
+ this.zipFile = zip;
389
+ this.imageCache.clear(); // Clear cache for new file
274
390
  console.log('[OBF] Detected zip archive, extracting .obf files');
275
391
  zip.getEntries().forEach((entry) => {
276
392
  if (entry.entryName.endsWith('.obf')) {
@@ -39,6 +39,23 @@ export declare class MetricsCalculator {
39
39
  * Calculate what percentage of links to this board match semantic_id/clone_id
40
40
  */
41
41
  private calculateBoardLinkPercentages;
42
+ /**
43
+ * Calculate metrics for word forms (smart grammar predictions)
44
+ *
45
+ * Word forms are dynamically generated and not part of the tree structure.
46
+ * Their effort is calculated as:
47
+ * - Parent button's cumulative effort (to reach the button)
48
+ * - + Effort to select the word form from its position in predictions grid
49
+ *
50
+ * If a word exists as both a regular button and a word form, the version
51
+ * with lower effort is kept.
52
+ *
53
+ * @param tree - The AAC tree
54
+ * @param buttons - Already calculated button metrics
55
+ * @param options - Metrics options
56
+ * @returns Object containing word form metrics and labels that were replaced
57
+ */
58
+ private calculateWordFormMetrics;
42
59
  /**
43
60
  * Calculate grid dimensions from the tree
44
61
  */
@@ -83,6 +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) 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
+ }
86
100
  // Calculate grid dimensions
87
101
  const grid = this.calculateGridDimensions(tree);
88
102
  // Identify prediction metrics
@@ -560,6 +574,88 @@ class MetricsCalculator {
560
574
  boardPcts['all'] = totalLinks;
561
575
  return boardPcts;
562
576
  }
577
+ /**
578
+ * Calculate metrics for word forms (smart grammar predictions)
579
+ *
580
+ * Word forms are dynamically generated and not part of the tree structure.
581
+ * Their effort is calculated as:
582
+ * - Parent button's cumulative effort (to reach the button)
583
+ * - + Effort to select the word form from its position in predictions grid
584
+ *
585
+ * If a word exists as both a regular button and a word form, the version
586
+ * with lower effort is kept.
587
+ *
588
+ * @param tree - The AAC tree
589
+ * @param buttons - Already calculated button metrics
590
+ * @param options - Metrics options
591
+ * @returns Object containing word form metrics and labels that were replaced
592
+ */
593
+ calculateWordFormMetrics(tree, buttons, _options = {}) {
594
+ const wordFormMetrics = [];
595
+ const replacedLabels = new Set();
596
+ // Track buttons by label to compare efforts
597
+ const existingLabels = new Map();
598
+ buttons.forEach((btn) => existingLabels.set(btn.label.toLowerCase(), btn));
599
+ // Iterate through all pages to find buttons with predictions
600
+ Object.values(tree.pages).forEach((page) => {
601
+ page.grid.forEach((row) => {
602
+ row.forEach((btn) => {
603
+ if (!btn || !btn.predictions || btn.predictions.length === 0)
604
+ return;
605
+ // Find the parent button's metrics
606
+ const parentMetrics = buttons.find((b) => b.id === btn.id);
607
+ if (!parentMetrics)
608
+ return;
609
+ // Calculate effort for each word form
610
+ btn.predictions.forEach((wordForm, index) => {
611
+ const wordFormLower = wordForm.toLowerCase();
612
+ // Calculate effort based on position in predictions array
613
+ // Assume predictions are displayed in a grid layout (e.g., 2 columns)
614
+ const predictionsGridCols = 2; // Typical predictions layout
615
+ const predictionRowIndex = Math.floor(index / predictionsGridCols);
616
+ const predictionColIndex = index % predictionsGridCols;
617
+ // Calculate visual scan effort to reach this word form position
618
+ // Using similar logic to button scanning effort
619
+ const predictionPriorItems = predictionRowIndex * predictionsGridCols + predictionColIndex;
620
+ const predictionSelectionEffort = (0, effort_1.visualScanEffort)(predictionPriorItems);
621
+ // Word form effort = parent button's cumulative effort + selection effort
622
+ const wordFormEffort = parentMetrics.effort + predictionSelectionEffort;
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
634
+ const wordFormBtn = {
635
+ id: `${btn.id}_wordform_${index}`,
636
+ label: wordForm,
637
+ level: parentMetrics.level,
638
+ effort: wordFormEffort,
639
+ count: 1,
640
+ semantic_id: parentMetrics.semantic_id,
641
+ clone_id: parentMetrics.clone_id,
642
+ temporary_home_id: parentMetrics.temporary_home_id,
643
+ is_word_form: true, // Mark this as a word form metric
644
+ parent_button_id: btn.id, // Track parent button
645
+ parent_button_label: parentMetrics.label, // Track parent label
646
+ };
647
+ wordFormMetrics.push(wordFormBtn);
648
+ existingLabels.set(wordFormLower, wordFormBtn);
649
+ });
650
+ });
651
+ });
652
+ });
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 };
658
+ }
563
659
  /**
564
660
  * Calculate grid dimensions from the tree
565
661
  */
@@ -18,6 +18,9 @@ export interface ButtonMetrics {
18
18
  temporary_home_id?: string;
19
19
  comp_level?: number;
20
20
  comp_effort?: number;
21
+ is_word_form?: boolean;
22
+ parent_button_id?: string;
23
+ parent_button_label?: string;
21
24
  }
22
25
  /**
23
26
  * Board/page level analysis result
@@ -114,6 +117,20 @@ export interface MetricsOptions {
114
117
  * Default is 1.5 (checking 1-2 predictions on average).
115
118
  */
116
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;
117
134
  }
118
135
  /**
119
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.26",
3
+ "version": "0.0.28",
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",