@willwade/aac-processors 0.3.0 → 0.3.2

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.
@@ -5,3 +5,4 @@
5
5
  * This is separate from pageset metrics.
6
6
  */
7
7
  export * from './utilities/analytics/history';
8
+ export * from './utilities/analytics/competence';
package/dist/analytics.js CHANGED
@@ -21,3 +21,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
21
21
  };
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
23
  __exportStar(require("./utilities/analytics/history"), exports);
24
+ __exportStar(require("./utilities/analytics/competence"), exports);
@@ -44,16 +44,23 @@ export function decodeNsColor(buffer) {
44
44
  if (!buffer)
45
45
  return undefined;
46
46
  const s = bufferToLatinString(buffer);
47
- // Require decimal points to avoid matching bplist header bytes (e.g. "00").
48
- // Accept 3 or 4 components (RGB or RGBA).
49
- const rgbMatch = s.match(/(\d+\.\d+)\s+(\d+\.\d+)\s+(\d+\.\d+)(?:\s+(\d+\.\d+))?/);
50
- if (rgbMatch) {
51
- const [, r, g, b] = rgbMatch;
52
- return rgbToHex(parseFloat(r), parseFloat(g), parseFloat(b));
47
+ // Match RGB[A] component strings like "1 0.949 0.561" or "0.5 0.5 0.5 1".
48
+ // Allow integer components (e.g. "1") but require at least one decimal to
49
+ // avoid matching bplist header bytes (e.g. "00 00 00").
50
+ const candidates = s.match(/\d+(?:\.\d+)?\s+\d+(?:\.\d+)?\s+\d+(?:\.\d+)?(?:\s+\d+(?:\.\d+)?)?/g);
51
+ if (candidates) {
52
+ // Find the first candidate that looks like RGB (has at least one decimal point).
53
+ const rgbStr = candidates.find((c) => c.includes('.'));
54
+ if (rgbStr) {
55
+ const parts = rgbStr.split(/\s+/).map(Number);
56
+ return rgbToHex(parts[0], parts[1], parts[2]);
57
+ }
53
58
  }
54
59
  return undefined;
55
60
  }
56
61
  const FONT_KEYWORDS = new Set([
62
+ 'bplist',
63
+ 'bplist00',
57
64
  'UIFont',
58
65
  'UIFontName',
59
66
  'UIFontPointSize',
@@ -35,6 +35,7 @@ function buildSemanticAction(rawButton, pageId, buttonIndex) {
35
35
  const actionData = rawButton.ActionData || {};
36
36
  const label = rawButton.ButtonText ?? '';
37
37
  const ttsText = actionData.TTSText;
38
+ const spoken = ttsText && ttsText.length > 0 ? ttsText : label;
38
39
  switch (buttonType) {
39
40
  case 'Jump': {
40
41
  const jumpTo = actionData.JumpTo;
@@ -45,7 +46,7 @@ function buildSemanticAction(rawButton, pageId, buttonIndex) {
45
46
  intent: AACSemanticIntent.NAVIGATE_TO,
46
47
  targetId: target,
47
48
  platformData: {
48
- gotalkNow: { buttonType: 'Jump', jumpTo },
49
+ gotalkNow: { buttonType: 'Jump', jumpTo, jumpBook: actionData.JumpBook },
49
50
  },
50
51
  fallback: { type: 'NAVIGATE', targetPageId: target },
51
52
  },
@@ -53,25 +54,78 @@ function buildSemanticAction(rawButton, pageId, buttonIndex) {
53
54
  targetPageId: target,
54
55
  };
55
56
  }
56
- case 'Audio': {
57
- // Audio buttons play a recorded .caf file named <pageId>-<buttonIndex>.caf
57
+ case 'Audio':
58
+ case 'Recorded': {
59
+ // Recorded audio: plays a .caf file named <pageId>-<buttonIndex>.caf.
60
+ // Recorded buttons may also have TrimStart/TrimEnd for audio trimming.
58
61
  const audioLocation = `${pageId}-${buttonIndex}.caf`;
59
62
  return {
60
63
  semanticAction: {
61
64
  category: AACSemanticCategory.MEDIA,
62
65
  intent: AACSemanticIntent.PLAY_SOUND,
63
66
  platformData: {
64
- gotalkNow: { buttonType: 'Audio', audioLocation },
67
+ gotalkNow: {
68
+ buttonType,
69
+ audioLocation,
70
+ trimStart: actionData.TrimStart,
71
+ trimEnd: actionData.TrimEnd,
72
+ },
65
73
  },
66
74
  fallback: { type: 'ACTION' },
67
75
  },
68
- message: label,
76
+ message: spoken,
77
+ };
78
+ }
79
+ case 'URL': {
80
+ return {
81
+ semanticAction: {
82
+ category: AACSemanticCategory.SYSTEM_CONTROL,
83
+ intent: AACSemanticIntent.WEB_NAVIGATE,
84
+ text: actionData.URL,
85
+ platformData: {
86
+ gotalkNow: {
87
+ buttonType: 'URL',
88
+ url: actionData.URL,
89
+ openInternally: actionData.OpenInternally,
90
+ },
91
+ },
92
+ fallback: { type: 'ACTION' },
93
+ },
94
+ message: spoken,
95
+ };
96
+ }
97
+ case 'GoVisual': {
98
+ return {
99
+ semanticAction: {
100
+ category: AACSemanticCategory.SYSTEM_CONTROL,
101
+ intent: AACSemanticIntent.PLATFORM_SPECIFIC,
102
+ platformData: {
103
+ gotalkNow: {
104
+ buttonType: 'GoVisual',
105
+ goVisual: actionData.GoVisual,
106
+ goVisualLink: actionData.GoVisualLink,
107
+ },
108
+ },
109
+ fallback: { type: 'ACTION' },
110
+ },
111
+ message: spoken,
69
112
  };
70
113
  }
71
- case 'Express':
72
- case 'Bookmark':
73
- case 'URL':
74
114
  case 'Video': {
115
+ return {
116
+ semanticAction: {
117
+ category: AACSemanticCategory.MEDIA,
118
+ intent: AACSemanticIntent.PLAY_VIDEO,
119
+ platformData: {
120
+ gotalkNow: { buttonType: 'Video' },
121
+ },
122
+ fallback: { type: 'ACTION' },
123
+ },
124
+ message: spoken,
125
+ };
126
+ }
127
+ case 'Express':
128
+ case 'Bookmark': {
75
129
  return {
76
130
  semanticAction: {
77
131
  category: AACSemanticCategory.SYSTEM_CONTROL,
@@ -81,20 +135,19 @@ function buildSemanticAction(rawButton, pageId, buttonIndex) {
81
135
  },
82
136
  fallback: { type: 'ACTION' },
83
137
  },
84
- message: ttsText && ttsText.length > 0 ? ttsText : label,
138
+ message: spoken,
85
139
  };
86
140
  }
87
141
  case 'TTS':
88
142
  default: {
89
- // When TTSText is empty the visible ButtonText is spoken.
90
- const spoken = ttsText && ttsText.length > 0 ? ttsText : label;
143
+ // Empty/placeholder cells (no ButtonType or no label) get a SPEAK with empty text.
91
144
  return {
92
145
  semanticAction: {
93
146
  category: AACSemanticCategory.COMMUNICATION,
94
147
  intent: AACSemanticIntent.SPEAK_TEXT,
95
148
  text: spoken,
96
149
  platformData: {
97
- gotalkNow: { buttonType: 'TTS' },
150
+ gotalkNow: { buttonType: buttonType === 'TTS' ? 'TTS' : 'Empty' },
98
151
  },
99
152
  fallback: { type: 'SPEAK', message: spoken },
100
153
  },
@@ -302,17 +355,39 @@ class GotalkNowProcessor extends BaseProcessor {
302
355
  if (first?.SourceImageName)
303
356
  button.symbolPath = first.SourceImageName;
304
357
  }
305
- // Visibility: GoTalk buttons can be disabled.
306
- if (rawButton.Enabled === false) {
358
+ // Visibility: GoTalk buttons can be disabled via either field.
359
+ if (rawButton.Enabled === false || rawButton.Disabled === true) {
307
360
  button.visibility = 'Disabled';
308
361
  }
309
- // Preserve raw style data opaquely for round-trip styling.
362
+ // Auditory cue (played when scanning/highlighting).
363
+ if (rawButton.CueType && rawButton.CueType !== 'None') {
364
+ const cueText = rawButton.CueData?.TTSText;
365
+ if (cueText && cueText.length > 0) {
366
+ button.audioDescription = cueText;
367
+ }
368
+ }
369
+ // Recorded audio reference.
370
+ const btnType = rawButton.ButtonType;
371
+ if (btnType === 'Audio' || btnType === 'Recorded') {
372
+ button.audioRecording = {
373
+ identifier: `${pageId}-${btnIndex}.caf`,
374
+ };
375
+ }
376
+ // AfterAction: motor-planning "speak and return" behaviour.
377
+ // AfterAction is an enum (0=stay, 2=..., 50=jump-back) and
378
+ // AfterActionData has the same shape as ActionData (e.g. {JumpTo: 0}).
379
+ const afterActionJumpTo = rawButton.AfterActionData?.JumpTo;
380
+ // Preserve raw style + extended data opaquely for round-trip styling.
310
381
  button.parameters = {
311
382
  ...(button.parameters || {}),
312
383
  __gotalkNow: {
313
384
  buttonType: rawButton.ButtonType,
314
385
  textShadow: rawButton.TextShadow,
315
386
  textCenter: rawButton.TextCenter,
387
+ cueType: rawButton.CueType,
388
+ afterAction: rawButton.AfterAction,
389
+ afterActionJumpTo: afterActionJumpTo !== undefined ? String(afterActionJumpTo) : undefined,
390
+ jumpBook: rawButton.ActionData?.JumpBook,
316
391
  },
317
392
  };
318
393
  page._loadButton(button);
@@ -2960,8 +2960,8 @@ class GridsetProcessor extends BaseProcessor {
2960
2960
  cellContent.CaptionAndImage = captionAndImage;
2961
2961
  }
2962
2962
  const cellData = {
2963
- '@_X': position.x + 1, // Grid3 uses 1-based X coordinates
2964
- '@_Y': position.y + yOffset + 1, // Grid3 uses 1-based Y coordinates with workspace offset
2963
+ '@_X': position.x, // Grid 3 coordinates are 0-based (matching loader)
2964
+ '@_Y': position.y + yOffset,
2965
2965
  '@_ColumnSpan': position.columnSpan,
2966
2966
  '@_RowSpan': position.rowSpan,
2967
2967
  Content: cellContent,
@@ -144,14 +144,21 @@ class ObfProcessor extends BaseProcessor {
144
144
  const pageId = boardData?.id ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || '';
145
145
  const images = boardData.images;
146
146
  const buttons = await Promise.all(sourceButtons.map(async (btn) => {
147
+ // Resolve load_board.path to a board ID (strip .obf suffix if present).
148
+ // The OBF spec says load_board.path is a file path, but internally we
149
+ // store the board ID as targetPageId.
150
+ const rawPath = btn.load_board?.path;
151
+ const boardRef = rawPath
152
+ ? rawPath.replace(/\.obf$/i, '').replace(/\.obz$/i, '')
153
+ : undefined;
147
154
  const semanticAction = btn.load_board
148
155
  ? {
149
156
  category: AACSemanticCategory.NAVIGATION,
150
157
  intent: AACSemanticIntent.NAVIGATE_TO,
151
- targetId: btn.load_board.path,
158
+ targetId: boardRef,
152
159
  fallback: {
153
160
  type: 'NAVIGATE',
154
- targetPageId: btn.load_board.path,
161
+ targetPageId: boardRef,
155
162
  },
156
163
  }
157
164
  : {
@@ -199,7 +206,7 @@ class ObfProcessor extends BaseProcessor {
199
206
  resolvedImageEntry: resolvedImage,
200
207
  parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
201
208
  semanticAction,
202
- targetPageId: btn.load_board?.path,
209
+ targetPageId: boardRef,
203
210
  semantic_id: btn.semantic_id, // Extract semantic_id if present
204
211
  });
205
212
  }));
@@ -618,7 +625,7 @@ class ObfProcessor extends BaseProcessor {
618
625
  vocalization: button.message || button.label,
619
626
  load_board: button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && button.targetPageId
620
627
  ? {
621
- path: button.targetPageId,
628
+ path: this.getPageFilename(button.targetPageId, metadata),
622
629
  }
623
630
  : undefined,
624
631
  background_color: button.style?.backgroundColor,