@willwade/aac-processors 0.1.18 → 0.1.20

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.
@@ -48,29 +48,29 @@ export { configureSqlJs } from './utils/sqlite';
48
48
  * @returns The appropriate processor instance
49
49
  * @throws Error if the file extension is not supported
50
50
  */
51
- export function getProcessor(filePathOrExtension) {
51
+ export function getProcessor(filePathOrExtension, options) {
52
52
  const extension = filePathOrExtension.includes('.')
53
53
  ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.'))
54
54
  : filePathOrExtension;
55
55
  switch (extension.toLowerCase()) {
56
56
  case '.dot':
57
- return new DotProcessor();
57
+ return new DotProcessor(options);
58
58
  case '.opml':
59
- return new OpmlProcessor();
59
+ return new OpmlProcessor(options);
60
60
  case '.obf':
61
61
  case '.obz':
62
- return new ObfProcessor();
62
+ return new ObfProcessor(options);
63
63
  case '.gridset':
64
- return new GridsetProcessor();
64
+ return new GridsetProcessor(options);
65
65
  case '.spb':
66
66
  case '.sps':
67
- return new SnapProcessor();
67
+ return new SnapProcessor(options);
68
68
  case '.ce':
69
- return new TouchChatProcessor();
69
+ return new TouchChatProcessor(options);
70
70
  case '.plist':
71
- return new ApplePanelsProcessor();
71
+ return new ApplePanelsProcessor(options);
72
72
  case '.grd':
73
- return new AstericsGridProcessor();
73
+ return new AstericsGridProcessor(options);
74
74
  default:
75
75
  throw new Error(`Unsupported file extension: ${extension}`);
76
76
  }
@@ -52,9 +52,11 @@ export function getAllowedImageEntries(tree) {
52
52
  * @param entryPath Entry name inside the zip
53
53
  * @returns Image data buffer or null if not found
54
54
  */
55
- export async function openImage(gridsetBuffer, entryPath, password = resolveGridsetPasswordFromEnv()) {
55
+ export async function openImage(gridsetBuffer, entryPath, password = resolveGridsetPasswordFromEnv(), zipAdapter) {
56
56
  try {
57
- const { zip } = await openZipFromInput(gridsetBuffer);
57
+ const { zip } = zipAdapter
58
+ ? await zipAdapter(gridsetBuffer)
59
+ : await openZipFromInput(gridsetBuffer);
58
60
  const entries = getZipEntriesFromAdapter(zip, password);
59
61
  const want = normalizeZipPath(entryPath);
60
62
  const entry = entries.find((e) => normalizeZipPath(e.entryName) === want);
@@ -398,7 +398,10 @@ class GridsetProcessor extends BaseProcessor {
398
398
  const tree = new AACTree();
399
399
  let zipResult;
400
400
  try {
401
- zipResult = await openZipFromInput(readBinaryFromInput(filePathOrBuffer));
401
+ const zipInput = readBinaryFromInput(filePathOrBuffer);
402
+ zipResult = this.options.zipAdapter
403
+ ? await this.options.zipAdapter(zipInput)
404
+ : await openZipFromInput(zipInput);
402
405
  }
403
406
  catch (error) {
404
407
  throw new Error(`Invalid ZIP file format: ${error.message}`);
@@ -120,13 +120,9 @@ class ObfProcessor extends BaseProcessor {
120
120
  return 'image/png';
121
121
  }
122
122
  }
123
- async processBoard(boardData, _boardPath) {
123
+ async processBoard(boardData, _boardPath, isZipEntry) {
124
124
  const sourceButtons = boardData.buttons || [];
125
125
  // Calculate page ID first (used to make button IDs unique)
126
- const isZipEntry = _boardPath &&
127
- _boardPath.endsWith('.obf') &&
128
- !_boardPath.includes('/') &&
129
- !_boardPath.includes('\\');
130
126
  const pageId = isZipEntry
131
127
  ? _boardPath // Zip entry - use filename to match navigation paths
132
128
  : boardData?.id
@@ -328,7 +324,7 @@ class ObfProcessor extends BaseProcessor {
328
324
  const boardData = tryParseObfJson(content);
329
325
  if (boardData) {
330
326
  console.log('[OBF] Detected .obf file, parsed as JSON');
331
- const page = await this.processBoard(boardData, filePathOrBuffer);
327
+ const page = await this.processBoard(boardData, filePathOrBuffer, false);
332
328
  tree.addPage(page);
333
329
  // Set metadata from root board
334
330
  tree.metadata.format = 'obf';
@@ -352,11 +348,22 @@ class ObfProcessor extends BaseProcessor {
352
348
  throw err;
353
349
  }
354
350
  }
355
- // If input is a buffer or string that parses as OBF JSON
356
- const asJson = tryParseObfJson(filePathOrBuffer);
357
- if (asJson) {
351
+ // Detect likely zip signature first
352
+ function isLikelyZip(input) {
353
+ if (typeof input === 'string') {
354
+ const lowered = input.toLowerCase();
355
+ return lowered.endsWith('.zip') || lowered.endsWith('.obz');
356
+ }
357
+ const bytes = readBinaryFromInput(input);
358
+ return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
359
+ }
360
+ // Check if input is a buffer or string that parses as OBF JSON; throw if neither JSON nor ZIP
361
+ if (!isLikelyZip(filePathOrBuffer)) {
362
+ const asJson = tryParseObfJson(filePathOrBuffer);
363
+ if (!asJson)
364
+ throw new Error('Invalid OBF content: not JSON and not ZIP');
358
365
  console.log('[OBF] Detected buffer/string as OBF JSON');
359
- const page = await this.processBoard(asJson, '[bufferOrString]');
366
+ const page = await this.processBoard(asJson, '[bufferOrString]', false);
360
367
  tree.addPage(page);
361
368
  // Set metadata from root board
362
369
  tree.metadata.format = 'obf';
@@ -372,20 +379,10 @@ class ObfProcessor extends BaseProcessor {
372
379
  tree.rootId = page.id;
373
380
  return tree;
374
381
  }
375
- // Otherwise, try as ZIP (.obz). Detect likely zip signature first; throw if neither JSON nor ZIP
376
- function isLikelyZip(input) {
377
- if (typeof input === 'string') {
378
- const lowered = input.toLowerCase();
379
- return lowered.endsWith('.zip') || lowered.endsWith('.obz');
380
- }
381
- const bytes = readBinaryFromInput(input);
382
- return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
383
- }
384
- if (!isLikelyZip(filePathOrBuffer)) {
385
- throw new Error('Invalid OBF content: not JSON and not ZIP');
386
- }
387
382
  try {
388
- const zipResult = await openZipFromInput(filePathOrBuffer);
383
+ const zipResult = this.options.zipAdapter
384
+ ? await this.options.zipAdapter(filePathOrBuffer)
385
+ : await openZipFromInput(filePathOrBuffer);
389
386
  this.zipFile = zipResult.zip;
390
387
  }
391
388
  catch (err) {
@@ -395,17 +392,42 @@ class ObfProcessor extends BaseProcessor {
395
392
  // Store the ZIP file reference for image extraction
396
393
  this.imageCache.clear(); // Clear cache for new file
397
394
  console.log('[OBF] Detected zip archive, extracting .obf files');
398
- // Collect all .obf entries
399
- const obfEntries = this.zipFile
400
- .listFiles()
401
- .filter((name) => name.toLowerCase().endsWith('.obf'));
395
+ // List manifest and OBF files
396
+ const filesInZip = this.zipFile.listFiles();
397
+ const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json');
398
+ let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf'));
399
+ // Attempt to read manifest
400
+ if (manifestFile && manifestFile.length === 1) {
401
+ try {
402
+ const content = await this.zipFile.readFile(manifestFile[0]);
403
+ const data = decodeText(content);
404
+ const str = typeof data === 'string' ? data : readTextFromInput(data);
405
+ if (!str.trim())
406
+ throw new Error('Manifest object missing');
407
+ const manifestObject = JSON.parse(str);
408
+ if (!manifestObject)
409
+ throw new Error('Manifest object is empty');
410
+ // Replace OBF file list
411
+ if (manifestObject.paths && manifestObject.paths.boards) {
412
+ obfEntries = Object.values(manifestObject.paths.boards);
413
+ }
414
+ // Move root board to top of list
415
+ if (manifestObject.root) {
416
+ obfEntries = obfEntries.filter((item) => item !== manifestObject.root);
417
+ obfEntries.unshift(manifestObject.root);
418
+ }
419
+ }
420
+ catch (err) {
421
+ console.warn('[OBF] Error processing mainfest', err);
422
+ }
423
+ }
402
424
  // Process each .obf entry
403
425
  for (const entryName of obfEntries) {
404
426
  try {
405
427
  const content = await this.zipFile.readFile(entryName);
406
428
  const boardData = tryParseObfJson(decodeText(content));
407
429
  if (boardData) {
408
- const page = await this.processBoard(boardData, entryName);
430
+ const page = await this.processBoard(boardData, entryName, true);
409
431
  tree.addPage(page);
410
432
  // Set metadata if not already set (use first board as reference)
411
433
  if (!tree.metadata.format) {
@@ -2,8 +2,28 @@ import { BaseProcessor, } from '../core/baseProcessor';
2
2
  import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, } from '../core/treeStructure';
3
3
  import { generateCloneId } from '../utilities/analytics/utils/idGenerator';
4
4
  import { SnapValidator } from '../validation/snapValidator';
5
- import { getFs, getNodeRequire, getPath, isNodeRuntime } from '../utils/io';
5
+ import { getFs, getNodeRequire, getPath, isNodeRuntime, getOs } from '../utils/io';
6
6
  import { openSqliteDatabase, requireBetterSqlite3 } from '../utils/sqlite';
7
+ import { openZipFromInput } from '../utils/zip';
8
+ /**
9
+ * Convert a Buffer or Uint8Array to base64 string (browser and Node compatible)
10
+ * Node.js Buffers support toString('base64'), but Uint8Arrays in browser do not.
11
+ * This function works in both environments.
12
+ */
13
+ function arrayBufferToBase64(data) {
14
+ // Node.js environment - Buffer has built-in base64 encoding
15
+ if (typeof Buffer !== 'undefined' && data instanceof Buffer) {
16
+ return data.toString('base64');
17
+ }
18
+ // Browser environment - use btoa with binary string conversion
19
+ const bytes = new Uint8Array(data);
20
+ let binary = '';
21
+ const len = bytes.byteLength;
22
+ for (let i = 0; i < len; i++) {
23
+ binary += String.fromCharCode(bytes[i]);
24
+ }
25
+ return btoa(binary);
26
+ }
7
27
  /**
8
28
  * Map Snap Visible value to AAC standard visibility
9
29
  * Snap: 0 = hidden, 1 (or non-zero) = visible
@@ -48,8 +68,37 @@ class SnapProcessor extends BaseProcessor {
48
68
  await Promise.resolve();
49
69
  const tree = new AACTree();
50
70
  let dbResult = null;
71
+ let cleanupTempZip = null;
51
72
  try {
52
- dbResult = await openSqliteDatabase(filePathOrBuffer, { readonly: true });
73
+ // Handle .sub.zip files (Snap pageset backups containing .sps files)
74
+ let inputFile = filePathOrBuffer;
75
+ if (typeof filePathOrBuffer === 'string') {
76
+ const fileName = getPath().basename(filePathOrBuffer).toLowerCase();
77
+ if (fileName.endsWith('.sub.zip') || filePathOrBuffer.endsWith('.sub')) {
78
+ const fs = getFs();
79
+ const path = getPath();
80
+ const os = getOs();
81
+ // Extract .sub.zip to find the embedded .sps file
82
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snap-sub-'));
83
+ const { zip } = await openZipFromInput(filePathOrBuffer);
84
+ // Find the .sps file in the archive
85
+ const files = zip.listFiles();
86
+ const spsFile = files.find((f) => f.endsWith('.sps'));
87
+ if (!spsFile) {
88
+ fs.rmSync(tempDir, { recursive: true, force: true });
89
+ throw new Error('No .sps file found in .sub.zip archive');
90
+ }
91
+ // Extract the .sps file
92
+ const spsData = await zip.readFile(spsFile);
93
+ const extractedSpsPath = path.join(tempDir, path.basename(spsFile));
94
+ fs.writeFileSync(extractedSpsPath, Buffer.from(spsData));
95
+ inputFile = extractedSpsPath;
96
+ cleanupTempZip = () => {
97
+ fs.rmSync(tempDir, { recursive: true, force: true });
98
+ };
99
+ }
100
+ }
101
+ dbResult = await openSqliteDatabase(inputFile, { readonly: true });
53
102
  const db = dbResult.db;
54
103
  const getTableColumns = (tableName) => {
55
104
  try {
@@ -445,7 +494,7 @@ class SnapProcessor extends BaseProcessor {
445
494
  if (isPng || isJpeg) {
446
495
  // Actual PNG/JPEG image - can be displayed
447
496
  const mimeType = isPng ? 'image/png' : 'image/jpeg';
448
- const base64 = data.toString('base64');
497
+ const base64 = arrayBufferToBase64(data);
449
498
  buttonImage = `data:${mimeType};base64,${base64}`;
450
499
  buttonParameters.image_id = imageData.Identifier;
451
500
  }
@@ -639,6 +688,15 @@ class SnapProcessor extends BaseProcessor {
639
688
  else if (dbResult?.db) {
640
689
  dbResult.db.close();
641
690
  }
691
+ // Clean up temporary extracted .sps file from .sub.zip
692
+ if (cleanupTempZip) {
693
+ try {
694
+ cleanupTempZip();
695
+ }
696
+ catch (e) {
697
+ console.warn('[SnapProcessor] Failed to clean up temporary .sps file:', e);
698
+ }
699
+ }
642
700
  }
643
701
  }
644
702
  async processTexts(filePathOrBuffer, translations, outputPath) {
@@ -64,7 +64,9 @@ class TouchChatProcessor extends BaseProcessor {
64
64
  this.sourceFile = filePathOrBuffer;
65
65
  // Step 1: Unzip
66
66
  const zipInput = readBinaryFromInput(filePathOrBuffer);
67
- const { zip } = await openZipFromInput(zipInput);
67
+ const { zip } = this.options.zipAdapter
68
+ ? await this.options.zipAdapter(zipInput)
69
+ : await openZipFromInput(zipInput);
68
70
  const vocabEntry = zip.listFiles().find((name) => name.endsWith('.c4v'));
69
71
  if (!vocabEntry) {
70
72
  throw new Error('No .c4v vocab DB found in TouchChat export');
@@ -3,7 +3,7 @@
3
3
  /* eslint-disable @typescript-eslint/no-unsafe-return */
4
4
  import * as xml2js from 'xml2js';
5
5
  import { BaseValidator } from './baseValidator';
6
- import { decodeText, getBasename, getFs, readBinaryFromInput, toUint8Array } from '../utils/io';
6
+ import { decodeText, getBasename, getFs, readBinaryFromInput, toUint8Array, } from '../utils/io';
7
7
  import { openZipFromInput } from '../utils/zip';
8
8
  import { openSqliteDatabase } from '../utils/sqlite';
9
9
  /**
@@ -27,14 +27,14 @@ export class TouchChatValidator extends BaseValidator {
27
27
  /**
28
28
  * Check if content is TouchChat format
29
29
  */
30
- static async identifyFormat(content, filename) {
30
+ static async identifyFormat(content, filename, zipAdapter) {
31
31
  const name = filename.toLowerCase();
32
32
  if (name.endsWith('.ce')) {
33
33
  return true;
34
34
  }
35
35
  // Try to parse as ZIP and check for .c4v database
36
36
  try {
37
- const { zip } = await openZipFromInput(content);
37
+ const { zip } = zipAdapter ? await zipAdapter(content) : await openZipFromInput(content);
38
38
  const entries = zip.listFiles();
39
39
  if (entries.some((entry) => entry.toLowerCase().endsWith('.c4v'))) {
40
40
  return true;
@@ -43,6 +43,7 @@ import { AACTree, AACButton } from './treeStructure';
43
43
  import { StringCasing } from './stringCasing';
44
44
  import { ValidationResult } from '../validation/validationTypes';
45
45
  import { BinaryOutput, ProcessorInput } from '../utils/io';
46
+ import type { ZipAdapter } from '../utils/zip';
46
47
  export interface ProcessorOptions {
47
48
  excludeNavigationButtons?: boolean;
48
49
  excludeSystemButtons?: boolean;
@@ -52,6 +53,9 @@ export interface ProcessorOptions {
52
53
  grid3SymbolDir?: string;
53
54
  grid3Path?: string;
54
55
  grid3Locale?: string;
56
+ zipAdapter?: (input: ProcessorInput) => Promise<{
57
+ zip: ZipAdapter;
58
+ }>;
55
59
  }
56
60
  export interface ExtractedString {
57
61
  string: string;
@@ -18,6 +18,11 @@ class FileProcessor {
18
18
  static detectFormat(filePathOrBuffer) {
19
19
  if (typeof filePathOrBuffer === 'string') {
20
20
  const ext = path_1.default.extname(filePathOrBuffer).toLowerCase();
21
+ const fileName = path_1.default.basename(filePathOrBuffer).toLowerCase();
22
+ // Handle double extensions like .sub.zip
23
+ if (fileName.endsWith('.sub.zip') || ext === '.sub') {
24
+ return 'snap';
25
+ }
21
26
  switch (ext) {
22
27
  case '.gridset':
23
28
  case '.gridsetx':
@@ -23,7 +23,7 @@ export { TouchChatProcessor } from './processors/touchchatProcessor';
23
23
  export { ApplePanelsProcessor } from './processors/applePanelsProcessor';
24
24
  export { AstericsGridProcessor } from './processors/astericsGridProcessor';
25
25
  export * as Metrics from './metrics';
26
- import { BaseProcessor } from './core/baseProcessor';
26
+ import { BaseProcessor, ProcessorOptions } from './core/baseProcessor';
27
27
  export { configureSqlJs } from './utils/sqlite';
28
28
  /**
29
29
  * Factory function to get the appropriate processor for a file extension
@@ -31,7 +31,7 @@ export { configureSqlJs } from './utils/sqlite';
31
31
  * @returns The appropriate processor instance
32
32
  * @throws Error if the file extension is not supported
33
33
  */
34
- export declare function getProcessor(filePathOrExtension: string): BaseProcessor;
34
+ export declare function getProcessor(filePathOrExtension: string, options?: ProcessorOptions): BaseProcessor;
35
35
  /**
36
36
  * Get all supported file extensions
37
37
  * @returns Array of supported file extensions
@@ -89,29 +89,29 @@ Object.defineProperty(exports, "configureSqlJs", { enumerable: true, get: functi
89
89
  * @returns The appropriate processor instance
90
90
  * @throws Error if the file extension is not supported
91
91
  */
92
- function getProcessor(filePathOrExtension) {
92
+ function getProcessor(filePathOrExtension, options) {
93
93
  const extension = filePathOrExtension.includes('.')
94
94
  ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.'))
95
95
  : filePathOrExtension;
96
96
  switch (extension.toLowerCase()) {
97
97
  case '.dot':
98
- return new dotProcessor_2.DotProcessor();
98
+ return new dotProcessor_2.DotProcessor(options);
99
99
  case '.opml':
100
- return new opmlProcessor_2.OpmlProcessor();
100
+ return new opmlProcessor_2.OpmlProcessor(options);
101
101
  case '.obf':
102
102
  case '.obz':
103
- return new obfProcessor_2.ObfProcessor();
103
+ return new obfProcessor_2.ObfProcessor(options);
104
104
  case '.gridset':
105
- return new gridsetProcessor_2.GridsetProcessor();
105
+ return new gridsetProcessor_2.GridsetProcessor(options);
106
106
  case '.spb':
107
107
  case '.sps':
108
- return new snapProcessor_2.SnapProcessor();
108
+ return new snapProcessor_2.SnapProcessor(options);
109
109
  case '.ce':
110
- return new touchchatProcessor_2.TouchChatProcessor();
110
+ return new touchchatProcessor_2.TouchChatProcessor(options);
111
111
  case '.plist':
112
- return new applePanelsProcessor_2.ApplePanelsProcessor();
112
+ return new applePanelsProcessor_2.ApplePanelsProcessor(options);
113
113
  case '.grd':
114
- return new astericsGridProcessor_2.AstericsGridProcessor();
114
+ return new astericsGridProcessor_2.AstericsGridProcessor(options);
115
115
  default:
116
116
  throw new Error(`Unsupported file extension: ${extension}`);
117
117
  }
@@ -23,7 +23,7 @@ export * as Opml from './opml';
23
23
  export * as ApplePanels from './applePanels';
24
24
  export * as AstericsGrid from './astericsGrid';
25
25
  export * as Translation from './translation';
26
- import { BaseProcessor } from './core/baseProcessor';
26
+ import { BaseProcessor, ProcessorOptions } from './core/baseProcessor';
27
27
  /**
28
28
  * Factory function to get the appropriate processor for a file extension
29
29
  * @param filePathOrExtension - File path or extension (e.g., '.dot', '/path/to/file.obf')
@@ -34,7 +34,7 @@ import { BaseProcessor } from './core/baseProcessor';
34
34
  * const processor = getProcessor('/path/to/file.gridset');
35
35
  * const tree = processor.loadIntoTree('/path/to/file.gridset');
36
36
  */
37
- export declare function getProcessor(filePathOrExtension: string): BaseProcessor;
37
+ export declare function getProcessor(filePathOrExtension: string, options?: ProcessorOptions): BaseProcessor;
38
38
  /**
39
39
  * Get all supported file extensions
40
40
  * @returns Array of supported file extensions
@@ -88,35 +88,35 @@ const obfsetProcessor_1 = require("./processors/obfsetProcessor");
88
88
  * const processor = getProcessor('/path/to/file.gridset');
89
89
  * const tree = processor.loadIntoTree('/path/to/file.gridset');
90
90
  */
91
- function getProcessor(filePathOrExtension) {
91
+ function getProcessor(filePathOrExtension, options) {
92
92
  // Extract extension from file path
93
93
  const extension = filePathOrExtension.includes('.')
94
94
  ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.'))
95
95
  : filePathOrExtension;
96
96
  switch (extension.toLowerCase()) {
97
97
  case '.dot':
98
- return new dotProcessor_1.DotProcessor();
98
+ return new dotProcessor_1.DotProcessor(options);
99
99
  case '.xlsx':
100
- return new excelProcessor_1.ExcelProcessor();
100
+ return new excelProcessor_1.ExcelProcessor(options);
101
101
  case '.opml':
102
- return new opmlProcessor_1.OpmlProcessor();
102
+ return new opmlProcessor_1.OpmlProcessor(options);
103
103
  case '.obf':
104
104
  case '.obz':
105
- return new obfProcessor_1.ObfProcessor();
105
+ return new obfProcessor_1.ObfProcessor(options);
106
106
  case '.obfset':
107
- return new obfsetProcessor_1.ObfsetProcessor();
107
+ return new obfsetProcessor_1.ObfsetProcessor(options);
108
108
  case '.gridset':
109
109
  case '.gridsetx':
110
- return new gridsetProcessor_1.GridsetProcessor();
110
+ return new gridsetProcessor_1.GridsetProcessor(options);
111
111
  case '.spb':
112
112
  case '.sps':
113
- return new snapProcessor_1.SnapProcessor();
113
+ return new snapProcessor_1.SnapProcessor(options);
114
114
  case '.ce':
115
- return new touchchatProcessor_1.TouchChatProcessor();
115
+ return new touchchatProcessor_1.TouchChatProcessor(options);
116
116
  case '.plist':
117
- return new applePanelsProcessor_1.ApplePanelsProcessor();
117
+ return new applePanelsProcessor_1.ApplePanelsProcessor(options);
118
118
  case '.grd':
119
- return new astericsGridProcessor_1.AstericsGridProcessor();
119
+ return new astericsGridProcessor_1.AstericsGridProcessor(options);
120
120
  default:
121
121
  throw new Error(`Unsupported file extension: ${extension}`);
122
122
  }
@@ -1,4 +1,6 @@
1
1
  import { AACTree, AACSemanticCategory, AACSemanticIntent } from '../../core/treeStructure';
2
+ import { type ZipAdapter } from '../../utils/zip';
3
+ import type { ProcessorInput } from '../../utils/io';
2
4
  /**
3
5
  * Build a map of button IDs to resolved image entry paths for a specific page.
4
6
  * Helpful when rewriting zip entry names or validating images referenced in a grid.
@@ -15,7 +17,9 @@ export declare function getAllowedImageEntries(tree: AACTree): Set<string>;
15
17
  * @param entryPath Entry name inside the zip
16
18
  * @returns Image data buffer or null if not found
17
19
  */
18
- export declare function openImage(gridsetBuffer: Uint8Array, entryPath: string, password?: string | undefined): Promise<Uint8Array | null>;
20
+ export declare function openImage(gridsetBuffer: Uint8Array, entryPath: string, password?: string | undefined, zipAdapter?: (input: ProcessorInput) => Promise<{
21
+ zip: ZipAdapter;
22
+ }>): Promise<Uint8Array | null>;
19
23
  /**
20
24
  * Generate a random GUID for Grid3 elements
21
25
  * Grid3 uses GUIDs for grid identification
@@ -96,9 +96,11 @@ function getAllowedImageEntries(tree) {
96
96
  * @param entryPath Entry name inside the zip
97
97
  * @returns Image data buffer or null if not found
98
98
  */
99
- async function openImage(gridsetBuffer, entryPath, password = (0, password_1.resolveGridsetPasswordFromEnv)()) {
99
+ async function openImage(gridsetBuffer, entryPath, password = (0, password_1.resolveGridsetPasswordFromEnv)(), zipAdapter) {
100
100
  try {
101
- const { zip } = await (0, zip_1.openZipFromInput)(gridsetBuffer);
101
+ const { zip } = zipAdapter
102
+ ? await zipAdapter(gridsetBuffer)
103
+ : await (0, zip_1.openZipFromInput)(gridsetBuffer);
102
104
  const entries = (0, password_1.getZipEntriesFromAdapter)(zip, password);
103
105
  const want = normalizeZipPath(entryPath);
104
106
  const entry = entries.find((e) => normalizeZipPath(e.entryName) === want);
@@ -4,6 +4,8 @@
4
4
  * These utilities help developers understand why images might not be resolving
5
5
  * correctly in Grid3 gridsets.
6
6
  */
7
+ import { type ZipAdapter } from '../../utils/zip';
8
+ import { type ProcessorInput } from '../../utils/io';
7
9
  export interface ImageIssue {
8
10
  gridName: string;
9
11
  cellX: number;
@@ -34,7 +36,9 @@ export interface ImageAuditResult {
34
36
  * console.log(`Cell (${issue.cellX}, ${issue.cellY}): ${issue.suggestion}`);
35
37
  * });
36
38
  */
37
- export declare function auditGridsetImages(gridsetBuffer: Uint8Array, password?: string | undefined): Promise<ImageAuditResult>;
39
+ export declare function auditGridsetImages(gridsetBuffer: Uint8Array, password?: string | undefined, zipAdapter?: (input: ProcessorInput) => Promise<{
40
+ zip: ZipAdapter;
41
+ }>): Promise<ImageAuditResult>;
38
42
  /**
39
43
  * Get a human-readable summary of image audit results
40
44
  */
@@ -26,7 +26,7 @@ const io_1 = require("../../utils/io");
26
26
  * console.log(`Cell (${issue.cellX}, ${issue.cellY}): ${issue.suggestion}`);
27
27
  * });
28
28
  */
29
- async function auditGridsetImages(gridsetBuffer, password = (0, password_2.resolveGridsetPasswordFromEnv)()) {
29
+ async function auditGridsetImages(gridsetBuffer, password = (0, password_2.resolveGridsetPasswordFromEnv)(), zipAdapter) {
30
30
  const issues = [];
31
31
  const availableImages = new Set();
32
32
  let totalCells = 0;
@@ -34,7 +34,9 @@ async function auditGridsetImages(gridsetBuffer, password = (0, password_2.resol
34
34
  let resolvedImages = 0;
35
35
  let unresolvedImages = 0;
36
36
  try {
37
- const { zip } = await (0, zip_1.openZipFromInput)(gridsetBuffer);
37
+ const { zip } = zipAdapter
38
+ ? await zipAdapter(gridsetBuffer)
39
+ : await (0, zip_1.openZipFromInput)(gridsetBuffer);
38
40
  const entries = (0, password_1.getZipEntriesFromAdapter)(zip, password);
39
41
  const parser = new fast_xml_parser_1.XMLParser();
40
42
  // Collect all image files in the gridset
@@ -8,6 +8,8 @@
8
8
  * Note: Wordlists are only supported in Grid3 format. Other AAC formats
9
9
  * do not have equivalent wordlist functionality.
10
10
  */
11
+ import { type ZipAdapter } from '../../utils/zip';
12
+ import { type ProcessorInput } from '../../utils/io';
11
13
  /**
12
14
  * Represents a single item in a wordlist
13
15
  */
@@ -64,7 +66,9 @@ export declare function wordlistToXml(wordlist: WordList): string;
64
66
  * console.log(`Grid "${gridName}" has ${wordlist.items.length} items`);
65
67
  * });
66
68
  */
67
- export declare function extractWordlists(gridsetBuffer: Uint8Array, password?: string | undefined): Promise<Map<string, WordList>>;
69
+ export declare function extractWordlists(gridsetBuffer: Uint8Array, password?: string | undefined, zipAdapter?: (input: ProcessorInput) => Promise<{
70
+ zip: ZipAdapter;
71
+ }>): Promise<Map<string, WordList>>;
68
72
  /**
69
73
  * Updates or adds a wordlist to a specific grid in a gridset
70
74
  *
@@ -127,11 +127,13 @@ function wordlistToXml(wordlist) {
127
127
  * console.log(`Grid "${gridName}" has ${wordlist.items.length} items`);
128
128
  * });
129
129
  */
130
- async function extractWordlists(gridsetBuffer, password = (0, password_1.resolveGridsetPasswordFromEnv)()) {
130
+ async function extractWordlists(gridsetBuffer, password = (0, password_1.resolveGridsetPasswordFromEnv)(), zipAdapter) {
131
131
  const wordlists = new Map();
132
132
  const parser = new fast_xml_parser_1.XMLParser();
133
133
  try {
134
- const { zip } = await (0, zip_1.openZipFromInput)(gridsetBuffer);
134
+ const { zip } = zipAdapter
135
+ ? await zipAdapter(gridsetBuffer)
136
+ : await (0, zip_1.openZipFromInput)(gridsetBuffer);
135
137
  const entries = (0, password_1.getZipEntriesFromAdapter)(zip, password);
136
138
  // Process each grid file
137
139
  for (const entry of entries) {
@@ -424,7 +424,10 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
424
424
  const tree = new treeStructure_1.AACTree();
425
425
  let zipResult;
426
426
  try {
427
- zipResult = await (0, zip_1.openZipFromInput)((0, io_1.readBinaryFromInput)(filePathOrBuffer));
427
+ const zipInput = (0, io_1.readBinaryFromInput)(filePathOrBuffer);
428
+ zipResult = this.options.zipAdapter
429
+ ? await this.options.zipAdapter(zipInput)
430
+ : await (0, zip_1.openZipFromInput)(zipInput);
428
431
  }
429
432
  catch (error) {
430
433
  throw new Error(`Invalid ZIP file format: ${error.message}`);
@@ -146,13 +146,9 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
146
146
  return 'image/png';
147
147
  }
148
148
  }
149
- async processBoard(boardData, _boardPath) {
149
+ async processBoard(boardData, _boardPath, isZipEntry) {
150
150
  const sourceButtons = boardData.buttons || [];
151
151
  // Calculate page ID first (used to make button IDs unique)
152
- const isZipEntry = _boardPath &&
153
- _boardPath.endsWith('.obf') &&
154
- !_boardPath.includes('/') &&
155
- !_boardPath.includes('\\');
156
152
  const pageId = isZipEntry
157
153
  ? _boardPath // Zip entry - use filename to match navigation paths
158
154
  : boardData?.id
@@ -354,7 +350,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
354
350
  const boardData = tryParseObfJson(content);
355
351
  if (boardData) {
356
352
  console.log('[OBF] Detected .obf file, parsed as JSON');
357
- const page = await this.processBoard(boardData, filePathOrBuffer);
353
+ const page = await this.processBoard(boardData, filePathOrBuffer, false);
358
354
  tree.addPage(page);
359
355
  // Set metadata from root board
360
356
  tree.metadata.format = 'obf';
@@ -378,11 +374,22 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
378
374
  throw err;
379
375
  }
380
376
  }
381
- // If input is a buffer or string that parses as OBF JSON
382
- const asJson = tryParseObfJson(filePathOrBuffer);
383
- if (asJson) {
377
+ // Detect likely zip signature first
378
+ function isLikelyZip(input) {
379
+ if (typeof input === 'string') {
380
+ const lowered = input.toLowerCase();
381
+ return lowered.endsWith('.zip') || lowered.endsWith('.obz');
382
+ }
383
+ const bytes = (0, io_1.readBinaryFromInput)(input);
384
+ return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
385
+ }
386
+ // Check if input is a buffer or string that parses as OBF JSON; throw if neither JSON nor ZIP
387
+ if (!isLikelyZip(filePathOrBuffer)) {
388
+ const asJson = tryParseObfJson(filePathOrBuffer);
389
+ if (!asJson)
390
+ throw new Error('Invalid OBF content: not JSON and not ZIP');
384
391
  console.log('[OBF] Detected buffer/string as OBF JSON');
385
- const page = await this.processBoard(asJson, '[bufferOrString]');
392
+ const page = await this.processBoard(asJson, '[bufferOrString]', false);
386
393
  tree.addPage(page);
387
394
  // Set metadata from root board
388
395
  tree.metadata.format = 'obf';
@@ -398,20 +405,10 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
398
405
  tree.rootId = page.id;
399
406
  return tree;
400
407
  }
401
- // Otherwise, try as ZIP (.obz). Detect likely zip signature first; throw if neither JSON nor ZIP
402
- function isLikelyZip(input) {
403
- if (typeof input === 'string') {
404
- const lowered = input.toLowerCase();
405
- return lowered.endsWith('.zip') || lowered.endsWith('.obz');
406
- }
407
- const bytes = (0, io_1.readBinaryFromInput)(input);
408
- return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
409
- }
410
- if (!isLikelyZip(filePathOrBuffer)) {
411
- throw new Error('Invalid OBF content: not JSON and not ZIP');
412
- }
413
408
  try {
414
- const zipResult = await (0, zip_1.openZipFromInput)(filePathOrBuffer);
409
+ const zipResult = this.options.zipAdapter
410
+ ? await this.options.zipAdapter(filePathOrBuffer)
411
+ : await (0, zip_1.openZipFromInput)(filePathOrBuffer);
415
412
  this.zipFile = zipResult.zip;
416
413
  }
417
414
  catch (err) {
@@ -421,17 +418,42 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
421
418
  // Store the ZIP file reference for image extraction
422
419
  this.imageCache.clear(); // Clear cache for new file
423
420
  console.log('[OBF] Detected zip archive, extracting .obf files');
424
- // Collect all .obf entries
425
- const obfEntries = this.zipFile
426
- .listFiles()
427
- .filter((name) => name.toLowerCase().endsWith('.obf'));
421
+ // List manifest and OBF files
422
+ const filesInZip = this.zipFile.listFiles();
423
+ const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json');
424
+ let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf'));
425
+ // Attempt to read manifest
426
+ if (manifestFile && manifestFile.length === 1) {
427
+ try {
428
+ const content = await this.zipFile.readFile(manifestFile[0]);
429
+ const data = (0, io_1.decodeText)(content);
430
+ const str = typeof data === 'string' ? data : (0, io_1.readTextFromInput)(data);
431
+ if (!str.trim())
432
+ throw new Error('Manifest object missing');
433
+ const manifestObject = JSON.parse(str);
434
+ if (!manifestObject)
435
+ throw new Error('Manifest object is empty');
436
+ // Replace OBF file list
437
+ if (manifestObject.paths && manifestObject.paths.boards) {
438
+ obfEntries = Object.values(manifestObject.paths.boards);
439
+ }
440
+ // Move root board to top of list
441
+ if (manifestObject.root) {
442
+ obfEntries = obfEntries.filter((item) => item !== manifestObject.root);
443
+ obfEntries.unshift(manifestObject.root);
444
+ }
445
+ }
446
+ catch (err) {
447
+ console.warn('[OBF] Error processing mainfest', err);
448
+ }
449
+ }
428
450
  // Process each .obf entry
429
451
  for (const entryName of obfEntries) {
430
452
  try {
431
453
  const content = await this.zipFile.readFile(entryName);
432
454
  const boardData = tryParseObfJson((0, io_1.decodeText)(content));
433
455
  if (boardData) {
434
- const page = await this.processBoard(boardData, entryName);
456
+ const page = await this.processBoard(boardData, entryName, true);
435
457
  tree.addPage(page);
436
458
  // Set metadata if not already set (use first board as reference)
437
459
  if (!tree.metadata.format) {
@@ -7,6 +7,26 @@ const idGenerator_1 = require("../utilities/analytics/utils/idGenerator");
7
7
  const snapValidator_1 = require("../validation/snapValidator");
8
8
  const io_1 = require("../utils/io");
9
9
  const sqlite_1 = require("../utils/sqlite");
10
+ const zip_1 = require("../utils/zip");
11
+ /**
12
+ * Convert a Buffer or Uint8Array to base64 string (browser and Node compatible)
13
+ * Node.js Buffers support toString('base64'), but Uint8Arrays in browser do not.
14
+ * This function works in both environments.
15
+ */
16
+ function arrayBufferToBase64(data) {
17
+ // Node.js environment - Buffer has built-in base64 encoding
18
+ if (typeof Buffer !== 'undefined' && data instanceof Buffer) {
19
+ return data.toString('base64');
20
+ }
21
+ // Browser environment - use btoa with binary string conversion
22
+ const bytes = new Uint8Array(data);
23
+ let binary = '';
24
+ const len = bytes.byteLength;
25
+ for (let i = 0; i < len; i++) {
26
+ binary += String.fromCharCode(bytes[i]);
27
+ }
28
+ return btoa(binary);
29
+ }
10
30
  /**
11
31
  * Map Snap Visible value to AAC standard visibility
12
32
  * Snap: 0 = hidden, 1 (or non-zero) = visible
@@ -51,8 +71,37 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
51
71
  await Promise.resolve();
52
72
  const tree = new treeStructure_1.AACTree();
53
73
  let dbResult = null;
74
+ let cleanupTempZip = null;
54
75
  try {
55
- dbResult = await (0, sqlite_1.openSqliteDatabase)(filePathOrBuffer, { readonly: true });
76
+ // Handle .sub.zip files (Snap pageset backups containing .sps files)
77
+ let inputFile = filePathOrBuffer;
78
+ if (typeof filePathOrBuffer === 'string') {
79
+ const fileName = (0, io_1.getPath)().basename(filePathOrBuffer).toLowerCase();
80
+ if (fileName.endsWith('.sub.zip') || filePathOrBuffer.endsWith('.sub')) {
81
+ const fs = (0, io_1.getFs)();
82
+ const path = (0, io_1.getPath)();
83
+ const os = (0, io_1.getOs)();
84
+ // Extract .sub.zip to find the embedded .sps file
85
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snap-sub-'));
86
+ const { zip } = await (0, zip_1.openZipFromInput)(filePathOrBuffer);
87
+ // Find the .sps file in the archive
88
+ const files = zip.listFiles();
89
+ const spsFile = files.find((f) => f.endsWith('.sps'));
90
+ if (!spsFile) {
91
+ fs.rmSync(tempDir, { recursive: true, force: true });
92
+ throw new Error('No .sps file found in .sub.zip archive');
93
+ }
94
+ // Extract the .sps file
95
+ const spsData = await zip.readFile(spsFile);
96
+ const extractedSpsPath = path.join(tempDir, path.basename(spsFile));
97
+ fs.writeFileSync(extractedSpsPath, Buffer.from(spsData));
98
+ inputFile = extractedSpsPath;
99
+ cleanupTempZip = () => {
100
+ fs.rmSync(tempDir, { recursive: true, force: true });
101
+ };
102
+ }
103
+ }
104
+ dbResult = await (0, sqlite_1.openSqliteDatabase)(inputFile, { readonly: true });
56
105
  const db = dbResult.db;
57
106
  const getTableColumns = (tableName) => {
58
107
  try {
@@ -448,7 +497,7 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
448
497
  if (isPng || isJpeg) {
449
498
  // Actual PNG/JPEG image - can be displayed
450
499
  const mimeType = isPng ? 'image/png' : 'image/jpeg';
451
- const base64 = data.toString('base64');
500
+ const base64 = arrayBufferToBase64(data);
452
501
  buttonImage = `data:${mimeType};base64,${base64}`;
453
502
  buttonParameters.image_id = imageData.Identifier;
454
503
  }
@@ -642,6 +691,15 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
642
691
  else if (dbResult?.db) {
643
692
  dbResult.db.close();
644
693
  }
694
+ // Clean up temporary extracted .sps file from .sub.zip
695
+ if (cleanupTempZip) {
696
+ try {
697
+ cleanupTempZip();
698
+ }
699
+ catch (e) {
700
+ console.warn('[SnapProcessor] Failed to clean up temporary .sps file:', e);
701
+ }
702
+ }
645
703
  }
646
704
  }
647
705
  async processTexts(filePathOrBuffer, translations, outputPath) {
@@ -67,7 +67,9 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
67
67
  this.sourceFile = filePathOrBuffer;
68
68
  // Step 1: Unzip
69
69
  const zipInput = (0, io_1.readBinaryFromInput)(filePathOrBuffer);
70
- const { zip } = await (0, zip_1.openZipFromInput)(zipInput);
70
+ const { zip } = this.options.zipAdapter
71
+ ? await this.options.zipAdapter(zipInput)
72
+ : await (0, zip_1.openZipFromInput)(zipInput);
71
73
  const vocabEntry = zip.listFiles().find((name) => name.endsWith('.c4v'));
72
74
  if (!vocabEntry) {
73
75
  throw new Error('No .c4v vocab DB found in TouchChat export');
@@ -1,5 +1,7 @@
1
1
  import { BaseValidator } from './baseValidator';
2
2
  import { ValidationResult } from './validationTypes';
3
+ import { type ProcessorInput } from '../utils/io';
4
+ import { type ZipAdapter } from '../utils/zip';
3
5
  /**
4
6
  * Validator for TouchChat files (.ce)
5
7
  * TouchChat files are ZIP archives that contain a .c4v SQLite database.
@@ -14,7 +16,9 @@ export declare class TouchChatValidator extends BaseValidator {
14
16
  /**
15
17
  * Check if content is TouchChat format
16
18
  */
17
- static identifyFormat(content: any, filename: string): Promise<boolean>;
19
+ static identifyFormat(content: any, filename: string, zipAdapter?: (input: ProcessorInput) => Promise<{
20
+ zip: ZipAdapter;
21
+ }>): Promise<boolean>;
18
22
  /**
19
23
  * Main validation method
20
24
  */
@@ -53,14 +53,14 @@ class TouchChatValidator extends baseValidator_1.BaseValidator {
53
53
  /**
54
54
  * Check if content is TouchChat format
55
55
  */
56
- static async identifyFormat(content, filename) {
56
+ static async identifyFormat(content, filename, zipAdapter) {
57
57
  const name = filename.toLowerCase();
58
58
  if (name.endsWith('.ce')) {
59
59
  return true;
60
60
  }
61
61
  // Try to parse as ZIP and check for .c4v database
62
62
  try {
63
- const { zip } = await (0, zip_1.openZipFromInput)(content);
63
+ const { zip } = zipAdapter ? await zipAdapter(content) : await (0, zip_1.openZipFromInput)(content);
64
64
  const entries = zip.listFiles();
65
65
  if (entries.some((entry) => entry.toLowerCase().endsWith('.c4v'))) {
66
66
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willwade/aac-processors",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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",