@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.
- package/dist/browser/processors/gridsetProcessor.js +29 -0
- package/dist/browser/processors/snap/helpers.js +73 -7
- package/dist/browser/processors/snapProcessor.js +106 -1
- package/dist/browser/validation/touchChatValidator.js +19 -1
- package/dist/processors/gridsetProcessor.js +29 -0
- package/dist/processors/snap/helpers.d.ts +7 -4
- package/dist/processors/snap/helpers.js +72 -6
- package/dist/processors/snapProcessor.js +106 -1
- package/dist/validation/touchChatValidator.d.ts +1 -0
- package/dist/validation/touchChatValidator.js +19 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
54
|
+
* Returns the set of symbol identifiers (e.g., "SYM:12345") that are referenced by buttons.
|
|
55
55
|
*/
|
|
56
|
-
export function getAllowedImageEntries(
|
|
57
|
-
|
|
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
|
-
*
|
|
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(
|
|
64
|
-
|
|
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,
|
|
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
|
|
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
|
-
*
|
|
10
|
+
* Returns the set of symbol identifiers (e.g., "SYM:12345") that are referenced by buttons.
|
|
10
11
|
*/
|
|
11
|
-
export declare function getAllowedImageEntries(
|
|
12
|
+
export declare function getAllowedImageEntries(tree: AACTree): Set<string>;
|
|
12
13
|
/**
|
|
13
14
|
* Read a binary asset from a Snap pageset.
|
|
14
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
93
|
+
* Returns the set of symbol identifiers (e.g., "SYM:12345") that are referenced by buttons.
|
|
94
94
|
*/
|
|
95
|
-
function getAllowedImageEntries(
|
|
96
|
-
|
|
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
|
-
*
|
|
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(
|
|
103
|
-
|
|
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,
|
|
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)
|
|
@@ -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
|
|
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.
|
|
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",
|