@willwade/aac-processors 0.1.0 → 0.1.1

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.
@@ -1,4 +1,9 @@
1
- import path from 'path';
1
+ function getExtension(source) {
2
+ const index = source.lastIndexOf('.');
3
+ if (index === -1)
4
+ return '';
5
+ return source.slice(index);
6
+ }
2
7
  /**
3
8
  * Resolve the password to use for Grid3 archives.
4
9
  * Preference order:
@@ -8,17 +13,18 @@ import path from 'path';
8
13
  export function resolveGridsetPassword(options, source) {
9
14
  if (options?.gridsetPassword)
10
15
  return options.gridsetPassword;
11
- if (process.env.GRIDSET_PASSWORD)
12
- return process.env.GRIDSET_PASSWORD;
16
+ const envPassword = typeof process !== 'undefined' ? process.env?.GRIDSET_PASSWORD : undefined;
17
+ if (envPassword)
18
+ return envPassword;
13
19
  if (typeof source === 'string') {
14
- const ext = path.extname(source).toLowerCase();
20
+ const ext = getExtension(source).toLowerCase();
15
21
  if (ext === '.gridsetx')
16
- return process.env.GRIDSET_PASSWORD;
22
+ return envPassword;
17
23
  }
18
24
  return undefined;
19
25
  }
20
26
  export function resolveGridsetPasswordFromEnv() {
21
- return process.env.GRIDSET_PASSWORD;
27
+ return typeof process !== 'undefined' ? process.env?.GRIDSET_PASSWORD : undefined;
22
28
  }
23
29
  export function getZipEntriesWithPassword(zip, password) {
24
30
  const entries = [];
@@ -12,9 +12,7 @@
12
12
  *
13
13
  * This module provides symbol resolution and metadata extraction.
14
14
  */
15
- import * as fs from 'fs';
16
- import * as path from 'path';
17
- import AdmZip from 'adm-zip';
15
+ import { getFs, getPath } from '../../utils/io';
18
16
  /**
19
17
  * Default Grid 3 installation paths by platform
20
18
  */
@@ -57,6 +55,37 @@ export const SYMBOL_LIBRARIES = {
57
55
  * Default locale to use
58
56
  */
59
57
  export const DEFAULT_LOCALE = 'en-GB';
58
+ function getNodeFs() {
59
+ try {
60
+ return getFs();
61
+ }
62
+ catch {
63
+ throw new Error('Symbol library access is not available in this environment.');
64
+ }
65
+ }
66
+ function getNodePath() {
67
+ try {
68
+ return getPath();
69
+ }
70
+ catch {
71
+ throw new Error('Path utilities are not available in this environment.');
72
+ }
73
+ }
74
+ let cachedAdmZip = null;
75
+ function getAdmZip() {
76
+ if (cachedAdmZip)
77
+ return cachedAdmZip;
78
+ try {
79
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
80
+ const module = require('adm-zip');
81
+ const resolved = module.default || module;
82
+ cachedAdmZip = resolved;
83
+ return resolved;
84
+ }
85
+ catch {
86
+ throw new Error('Symbol library access requires AdmZip in this environment.');
87
+ }
88
+ }
60
89
  /**
61
90
  * Parse a symbol reference string
62
91
  * @param reference - Symbol reference like "[widgit]/food/apple.png"
@@ -95,24 +124,30 @@ export function isSymbolReference(reference) {
95
124
  * @returns Default Grid 3 path or empty string if not found
96
125
  */
97
126
  export function getDefaultGrid3Path() {
98
- const platform = process.platform;
127
+ const platform = (typeof process !== 'undefined' && process.platform ? process.platform : 'unknown');
99
128
  const defaultPath = DEFAULT_GRID3_PATHS[platform] || '';
100
- if (defaultPath && fs.existsSync(defaultPath)) {
101
- return defaultPath;
102
- }
103
- // Try to find Grid 3 in common locations
104
- const commonPaths = [
105
- 'C:\\Program Files (x86)\\Smartbox\\Grid 3',
106
- 'C:\\Program Files\\Smartbox\\Grid 3',
107
- 'C:\\Program Files\\Smartbox\\Grid 3',
108
- '/Applications/Grid 3.app',
109
- '/opt/smartbox/grid3',
110
- ];
111
- for (const testPath of commonPaths) {
112
- if (fs.existsSync(testPath)) {
113
- return testPath;
129
+ try {
130
+ const fs = getNodeFs();
131
+ if (defaultPath && fs.existsSync(defaultPath)) {
132
+ return defaultPath;
133
+ }
134
+ // Try to find Grid 3 in common locations
135
+ const commonPaths = [
136
+ 'C:\\Program Files (x86)\\Smartbox\\Grid 3',
137
+ 'C:\\Program Files\\Smartbox\\Grid 3',
138
+ 'C:\\Program Files\\Smartbox\\Grid 3',
139
+ '/Applications/Grid 3.app',
140
+ '/opt/smartbox/grid3',
141
+ ];
142
+ for (const testPath of commonPaths) {
143
+ if (fs.existsSync(testPath)) {
144
+ return testPath;
145
+ }
114
146
  }
115
147
  }
148
+ catch {
149
+ return '';
150
+ }
116
151
  return '';
117
152
  }
118
153
  /**
@@ -122,6 +157,7 @@ export function getDefaultGrid3Path() {
122
157
  * @returns Path to Symbol Libraries directory (e.g., "C:\...\Grid 3\Resources\Symbols")
123
158
  */
124
159
  export function getSymbolLibrariesDir(grid3Path) {
160
+ const path = getNodePath();
125
161
  return path.join(grid3Path, SYMBOLS_SUBDIR);
126
162
  }
127
163
  /**
@@ -132,6 +168,7 @@ export function getSymbolLibrariesDir(grid3Path) {
132
168
  * @returns Path to symbol search indexes directory (e.g., "C:\...\Grid 3\Locale\en-GB\symbolsearch")
133
169
  */
134
170
  export function getSymbolSearchIndexesDir(grid3Path, locale = DEFAULT_LOCALE) {
171
+ const path = getNodePath();
135
172
  return path.join(grid3Path, SYMBOLSEARCH_SUBDIR, locale, 'symbolsearch');
136
173
  }
137
174
  /**
@@ -145,6 +182,7 @@ export function getAvailableSymbolLibraries(options = {}) {
145
182
  return [];
146
183
  }
147
184
  const symbolsDir = getSymbolLibrariesDir(grid3Path);
185
+ const fs = getNodeFs();
148
186
  if (!fs.existsSync(symbolsDir)) {
149
187
  return [];
150
188
  }
@@ -152,6 +190,7 @@ export function getAvailableSymbolLibraries(options = {}) {
152
190
  const files = fs.readdirSync(symbolsDir);
153
191
  for (const file of files) {
154
192
  if (file.endsWith('.symbols')) {
193
+ const path = getNodePath();
155
194
  const fullPath = path.join(symbolsDir, file);
156
195
  const stats = fs.statSync(fullPath);
157
196
  const libraryName = path.basename(file, '.symbols');
@@ -186,7 +225,9 @@ export function getSymbolLibraryInfo(libraryName, options = {}) {
186
225
  libraryName + '.symbols',
187
226
  ];
188
227
  for (const file of variations) {
228
+ const path = getNodePath();
189
229
  const fullPath = path.join(symbolsDir, file);
230
+ const fs = getNodeFs();
190
231
  if (fs.existsSync(fullPath)) {
191
232
  const stats = fs.statSync(fullPath);
192
233
  return {
@@ -233,6 +274,7 @@ export function resolveSymbolReference(reference, options = {}) {
233
274
  }
234
275
  try {
235
276
  // .symbols files are ZIP archives
277
+ const AdmZip = getAdmZip();
236
278
  const zip = new AdmZip(libraryInfo.pixFile);
237
279
  // The path in the symbol reference becomes the path within the symbols/ folder
238
280
  // e.g., [tawasl]/above bw.png becomes symbols/above bw.png
@@ -398,7 +440,8 @@ export function analyzeSymbolUsage(tree) {
398
440
  */
399
441
  export function symbolReferenceToFilename(reference, cellX, cellY) {
400
442
  const parsed = parseSymbolReference(reference);
401
- const ext = path.extname(parsed.path) || '.png';
443
+ const dotIndex = parsed.path.lastIndexOf('.');
444
+ const ext = dotIndex >= 0 ? parsed.path.slice(dotIndex) : '.png';
402
445
  // Grid 3 format: {x}-{y}-0-text-0.{ext}
403
446
  return `${cellX}-${cellY}-0-text-0${ext}`;
404
447
  }
@@ -1,11 +1,10 @@
1
1
  /* eslint-disable @typescript-eslint/require-await */
2
2
  /* eslint-disable @typescript-eslint/no-unsafe-argument */
3
3
  /* eslint-disable @typescript-eslint/no-unsafe-return */
4
- import * as fs from 'fs';
5
- import * as path from 'path';
6
4
  import * as xml2js from 'xml2js';
7
5
  import JSZip from 'jszip';
8
6
  import { BaseValidator } from './baseValidator';
7
+ import { getFs, getPath } from '../utils/io';
9
8
  /**
10
9
  * Validator for Grid3/Smartbox Gridset files (.gridset, .gridsetx)
11
10
  */
@@ -18,6 +17,8 @@ export class GridsetValidator extends BaseValidator {
18
17
  */
19
18
  static async validateFile(filePath) {
20
19
  const validator = new GridsetValidator();
20
+ const fs = getFs();
21
+ const path = getPath();
21
22
  const content = fs.readFileSync(filePath);
22
23
  const stats = fs.statSync(filePath);
23
24
  return validator.validate(content, path.basename(filePath), stats.size);
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.resolveGridsetPassword = resolveGridsetPassword;
7
4
  exports.resolveGridsetPasswordFromEnv = resolveGridsetPasswordFromEnv;
8
5
  exports.getZipEntriesWithPassword = getZipEntriesWithPassword;
9
- const path_1 = __importDefault(require("path"));
6
+ function getExtension(source) {
7
+ const index = source.lastIndexOf('.');
8
+ if (index === -1)
9
+ return '';
10
+ return source.slice(index);
11
+ }
10
12
  /**
11
13
  * Resolve the password to use for Grid3 archives.
12
14
  * Preference order:
@@ -16,17 +18,18 @@ const path_1 = __importDefault(require("path"));
16
18
  function resolveGridsetPassword(options, source) {
17
19
  if (options?.gridsetPassword)
18
20
  return options.gridsetPassword;
19
- if (process.env.GRIDSET_PASSWORD)
20
- return process.env.GRIDSET_PASSWORD;
21
+ const envPassword = typeof process !== 'undefined' ? process.env?.GRIDSET_PASSWORD : undefined;
22
+ if (envPassword)
23
+ return envPassword;
21
24
  if (typeof source === 'string') {
22
- const ext = path_1.default.extname(source).toLowerCase();
25
+ const ext = getExtension(source).toLowerCase();
23
26
  if (ext === '.gridsetx')
24
- return process.env.GRIDSET_PASSWORD;
27
+ return envPassword;
25
28
  }
26
29
  return undefined;
27
30
  }
28
31
  function resolveGridsetPasswordFromEnv() {
29
- return process.env.GRIDSET_PASSWORD;
32
+ return typeof process !== 'undefined' ? process.env?.GRIDSET_PASSWORD : undefined;
30
33
  }
31
34
  function getZipEntriesWithPassword(zip, password) {
32
35
  const entries = [];
@@ -13,32 +13,6 @@
13
13
  *
14
14
  * This module provides symbol resolution and metadata extraction.
15
15
  */
16
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
- if (k2 === undefined) k2 = k;
18
- var desc = Object.getOwnPropertyDescriptor(m, k);
19
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
- desc = { enumerable: true, get: function() { return m[k]; } };
21
- }
22
- Object.defineProperty(o, k2, desc);
23
- }) : (function(o, m, k, k2) {
24
- if (k2 === undefined) k2 = k;
25
- o[k2] = m[k];
26
- }));
27
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
- Object.defineProperty(o, "default", { enumerable: true, value: v });
29
- }) : function(o, v) {
30
- o["default"] = v;
31
- });
32
- var __importStar = (this && this.__importStar) || function (mod) {
33
- if (mod && mod.__esModule) return mod;
34
- var result = {};
35
- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
36
- __setModuleDefault(result, mod);
37
- return result;
38
- };
39
- var __importDefault = (this && this.__importDefault) || function (mod) {
40
- return (mod && mod.__esModule) ? mod : { "default": mod };
41
- };
42
16
  Object.defineProperty(exports, "__esModule", { value: true });
43
17
  exports.DEFAULT_LOCALE = exports.SYMBOL_LIBRARIES = void 0;
44
18
  exports.parseSymbolReference = parseSymbolReference;
@@ -59,9 +33,7 @@ exports.analyzeSymbolUsage = analyzeSymbolUsage;
59
33
  exports.symbolReferenceToFilename = symbolReferenceToFilename;
60
34
  exports.getSymbolsDir = getSymbolsDir;
61
35
  exports.getSymbolSearchDir = getSymbolSearchDir;
62
- const fs = __importStar(require("fs"));
63
- const path = __importStar(require("path"));
64
- const adm_zip_1 = __importDefault(require("adm-zip"));
36
+ const io_1 = require("../../utils/io");
65
37
  /**
66
38
  * Default Grid 3 installation paths by platform
67
39
  */
@@ -104,6 +76,37 @@ exports.SYMBOL_LIBRARIES = {
104
76
  * Default locale to use
105
77
  */
106
78
  exports.DEFAULT_LOCALE = 'en-GB';
79
+ function getNodeFs() {
80
+ try {
81
+ return (0, io_1.getFs)();
82
+ }
83
+ catch {
84
+ throw new Error('Symbol library access is not available in this environment.');
85
+ }
86
+ }
87
+ function getNodePath() {
88
+ try {
89
+ return (0, io_1.getPath)();
90
+ }
91
+ catch {
92
+ throw new Error('Path utilities are not available in this environment.');
93
+ }
94
+ }
95
+ let cachedAdmZip = null;
96
+ function getAdmZip() {
97
+ if (cachedAdmZip)
98
+ return cachedAdmZip;
99
+ try {
100
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
101
+ const module = require('adm-zip');
102
+ const resolved = module.default || module;
103
+ cachedAdmZip = resolved;
104
+ return resolved;
105
+ }
106
+ catch {
107
+ throw new Error('Symbol library access requires AdmZip in this environment.');
108
+ }
109
+ }
107
110
  /**
108
111
  * Parse a symbol reference string
109
112
  * @param reference - Symbol reference like "[widgit]/food/apple.png"
@@ -142,23 +145,29 @@ function isSymbolReference(reference) {
142
145
  * @returns Default Grid 3 path or empty string if not found
143
146
  */
144
147
  function getDefaultGrid3Path() {
145
- const platform = process.platform;
148
+ const platform = (typeof process !== 'undefined' && process.platform ? process.platform : 'unknown');
146
149
  const defaultPath = DEFAULT_GRID3_PATHS[platform] || '';
147
- if (defaultPath && fs.existsSync(defaultPath)) {
148
- return defaultPath;
149
- }
150
- // Try to find Grid 3 in common locations
151
- const commonPaths = [
152
- 'C:\\Program Files (x86)\\Smartbox\\Grid 3',
153
- 'C:\\Program Files\\Smartbox\\Grid 3',
154
- 'C:\\Program Files\\Smartbox\\Grid 3',
155
- '/Applications/Grid 3.app',
156
- '/opt/smartbox/grid3',
157
- ];
158
- for (const testPath of commonPaths) {
159
- if (fs.existsSync(testPath)) {
160
- return testPath;
150
+ try {
151
+ const fs = getNodeFs();
152
+ if (defaultPath && fs.existsSync(defaultPath)) {
153
+ return defaultPath;
161
154
  }
155
+ // Try to find Grid 3 in common locations
156
+ const commonPaths = [
157
+ 'C:\\Program Files (x86)\\Smartbox\\Grid 3',
158
+ 'C:\\Program Files\\Smartbox\\Grid 3',
159
+ 'C:\\Program Files\\Smartbox\\Grid 3',
160
+ '/Applications/Grid 3.app',
161
+ '/opt/smartbox/grid3',
162
+ ];
163
+ for (const testPath of commonPaths) {
164
+ if (fs.existsSync(testPath)) {
165
+ return testPath;
166
+ }
167
+ }
168
+ }
169
+ catch {
170
+ return '';
162
171
  }
163
172
  return '';
164
173
  }
@@ -169,6 +178,7 @@ function getDefaultGrid3Path() {
169
178
  * @returns Path to Symbol Libraries directory (e.g., "C:\...\Grid 3\Resources\Symbols")
170
179
  */
171
180
  function getSymbolLibrariesDir(grid3Path) {
181
+ const path = getNodePath();
172
182
  return path.join(grid3Path, SYMBOLS_SUBDIR);
173
183
  }
174
184
  /**
@@ -179,6 +189,7 @@ function getSymbolLibrariesDir(grid3Path) {
179
189
  * @returns Path to symbol search indexes directory (e.g., "C:\...\Grid 3\Locale\en-GB\symbolsearch")
180
190
  */
181
191
  function getSymbolSearchIndexesDir(grid3Path, locale = exports.DEFAULT_LOCALE) {
192
+ const path = getNodePath();
182
193
  return path.join(grid3Path, SYMBOLSEARCH_SUBDIR, locale, 'symbolsearch');
183
194
  }
184
195
  /**
@@ -192,6 +203,7 @@ function getAvailableSymbolLibraries(options = {}) {
192
203
  return [];
193
204
  }
194
205
  const symbolsDir = getSymbolLibrariesDir(grid3Path);
206
+ const fs = getNodeFs();
195
207
  if (!fs.existsSync(symbolsDir)) {
196
208
  return [];
197
209
  }
@@ -199,6 +211,7 @@ function getAvailableSymbolLibraries(options = {}) {
199
211
  const files = fs.readdirSync(symbolsDir);
200
212
  for (const file of files) {
201
213
  if (file.endsWith('.symbols')) {
214
+ const path = getNodePath();
202
215
  const fullPath = path.join(symbolsDir, file);
203
216
  const stats = fs.statSync(fullPath);
204
217
  const libraryName = path.basename(file, '.symbols');
@@ -233,7 +246,9 @@ function getSymbolLibraryInfo(libraryName, options = {}) {
233
246
  libraryName + '.symbols',
234
247
  ];
235
248
  for (const file of variations) {
249
+ const path = getNodePath();
236
250
  const fullPath = path.join(symbolsDir, file);
251
+ const fs = getNodeFs();
237
252
  if (fs.existsSync(fullPath)) {
238
253
  const stats = fs.statSync(fullPath);
239
254
  return {
@@ -280,7 +295,8 @@ function resolveSymbolReference(reference, options = {}) {
280
295
  }
281
296
  try {
282
297
  // .symbols files are ZIP archives
283
- const zip = new adm_zip_1.default(libraryInfo.pixFile);
298
+ const AdmZip = getAdmZip();
299
+ const zip = new AdmZip(libraryInfo.pixFile);
284
300
  // The path in the symbol reference becomes the path within the symbols/ folder
285
301
  // e.g., [tawasl]/above bw.png becomes symbols/above bw.png
286
302
  const symbolPath = `symbols/${parsed.path}`;
@@ -445,7 +461,8 @@ function analyzeSymbolUsage(tree) {
445
461
  */
446
462
  function symbolReferenceToFilename(reference, cellX, cellY) {
447
463
  const parsed = parseSymbolReference(reference);
448
- const ext = path.extname(parsed.path) || '.png';
464
+ const dotIndex = parsed.path.lastIndexOf('.');
465
+ const ext = dotIndex >= 0 ? parsed.path.slice(dotIndex) : '.png';
449
466
  // Grid 3 format: {x}-{y}-0-text-0.{ext}
450
467
  return `${cellX}-${cellY}-0-text-0${ext}`;
451
468
  }
@@ -30,11 +30,10 @@ exports.GridsetValidator = void 0;
30
30
  /* eslint-disable @typescript-eslint/require-await */
31
31
  /* eslint-disable @typescript-eslint/no-unsafe-argument */
32
32
  /* eslint-disable @typescript-eslint/no-unsafe-return */
33
- const fs = __importStar(require("fs"));
34
- const path = __importStar(require("path"));
35
33
  const xml2js = __importStar(require("xml2js"));
36
34
  const jszip_1 = __importDefault(require("jszip"));
37
35
  const baseValidator_1 = require("./baseValidator");
36
+ const io_1 = require("../utils/io");
38
37
  /**
39
38
  * Validator for Grid3/Smartbox Gridset files (.gridset, .gridsetx)
40
39
  */
@@ -47,6 +46,8 @@ class GridsetValidator extends baseValidator_1.BaseValidator {
47
46
  */
48
47
  static async validateFile(filePath) {
49
48
  const validator = new GridsetValidator();
49
+ const fs = (0, io_1.getFs)();
50
+ const path = (0, io_1.getPath)();
50
51
  const content = fs.readFileSync(filePath);
51
52
  const stats = fs.statSync(filePath);
52
53
  return validator.validate(content, path.basename(filePath), stats.size);
@@ -0,0 +1,185 @@
1
+ # AAC Pageset Quickstart (Node + Browser)
2
+
3
+ This guide shows two simple ways to generate or convert AAC pagesets using `aac-processors`:
4
+
5
+ - Node.js: full conversion between formats (read + write)
6
+ - Browser: generate or export to OBF/OBZ in-memory (downloadable)
7
+
8
+ If you need lossless conversion in the browser, use a Node/worker service for the save step (file I/O is required for most formats).
9
+
10
+ ## Node.js: Convert and Generate Pagesets
11
+
12
+ ### Install
13
+
14
+ ```bash
15
+ npm install aac-processors
16
+ ```
17
+
18
+ ### Convert a Gridset to OBF
19
+
20
+ ```ts
21
+ import { getProcessor, ObfProcessor } from 'aac-processors';
22
+
23
+ async function convertGridsetToObf() {
24
+ const sourcePath = './input/example.gridset';
25
+ const targetPath = './output/example.obf';
26
+
27
+ const sourceProcessor = getProcessor(sourcePath); // GridsetProcessor
28
+ const tree = await sourceProcessor.loadIntoTree(sourcePath);
29
+
30
+ const obf = new ObfProcessor();
31
+ await obf.saveFromTree(tree, targetPath);
32
+
33
+ console.log('Saved:', targetPath);
34
+ }
35
+
36
+ convertGridsetToObf().catch(console.error);
37
+ ```
38
+
39
+ ### Generate a Simple Pageset and Save as OBZ
40
+
41
+ ```ts
42
+ import { AACTree, AACPage, AACButton, ObfProcessor } from 'aac-processors';
43
+
44
+ async function generateObz() {
45
+ const tree = new AACTree();
46
+ tree.metadata = { name: 'Starter Demo', locale: 'en' };
47
+
48
+ const hello = new AACButton({ id: 'hello', label: 'Hello', message: 'Hello' });
49
+ const thanks = new AACButton({ id: 'thanks', label: 'Thanks', message: 'Thank you' });
50
+
51
+ const home = new AACPage({
52
+ id: 'home',
53
+ name: 'Home',
54
+ buttons: [hello, thanks],
55
+ grid: [[hello, thanks]],
56
+ });
57
+
58
+ tree.addPage(home);
59
+ tree.rootId = 'home';
60
+
61
+ const obf = new ObfProcessor();
62
+ await obf.saveFromTree(tree, './output/starter.obz');
63
+
64
+ console.log('Saved: ./output/starter.obz');
65
+ }
66
+
67
+ generateObz().catch(console.error);
68
+ ```
69
+
70
+ ## Browser: Generate or Convert to OBF/OBZ
71
+
72
+ In the browser you can still parse files and build an `AACTree`, but most processors cannot write to disk (no `fs`).
73
+ The example below generates an `AACTree`, converts it to OBF JSON in-memory, and downloads it.
74
+
75
+ If you need full conversions in a browser app, do the save step in Node (server or worker).
76
+
77
+ ### Generate a Pageset and Download as OBF
78
+
79
+ ```ts
80
+ import { AACTree, AACPage, AACButton, ObfProcessor } from 'aac-processors';
81
+
82
+ function downloadBlob(data: BlobPart, filename: string, type: string) {
83
+ const blob = new Blob([data], { type });
84
+ const url = URL.createObjectURL(blob);
85
+ const a = document.createElement('a');
86
+ a.href = url;
87
+ a.download = filename;
88
+ a.click();
89
+ URL.revokeObjectURL(url);
90
+ }
91
+
92
+ function buildSampleTree() {
93
+ const tree = new AACTree();
94
+ tree.metadata = { name: 'Browser Demo', locale: 'en' };
95
+
96
+ const hello = new AACButton({ id: 'hello', label: 'Hello', message: 'Hello' });
97
+ const yes = new AACButton({ id: 'yes', label: 'Yes', message: 'Yes' });
98
+
99
+ const home = new AACPage({
100
+ id: 'home',
101
+ name: 'Home',
102
+ buttons: [hello, yes],
103
+ grid: [[hello, yes]],
104
+ });
105
+
106
+ tree.addPage(home);
107
+ tree.rootId = 'home';
108
+ return tree;
109
+ }
110
+
111
+ async function exportObf(tree: AACTree) {
112
+ // This mirrors the browser demo approach: create OBF JSON and download.
113
+ // ObfProcessor.saveFromTree writes to disk, so we build a board in-memory.
114
+ const obf = new ObfProcessor() as ObfProcessor & {
115
+ createObfBoardFromPage?: (page: AACPage, fallbackName: string, metadata?: AACTree['metadata']) => any;
116
+ };
117
+
118
+ const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0];
119
+ const board = obf.createObfBoardFromPage
120
+ ? obf.createObfBoardFromPage(rootPage!, 'Board', tree.metadata)
121
+ : {
122
+ format: 'open-board-0.1',
123
+ id: rootPage?.id ?? 'board',
124
+ name: rootPage?.name ?? 'Board',
125
+ locale: tree.metadata?.locale || 'en',
126
+ grid: { rows: 1, columns: rootPage?.buttons.length ?? 0, order: [] },
127
+ buttons: (rootPage?.buttons || []).map((button) => ({
128
+ id: button.id,
129
+ label: button.label,
130
+ vocalization: button.message || button.label,
131
+ })),
132
+ };
133
+
134
+ const json = JSON.stringify(board, null, 2);
135
+ downloadBlob(json, 'browser-demo.obf', 'application/json');
136
+ }
137
+
138
+ const tree = buildSampleTree();
139
+ exportObf(tree);
140
+ ```
141
+
142
+ ### Convert an Uploaded Pageset to OBZ (Browser)
143
+
144
+ This uses the same idea as the Vite demo: parse any supported file into a tree,
145
+ then export OBF/OBZ in-memory and download.
146
+
147
+ ```ts
148
+ import { getProcessor, ObfProcessor, type AACTree, type AACPage } from 'aac-processors';
149
+ import JSZip from 'jszip';
150
+
151
+ async function convertToObz(file: File) {
152
+ const extension = '.' + file.name.split('.').pop();
153
+ const processor = getProcessor(extension);
154
+ const buffer = await file.arrayBuffer();
155
+ const tree = await processor.loadIntoTree(buffer);
156
+
157
+ const obf = new ObfProcessor() as ObfProcessor & {
158
+ createObfBoardFromPage?: (page: AACPage, fallbackName: string, metadata?: AACTree['metadata']) => any;
159
+ };
160
+
161
+ const zip = new JSZip();
162
+ Object.values(tree.pages).forEach((page) => {
163
+ const board = obf.createObfBoardFromPage
164
+ ? obf.createObfBoardFromPage(page, 'Board', tree.metadata)
165
+ : { format: 'open-board-0.1', id: page.id, name: page.name, grid: { rows: 0, columns: 0, order: [] }, buttons: [] };
166
+ zip.file(`${page.id}.obf`, JSON.stringify(board, null, 2));
167
+ });
168
+
169
+ const data = await zip.generateAsync({ type: 'uint8array' });
170
+ const blob = new Blob([data], { type: 'application/zip' });
171
+ const url = URL.createObjectURL(blob);
172
+ const a = document.createElement('a');
173
+ a.href = url;
174
+ a.download = 'converted.obz';
175
+ a.click();
176
+ URL.revokeObjectURL(url);
177
+ }
178
+ ```
179
+
180
+ ## Tips
181
+
182
+ - Use `loadIntoTree()` to normalize different AAC formats into one structure.
183
+ - In Node, `saveFromTree()` lets you write to OBF, OBZ, Gridset, etc.
184
+ - In the browser, build the output in memory and offer it for download.
185
+ - For full-fidelity conversions in the browser, use a server-side endpoint to save files.
@@ -40,6 +40,7 @@ The `test-files/` folder contains example AAC files you can use:
40
40
  - **Navigation**: Click NAVIGATE buttons to jump between pages
41
41
  - **Stats**: See page/button/text counts and load time
42
42
  - **Logs**: Watch the processing log in real-time
43
+ - **Pageset Lab**: Open the "Create & Convert" tab to generate a sample pageset or convert an upload to OBF/OBZ
43
44
 
44
45
  ## 🛠️ Development
45
46
 
@@ -63,6 +63,39 @@
63
63
  border-bottom: 2px solid #667eea;
64
64
  }
65
65
 
66
+ .tab-header {
67
+ display: flex;
68
+ gap: 8px;
69
+ margin-bottom: 15px;
70
+ }
71
+
72
+ .tab-btn {
73
+ flex: 1;
74
+ border: 1px solid #d6daf5;
75
+ background: #f7f8ff;
76
+ color: #3d4bb8;
77
+ padding: 8px 12px;
78
+ border-radius: 8px;
79
+ font-size: 13px;
80
+ font-weight: 600;
81
+ cursor: pointer;
82
+ transition: all 0.2s;
83
+ }
84
+
85
+ .tab-btn.active {
86
+ background: #667eea;
87
+ color: #fff;
88
+ border-color: #667eea;
89
+ }
90
+
91
+ .tab-content {
92
+ display: none;
93
+ }
94
+
95
+ .tab-content.active {
96
+ display: block;
97
+ }
98
+
66
99
  .upload-area {
67
100
  border: 2px dashed #ccc;
68
101
  border-radius: 8px;
@@ -290,6 +323,83 @@
290
323
  font-size: 13px;
291
324
  }
292
325
 
326
+ .demo-section {
327
+ background: #f8f9ff;
328
+ border-radius: 10px;
329
+ padding: 15px;
330
+ border: 1px solid #e4e7ff;
331
+ margin-bottom: 15px;
332
+ }
333
+
334
+ .section-title {
335
+ font-size: 14px;
336
+ font-weight: 700;
337
+ color: #3942a3;
338
+ margin-bottom: 10px;
339
+ }
340
+
341
+ .field {
342
+ display: flex;
343
+ flex-direction: column;
344
+ gap: 6px;
345
+ margin-bottom: 10px;
346
+ font-size: 12px;
347
+ color: #4d4d4d;
348
+ }
349
+
350
+ .field select,
351
+ .field input {
352
+ border: 1px solid #d7d9f5;
353
+ border-radius: 6px;
354
+ padding: 8px 10px;
355
+ font-size: 13px;
356
+ }
357
+
358
+ .action-row {
359
+ display: flex;
360
+ gap: 10px;
361
+ }
362
+
363
+ .action-row .btn {
364
+ margin-bottom: 0;
365
+ }
366
+
367
+ .hint {
368
+ font-size: 12px;
369
+ color: #667;
370
+ margin-top: 8px;
371
+ }
372
+
373
+ .status-pill {
374
+ margin-top: 10px;
375
+ font-size: 12px;
376
+ padding: 8px 10px;
377
+ border-radius: 8px;
378
+ background: #eef0ff;
379
+ color: #4a4a8a;
380
+ }
381
+
382
+ .status-pill.success {
383
+ background: #e0f4e7;
384
+ color: #256d3b;
385
+ }
386
+
387
+ .status-pill.warn {
388
+ background: #fff4d6;
389
+ color: #7a5a00;
390
+ }
391
+
392
+ .code-preview {
393
+ background: #0f172a;
394
+ color: #d7e3ff;
395
+ padding: 12px;
396
+ border-radius: 8px;
397
+ font-size: 12px;
398
+ max-height: 220px;
399
+ overflow: auto;
400
+ white-space: pre-wrap;
401
+ }
402
+
293
403
  @media (max-width: 1024px) {
294
404
  .main-grid {
295
405
  grid-template-columns: 1fr;
@@ -361,11 +471,56 @@
361
471
 
362
472
  <!-- Right Panel: Results -->
363
473
  <div class="panel results-panel">
364
- <div class="panel-title">📊 File Contents</div>
365
- <div id="results">
366
- <p style="color: #999; text-align: center; padding: 40px;">
367
- Load a file to see its contents here
368
- </p>
474
+ <div class="panel-title">📊 AAC Pageset Lab</div>
475
+ <div class="tab-header">
476
+ <button class="tab-btn active" data-tab="inspectTab">Inspect</button>
477
+ <button class="tab-btn" data-tab="pagesetTab">Create & Convert</button>
478
+ </div>
479
+ <div class="tab-content active" id="inspectTab">
480
+ <div id="results">
481
+ <p style="color: #999; text-align: center; padding: 40px;">
482
+ Load a file to see its contents here
483
+ </p>
484
+ </div>
485
+ </div>
486
+ <div class="tab-content" id="pagesetTab">
487
+ <div class="demo-section">
488
+ <div class="section-title">✨ Create a Sample Pageset</div>
489
+ <div class="field">
490
+ <label for="templateSelect">Template</label>
491
+ <select id="templateSelect">
492
+ <option value="starter">Starter 2x2 + Feelings</option>
493
+ <option value="home">Home & Core 3x3</option>
494
+ </select>
495
+ </div>
496
+ <div class="field">
497
+ <label for="formatSelect">Output format</label>
498
+ <select id="formatSelect">
499
+ <option value="obf">OBF (.obf)</option>
500
+ <option value="obz">OBZ (.obz)</option>
501
+ </select>
502
+ </div>
503
+ <div class="action-row">
504
+ <button class="btn" id="createPagesetBtn">Generate & Download</button>
505
+ <button class="btn btn-secondary" id="previewPagesetBtn">Preview in Viewer</button>
506
+ </div>
507
+ <div class="hint">Creates a demo AACTree, then exports to OBF/OBZ.</div>
508
+ </div>
509
+
510
+ <div class="demo-section">
511
+ <div class="section-title">🔁 Convert Loaded Pageset</div>
512
+ <div class="action-row">
513
+ <button class="btn" id="convertToObfBtn" disabled>Download .obf</button>
514
+ <button class="btn btn-secondary" id="convertToObzBtn" disabled>Download .obz</button>
515
+ </div>
516
+ <div class="hint">Upload and process a file first, then export it in another format.</div>
517
+ <div class="status-pill" id="conversionStatus">No pageset loaded yet.</div>
518
+ </div>
519
+
520
+ <div class="demo-section">
521
+ <div class="section-title">📄 Export Preview</div>
522
+ <pre class="code-preview" id="pagesetOutput">Generate or convert a pageset to preview the output JSON.</pre>
523
+ </div>
369
524
  </div>
370
525
  </div>
371
526
  </div>
@@ -74,9 +74,9 @@ import {
74
74
  GridsetProcessor,
75
75
  ApplePanelsProcessor,
76
76
  AstericsGridProcessor,
77
- type AACTree,
78
- type AACPage,
79
- type AACButton
77
+ AACTree,
78
+ AACPage,
79
+ AACButton
80
80
  } from 'aac-processors';
81
81
 
82
82
  // UI Elements
@@ -93,11 +93,38 @@ const results = document.getElementById('results') as HTMLElement;
93
93
  const logPanel = document.getElementById('logPanel') as HTMLElement;
94
94
  const testResults = document.getElementById('testResults') as HTMLElement;
95
95
  const testList = document.getElementById('testList') as HTMLElement;
96
+ const tabButtons = document.querySelectorAll('.tab-btn') as NodeListOf<HTMLButtonElement>;
97
+ const inspectTab = document.getElementById('inspectTab') as HTMLElement;
98
+ const pagesetTab = document.getElementById('pagesetTab') as HTMLElement;
99
+ const templateSelect = document.getElementById('templateSelect') as HTMLSelectElement;
100
+ const formatSelect = document.getElementById('formatSelect') as HTMLSelectElement;
101
+ const createPagesetBtn = document.getElementById('createPagesetBtn') as HTMLButtonElement;
102
+ const previewPagesetBtn = document.getElementById('previewPagesetBtn') as HTMLButtonElement;
103
+ const convertToObfBtn = document.getElementById('convertToObfBtn') as HTMLButtonElement;
104
+ const convertToObzBtn = document.getElementById('convertToObzBtn') as HTMLButtonElement;
105
+ const conversionStatus = document.getElementById('conversionStatus') as HTMLElement;
106
+ const pagesetOutput = document.getElementById('pagesetOutput') as HTMLElement;
96
107
 
97
108
  // State
98
109
  let currentFile: File | null = null;
99
110
  let currentProcessor: any = null;
100
111
  let currentTree: AACTree | null = null;
112
+ let currentSourceLabel = 'pageset';
113
+
114
+ // Tabs
115
+ function setActiveTab(tabId: string) {
116
+ tabButtons.forEach((btn) => {
117
+ btn.classList.toggle('active', btn.dataset.tab === tabId);
118
+ });
119
+ inspectTab.classList.toggle('active', tabId === 'inspectTab');
120
+ pagesetTab.classList.toggle('active', tabId === 'pagesetTab');
121
+ }
122
+
123
+ tabButtons.forEach((btn) => {
124
+ btn.addEventListener('click', () => {
125
+ setActiveTab(btn.dataset.tab || 'inspectTab');
126
+ });
127
+ });
101
128
 
102
129
  // Logging
103
130
  function log(message: string, type: 'info' | 'success' | 'error' | 'warn' = 'info') {
@@ -109,6 +136,52 @@ function log(message: string, type: 'info' | 'success' | 'error' | 'warn' = 'inf
109
136
  console.log(`[${type.toUpperCase()}]`, message);
110
137
  }
111
138
 
139
+ function setConversionStatus(message: string, state: 'success' | 'warn' | 'info' = 'info') {
140
+ conversionStatus.textContent = message;
141
+ conversionStatus.classList.remove('success', 'warn');
142
+ if (state !== 'info') {
143
+ conversionStatus.classList.add(state);
144
+ }
145
+ }
146
+
147
+ function updateConvertButtons() {
148
+ const hasTree = !!currentTree;
149
+ convertToObfBtn.disabled = !hasTree;
150
+ convertToObzBtn.disabled = !hasTree;
151
+ if (!hasTree) {
152
+ setConversionStatus('No pageset loaded yet.', 'info');
153
+ } else {
154
+ setConversionStatus(`Ready to export: ${currentSourceLabel}`, 'success');
155
+ }
156
+ }
157
+
158
+ function updateStatsForTree(tree: AACTree, textCount?: number, loadTimeMs?: number) {
159
+ const pageCount = Object.keys(tree.pages).length;
160
+ const buttonCount = Object.values(tree.pages).reduce(
161
+ (sum: number, page: AACPage) => sum + page.buttons.length,
162
+ 0
163
+ );
164
+
165
+ document.getElementById('pageCount')!.textContent = pageCount.toString();
166
+ document.getElementById('buttonCount')!.textContent = buttonCount.toString();
167
+ document.getElementById('textCount')!.textContent = (textCount ?? 0).toString();
168
+ document.getElementById('loadTime')!.textContent =
169
+ loadTimeMs !== undefined ? `${loadTimeMs.toFixed(0)}ms` : '—';
170
+ stats.style.display = 'grid';
171
+ }
172
+
173
+ function collectTextCount(tree: AACTree): number {
174
+ const texts = new Set<string>();
175
+ Object.values(tree.pages).forEach((page) => {
176
+ if (page.name) texts.add(page.name);
177
+ page.buttons.forEach((button) => {
178
+ if (button.label) texts.add(button.label);
179
+ if (button.message) texts.add(button.message);
180
+ });
181
+ });
182
+ return texts.size;
183
+ }
184
+
112
185
  // Get file extension
113
186
  function getFileExtension(filename: string): string {
114
187
  const match = filename.toLowerCase().match(/\.\w+$/);
@@ -146,6 +219,7 @@ function handleFile(file: File) {
146
219
  fileDetails.textContent = `${file.name} • ${formatFileSize(file.size)}`;
147
220
  fileInfo.style.display = 'block';
148
221
  processBtn.disabled = false;
222
+ currentSourceLabel = file.name;
149
223
 
150
224
  log(`Using processor: ${currentProcessor.constructor.name}`, 'success');
151
225
  } catch (error) {
@@ -213,22 +287,13 @@ processBtn.addEventListener('click', async () => {
213
287
  log(`Extracted ${texts.length} texts`, 'success');
214
288
 
215
289
  // Update stats
216
- const pageCount = Object.keys(currentTree.pages).length;
217
- const buttonCount = Object.values(currentTree.pages).reduce(
218
- (sum: number, page: AACPage) => sum + page.buttons.length,
219
- 0
220
- );
221
-
222
- document.getElementById('pageCount')!.textContent = pageCount.toString();
223
- document.getElementById('buttonCount')!.textContent = buttonCount.toString();
224
- document.getElementById('textCount')!.textContent = texts.length.toString();
225
- document.getElementById('loadTime')!.textContent = `${loadTime.toFixed(0)}ms`;
226
- stats.style.display = 'grid';
290
+ updateStatsForTree(currentTree, texts.length, loadTime);
227
291
 
228
292
  // Display results
229
293
  displayResults(currentTree);
294
+ updateConvertButtons();
230
295
 
231
- log(`✅ Successfully processed ${pageCount} pages with ${buttonCount} buttons`, 'success');
296
+ log(`✅ Successfully processed ${Object.keys(currentTree.pages).length} pages`, 'success');
232
297
  } catch (error) {
233
298
  const errorMsg = (error as Error).message;
234
299
  log(`❌ Error: ${errorMsg}`, 'error');
@@ -343,12 +408,299 @@ clearBtn.addEventListener('click', () => {
343
408
  currentFile = null;
344
409
  currentProcessor = null;
345
410
  currentTree = null;
411
+ currentSourceLabel = 'pageset';
346
412
  fileInput.value = '';
347
413
  fileInfo.style.display = 'none';
348
414
  stats.style.display = 'none';
349
415
  results.innerHTML = '<p style="color: #999; text-align: center; padding: 40px;">Load a file to see its contents here</p>';
350
416
  testResults.style.display = 'none';
351
417
  logPanel.innerHTML = '<div class="log-entry log-info">Cleared. Ready to process files...</div>';
418
+ pagesetOutput.textContent = 'Generate or convert a pageset to preview the output JSON.';
419
+ updateConvertButtons();
420
+ });
421
+
422
+ function sanitizeFilename(name: string): string {
423
+ return name
424
+ .toLowerCase()
425
+ .replace(/[^a-z0-9]+/g, '-')
426
+ .replace(/(^-|-$)/g, '') || 'pageset';
427
+ }
428
+
429
+ function buildSampleTree(template: string): AACTree {
430
+ const tree = new AACTree();
431
+ tree.metadata = {
432
+ name: template === 'home' ? 'Home & Core Demo' : 'Starter Demo',
433
+ description: 'Generated in the AAC Processors browser demo',
434
+ locale: 'en',
435
+ };
436
+
437
+ if (template === 'home') {
438
+ const hello = new AACButton({ id: 'hello', label: 'Hello', message: 'Hello', action: { type: 'SPEAK' } });
439
+ const want = new AACButton({ id: 'want', label: 'I want', message: 'I want', action: { type: 'SPEAK' } });
440
+ const help = new AACButton({ id: 'help', label: 'Help', message: 'Help', action: { type: 'SPEAK' } });
441
+ const more = new AACButton({
442
+ id: 'more',
443
+ label: 'More',
444
+ targetPageId: 'core',
445
+ action: { type: 'NAVIGATE', targetPageId: 'core' },
446
+ });
447
+ const yes = new AACButton({ id: 'yes', label: 'Yes', message: 'Yes', action: { type: 'SPEAK' } });
448
+ const no = new AACButton({ id: 'no', label: 'No', message: 'No', action: { type: 'SPEAK' } });
449
+ const stop = new AACButton({ id: 'stop', label: 'Stop', message: 'Stop', action: { type: 'SPEAK' } });
450
+ const go = new AACButton({ id: 'go', label: 'Go', message: 'Go', action: { type: 'SPEAK' } });
451
+ const food = new AACButton({
452
+ id: 'food',
453
+ label: 'Food',
454
+ targetPageId: 'food',
455
+ action: { type: 'NAVIGATE', targetPageId: 'food' },
456
+ });
457
+
458
+ const homePage = new AACPage({
459
+ id: 'home',
460
+ name: 'Home',
461
+ buttons: [hello, want, help, more, yes, no, stop, go, food],
462
+ grid: [
463
+ [hello, want, help],
464
+ [more, yes, no],
465
+ [stop, go, food],
466
+ ],
467
+ });
468
+
469
+ const hungry = new AACButton({ id: 'hungry', label: 'Hungry', message: 'I am hungry', action: { type: 'SPEAK' } });
470
+ const drink = new AACButton({ id: 'drink', label: 'Drink', message: 'I want a drink', action: { type: 'SPEAK' } });
471
+ const snack = new AACButton({ id: 'snack', label: 'Snack', message: 'Snack', action: { type: 'SPEAK' } });
472
+ const backFood = new AACButton({
473
+ id: 'back-food',
474
+ label: 'Back',
475
+ targetPageId: 'home',
476
+ action: { type: 'NAVIGATE', targetPageId: 'home' },
477
+ });
478
+
479
+ const foodPage = new AACPage({
480
+ id: 'food',
481
+ name: 'Food',
482
+ buttons: [hungry, drink, snack, backFood],
483
+ grid: [
484
+ [hungry, drink],
485
+ [snack, backFood],
486
+ ],
487
+ });
488
+
489
+ const coreYes = new AACButton({ id: 'core-yes', label: 'Yes', message: 'Yes', action: { type: 'SPEAK' } });
490
+ const coreNo = new AACButton({ id: 'core-no', label: 'No', message: 'No', action: { type: 'SPEAK' } });
491
+ const coreStop = new AACButton({ id: 'core-stop', label: 'Stop', message: 'Stop', action: { type: 'SPEAK' } });
492
+ const coreGo = new AACButton({ id: 'core-go', label: 'Go', message: 'Go', action: { type: 'SPEAK' } });
493
+ const backCore = new AACButton({
494
+ id: 'back-core',
495
+ label: 'Back',
496
+ targetPageId: 'home',
497
+ action: { type: 'NAVIGATE', targetPageId: 'home' },
498
+ });
499
+
500
+ const corePage = new AACPage({
501
+ id: 'core',
502
+ name: 'Core Words',
503
+ buttons: [coreYes, coreNo, coreStop, coreGo, backCore],
504
+ grid: [
505
+ [coreYes, coreNo],
506
+ [coreStop, coreGo],
507
+ [backCore, null],
508
+ ],
509
+ });
510
+
511
+ tree.addPage(homePage);
512
+ tree.addPage(corePage);
513
+ tree.addPage(foodPage);
514
+ tree.rootId = 'home';
515
+ return tree;
516
+ }
517
+
518
+ const hello = new AACButton({ id: 'hello', label: 'Hello', message: 'Hello', action: { type: 'SPEAK' } });
519
+ const thanks = new AACButton({ id: 'thanks', label: 'Thanks', message: 'Thank you', action: { type: 'SPEAK' } });
520
+ const yes = new AACButton({ id: 'yes', label: 'Yes', message: 'Yes', action: { type: 'SPEAK' } });
521
+ const more = new AACButton({
522
+ id: 'more',
523
+ label: 'Feelings',
524
+ targetPageId: 'feelings',
525
+ action: { type: 'NAVIGATE', targetPageId: 'feelings' },
526
+ });
527
+
528
+ const homePage = new AACPage({
529
+ id: 'home',
530
+ name: 'Starter',
531
+ buttons: [hello, thanks, yes, more],
532
+ grid: [
533
+ [hello, thanks],
534
+ [yes, more],
535
+ ],
536
+ });
537
+
538
+ const happy = new AACButton({ id: 'happy', label: 'Happy', message: 'I feel happy', action: { type: 'SPEAK' } });
539
+ const sad = new AACButton({ id: 'sad', label: 'Sad', message: 'I feel sad', action: { type: 'SPEAK' } });
540
+ const back = new AACButton({
541
+ id: 'back',
542
+ label: 'Back',
543
+ targetPageId: 'home',
544
+ action: { type: 'NAVIGATE', targetPageId: 'home' },
545
+ });
546
+
547
+ const feelingsPage = new AACPage({
548
+ id: 'feelings',
549
+ name: 'Feelings',
550
+ buttons: [happy, sad, back],
551
+ grid: [
552
+ [happy, sad],
553
+ [back, null],
554
+ ],
555
+ });
556
+
557
+ tree.addPage(homePage);
558
+ tree.addPage(feelingsPage);
559
+ tree.rootId = 'home';
560
+ return tree;
561
+ }
562
+
563
+ function buildFallbackObfBoard(page: AACPage, metadata?: AACTree['metadata']) {
564
+ const rows = page.grid.length || 1;
565
+ const columns = page.grid.reduce((max, row) => Math.max(max, row.length), 0) || page.buttons.length;
566
+ const order: (string | null)[][] = [];
567
+ const positions = new Map<string, number>();
568
+
569
+ if (page.grid.length) {
570
+ page.grid.forEach((row, rowIndex) => {
571
+ const orderRow: (string | null)[] = [];
572
+ for (let colIndex = 0; colIndex < columns; colIndex++) {
573
+ const cell = row[colIndex] || null;
574
+ if (cell) {
575
+ const id = String(cell.id ?? '');
576
+ orderRow.push(id);
577
+ positions.set(id, rowIndex * columns + colIndex);
578
+ } else {
579
+ orderRow.push(null);
580
+ }
581
+ }
582
+ order.push(orderRow);
583
+ });
584
+ } else {
585
+ const fallbackRow = page.buttons.map((button, index) => {
586
+ const id = String(button.id ?? '');
587
+ positions.set(id, index);
588
+ return id;
589
+ });
590
+ order.push(fallbackRow);
591
+ }
592
+
593
+ return {
594
+ format: 'open-board-0.1',
595
+ id: page.id,
596
+ locale: metadata?.locale || page.locale || 'en',
597
+ name: page.name || metadata?.name || 'Board',
598
+ description_html: page.descriptionHtml || metadata?.description || '',
599
+ grid: { rows, columns, order },
600
+ buttons: page.buttons.map((button) => ({
601
+ id: button.id,
602
+ label: button.label,
603
+ vocalization: button.message || button.label,
604
+ load_board: button.targetPageId ? { path: button.targetPageId } : undefined,
605
+ box_id: positions.get(String(button.id ?? '')),
606
+ background_color: button.style?.backgroundColor,
607
+ border_color: button.style?.borderColor,
608
+ })),
609
+ };
610
+ }
611
+
612
+ async function buildObfExport(tree: AACTree, format: 'obf' | 'obz') {
613
+ const obfProcessor = new ObfProcessor();
614
+ const obfInternal = obfProcessor as ObfProcessor & {
615
+ createObfBoardFromPage?: (page: AACPage, fallbackName: string, metadata?: AACTree['metadata']) => any;
616
+ };
617
+
618
+ const boards = Object.values(tree.pages).map((page) => ({
619
+ pageId: page.id,
620
+ board: obfInternal.createObfBoardFromPage
621
+ ? obfInternal.createObfBoardFromPage(page, 'Board', tree.metadata)
622
+ : buildFallbackObfBoard(page, tree.metadata),
623
+ }));
624
+
625
+ if (format === 'obf') {
626
+ const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0];
627
+ const board =
628
+ boards.find((entry) => entry.pageId === rootPage?.id)?.board ?? boards[0]?.board ?? {};
629
+ const json = JSON.stringify(board, null, 2);
630
+ return { filename: `${sanitizeFilename(tree.metadata?.name || 'pageset')}.obf`, data: json };
631
+ }
632
+
633
+ const module = await import('jszip');
634
+ const JSZip = module.default || module;
635
+ const zip = new JSZip();
636
+ boards.forEach((entry) => {
637
+ zip.file(`${entry.pageId}.obf`, JSON.stringify(entry.board, null, 2));
638
+ });
639
+ const zipData = await zip.generateAsync({ type: 'uint8array' });
640
+ return { filename: `${sanitizeFilename(tree.metadata?.name || 'pageset')}.obz`, data: zipData };
641
+ }
642
+
643
+ function triggerDownload(data: Uint8Array | string, filename: string, mime: string) {
644
+ const blob = new Blob([data], { type: mime });
645
+ const url = URL.createObjectURL(blob);
646
+ const a = document.createElement('a');
647
+ a.href = url;
648
+ a.download = filename;
649
+ a.click();
650
+ URL.revokeObjectURL(url);
651
+ }
652
+
653
+ createPagesetBtn.addEventListener('click', async () => {
654
+ const template = templateSelect.value;
655
+ const format = formatSelect.value === 'obz' ? 'obz' : 'obf';
656
+ const tree = buildSampleTree(template);
657
+ currentTree = tree;
658
+ currentSourceLabel = `${tree.metadata?.name || 'sample pageset'}`;
659
+ updateConvertButtons();
660
+
661
+ const exportData = await buildObfExport(tree, format);
662
+ const isObf = typeof exportData.data === 'string';
663
+ triggerDownload(
664
+ exportData.data,
665
+ exportData.filename,
666
+ isObf ? 'application/json' : 'application/zip'
667
+ );
668
+
669
+ pagesetOutput.textContent = isObf
670
+ ? exportData.data
671
+ : `Generated OBZ with ${Object.keys(tree.pages).length} boards.`;
672
+
673
+ log(`Created sample pageset and exported ${exportData.filename}`, 'success');
674
+ setConversionStatus(`Exported ${exportData.filename}`, 'success');
675
+ });
676
+
677
+ previewPagesetBtn.addEventListener('click', () => {
678
+ const tree = buildSampleTree(templateSelect.value);
679
+ currentTree = tree;
680
+ currentSourceLabel = `${tree.metadata?.name || 'sample pageset'}`;
681
+ displayResults(tree);
682
+ updateStatsForTree(tree, collectTextCount(tree));
683
+ updateConvertButtons();
684
+ setActiveTab('inspectTab');
685
+ log('Previewing sample pageset in viewer', 'info');
686
+ });
687
+
688
+ convertToObfBtn.addEventListener('click', async () => {
689
+ if (!currentTree) return;
690
+ const exportData = await buildObfExport(currentTree, 'obf');
691
+ triggerDownload(exportData.data, exportData.filename, 'application/json');
692
+ pagesetOutput.textContent = exportData.data as string;
693
+ log(`Converted ${currentSourceLabel} to ${exportData.filename}`, 'success');
694
+ setConversionStatus(`Exported ${exportData.filename}`, 'success');
695
+ });
696
+
697
+ convertToObzBtn.addEventListener('click', async () => {
698
+ if (!currentTree) return;
699
+ const exportData = await buildObfExport(currentTree, 'obz');
700
+ triggerDownload(exportData.data, exportData.filename, 'application/zip');
701
+ pagesetOutput.textContent = `Generated OBZ with ${Object.keys(currentTree.pages).length} boards.`;
702
+ log(`Converted ${currentSourceLabel} to ${exportData.filename}`, 'success');
703
+ setConversionStatus(`Exported ${exportData.filename}`, 'success');
352
704
  });
353
705
 
354
706
  // Run compatibility tests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willwade/aac-processors",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",
@@ -93,6 +93,7 @@
93
93
  "lint:fix": "eslint \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\" --fix",
94
94
  "format": "prettier --write \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\" \"*.{js,ts,json,md}\"",
95
95
  "format:check": "prettier --check \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\" \"*.{js,ts,json,md}\"",
96
+ "smoke:browser": "node scripts/smoke-browser-bundle.js",
96
97
  "test": "npm run build && jest",
97
98
  "test:watch": "npm run build && jest --watch",
98
99
  "test:coverage": "npm run build && jest --coverage",