@pie-players/pie-assessment-toolkit 0.3.63 → 0.3.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -1
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +11 -11
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/ItemToolBar-cckwpz6c.js +51 -0
- package/dist/components/chunks/ItemToolBar-pryf0rtz.js +22 -0
- package/dist/index.d.ts +10 -6
- package/dist/index.js +12 -4
- package/dist/policy/core/ToolPolicyEngine.d.ts +21 -0
- package/dist/policy/core/ToolPolicyEngine.js +27 -0
- package/dist/policy/core/feature-decision.d.ts +57 -0
- package/dist/policy/core/feature-decision.js +40 -0
- package/dist/policy/engine.d.ts +1 -0
- package/dist/policy/sources/PnpPolicySource.d.ts +22 -0
- package/dist/policy/sources/PnpPolicySource.js +41 -11
- package/dist/runtime/catalog-registration.d.ts +56 -1
- package/dist/runtime/catalog-registration.js +64 -31
- package/dist/runtime/composition-emit-scheduler.d.ts +78 -0
- package/dist/runtime/composition-emit-scheduler.js +154 -0
- package/dist/runtime/core/engine-resolver.d.ts +1 -1
- package/dist/services/AccessibilityCatalogResolver.d.ts +100 -4
- package/dist/services/AccessibilityCatalogResolver.js +183 -58
- package/dist/services/SSMLExtractor.js +28 -18
- package/dist/services/TTSService.d.ts +25 -0
- package/dist/services/TTSService.js +241 -45
- package/dist/services/ToolRegistry.d.ts +218 -8
- package/dist/services/ToolRegistry.js +124 -8
- package/dist/services/ToolkitCoordinator.d.ts +25 -3
- package/dist/services/ToolkitCoordinator.js +47 -6
- package/dist/services/catalog-media.d.ts +25 -0
- package/dist/services/catalog-media.js +101 -0
- package/dist/services/createDefaultToolRegistry.d.ts +25 -58
- package/dist/services/createDefaultToolRegistry.js +24 -104
- package/dist/services/defaultPersonalNeedsProfile.d.ts +20 -2
- package/dist/services/defaultPersonalNeedsProfile.js +21 -19
- package/dist/services/interfaces.d.ts +29 -2
- package/dist/services/pnp-standard-features.d.ts +1 -1
- package/dist/services/spoken-audio-cards.d.ts +54 -0
- package/dist/services/spoken-audio-cards.js +66 -0
- package/dist/services/tool-config-defaults.d.ts +7 -23
- package/dist/services/tool-config-defaults.js +7 -46
- package/dist/services/tool-config-validation.d.ts +1 -1
- package/dist/services/tool-config-validation.js +44 -4
- package/dist/services/tts/browser-provider.js +2 -1
- package/dist/services/tts/math-aware-text-processing.js +3 -3
- package/dist/services/tts/text-processing.d.ts +51 -0
- package/dist/services/tts/text-processing.js +117 -1
- package/dist/services/tts-runtime-config.js +7 -2
- package/dist/tools/internal.d.ts +34 -0
- package/dist/tools/internal.js +33 -0
- package/dist/tools/tool-tag-map.d.ts +15 -3
- package/dist/tools/tool-tag-map.js +21 -18
- package/package.json +14 -10
- package/dist/components/chunks/ItemToolBar-843902tp.js +0 -51
- package/dist/components/chunks/ItemToolBar-84nv78dy.js +0 -22
- package/dist/tools/registrations/accessibility-tools.d.ts +0 -34
- package/dist/tools/registrations/accessibility-tools.js +0 -217
- package/dist/tools/registrations/calculator.d.ts +0 -20
- package/dist/tools/registrations/calculator.js +0 -228
- package/dist/tools/registrations/interaction-tools.d.ts +0 -27
- package/dist/tools/registrations/interaction-tools.js +0 -143
- package/dist/tools/registrations/measurement-tools.d.ts +0 -24
- package/dist/tools/registrations/measurement-tools.js +0 -130
- package/dist/tools/registrations/subject-specific-tools.d.ts +0 -27
- package/dist/tools/registrations/subject-specific-tools.js +0 -158
- package/dist/tools/registrations/tts.d.ts +0 -21
- package/dist/tools/registrations/tts.js +0 -184
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccessibilityCatalog } from "@pie-players/pie-players-shared/types";
|
|
1
|
+
import type { AccessibilityCatalog, CatalogCard, CatalogCardPayload } from "@pie-players/pie-players-shared/types";
|
|
2
2
|
export type CatalogOwnerKind = "global" | "passage" | "itemModel";
|
|
3
3
|
export interface CatalogOwnerContext {
|
|
4
4
|
ownerKind: CatalogOwnerKind;
|
|
@@ -10,10 +10,50 @@ export interface CatalogOwnerContext {
|
|
|
10
10
|
modelId?: string;
|
|
11
11
|
}
|
|
12
12
|
export type CatalogLookupContext = CatalogOwnerContext;
|
|
13
|
+
/** What changed in the resolver's catalog set. */
|
|
14
|
+
export type CatalogChangeReason = "scoped-registered" | "scoped-removed" | "item-added" | "item-cleared";
|
|
15
|
+
/**
|
|
16
|
+
* Emitted after the resolver's catalog set changes.
|
|
17
|
+
*
|
|
18
|
+
* Carries no resolved cards on purpose — a listener re-queries with its own
|
|
19
|
+
* lookup context and options, the same way `ToolPolicyChangeEvent` leaves the
|
|
20
|
+
* new decision to `decideToolPolicy`. `context` is present for the scoped
|
|
21
|
+
* reasons, so a listener can cheaply ignore owners it does not render.
|
|
22
|
+
*/
|
|
23
|
+
export interface CatalogChangeEvent {
|
|
24
|
+
reason: CatalogChangeReason;
|
|
25
|
+
context?: CatalogOwnerContext;
|
|
26
|
+
}
|
|
27
|
+
export type CatalogChangeListener = (event: CatalogChangeEvent) => void;
|
|
13
28
|
/**
|
|
14
29
|
* Supported accessibility catalog types from QTI 3.0 / APIP
|
|
15
30
|
*/
|
|
16
|
-
export type CatalogType = "spoken" | "sign-language" | "braille" | "tactile" | "simplified-language" | "audio-description" | "extended-description" | string;
|
|
31
|
+
export type CatalogType = "spoken" | "sign-language" | "transcript" | "braille" | "tactile" | "simplified-language" | "audio-description" | "extended-description" | string;
|
|
32
|
+
/**
|
|
33
|
+
* The catalog types PIE names, plus the rule for the ones it does not.
|
|
34
|
+
*
|
|
35
|
+
* The type above stays open on purpose: QTI treats the support vocabulary as
|
|
36
|
+
* extensible, and closing it here would reject content PIE has no reason to
|
|
37
|
+
* reject and could not usefully validate anyway, since catalogs arrive as
|
|
38
|
+
* authored JSON rather than through this type. Keeping it open cost something
|
|
39
|
+
* though — the named literals were documentation only, so a card written
|
|
40
|
+
* `"spokn"` was a perfectly valid `CatalogType` that no reader would ever ask
|
|
41
|
+
* for, and it failed by being invisible rather than by failing. That is what
|
|
42
|
+
* `isKnownCatalogType` and the warnings below are for: the openness stays, the
|
|
43
|
+
* silence does not.
|
|
44
|
+
*/
|
|
45
|
+
export declare const KNOWN_CATALOG_TYPES: ReadonlySet<string>;
|
|
46
|
+
export declare function isKnownCatalogType(type: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Which of a card's two content slots it fills.
|
|
49
|
+
*
|
|
50
|
+
* Not a new field on the card and not a second discriminant: the card already
|
|
51
|
+
* says which form it is by carrying `content` or `payload`, and the
|
|
52
|
+
* exactly-one-of invariant is what makes that unambiguous. This names the
|
|
53
|
+
* distinction so a lookup can ask for one.
|
|
54
|
+
*/
|
|
55
|
+
export type CatalogCardForm = "content" | "payload";
|
|
56
|
+
export declare const catalogCardForm: (card: CatalogCard) => CatalogCardForm;
|
|
17
57
|
/**
|
|
18
58
|
* Lookup options for catalog resolution
|
|
19
59
|
*/
|
|
@@ -26,6 +66,23 @@ export interface CatalogLookupOptions {
|
|
|
26
66
|
useFallback?: boolean;
|
|
27
67
|
/** Scope used to resolve local catalog idrefs for rendered content */
|
|
28
68
|
context?: CatalogLookupContext;
|
|
69
|
+
/**
|
|
70
|
+
* Preferred content form, when one catalog type legitimately has both on the
|
|
71
|
+
* same node.
|
|
72
|
+
*
|
|
73
|
+
* The case this exists for is a `spoken` node carrying both a reading script
|
|
74
|
+
* and a recording of it — which is APIP's authoring pattern and what QTI 3's
|
|
75
|
+
* migration guidance tells you to keep, the script doubling as the source the
|
|
76
|
+
* audio was generated from and as the fallback when it cannot play. Before
|
|
77
|
+
* this, both resolution rungs took the first card matching type and language,
|
|
78
|
+
* so whichever of the two was written second in the array was unreachable and
|
|
79
|
+
* nothing said so.
|
|
80
|
+
*
|
|
81
|
+
* A preference, not a filter: if the requested form is not present, the other
|
|
82
|
+
* one is still returned. Callers that cannot use a form must check what they
|
|
83
|
+
* got, exactly as they already must for a card of a type they did not expect.
|
|
84
|
+
*/
|
|
85
|
+
form?: CatalogCardForm;
|
|
29
86
|
}
|
|
30
87
|
/**
|
|
31
88
|
* Resolved catalog result
|
|
@@ -37,8 +94,16 @@ export interface ResolvedCatalog {
|
|
|
37
94
|
type: CatalogType;
|
|
38
95
|
/** The language code */
|
|
39
96
|
language?: string;
|
|
40
|
-
/**
|
|
41
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The string form (SSML, HTML, or plain text). Absent on cards whose content
|
|
99
|
+
* is structured; those carry `payload` instead.
|
|
100
|
+
*/
|
|
101
|
+
content?: string;
|
|
102
|
+
/**
|
|
103
|
+
* The structured form, for catalog types a string cannot express — a signing
|
|
104
|
+
* video's sources, poster, and time range. Interpreted according to `type`.
|
|
105
|
+
*/
|
|
106
|
+
payload?: CatalogCardPayload;
|
|
42
107
|
/** Source of the catalog (assessment or item) */
|
|
43
108
|
source: "assessment" | "item";
|
|
44
109
|
}
|
|
@@ -89,11 +154,35 @@ export declare class AccessibilityCatalogResolver {
|
|
|
89
154
|
private scopedCatalogs;
|
|
90
155
|
private defaultLanguage;
|
|
91
156
|
private sanitizedSpokenCache;
|
|
157
|
+
private catalogChangeListeners;
|
|
92
158
|
constructor(assessmentCatalogs?: AccessibilityCatalog[], defaultLanguage?: string);
|
|
93
159
|
/**
|
|
94
160
|
* Set the default language for fallback resolution
|
|
95
161
|
*/
|
|
96
162
|
setDefaultLanguage(language: string): void;
|
|
163
|
+
/**
|
|
164
|
+
* Subscribe to catalog registrations and removals.
|
|
165
|
+
*
|
|
166
|
+
* Readers that render a catalog — as opposed to TTS, which resolves by DOM
|
|
167
|
+
* lookup at the moment it speaks — have to compute "is there a card for this
|
|
168
|
+
* item" before the catalogs exist: registration is driven by an item shell's
|
|
169
|
+
* mount event, so a card that renders alongside the item legitimately looks
|
|
170
|
+
* too early. Without a signal the only options are polling on a deadline (no
|
|
171
|
+
* budget is right: too short strands the accommodation, too long is a visible
|
|
172
|
+
* delay) or missing the content silently.
|
|
173
|
+
*
|
|
174
|
+
* Same contract as `ToolPolicyEngine.onPolicyChange`, deliberately: a listener
|
|
175
|
+
* plus an unsubscribe, an event that names the `reason` and carries no
|
|
176
|
+
* resolved state, and subscriber errors swallowed so one bad listener cannot
|
|
177
|
+
* break registration. Listeners re-query rather than consuming a payload,
|
|
178
|
+
* which is what keeps the resolver free of assumptions about who is reading.
|
|
179
|
+
*
|
|
180
|
+
* Fires after the mutation, so a listener that re-queries sees the new state.
|
|
181
|
+
*
|
|
182
|
+
* @returns Unsubscribe function
|
|
183
|
+
*/
|
|
184
|
+
onCatalogsChange(listener: CatalogChangeListener): () => void;
|
|
185
|
+
private emitCatalogsChange;
|
|
97
186
|
/**
|
|
98
187
|
* Get the default language
|
|
99
188
|
*/
|
|
@@ -134,6 +223,13 @@ export declare class AccessibilityCatalogResolver {
|
|
|
134
223
|
private findMatchingCard;
|
|
135
224
|
/**
|
|
136
225
|
* Get all available alternatives for a catalog identifier
|
|
226
|
+
*
|
|
227
|
+
* Every card goes through `resolveCard`, the same projection `getAlternative`
|
|
228
|
+
* uses, so enumeration cannot describe a card differently from the resolution
|
|
229
|
+
* that renders it. It was hand-rolled here once and drifted immediately: the
|
|
230
|
+
* `signLanguage` alias was folded in on the resolution path only, so a card
|
|
231
|
+
* that arrived under the alias rendered correctly and was still reported as
|
|
232
|
+
* carrying no payload by anything asking what alternates exist.
|
|
137
233
|
*/
|
|
138
234
|
getAllAlternatives(catalogId: string): ResolvedCatalog[];
|
|
139
235
|
/**
|
|
@@ -1,4 +1,57 @@
|
|
|
1
1
|
import { sanitizeSsmlString } from "./SSMLExtractor.js";
|
|
2
|
+
/**
|
|
3
|
+
* The catalog types PIE names, plus the rule for the ones it does not.
|
|
4
|
+
*
|
|
5
|
+
* The type above stays open on purpose: QTI treats the support vocabulary as
|
|
6
|
+
* extensible, and closing it here would reject content PIE has no reason to
|
|
7
|
+
* reject and could not usefully validate anyway, since catalogs arrive as
|
|
8
|
+
* authored JSON rather than through this type. Keeping it open cost something
|
|
9
|
+
* though — the named literals were documentation only, so a card written
|
|
10
|
+
* `"spokn"` was a perfectly valid `CatalogType` that no reader would ever ask
|
|
11
|
+
* for, and it failed by being invisible rather than by failing. That is what
|
|
12
|
+
* `isKnownCatalogType` and the warnings below are for: the openness stays, the
|
|
13
|
+
* silence does not.
|
|
14
|
+
*/
|
|
15
|
+
export const KNOWN_CATALOG_TYPES = new Set([
|
|
16
|
+
"spoken",
|
|
17
|
+
"sign-language",
|
|
18
|
+
"transcript",
|
|
19
|
+
"braille",
|
|
20
|
+
"tactile",
|
|
21
|
+
"simplified-language",
|
|
22
|
+
"audio-description",
|
|
23
|
+
"extended-description",
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* QTI reserves an `ext:` prefix for vendor extensions, and pairs such a card
|
|
27
|
+
* with a standard one on the same node in its own examples. A prefixed token is
|
|
28
|
+
* therefore a deliberate extension rather than a typo, and passes without
|
|
29
|
+
* comment even though PIE ships no consumer for it.
|
|
30
|
+
*/
|
|
31
|
+
const EXTENSION_TYPE_PREFIX = "ext:";
|
|
32
|
+
export function isKnownCatalogType(type) {
|
|
33
|
+
if (KNOWN_CATALOG_TYPES.has(type))
|
|
34
|
+
return true;
|
|
35
|
+
return (type.startsWith(EXTENSION_TYPE_PREFIX) &&
|
|
36
|
+
type.length > EXTENSION_TYPE_PREFIX.length);
|
|
37
|
+
}
|
|
38
|
+
// One report per distinct token per side, because the interesting information is
|
|
39
|
+
// "this token is not a thing", and repeating it per card or per lookup would bury
|
|
40
|
+
// it under itself.
|
|
41
|
+
const reportedUnknownTypes = new Set();
|
|
42
|
+
function reportUnknownCatalogType(type, side, where) {
|
|
43
|
+
const key = `${side}|${type}`;
|
|
44
|
+
if (reportedUnknownTypes.has(key))
|
|
45
|
+
return;
|
|
46
|
+
reportedUnknownTypes.add(key);
|
|
47
|
+
const known = `${Array.from(KNOWN_CATALOG_TYPES).join(", ")}, or an "${EXTENSION_TYPE_PREFIX}" prefixed vendor extension`;
|
|
48
|
+
if (side === "card") {
|
|
49
|
+
console.warn(`[AccessibilityCatalogResolver] catalog "${where}" has a card of unknown type "${type}"; it is stored but no reader asks for that type, so the alternate will never be shown. Expected one of: ${known}.`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
console.warn(`[AccessibilityCatalogResolver] lookup for unknown catalog type "${type}" on "${where}" cannot match any card. Expected one of: ${known}.`);
|
|
53
|
+
}
|
|
54
|
+
export const catalogCardForm = (card) => card.payload !== undefined ? "payload" : "content";
|
|
2
55
|
/**
|
|
3
56
|
* Accessibility Catalog Resolver Service
|
|
4
57
|
*
|
|
@@ -39,6 +92,7 @@ export class AccessibilityCatalogResolver {
|
|
|
39
92
|
// provider regardless of where indexing happened; the cache keeps it to one
|
|
40
93
|
// pass per unique string (sanitizeSsmlString is idempotent).
|
|
41
94
|
sanitizedSpokenCache = new Map();
|
|
95
|
+
catalogChangeListeners = new Set();
|
|
42
96
|
constructor(assessmentCatalogs, defaultLanguage = "en-US") {
|
|
43
97
|
this.defaultLanguage = defaultLanguage;
|
|
44
98
|
this.indexCatalogs(assessmentCatalogs ?? [], "assessment");
|
|
@@ -49,6 +103,46 @@ export class AccessibilityCatalogResolver {
|
|
|
49
103
|
setDefaultLanguage(language) {
|
|
50
104
|
this.defaultLanguage = language;
|
|
51
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Subscribe to catalog registrations and removals.
|
|
108
|
+
*
|
|
109
|
+
* Readers that render a catalog — as opposed to TTS, which resolves by DOM
|
|
110
|
+
* lookup at the moment it speaks — have to compute "is there a card for this
|
|
111
|
+
* item" before the catalogs exist: registration is driven by an item shell's
|
|
112
|
+
* mount event, so a card that renders alongside the item legitimately looks
|
|
113
|
+
* too early. Without a signal the only options are polling on a deadline (no
|
|
114
|
+
* budget is right: too short strands the accommodation, too long is a visible
|
|
115
|
+
* delay) or missing the content silently.
|
|
116
|
+
*
|
|
117
|
+
* Same contract as `ToolPolicyEngine.onPolicyChange`, deliberately: a listener
|
|
118
|
+
* plus an unsubscribe, an event that names the `reason` and carries no
|
|
119
|
+
* resolved state, and subscriber errors swallowed so one bad listener cannot
|
|
120
|
+
* break registration. Listeners re-query rather than consuming a payload,
|
|
121
|
+
* which is what keeps the resolver free of assumptions about who is reading.
|
|
122
|
+
*
|
|
123
|
+
* Fires after the mutation, so a listener that re-queries sees the new state.
|
|
124
|
+
*
|
|
125
|
+
* @returns Unsubscribe function
|
|
126
|
+
*/
|
|
127
|
+
onCatalogsChange(listener) {
|
|
128
|
+
this.catalogChangeListeners.add(listener);
|
|
129
|
+
return () => {
|
|
130
|
+
this.catalogChangeListeners.delete(listener);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
emitCatalogsChange(event) {
|
|
134
|
+
// Iterated over a copy: a listener that unsubscribes itself (or another)
|
|
135
|
+
// while handling the event must not make the loop skip its neighbours.
|
|
136
|
+
for (const listener of Array.from(this.catalogChangeListeners)) {
|
|
137
|
+
try {
|
|
138
|
+
listener(event);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Subscriber errors must not break registration. Hosts that want
|
|
142
|
+
// error telemetry should wrap their listener.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
52
146
|
/**
|
|
53
147
|
* Get the default language
|
|
54
148
|
*/
|
|
@@ -89,6 +183,12 @@ export class AccessibilityCatalogResolver {
|
|
|
89
183
|
catalogs: scoped,
|
|
90
184
|
});
|
|
91
185
|
}
|
|
186
|
+
// Only when something was actually inserted: an all-duplicates call changes
|
|
187
|
+
// nothing, and waking every reader to re-resolve for that would make the
|
|
188
|
+
// signal untrustworthy.
|
|
189
|
+
if (insertedIds.length > 0) {
|
|
190
|
+
this.emitCatalogsChange({ reason: "scoped-registered", context });
|
|
191
|
+
}
|
|
92
192
|
return () => {
|
|
93
193
|
const current = this.scopedCatalogs.get(key);
|
|
94
194
|
if (current?.catalogs !== scoped)
|
|
@@ -99,6 +199,9 @@ export class AccessibilityCatalogResolver {
|
|
|
99
199
|
if (current.catalogs.size === 0) {
|
|
100
200
|
this.scopedCatalogs.delete(key);
|
|
101
201
|
}
|
|
202
|
+
if (insertedIds.length > 0) {
|
|
203
|
+
this.emitCatalogsChange({ reason: "scoped-removed", context });
|
|
204
|
+
}
|
|
102
205
|
};
|
|
103
206
|
}
|
|
104
207
|
/**
|
|
@@ -108,12 +211,16 @@ export class AccessibilityCatalogResolver {
|
|
|
108
211
|
if (!catalogs || catalogs.length === 0)
|
|
109
212
|
return;
|
|
110
213
|
this.indexCatalogs(catalogs, "item");
|
|
214
|
+
this.emitCatalogsChange({ reason: "item-added" });
|
|
111
215
|
}
|
|
112
216
|
/**
|
|
113
217
|
* Clear item-level catalogs (called when leaving an item)
|
|
114
218
|
*/
|
|
115
219
|
clearItemCatalogs() {
|
|
220
|
+
if (this.itemCatalogs.size === 0)
|
|
221
|
+
return;
|
|
116
222
|
this.itemCatalogs.clear();
|
|
223
|
+
this.emitCatalogsChange({ reason: "item-cleared" });
|
|
117
224
|
}
|
|
118
225
|
/**
|
|
119
226
|
* Check if a catalog exists (checks both assessment and item levels)
|
|
@@ -129,6 +236,11 @@ export class AccessibilityCatalogResolver {
|
|
|
129
236
|
* Priority: Item-level catalogs take precedence over assessment-level
|
|
130
237
|
*/
|
|
131
238
|
getAlternative(catalogId, options) {
|
|
239
|
+
// A typo on this side is as silent as one on a card: the lookup simply finds
|
|
240
|
+
// nothing and the caller reads that as "no alternate authored".
|
|
241
|
+
if (!isKnownCatalogType(options.type)) {
|
|
242
|
+
reportUnknownCatalogType(options.type, "lookup", catalogId);
|
|
243
|
+
}
|
|
132
244
|
const scopedCatalog = options.context
|
|
133
245
|
? this.scopedCatalogs
|
|
134
246
|
.get(this.getOwnerKey(options.context))
|
|
@@ -172,9 +284,10 @@ export class AccessibilityCatalogResolver {
|
|
|
172
284
|
catalogId,
|
|
173
285
|
type: card.catalog,
|
|
174
286
|
language: card.language,
|
|
175
|
-
content: card.catalog === "spoken"
|
|
287
|
+
content: card.catalog === "spoken" && card.content !== undefined
|
|
176
288
|
? this.ensureSpokenSanitized(card.content)
|
|
177
289
|
: card.content,
|
|
290
|
+
payload: card.payload,
|
|
178
291
|
source,
|
|
179
292
|
};
|
|
180
293
|
}
|
|
@@ -240,92 +353,104 @@ export class AccessibilityCatalogResolver {
|
|
|
240
353
|
}
|
|
241
354
|
return true;
|
|
242
355
|
}
|
|
356
|
+
// The single funnel every registration path runs through — the constructor and
|
|
357
|
+
// `addItemCatalogs` by way of `indexCatalogs`, and `registerCatalogs`
|
|
358
|
+
// directly — which is why the unknown-type report lives here rather than at
|
|
359
|
+
// each entry point.
|
|
243
360
|
sanitizeCatalogs(catalogs) {
|
|
244
361
|
return catalogs.map((catalog) => ({
|
|
245
362
|
...catalog,
|
|
246
|
-
cards: catalog.cards.map((card) =>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
363
|
+
cards: catalog.cards.map((card) => {
|
|
364
|
+
if (!isKnownCatalogType(card.catalog)) {
|
|
365
|
+
reportUnknownCatalogType(card.catalog, "card", catalog.identifier);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
...card,
|
|
369
|
+
content: card.catalog === "spoken" && card.content !== undefined
|
|
370
|
+
? sanitizeSsmlString(card.content)
|
|
371
|
+
: card.content,
|
|
372
|
+
};
|
|
373
|
+
}),
|
|
252
374
|
}));
|
|
253
375
|
}
|
|
254
376
|
/**
|
|
255
377
|
* Find a matching catalog card based on lookup options
|
|
256
378
|
*/
|
|
257
379
|
findMatchingCard(catalog, options) {
|
|
258
|
-
const { type, language, useFallback = true } = options;
|
|
259
|
-
//
|
|
380
|
+
const { type, language, useFallback = true, form } = options;
|
|
381
|
+
// Language rungs, most specific first: requested language, then the default
|
|
382
|
+
// language, then any. Unchanged — only what happens *within* a rung is new.
|
|
383
|
+
const languageRungs = [];
|
|
260
384
|
if (language) {
|
|
261
|
-
|
|
262
|
-
if (exactMatch)
|
|
263
|
-
return exactMatch;
|
|
385
|
+
languageRungs.push((card) => card.language === language);
|
|
264
386
|
}
|
|
265
|
-
// Try type match with default language (if fallback enabled)
|
|
266
387
|
if (useFallback) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
if (
|
|
273
|
-
|
|
388
|
+
languageRungs.push((card) => card.language === this.defaultLanguage);
|
|
389
|
+
languageRungs.push(() => true);
|
|
390
|
+
}
|
|
391
|
+
for (const matchesLanguage of languageRungs) {
|
|
392
|
+
const candidates = catalog.cards.filter((card) => card.catalog === type && matchesLanguage(card));
|
|
393
|
+
if (candidates.length === 0)
|
|
394
|
+
continue;
|
|
395
|
+
// Form is preferred inside a language rung and never across them: a
|
|
396
|
+
// recording in the requested language beats a script in that language,
|
|
397
|
+
// but a script in the requested language beats a recording in another
|
|
398
|
+
// one. Getting this backwards would answer a Spanish lookup with English
|
|
399
|
+
// audio, which is worse than answering it with Spanish text.
|
|
400
|
+
if (form) {
|
|
401
|
+
const preferred = candidates.find((card) => catalogCardForm(card) === form);
|
|
402
|
+
if (preferred)
|
|
403
|
+
return preferred;
|
|
404
|
+
}
|
|
405
|
+
// No preference expressed, or the preferred form is absent: first match,
|
|
406
|
+
// which is what every caller got before form preference existed.
|
|
407
|
+
return candidates[0];
|
|
274
408
|
}
|
|
275
409
|
return null;
|
|
276
410
|
}
|
|
277
411
|
/**
|
|
278
412
|
* Get all available alternatives for a catalog identifier
|
|
413
|
+
*
|
|
414
|
+
* Every card goes through `resolveCard`, the same projection `getAlternative`
|
|
415
|
+
* uses, so enumeration cannot describe a card differently from the resolution
|
|
416
|
+
* that renders it. It was hand-rolled here once and drifted immediately: the
|
|
417
|
+
* `signLanguage` alias was folded in on the resolution path only, so a card
|
|
418
|
+
* that arrived under the alias rendered correctly and was still reported as
|
|
419
|
+
* carrying no payload by anything asking what alternates exist.
|
|
279
420
|
*/
|
|
280
421
|
getAllAlternatives(catalogId) {
|
|
281
422
|
const results = [];
|
|
282
|
-
//
|
|
423
|
+
// Type, language *and* form: one catalog identifier legitimately carries
|
|
424
|
+
// several cards of the same type in different languages, and also a script
|
|
425
|
+
// and a recording of the same type in the *same* language. Keying on type
|
|
426
|
+
// and language alone dropped the second of those on the floor, so anything
|
|
427
|
+
// asking what alternates exist under-reported them.
|
|
428
|
+
const claimed = new Set();
|
|
429
|
+
const add = (card, source) => {
|
|
430
|
+
const key = `${card.catalog}|${card.language ?? ""}|${catalogCardForm(card)}`;
|
|
431
|
+
if (claimed.has(key))
|
|
432
|
+
return;
|
|
433
|
+
claimed.add(key);
|
|
434
|
+
results.push(this.resolveCard(catalogId, card, source));
|
|
435
|
+
};
|
|
436
|
+
// Item-level first, which is also the precedence `getAlternative` applies.
|
|
283
437
|
const itemCatalog = this.itemCatalogs.get(catalogId);
|
|
284
438
|
if (itemCatalog) {
|
|
285
|
-
for (const card of itemCatalog.cards)
|
|
286
|
-
|
|
287
|
-
catalogId,
|
|
288
|
-
type: card.catalog,
|
|
289
|
-
language: card.language,
|
|
290
|
-
content: card.content,
|
|
291
|
-
source: "item",
|
|
292
|
-
});
|
|
293
|
-
}
|
|
439
|
+
for (const card of itemCatalog.cards)
|
|
440
|
+
add(card, "item");
|
|
294
441
|
}
|
|
295
|
-
// Add assessment-level alternatives (if not already provided by item)
|
|
296
442
|
const assessmentCatalog = this.assessmentCatalogs.get(catalogId);
|
|
297
443
|
if (assessmentCatalog) {
|
|
298
|
-
for (const card of assessmentCatalog.cards)
|
|
299
|
-
|
|
300
|
-
const exists = results.some((r) => r.type === card.catalog && r.language === card.language);
|
|
301
|
-
if (!exists) {
|
|
302
|
-
results.push({
|
|
303
|
-
catalogId,
|
|
304
|
-
type: card.catalog,
|
|
305
|
-
language: card.language,
|
|
306
|
-
content: card.content,
|
|
307
|
-
source: "assessment",
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
}
|
|
444
|
+
for (const card of assessmentCatalog.cards)
|
|
445
|
+
add(card, "assessment");
|
|
311
446
|
}
|
|
312
|
-
//
|
|
313
|
-
//
|
|
447
|
+
// Scoped (context-registered) alternatives resolve as "item" in
|
|
448
|
+
// `getAlternative`, so report them the same way here.
|
|
314
449
|
for (const [id, catalog] of this.scopedCatalogEntries()) {
|
|
315
450
|
if (id !== catalogId)
|
|
316
451
|
continue;
|
|
317
|
-
for (const card of catalog.cards)
|
|
318
|
-
|
|
319
|
-
if (!exists) {
|
|
320
|
-
results.push({
|
|
321
|
-
catalogId,
|
|
322
|
-
type: card.catalog,
|
|
323
|
-
language: card.language,
|
|
324
|
-
content: card.content,
|
|
325
|
-
source: "item",
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
}
|
|
452
|
+
for (const card of catalog.cards)
|
|
453
|
+
add(card, "item");
|
|
329
454
|
}
|
|
330
455
|
return results;
|
|
331
456
|
}
|
|
@@ -377,26 +377,36 @@ export class SSMLExtractor {
|
|
|
377
377
|
const language = speakEl.getAttribute("xml:lang") ||
|
|
378
378
|
speakEl.getAttribute("lang") ||
|
|
379
379
|
"en-US";
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
380
|
+
// The docking node is the element the author already wrote around
|
|
381
|
+
// the visible content this SSML speaks. Nothing is synthesized to
|
|
382
|
+
// stand in for it: a `<speak>` with no element around it has no
|
|
383
|
+
// content node to be an alternate *for*, and inventing one would
|
|
384
|
+
// mean inventing visible content too.
|
|
385
|
+
const wrapper = speakEl.parentElement && speakEl.parentElement.tagName !== "BODY"
|
|
386
|
+
? speakEl.parentElement
|
|
387
|
+
: null;
|
|
388
|
+
// Visible content never keeps the SSML, docked or not.
|
|
389
|
+
speakEl.remove();
|
|
390
|
+
// Tag the docking node with the QTI-style catalog reference so the
|
|
391
|
+
// runtime can resolve this region's spoken content. Same attribute
|
|
392
|
+
// authored content uses (`data-catalog-idref`) — one canonical name,
|
|
393
|
+
// which is also why an existing value is never replaced: the
|
|
394
|
+
// reference names a whole card array, so overwriting it to win the
|
|
395
|
+
// spoken type would take that node's braille, simplified-language and
|
|
396
|
+
// sign-language cards down with it.
|
|
397
|
+
//
|
|
398
|
+
// Either way the catalog is still emitted, so a consumer resolving
|
|
399
|
+
// through the item's catalog set finds it. What an undocked catalog
|
|
400
|
+
// loses is DOM lookup, which is how TTS resolves — a content problem
|
|
401
|
+
// only the author can fix, so say so rather than failing silently.
|
|
402
|
+
const existingIdRef = wrapper?.getAttribute("data-catalog-idref");
|
|
403
|
+
if (!wrapper) {
|
|
404
|
+
console.warn(`[SSMLExtractor] "${idPrefix}" has a <speak> with no element around it, so catalog "${catalogId}" has no content node to dock to and TTS will not find it. Wrap the visible content this SSML speaks in an element.`);
|
|
391
405
|
}
|
|
392
|
-
else {
|
|
393
|
-
|
|
394
|
-
speakEl.remove();
|
|
406
|
+
else if (existingIdRef) {
|
|
407
|
+
console.warn(`[SSMLExtractor] "${idPrefix}" has an inline <speak> inside a node already docked to catalog "${existingIdRef}"; keeping the existing reference, so catalog "${catalogId}" is not reachable from this node. Give the <speak> its own wrapper element, or author the SSML as a "spoken" card on "${existingIdRef}".`);
|
|
395
408
|
}
|
|
396
|
-
|
|
397
|
-
// runtime can resolve this region's spoken content. Same attribute
|
|
398
|
-
// authored content uses (`data-catalog-idref`) — one canonical name.
|
|
399
|
-
if (wrapper) {
|
|
409
|
+
else {
|
|
400
410
|
wrapper.setAttribute("data-catalog-idref", catalogId);
|
|
401
411
|
}
|
|
402
412
|
// Create catalog entry
|
|
@@ -64,6 +64,7 @@ export declare class TTSService {
|
|
|
64
64
|
private activeWordBoundaryOffset;
|
|
65
65
|
private seekSegments;
|
|
66
66
|
private playbackChunks;
|
|
67
|
+
private activeRecordedAudio;
|
|
67
68
|
private sentenceHighlightSegments;
|
|
68
69
|
private currentSeekSegmentIndex;
|
|
69
70
|
private activeSentenceStartOffset;
|
|
@@ -108,6 +109,18 @@ export declare class TTSService {
|
|
|
108
109
|
* @param resolver AccessibilityCatalogResolver instance
|
|
109
110
|
*/
|
|
110
111
|
setCatalogResolver(resolver: AccessibilityCatalogResolver): void;
|
|
112
|
+
/**
|
|
113
|
+
* Whether a catalog holds spoken content this service could actually speak.
|
|
114
|
+
*
|
|
115
|
+
* Exists because `data-catalog-idref` is one attribute with several readers:
|
|
116
|
+
* a node can be docked to a catalog that carries only a signing card, and the
|
|
117
|
+
* nearest docked ancestor of a selection is therefore not necessarily the one
|
|
118
|
+
* holding its authored SSML. A caller resolving a selection climbs ancestors
|
|
119
|
+
* and asks this before settling on an id — see the TTS tool's
|
|
120
|
+
* `findSpokenCatalogId`. Without it, a signing card docked on an inner node
|
|
121
|
+
* silently shadows authored speech on an outer one.
|
|
122
|
+
*/
|
|
123
|
+
hasSpokenAlternate(catalogId: string, language?: string): boolean;
|
|
111
124
|
private getHighlightResolverRuntime;
|
|
112
125
|
private getNodeElement;
|
|
113
126
|
private isElementWithinScope;
|
|
@@ -193,6 +206,18 @@ export declare class TTSService {
|
|
|
193
206
|
private highlightRenderableActiveTarget;
|
|
194
207
|
private renderHighlightDecision;
|
|
195
208
|
private speakCatalogChunk;
|
|
209
|
+
/**
|
|
210
|
+
* Play a recorded spoken alternate, resolving when it finishes.
|
|
211
|
+
*
|
|
212
|
+
* Rejects if the clip cannot play, which is what routes playback to the
|
|
213
|
+
* chunk's `plainFallback` — the reading script — in `speakCatalogChunk`.
|
|
214
|
+
* Highlighting is the docked node as a block for the clip's duration: a
|
|
215
|
+
* recording emits no word boundaries, and inventing them from its duration
|
|
216
|
+
* would highlight the wrong words confidently rather than the right region
|
|
217
|
+
* vaguely.
|
|
218
|
+
*/
|
|
219
|
+
private playRecordedAudio;
|
|
220
|
+
private cancelRecordedAudio;
|
|
196
221
|
private speakCatalogChunkOnce;
|
|
197
222
|
private executeSpeakPlayback;
|
|
198
223
|
private clearHighlightsAndTracking;
|