@willwade/aac-processors 0.1.14 → 0.1.16

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.
@@ -513,6 +513,29 @@ class GridsetProcessor extends BaseProcessor {
513
513
  if (gridEntries.length > 0) {
514
514
  console.log('[Gridset] First few grid entries:', gridEntries.slice(0, 3).map((e) => e.entryName));
515
515
  }
516
+ // Pre-load all image data for conversion to other formats (e.g., Snap)
517
+ const imageDataCache = new Map();
518
+ const imageEntries = entries.filter((e) => {
519
+ const name = e.entryName.toLowerCase();
520
+ return (name.endsWith('.png') ||
521
+ name.endsWith('.jpg') ||
522
+ name.endsWith('.jpeg') ||
523
+ name.endsWith('.gif') ||
524
+ name.endsWith('.svg'));
525
+ });
526
+ for (const imageEntry of imageEntries) {
527
+ try {
528
+ const raw = await imageEntry.getData();
529
+ const data = isEncryptedArchive
530
+ ? decryptGridsetEntry(Buffer.from(raw), encryptedContentPassword)
531
+ : Buffer.from(raw);
532
+ const normalizedEntry = imageEntry.entryName.replace(/\\/g, '/');
533
+ imageDataCache.set(normalizedEntry, data);
534
+ }
535
+ catch (err) {
536
+ // Silently fail - individual image loading failures shouldn't break the entire load
537
+ }
538
+ }
516
539
  // First pass: collect all grid names and IDs for navigation resolution
517
540
  const gridNameToIdMap = new Map();
518
541
  const gridIdToNameMap = new Map();
@@ -943,6 +966,10 @@ class GridsetProcessor extends BaseProcessor {
943
966
  else if (declaredImageName && !resolvedImageEntry) {
944
967
  console.log(`[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> NOT FOUND`);
945
968
  }
969
+ // Load binary image data from cache for conversion to other formats (e.g., Snap)
970
+ const imageData = resolvedImageEntry
971
+ ? imageDataCache.get(resolvedImageEntry)
972
+ : undefined;
946
973
  // Check if image is a symbol library reference
947
974
  let symbolLibraryRef = null;
948
975
  if (declaredImageName && isSymbolLibraryReference(declaredImageName)) {
@@ -1498,6 +1525,8 @@ class GridsetProcessor extends BaseProcessor {
1498
1525
  !isMoreButton
1499
1526
  ? wordListCellIndex - 1
1500
1527
  : undefined,
1528
+ // Store binary image data for conversion to other formats
1529
+ ...(imageData ? { imageData, image_id: resolvedImageEntry } : {}),
1501
1530
  },
1502
1531
  });
1503
1532
  // Add button to page
@@ -1,4 +1,4 @@
1
- import { AACSemanticCategory, AACSemanticIntent } from '../../core/treeStructure';
1
+ import { AACSemanticCategory, AACSemanticIntent, } from '../../core/treeStructure';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import Database from 'better-sqlite3';
@@ -51,17 +51,83 @@ export function getPageTokenImageMap(tree, pageId) {
51
51
  }
52
52
  /**
53
53
  * Collect all image entry paths referenced in a Snap tree.
54
- * Currently empty until resolvedImageEntry is populated by the processor.
54
+ * Returns the set of symbol identifiers (e.g., "SYM:12345") that are referenced by buttons.
55
55
  */
56
- export function getAllowedImageEntries(_tree) {
57
- return new Set();
56
+ export function getAllowedImageEntries(tree) {
57
+ const out = new Set();
58
+ Object.values(tree.pages).forEach((page) => {
59
+ page.buttons.forEach((btn) => {
60
+ // Extract image_id from parameters if it exists
61
+ if (btn.parameters?.image_id && typeof btn.parameters.image_id === 'string') {
62
+ out.add(btn.parameters.image_id);
63
+ }
64
+ // Also add resolvedImageEntry if it's a symbol identifier
65
+ if (btn.resolvedImageEntry && typeof btn.resolvedImageEntry === 'string') {
66
+ const entry = btn.resolvedImageEntry;
67
+ if (entry.startsWith('SYM:')) {
68
+ out.add(entry);
69
+ }
70
+ }
71
+ });
72
+ });
73
+ return out;
58
74
  }
59
75
  /**
60
76
  * Read a binary asset from a Snap pageset.
61
- * Not implemented yet; provided for API symmetry with other processors.
77
+ * @param dbOrFile Path to Snap .sps/.spb file or Buffer containing the file data
78
+ * @param entryPath Symbol identifier (e.g., "SYM:12345")
79
+ * @returns Image data buffer or null if not found
62
80
  */
63
- export function openImage(_dbOrFile, _entryPath) {
64
- return null;
81
+ export function openImage(dbOrFile, entryPath) {
82
+ let dbPath;
83
+ let cleanupNeeded = false;
84
+ // Handle Buffer input by writing to temp file
85
+ if (Buffer.isBuffer(dbOrFile)) {
86
+ if (typeof fs.mkdtempSync !== 'function') {
87
+ return null; // Not in Node environment
88
+ }
89
+ const tempDir = fs.mkdtempSync(path.join(process.cwd(), 'snap-'));
90
+ dbPath = path.join(tempDir, 'temp.sps');
91
+ fs.writeFileSync(dbPath, dbOrFile);
92
+ cleanupNeeded = true;
93
+ }
94
+ else if (typeof dbOrFile === 'string') {
95
+ dbPath = dbOrFile;
96
+ }
97
+ else {
98
+ return null;
99
+ }
100
+ let db = null;
101
+ try {
102
+ db = new Database(dbPath, { readonly: true });
103
+ // Query PageSetData for the symbol
104
+ const row = db
105
+ .prepare('SELECT Id, Identifier, Data FROM PageSetData WHERE Identifier = ?')
106
+ .get(entryPath);
107
+ if (row && row.Data && row.Data.length > 0) {
108
+ return row.Data;
109
+ }
110
+ return null;
111
+ }
112
+ catch (error) {
113
+ console.warn(`[Snap helpers] Failed to open image ${entryPath}:`, error);
114
+ return null;
115
+ }
116
+ finally {
117
+ if (db) {
118
+ db.close();
119
+ }
120
+ if (cleanupNeeded && dbPath) {
121
+ try {
122
+ fs.unlinkSync(dbPath);
123
+ const dir = path.dirname(dbPath);
124
+ fs.rmdirSync(dir);
125
+ }
126
+ catch (e) {
127
+ // Ignore cleanup errors
128
+ }
129
+ }
130
+ }
65
131
  }
66
132
  /**
67
133
  * Find Tobii Communicator Snap package paths
@@ -15,6 +15,41 @@ function mapSnapVisibility(visible) {
15
15
  }
16
16
  return visible === 0 ? 'Hidden' : 'Visible';
17
17
  }
18
+ /**
19
+ * Detect image MIME type from binary data using magic bytes
20
+ * @param buffer Image data buffer
21
+ * @returns MIME type string (defaults to 'image/png' if unknown)
22
+ */
23
+ function detectImageMimeType(buffer) {
24
+ if (!buffer || buffer.length < 8) {
25
+ return 'image/png';
26
+ }
27
+ // Check for PNG: 89 50 4E 47 0D 0A 1A 0A
28
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
29
+ return 'image/png';
30
+ }
31
+ // Check for JPEG: FF D8 FF
32
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
33
+ return 'image/jpeg';
34
+ }
35
+ // Check for GIF: 47 49 46 38 (GIF8)
36
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
37
+ return 'image/gif';
38
+ }
39
+ // Check for WebP: 52 49 46 46 ... 57 45 42 50 (RIFF...WEBP)
40
+ if (buffer[0] === 0x52 &&
41
+ buffer[1] === 0x49 &&
42
+ buffer[2] === 0x46 &&
43
+ buffer[3] === 0x46 &&
44
+ buffer[8] === 0x57 &&
45
+ buffer[9] === 0x45 &&
46
+ buffer[10] === 0x42 &&
47
+ buffer[11] === 0x50) {
48
+ return 'image/webp';
49
+ }
50
+ // Default to PNG
51
+ return 'image/png';
52
+ }
18
53
  class SnapProcessor extends BaseProcessor {
19
54
  constructor(symbolResolver = null, options = {}) {
20
55
  super(options);
@@ -417,6 +452,30 @@ class SnapProcessor extends BaseProcessor {
417
452
  console.warn(`[SnapProcessor] Failed to load audio for button ${btnRow.Id}:`, e);
418
453
  }
419
454
  }
455
+ // Load symbol image if available
456
+ // Note: PageSetImageId references embedded images in PageSetData table
457
+ // LibrarySymbolId references external symbol libraries (SymbolStix, etc.)
458
+ let buttonImage;
459
+ const buttonParameters = {};
460
+ if (btnRow.PageSetImageId && btnRow.PageSetImageId > 0) {
461
+ try {
462
+ const imageData = db
463
+ .prepare(`
464
+ SELECT Id, Identifier, Data FROM PageSetData WHERE Id = ?
465
+ `)
466
+ .get(btnRow.PageSetImageId);
467
+ if (imageData && imageData.Data && imageData.Data.length > 0) {
468
+ const mimeType = detectImageMimeType(imageData.Data);
469
+ const base64 = imageData.Data.toString('base64');
470
+ buttonImage = `data:${mimeType};base64,${base64}`;
471
+ buttonParameters.image_id = imageData.Identifier;
472
+ buttonParameters.imageData = imageData.Data;
473
+ }
474
+ }
475
+ catch (e) {
476
+ console.warn(`[SnapProcessor] Failed to load image for button ${btnRow.Id} (PageSetImageId: ${btnRow.PageSetImageId}):`, e);
477
+ }
478
+ }
420
479
  // Create semantic action for Snap button
421
480
  let semanticAction;
422
481
  if (targetPageUniqueId) {
@@ -465,6 +524,9 @@ class SnapProcessor extends BaseProcessor {
465
524
  semantic_id: btnRow.LibrarySymbolId
466
525
  ? `snap_symbol_${btnRow.LibrarySymbolId}`
467
526
  : undefined, // Extract semantic_id from LibrarySymbolId
527
+ image: buttonImage,
528
+ resolvedImageEntry: buttonImage,
529
+ parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
468
530
  style: {
469
531
  backgroundColor: btnRow.BackgroundColor
470
532
  ? `#${btnRow.BackgroundColor.toString(16)}`
@@ -855,11 +917,54 @@ class SnapProcessor extends BaseProcessor {
855
917
  serializedMetadata = audio.metadata || null;
856
918
  useMessageRecording = 1;
857
919
  }
920
+ // Handle image data from button.parameters.imageData or button.image (data URL)
921
+ let pageSetImageId = null;
922
+ if (button.parameters?.imageData && Buffer.isBuffer(button.parameters.imageData)) {
923
+ // Use existing image data buffer
924
+ const imageIdentifier = button.parameters.image_id || `IMG_${buttonIdCounter}`;
925
+ let imageId = pageSetDataIdentifierMap.get(imageIdentifier);
926
+ if (!imageId) {
927
+ imageId = pageSetDataIdCounter++;
928
+ insertPageSetData.run(imageId, imageIdentifier, button.parameters.imageData, 1);
929
+ pageSetDataIdentifierMap.set(imageIdentifier, imageId);
930
+ }
931
+ else {
932
+ incrementRefCount.run(imageId);
933
+ }
934
+ pageSetImageId = imageId;
935
+ }
936
+ else if (button.image &&
937
+ typeof button.image === 'string' &&
938
+ button.image.startsWith('data:image')) {
939
+ // Convert data URL to buffer
940
+ try {
941
+ const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/);
942
+ if (matches && matches[2]) {
943
+ const imageData = Buffer.from(matches[2], 'base64');
944
+ const imageIdentifier = button.parameters?.image_id || `IMG_${buttonIdCounter}`;
945
+ let imageId = pageSetDataIdentifierMap.get(imageIdentifier);
946
+ if (!imageId) {
947
+ imageId = pageSetDataIdCounter++;
948
+ insertPageSetData.run(imageId, imageIdentifier, imageData, 1);
949
+ pageSetDataIdentifierMap.set(imageIdentifier, imageId);
950
+ }
951
+ else {
952
+ incrementRefCount.run(imageId);
953
+ }
954
+ pageSetImageId = imageId;
955
+ }
956
+ }
957
+ catch (err) {
958
+ console.warn(`[SnapProcessor] Failed to convert data URL to Buffer for button ${button.id}:`, err);
959
+ }
960
+ }
858
961
  // Retry logic for SQLite operations
859
962
  let retries = 3;
860
963
  while (retries > 0) {
861
964
  try {
862
- insertButton.run(buttonIdCounter++, button.label || '', button.message || button.label || '', navigatePageId, elementRefId, null, null, messageRecordingId, serializedMetadata, useMessageRecording, button.style?.fontColor
965
+ insertButton.run(buttonIdCounter++, button.label || '', button.message || button.label || '', navigatePageId, elementRefId, null, // LibrarySymbolId - not used for embedded images
966
+ pageSetImageId, // PageSetImageId - references embedded image in PageSetData
967
+ messageRecordingId, serializedMetadata, useMessageRecording, button.style?.fontColor
863
968
  ? parseInt(button.style.fontColor.replace('#', ''), 16)
864
969
  : null, button.style?.backgroundColor
865
970
  ? parseInt(button.style.backgroundColor.replace('#', ''), 16)
@@ -65,7 +65,8 @@ export class TouchChatValidator extends BaseValidator {
65
65
  this.warn('filename should end with .ce');
66
66
  }
67
67
  });
68
- const zipped = await this.tryValidateZipSqlite(content);
68
+ const looksLikeXml = this.isXmlBuffer(content);
69
+ const zipped = looksLikeXml ? false : await this.tryValidateZipSqlite(content);
69
70
  if (!zipped) {
70
71
  let xmlObj = null;
71
72
  await this.add_check('xml_parse', 'valid XML', async () => {
@@ -228,6 +229,23 @@ export class TouchChatValidator extends BaseValidator {
228
229
  }
229
230
  return true;
230
231
  }
232
+ isXmlBuffer(content) {
233
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
234
+ const max = Math.min(bytes.length, 256);
235
+ let start = 0;
236
+ while (start < max) {
237
+ const ch = bytes[start];
238
+ if (ch === 0x20 || ch === 0x0a || ch === 0x0d || ch === 0x09) {
239
+ start += 1;
240
+ continue;
241
+ }
242
+ break;
243
+ }
244
+ if (start >= max) {
245
+ return false;
246
+ }
247
+ return bytes[start] === 0x3c; // '<'
248
+ }
231
249
  async tryValidateZipSqlite(content) {
232
250
  let usedZip = false;
233
251
  await this.add_check('zip', 'TouchChat ZIP package', async () => {
@@ -539,6 +539,29 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
539
539
  if (gridEntries.length > 0) {
540
540
  console.log('[Gridset] First few grid entries:', gridEntries.slice(0, 3).map((e) => e.entryName));
541
541
  }
542
+ // Pre-load all image data for conversion to other formats (e.g., Snap)
543
+ const imageDataCache = new Map();
544
+ const imageEntries = entries.filter((e) => {
545
+ const name = e.entryName.toLowerCase();
546
+ return (name.endsWith('.png') ||
547
+ name.endsWith('.jpg') ||
548
+ name.endsWith('.jpeg') ||
549
+ name.endsWith('.gif') ||
550
+ name.endsWith('.svg'));
551
+ });
552
+ for (const imageEntry of imageEntries) {
553
+ try {
554
+ const raw = await imageEntry.getData();
555
+ const data = isEncryptedArchive
556
+ ? (0, crypto_1.decryptGridsetEntry)(Buffer.from(raw), encryptedContentPassword)
557
+ : Buffer.from(raw);
558
+ const normalizedEntry = imageEntry.entryName.replace(/\\/g, '/');
559
+ imageDataCache.set(normalizedEntry, data);
560
+ }
561
+ catch (err) {
562
+ // Silently fail - individual image loading failures shouldn't break the entire load
563
+ }
564
+ }
542
565
  // First pass: collect all grid names and IDs for navigation resolution
543
566
  const gridNameToIdMap = new Map();
544
567
  const gridIdToNameMap = new Map();
@@ -969,6 +992,10 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
969
992
  else if (declaredImageName && !resolvedImageEntry) {
970
993
  console.log(`[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> NOT FOUND`);
971
994
  }
995
+ // Load binary image data from cache for conversion to other formats (e.g., Snap)
996
+ const imageData = resolvedImageEntry
997
+ ? imageDataCache.get(resolvedImageEntry)
998
+ : undefined;
972
999
  // Check if image is a symbol library reference
973
1000
  let symbolLibraryRef = null;
974
1001
  if (declaredImageName && (0, resolver_2.isSymbolLibraryReference)(declaredImageName)) {
@@ -1524,6 +1551,8 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1524
1551
  !isMoreButton
1525
1552
  ? wordListCellIndex - 1
1526
1553
  : undefined,
1554
+ // Store binary image data for conversion to other formats
1555
+ ...(imageData ? { imageData, image_id: resolvedImageEntry } : {}),
1527
1556
  },
1528
1557
  });
1529
1558
  // Add button to page
@@ -1,4 +1,5 @@
1
1
  import { AACTree, AACSemanticCategory, AACSemanticIntent } from '../../core/treeStructure';
2
+ import { ProcessorInput } from '../../utils/io';
2
3
  /**
3
4
  * Build a map of button IDs to resolved image entries for a specific page.
4
5
  * Mirrors the Grid helper for consumers that expect image reference data.
@@ -6,14 +7,16 @@ import { AACTree, AACSemanticCategory, AACSemanticIntent } from '../../core/tree
6
7
  export declare function getPageTokenImageMap(tree: AACTree, pageId: string): Map<string, string>;
7
8
  /**
8
9
  * Collect all image entry paths referenced in a Snap tree.
9
- * Currently empty until resolvedImageEntry is populated by the processor.
10
+ * Returns the set of symbol identifiers (e.g., "SYM:12345") that are referenced by buttons.
10
11
  */
11
- export declare function getAllowedImageEntries(_tree: AACTree): Set<string>;
12
+ export declare function getAllowedImageEntries(tree: AACTree): Set<string>;
12
13
  /**
13
14
  * Read a binary asset from a Snap pageset.
14
- * Not implemented yet; provided for API symmetry with other processors.
15
+ * @param dbOrFile Path to Snap .sps/.spb file or Buffer containing the file data
16
+ * @param entryPath Symbol identifier (e.g., "SYM:12345")
17
+ * @returns Image data buffer or null if not found
15
18
  */
16
- export declare function openImage(_dbOrFile: string | Buffer, _entryPath: string): Buffer | null;
19
+ export declare function openImage(dbOrFile: ProcessorInput, entryPath: string): Buffer | null;
17
20
  /**
18
21
  * Snap package path information
19
22
  */
@@ -90,17 +90,83 @@ function getPageTokenImageMap(tree, pageId) {
90
90
  }
91
91
  /**
92
92
  * Collect all image entry paths referenced in a Snap tree.
93
- * Currently empty until resolvedImageEntry is populated by the processor.
93
+ * Returns the set of symbol identifiers (e.g., "SYM:12345") that are referenced by buttons.
94
94
  */
95
- function getAllowedImageEntries(_tree) {
96
- return new Set();
95
+ function getAllowedImageEntries(tree) {
96
+ const out = new Set();
97
+ Object.values(tree.pages).forEach((page) => {
98
+ page.buttons.forEach((btn) => {
99
+ // Extract image_id from parameters if it exists
100
+ if (btn.parameters?.image_id && typeof btn.parameters.image_id === 'string') {
101
+ out.add(btn.parameters.image_id);
102
+ }
103
+ // Also add resolvedImageEntry if it's a symbol identifier
104
+ if (btn.resolvedImageEntry && typeof btn.resolvedImageEntry === 'string') {
105
+ const entry = btn.resolvedImageEntry;
106
+ if (entry.startsWith('SYM:')) {
107
+ out.add(entry);
108
+ }
109
+ }
110
+ });
111
+ });
112
+ return out;
97
113
  }
98
114
  /**
99
115
  * Read a binary asset from a Snap pageset.
100
- * Not implemented yet; provided for API symmetry with other processors.
116
+ * @param dbOrFile Path to Snap .sps/.spb file or Buffer containing the file data
117
+ * @param entryPath Symbol identifier (e.g., "SYM:12345")
118
+ * @returns Image data buffer or null if not found
101
119
  */
102
- function openImage(_dbOrFile, _entryPath) {
103
- return null;
120
+ function openImage(dbOrFile, entryPath) {
121
+ let dbPath;
122
+ let cleanupNeeded = false;
123
+ // Handle Buffer input by writing to temp file
124
+ if (Buffer.isBuffer(dbOrFile)) {
125
+ if (typeof fs.mkdtempSync !== 'function') {
126
+ return null; // Not in Node environment
127
+ }
128
+ const tempDir = fs.mkdtempSync(path.join(process.cwd(), 'snap-'));
129
+ dbPath = path.join(tempDir, 'temp.sps');
130
+ fs.writeFileSync(dbPath, dbOrFile);
131
+ cleanupNeeded = true;
132
+ }
133
+ else if (typeof dbOrFile === 'string') {
134
+ dbPath = dbOrFile;
135
+ }
136
+ else {
137
+ return null;
138
+ }
139
+ let db = null;
140
+ try {
141
+ db = new better_sqlite3_1.default(dbPath, { readonly: true });
142
+ // Query PageSetData for the symbol
143
+ const row = db
144
+ .prepare('SELECT Id, Identifier, Data FROM PageSetData WHERE Identifier = ?')
145
+ .get(entryPath);
146
+ if (row && row.Data && row.Data.length > 0) {
147
+ return row.Data;
148
+ }
149
+ return null;
150
+ }
151
+ catch (error) {
152
+ console.warn(`[Snap helpers] Failed to open image ${entryPath}:`, error);
153
+ return null;
154
+ }
155
+ finally {
156
+ if (db) {
157
+ db.close();
158
+ }
159
+ if (cleanupNeeded && dbPath) {
160
+ try {
161
+ fs.unlinkSync(dbPath);
162
+ const dir = path.dirname(dbPath);
163
+ fs.rmdirSync(dir);
164
+ }
165
+ catch (e) {
166
+ // Ignore cleanup errors
167
+ }
168
+ }
169
+ }
104
170
  }
105
171
  /**
106
172
  * Find Tobii Communicator Snap package paths
@@ -18,6 +18,41 @@ function mapSnapVisibility(visible) {
18
18
  }
19
19
  return visible === 0 ? 'Hidden' : 'Visible';
20
20
  }
21
+ /**
22
+ * Detect image MIME type from binary data using magic bytes
23
+ * @param buffer Image data buffer
24
+ * @returns MIME type string (defaults to 'image/png' if unknown)
25
+ */
26
+ function detectImageMimeType(buffer) {
27
+ if (!buffer || buffer.length < 8) {
28
+ return 'image/png';
29
+ }
30
+ // Check for PNG: 89 50 4E 47 0D 0A 1A 0A
31
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
32
+ return 'image/png';
33
+ }
34
+ // Check for JPEG: FF D8 FF
35
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
36
+ return 'image/jpeg';
37
+ }
38
+ // Check for GIF: 47 49 46 38 (GIF8)
39
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
40
+ return 'image/gif';
41
+ }
42
+ // Check for WebP: 52 49 46 46 ... 57 45 42 50 (RIFF...WEBP)
43
+ if (buffer[0] === 0x52 &&
44
+ buffer[1] === 0x49 &&
45
+ buffer[2] === 0x46 &&
46
+ buffer[3] === 0x46 &&
47
+ buffer[8] === 0x57 &&
48
+ buffer[9] === 0x45 &&
49
+ buffer[10] === 0x42 &&
50
+ buffer[11] === 0x50) {
51
+ return 'image/webp';
52
+ }
53
+ // Default to PNG
54
+ return 'image/png';
55
+ }
21
56
  class SnapProcessor extends baseProcessor_1.BaseProcessor {
22
57
  constructor(symbolResolver = null, options = {}) {
23
58
  super(options);
@@ -420,6 +455,30 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
420
455
  console.warn(`[SnapProcessor] Failed to load audio for button ${btnRow.Id}:`, e);
421
456
  }
422
457
  }
458
+ // Load symbol image if available
459
+ // Note: PageSetImageId references embedded images in PageSetData table
460
+ // LibrarySymbolId references external symbol libraries (SymbolStix, etc.)
461
+ let buttonImage;
462
+ const buttonParameters = {};
463
+ if (btnRow.PageSetImageId && btnRow.PageSetImageId > 0) {
464
+ try {
465
+ const imageData = db
466
+ .prepare(`
467
+ SELECT Id, Identifier, Data FROM PageSetData WHERE Id = ?
468
+ `)
469
+ .get(btnRow.PageSetImageId);
470
+ if (imageData && imageData.Data && imageData.Data.length > 0) {
471
+ const mimeType = detectImageMimeType(imageData.Data);
472
+ const base64 = imageData.Data.toString('base64');
473
+ buttonImage = `data:${mimeType};base64,${base64}`;
474
+ buttonParameters.image_id = imageData.Identifier;
475
+ buttonParameters.imageData = imageData.Data;
476
+ }
477
+ }
478
+ catch (e) {
479
+ console.warn(`[SnapProcessor] Failed to load image for button ${btnRow.Id} (PageSetImageId: ${btnRow.PageSetImageId}):`, e);
480
+ }
481
+ }
423
482
  // Create semantic action for Snap button
424
483
  let semanticAction;
425
484
  if (targetPageUniqueId) {
@@ -468,6 +527,9 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
468
527
  semantic_id: btnRow.LibrarySymbolId
469
528
  ? `snap_symbol_${btnRow.LibrarySymbolId}`
470
529
  : undefined, // Extract semantic_id from LibrarySymbolId
530
+ image: buttonImage,
531
+ resolvedImageEntry: buttonImage,
532
+ parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
471
533
  style: {
472
534
  backgroundColor: btnRow.BackgroundColor
473
535
  ? `#${btnRow.BackgroundColor.toString(16)}`
@@ -858,11 +920,54 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
858
920
  serializedMetadata = audio.metadata || null;
859
921
  useMessageRecording = 1;
860
922
  }
923
+ // Handle image data from button.parameters.imageData or button.image (data URL)
924
+ let pageSetImageId = null;
925
+ if (button.parameters?.imageData && Buffer.isBuffer(button.parameters.imageData)) {
926
+ // Use existing image data buffer
927
+ const imageIdentifier = button.parameters.image_id || `IMG_${buttonIdCounter}`;
928
+ let imageId = pageSetDataIdentifierMap.get(imageIdentifier);
929
+ if (!imageId) {
930
+ imageId = pageSetDataIdCounter++;
931
+ insertPageSetData.run(imageId, imageIdentifier, button.parameters.imageData, 1);
932
+ pageSetDataIdentifierMap.set(imageIdentifier, imageId);
933
+ }
934
+ else {
935
+ incrementRefCount.run(imageId);
936
+ }
937
+ pageSetImageId = imageId;
938
+ }
939
+ else if (button.image &&
940
+ typeof button.image === 'string' &&
941
+ button.image.startsWith('data:image')) {
942
+ // Convert data URL to buffer
943
+ try {
944
+ const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/);
945
+ if (matches && matches[2]) {
946
+ const imageData = Buffer.from(matches[2], 'base64');
947
+ const imageIdentifier = button.parameters?.image_id || `IMG_${buttonIdCounter}`;
948
+ let imageId = pageSetDataIdentifierMap.get(imageIdentifier);
949
+ if (!imageId) {
950
+ imageId = pageSetDataIdCounter++;
951
+ insertPageSetData.run(imageId, imageIdentifier, imageData, 1);
952
+ pageSetDataIdentifierMap.set(imageIdentifier, imageId);
953
+ }
954
+ else {
955
+ incrementRefCount.run(imageId);
956
+ }
957
+ pageSetImageId = imageId;
958
+ }
959
+ }
960
+ catch (err) {
961
+ console.warn(`[SnapProcessor] Failed to convert data URL to Buffer for button ${button.id}:`, err);
962
+ }
963
+ }
861
964
  // Retry logic for SQLite operations
862
965
  let retries = 3;
863
966
  while (retries > 0) {
864
967
  try {
865
- insertButton.run(buttonIdCounter++, button.label || '', button.message || button.label || '', navigatePageId, elementRefId, null, null, messageRecordingId, serializedMetadata, useMessageRecording, button.style?.fontColor
968
+ insertButton.run(buttonIdCounter++, button.label || '', button.message || button.label || '', navigatePageId, elementRefId, null, // LibrarySymbolId - not used for embedded images
969
+ pageSetImageId, // PageSetImageId - references embedded image in PageSetData
970
+ messageRecordingId, serializedMetadata, useMessageRecording, button.style?.fontColor
866
971
  ? parseInt(button.style.fontColor.replace('#', ''), 16)
867
972
  : null, button.style?.backgroundColor
868
973
  ? parseInt(button.style.backgroundColor.replace('#', ''), 16)
@@ -32,6 +32,7 @@ export declare class TouchChatValidator extends BaseValidator {
32
32
  */
33
33
  private validateButton;
34
34
  private isSQLiteBuffer;
35
+ private isXmlBuffer;
35
36
  private tryValidateZipSqlite;
36
37
  private validateSqliteStructure;
37
38
  }
@@ -91,7 +91,8 @@ class TouchChatValidator extends baseValidator_1.BaseValidator {
91
91
  this.warn('filename should end with .ce');
92
92
  }
93
93
  });
94
- const zipped = await this.tryValidateZipSqlite(content);
94
+ const looksLikeXml = this.isXmlBuffer(content);
95
+ const zipped = looksLikeXml ? false : await this.tryValidateZipSqlite(content);
95
96
  if (!zipped) {
96
97
  let xmlObj = null;
97
98
  await this.add_check('xml_parse', 'valid XML', async () => {
@@ -254,6 +255,23 @@ class TouchChatValidator extends baseValidator_1.BaseValidator {
254
255
  }
255
256
  return true;
256
257
  }
258
+ isXmlBuffer(content) {
259
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
260
+ const max = Math.min(bytes.length, 256);
261
+ let start = 0;
262
+ while (start < max) {
263
+ const ch = bytes[start];
264
+ if (ch === 0x20 || ch === 0x0a || ch === 0x0d || ch === 0x09) {
265
+ start += 1;
266
+ continue;
267
+ }
268
+ break;
269
+ }
270
+ if (start >= max) {
271
+ return false;
272
+ }
273
+ return bytes[start] === 0x3c; // '<'
274
+ }
257
275
  async tryValidateZipSqlite(content) {
258
276
  let usedZip = false;
259
277
  await this.add_check('zip', 'TouchChat ZIP package', async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willwade/aac-processors",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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",