@willwade/aac-processors 0.1.15 → 0.1.17
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 +109 -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 +109 -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,33 @@ 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
|
+
// NOTE: We don't include imageData in parameters because Buffers don't serialize
|
|
473
|
+
// correctly across server/client boundaries (Next.js SSR, JSON, etc.)
|
|
474
|
+
// The data URL in buttonImage is sufficient for display purposes.
|
|
475
|
+
// For conversions, images can be reloaded from the source file/database.
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
console.warn(`[SnapProcessor] Failed to load image for button ${btnRow.Id} (PageSetImageId: ${btnRow.PageSetImageId}):`, e);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
420
482
|
// Create semantic action for Snap button
|
|
421
483
|
let semanticAction;
|
|
422
484
|
if (targetPageUniqueId) {
|
|
@@ -465,6 +527,9 @@ class SnapProcessor extends BaseProcessor {
|
|
|
465
527
|
semantic_id: btnRow.LibrarySymbolId
|
|
466
528
|
? `snap_symbol_${btnRow.LibrarySymbolId}`
|
|
467
529
|
: undefined, // Extract semantic_id from LibrarySymbolId
|
|
530
|
+
image: buttonImage,
|
|
531
|
+
resolvedImageEntry: buttonImage,
|
|
532
|
+
parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
|
|
468
533
|
style: {
|
|
469
534
|
backgroundColor: btnRow.BackgroundColor
|
|
470
535
|
? `#${btnRow.BackgroundColor.toString(16)}`
|
|
@@ -855,11 +920,54 @@ class SnapProcessor extends BaseProcessor {
|
|
|
855
920
|
serializedMetadata = audio.metadata || null;
|
|
856
921
|
useMessageRecording = 1;
|
|
857
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
|
+
}
|
|
858
964
|
// Retry logic for SQLite operations
|
|
859
965
|
let retries = 3;
|
|
860
966
|
while (retries > 0) {
|
|
861
967
|
try {
|
|
862
|
-
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
|
|
863
971
|
? parseInt(button.style.fontColor.replace('#', ''), 16)
|
|
864
972
|
: null, button.style?.backgroundColor
|
|
865
973
|
? parseInt(button.style.backgroundColor.replace('#', ''), 16)
|
|
@@ -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,33 @@ 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
|
+
// NOTE: We don't include imageData in parameters because Buffers don't serialize
|
|
476
|
+
// correctly across server/client boundaries (Next.js SSR, JSON, etc.)
|
|
477
|
+
// The data URL in buttonImage is sufficient for display purposes.
|
|
478
|
+
// For conversions, images can be reloaded from the source file/database.
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
console.warn(`[SnapProcessor] Failed to load image for button ${btnRow.Id} (PageSetImageId: ${btnRow.PageSetImageId}):`, e);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
423
485
|
// Create semantic action for Snap button
|
|
424
486
|
let semanticAction;
|
|
425
487
|
if (targetPageUniqueId) {
|
|
@@ -468,6 +530,9 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
468
530
|
semantic_id: btnRow.LibrarySymbolId
|
|
469
531
|
? `snap_symbol_${btnRow.LibrarySymbolId}`
|
|
470
532
|
: undefined, // Extract semantic_id from LibrarySymbolId
|
|
533
|
+
image: buttonImage,
|
|
534
|
+
resolvedImageEntry: buttonImage,
|
|
535
|
+
parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
|
|
471
536
|
style: {
|
|
472
537
|
backgroundColor: btnRow.BackgroundColor
|
|
473
538
|
? `#${btnRow.BackgroundColor.toString(16)}`
|
|
@@ -858,11 +923,54 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
858
923
|
serializedMetadata = audio.metadata || null;
|
|
859
924
|
useMessageRecording = 1;
|
|
860
925
|
}
|
|
926
|
+
// Handle image data from button.parameters.imageData or button.image (data URL)
|
|
927
|
+
let pageSetImageId = null;
|
|
928
|
+
if (button.parameters?.imageData && Buffer.isBuffer(button.parameters.imageData)) {
|
|
929
|
+
// Use existing image data buffer
|
|
930
|
+
const imageIdentifier = button.parameters.image_id || `IMG_${buttonIdCounter}`;
|
|
931
|
+
let imageId = pageSetDataIdentifierMap.get(imageIdentifier);
|
|
932
|
+
if (!imageId) {
|
|
933
|
+
imageId = pageSetDataIdCounter++;
|
|
934
|
+
insertPageSetData.run(imageId, imageIdentifier, button.parameters.imageData, 1);
|
|
935
|
+
pageSetDataIdentifierMap.set(imageIdentifier, imageId);
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
incrementRefCount.run(imageId);
|
|
939
|
+
}
|
|
940
|
+
pageSetImageId = imageId;
|
|
941
|
+
}
|
|
942
|
+
else if (button.image &&
|
|
943
|
+
typeof button.image === 'string' &&
|
|
944
|
+
button.image.startsWith('data:image')) {
|
|
945
|
+
// Convert data URL to buffer
|
|
946
|
+
try {
|
|
947
|
+
const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
948
|
+
if (matches && matches[2]) {
|
|
949
|
+
const imageData = Buffer.from(matches[2], 'base64');
|
|
950
|
+
const imageIdentifier = button.parameters?.image_id || `IMG_${buttonIdCounter}`;
|
|
951
|
+
let imageId = pageSetDataIdentifierMap.get(imageIdentifier);
|
|
952
|
+
if (!imageId) {
|
|
953
|
+
imageId = pageSetDataIdCounter++;
|
|
954
|
+
insertPageSetData.run(imageId, imageIdentifier, imageData, 1);
|
|
955
|
+
pageSetDataIdentifierMap.set(imageIdentifier, imageId);
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
incrementRefCount.run(imageId);
|
|
959
|
+
}
|
|
960
|
+
pageSetImageId = imageId;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
catch (err) {
|
|
964
|
+
console.warn(`[SnapProcessor] Failed to convert data URL to Buffer for button ${button.id}:`, err);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
861
967
|
// Retry logic for SQLite operations
|
|
862
968
|
let retries = 3;
|
|
863
969
|
while (retries > 0) {
|
|
864
970
|
try {
|
|
865
|
-
insertButton.run(buttonIdCounter++, button.label || '', button.message || button.label || '', navigatePageId, elementRefId, null,
|
|
971
|
+
insertButton.run(buttonIdCounter++, button.label || '', button.message || button.label || '', navigatePageId, elementRefId, null, // LibrarySymbolId - not used for embedded images
|
|
972
|
+
pageSetImageId, // PageSetImageId - references embedded image in PageSetData
|
|
973
|
+
messageRecordingId, serializedMetadata, useMessageRecording, button.style?.fontColor
|
|
866
974
|
? parseInt(button.style.fontColor.replace('#', ''), 16)
|
|
867
975
|
: null, button.style?.backgroundColor
|
|
868
976
|
? parseInt(button.style.backgroundColor.replace('#', ''), 16)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@willwade/aac-processors",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
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",
|