@pie-players/pie-assessment-toolkit 0.3.75 → 1.0.0-beta.0

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.
Files changed (142) hide show
  1. package/README.md +449 -541
  2. package/custom-elements.json +1315 -0
  3. package/dist/attempt/TestSession.d.ts +0 -1
  4. package/dist/attempt/TestSession.js +10 -5
  5. package/dist/components/ItemScope.custom-element.js +1 -1
  6. package/dist/components/ItemToolBar.custom-element.js +1 -1
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +8 -8
  8. package/dist/components/SectionToolBar.custom-element.js +1 -1
  9. package/dist/components/chunks/{ItemToolBar-7tt7v2xp.js → ItemToolBar-3zrc4p3d.js} +4 -4
  10. package/dist/components/chunks/{ItemToolBar-jx38w99s.js → ItemToolBar-66hcwmfw.js} +2 -2
  11. package/dist/components/chunks/ItemToolBar-9gqhfxtn.js +40 -0
  12. package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
  13. package/dist/components/chunks/{ItemToolBar-97hx60z4.js → ItemToolBar-kkj69ptr.js} +1 -1
  14. package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
  15. package/dist/context/assessment-toolkit-context.d.ts +6 -0
  16. package/dist/index.d.ts +7 -7
  17. package/dist/index.js +4 -10
  18. package/dist/policy/core/PolicySource.d.ts +1 -1
  19. package/dist/policy/core/ToolPolicyEngine.d.ts +46 -7
  20. package/dist/policy/core/ToolPolicyEngine.js +73 -16
  21. package/dist/policy/core/compose-decision.d.ts +3 -3
  22. package/dist/policy/core/compose-decision.js +30 -30
  23. package/dist/policy/core/decision-types.d.ts +44 -32
  24. package/dist/policy/core/feature-decision.d.ts +17 -15
  25. package/dist/policy/core/feature-decision.js +11 -13
  26. package/dist/policy/core/pnp-policy-inputs.d.ts +5 -2
  27. package/dist/policy/core/pnp-policy-inputs.js +12 -32
  28. package/dist/policy/core/provenance.d.ts +1 -1
  29. package/dist/policy/core/tool-parameters.d.ts +11 -0
  30. package/dist/policy/core/tool-parameters.js +15 -0
  31. package/dist/policy/engine.d.ts +1 -1
  32. package/dist/policy/sources/PnpPolicySource.d.ts +14 -22
  33. package/dist/policy/sources/PnpPolicySource.js +30 -28
  34. package/dist/runtime/SectionControllerBinding.d.ts +6 -2
  35. package/dist/runtime/SectionControllerBinding.js +21 -15
  36. package/dist/runtime/adapter/subscriber-fanout.d.ts +0 -1
  37. package/dist/runtime/adapter/subscriber-fanout.js +1 -4
  38. package/dist/runtime/composition-emit-scheduler.d.ts +3 -3
  39. package/dist/runtime/composition-emit-scheduler.js +1 -1
  40. package/dist/runtime/content-region.d.ts +0 -4
  41. package/dist/runtime/content-region.js +2 -2
  42. package/dist/runtime/core/SectionEngineCore.d.ts +0 -2
  43. package/dist/runtime/core/SectionEngineCore.js +0 -4
  44. package/dist/runtime/core/cohort.d.ts +0 -5
  45. package/dist/runtime/core/cohort.js +0 -9
  46. package/dist/runtime/core/engine-output.d.ts +3 -2
  47. package/dist/runtime/core/engine-transition.js +3 -2
  48. package/dist/runtime/registration-events.d.ts +11 -14
  49. package/dist/runtime/runtime-id.d.ts +1 -1
  50. package/dist/runtime/runtime-id.js +1 -1
  51. package/dist/runtime/shell-scope.d.ts +4 -4
  52. package/dist/runtime/shell-scope.js +15 -9
  53. package/dist/runtime/tool-host-contract.d.ts +0 -4
  54. package/dist/runtime/tool-host-contract.js +1 -1
  55. package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
  56. package/dist/services/AccessibilityCatalogResolver.js +29 -42
  57. package/dist/services/ElementToolStateStore.d.ts +32 -61
  58. package/dist/services/ElementToolStateStore.js +37 -67
  59. package/dist/services/HighlightCoordinator.d.ts +9 -21
  60. package/dist/services/HighlightCoordinator.js +52 -80
  61. package/dist/services/RangeSerializer.d.ts +4 -35
  62. package/dist/services/RangeSerializer.js +4 -48
  63. package/dist/services/SSMLExtractor.d.ts +8 -31
  64. package/dist/services/SSMLExtractor.js +18 -40
  65. package/dist/services/TTSService.d.ts +49 -56
  66. package/dist/services/TTSService.js +308 -283
  67. package/dist/services/ToolCoordinator.d.ts +6 -49
  68. package/dist/services/ToolCoordinator.js +25 -61
  69. package/dist/services/ToolRegistry.d.ts +82 -80
  70. package/dist/services/ToolRegistry.js +91 -67
  71. package/dist/services/ToolkitCoordinator.d.ts +83 -81
  72. package/dist/services/ToolkitCoordinator.js +186 -174
  73. package/dist/services/framework-error.d.ts +30 -7
  74. package/dist/services/framework-error.js +10 -13
  75. package/dist/services/interfaces.d.ts +47 -71
  76. package/dist/services/section-controller-types.d.ts +7 -7
  77. package/dist/services/section-session-assignment.js +6 -8
  78. package/dist/services/spoken-audio-cards.d.ts +2 -4
  79. package/dist/services/spoken-audio-cards.js +2 -4
  80. package/dist/services/tool-config-validation.d.ts +5 -7
  81. package/dist/services/tool-config-validation.js +11 -26
  82. package/dist/services/tool-context.d.ts +7 -23
  83. package/dist/services/tool-context.js +99 -53
  84. package/dist/services/tool-failure.d.ts +30 -0
  85. package/dist/services/tool-failure.js +11 -0
  86. package/dist/services/tool-icons.d.ts +1 -2
  87. package/dist/services/tool-icons.js +2 -2
  88. package/dist/services/tool-instance-id.d.ts +8 -6
  89. package/dist/services/tool-instance-id.js +12 -18
  90. package/dist/services/tool-providers/TTSToolProvider.d.ts +6 -43
  91. package/dist/services/tool-providers/TTSToolProvider.js +7 -63
  92. package/dist/services/tool-providers/ToolProviderApi.d.ts +0 -7
  93. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +7 -42
  94. package/dist/services/tool-providers/ToolProviderRegistry.js +21 -63
  95. package/dist/services/tool-request.d.ts +4 -0
  96. package/dist/services/tool-request.js +7 -2
  97. package/dist/services/toolbar-decided-tools.d.ts +1 -0
  98. package/dist/services/toolbar-decided-tools.js +1 -0
  99. package/dist/services/toolbar-items.js +2 -5
  100. package/dist/services/tools-config-normalizer.d.ts +18 -9
  101. package/dist/services/tools-config-normalizer.js +25 -19
  102. package/dist/services/tts/browser-provider.d.ts +2 -5
  103. package/dist/services/tts/browser-provider.js +4 -20
  104. package/dist/services/tts/catalog-span-alignment.js +14 -7
  105. package/dist/services/tts/flat-tree.d.ts +0 -7
  106. package/dist/services/tts/flat-tree.js +2 -2
  107. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
  108. package/dist/services/tts/generated-speech/index.d.ts +1 -1
  109. package/dist/services/tts/generated-speech/index.js +1 -1
  110. package/dist/services/tts/generated-speech/types.d.ts +1 -1
  111. package/dist/services/tts/generated-speech/types.js +1 -1
  112. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +10 -12
  113. package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
  114. package/dist/services/tts/math-aware-text-processing.js +10 -29
  115. package/dist/services/tts/math-speech.d.ts +1 -1
  116. package/dist/services/tts/math-speech.js +3 -0
  117. package/dist/services/tts/mathml-sanitization.d.ts +7 -0
  118. package/dist/services/tts/mathml-sanitization.js +9 -1
  119. package/dist/services/tts/provider-options.d.ts +12 -1
  120. package/dist/services/tts/ssml/spoken-text.js +1 -1
  121. package/dist/services/tts/start-failure.d.ts +0 -7
  122. package/dist/services/tts/start-failure.js +1 -1
  123. package/dist/services/tts/text-segmentation.d.ts +5 -0
  124. package/dist/services/tts/text-segmentation.js +13 -10
  125. package/dist/services/tts-runtime-config.d.ts +32 -12
  126. package/dist/services/tts-runtime-config.js +14 -20
  127. package/dist/tools/content-capability-resolution.d.ts +2 -2
  128. package/dist/tools/registration.d.ts +6 -5
  129. package/dist/tools/registration.js +10 -9
  130. package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
  131. package/dist/tools/registrations/toolbar-registration-helpers.js +6 -0
  132. package/dist/tools/tool-surface-host.js +1 -8
  133. package/package.json +8 -6
  134. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +0 -2
  135. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +0 -40
  136. package/dist/components/chunks/sre-engine-jd8cc08x.js +0 -1
  137. package/dist/services/ContextVariableStore.d.ts +0 -113
  138. package/dist/services/ContextVariableStore.js +0 -206
  139. package/dist/services/tts-control-events.d.ts +0 -13
  140. package/dist/services/tts-control-events.js +0 -7
  141. package/dist/utils/logger.d.ts +0 -6
  142. package/dist/utils/logger.js +0 -10
@@ -3,8 +3,6 @@
3
3
  *
4
4
  * Text-to-speech provider using the browser's Web Speech API.
5
5
  * Works in all modern browsers that support SpeechSynthesis.
6
- *
7
- * Part of PIE Assessment Toolkit.
8
6
  */
9
7
  import { createPieLogger, isTtsDebugEnabled, } from "@pie-players/pie-players-shared/pie";
10
8
  import { extractSpokenText, isSsmlDocument } from "./ssml/spoken-text.js";
@@ -12,6 +10,9 @@ import { segmentSentences as segmentTextToSentences } from "./text-segmentation.
12
10
  const logger = createPieLogger("browser-tts-provider", isTtsDebugEnabled);
13
11
  const NATIVE_START_TIMEOUT_MS = 5_000;
14
12
  const VOICE_INVENTORY_TIMEOUT_MS = 2_000;
13
+ const MAX_CHUNK_LENGTH = 260;
14
+ // A longer reported word length falls back to the inferred one.
15
+ const MAX_TRUSTED_REPORTED_WORD_LENGTH = 80;
15
16
  // The provider options the toolkit and this provider read. Every other option,
16
17
  // and `voice`, belongs to the provider it was configured for: a Polly voice such
17
18
  // as "Joanna" resolves to no browser voice, and an unresolved voice fails speak.
@@ -192,14 +193,11 @@ const shouldAssignBrowserVoice = (voice) => !voice.default;
192
193
  */
193
194
  export class BrowserTTSProvider {
194
195
  providerId = "browser";
195
- providerName = "Browser Speech Synthesis";
196
- version = "1.0.0";
197
196
  async initialize(config) {
198
197
  // SSR guard
199
198
  if (typeof window === "undefined") {
200
199
  throw new Error("BrowserTTSProvider requires browser environment");
201
200
  }
202
- // Check browser support
203
201
  if (!("speechSynthesis" in window)) {
204
202
  throw new Error("Browser does not support Speech Synthesis API");
205
203
  }
@@ -212,14 +210,9 @@ export class BrowserTTSProvider {
212
210
  }
213
211
  getCapabilities() {
214
212
  return {
215
- supportsPause: true,
216
- supportsResume: true,
217
213
  supportsWordBoundary: true,
218
214
  // Boundary events depend on the voice: several network voices send none.
219
215
  defaultHighlightMode: "sentence",
220
- supportsVoiceSelection: true,
221
- supportsRateControl: true,
222
- supportsPitchControl: true,
223
216
  // The Web Speech API voices plain text only; SSML tags are read aloud.
224
217
  supportsSSML: false,
225
218
  };
@@ -351,7 +344,6 @@ class BrowserTTSProviderImpl {
351
344
  return runId === this.speakRunId;
352
345
  }
353
346
  splitIntoChunks(text) {
354
- const MAX_CHUNK_LENGTH = 260;
355
347
  if (text.length <= MAX_CHUNK_LENGTH) {
356
348
  return [{ text, offset: 0 }];
357
349
  }
@@ -408,7 +400,6 @@ class BrowserTTSProviderImpl {
408
400
  : undefined;
409
401
  return {
410
402
  useSentenceSegmenter: useSegmenter,
411
- useWordSegmenter: useSegmenter,
412
403
  locale,
413
404
  };
414
405
  }
@@ -558,7 +549,7 @@ class BrowserTTSProviderImpl {
558
549
  const inferredLength = this.inferWordLength(chunkText, charIndex);
559
550
  const wordLength = Number.isFinite(reportedLength) &&
560
551
  reportedLength > 0 &&
561
- reportedLength <= 80 &&
552
+ reportedLength <= MAX_TRUSTED_REPORTED_WORD_LENGTH &&
562
553
  charIndex + reportedLength <= chunkText.length
563
554
  ? reportedLength
564
555
  : inferredLength;
@@ -624,12 +615,6 @@ class BrowserTTSProviderImpl {
624
615
  this._isPaused = false;
625
616
  this.releaseHold();
626
617
  }
627
- isPlaying() {
628
- return this._isPlaying && !this._isPaused;
629
- }
630
- isPaused() {
631
- return this._isPaused;
632
- }
633
618
  /**
634
619
  * Update settings dynamically (rate, pitch, voice)
635
620
  * Changes take effect on the next speak() call
@@ -638,7 +623,6 @@ class BrowserTTSProviderImpl {
638
623
  if (!this.config) {
639
624
  this.config = {};
640
625
  }
641
- // Update config with new settings
642
626
  if (settings.rate !== undefined) {
643
627
  this.config.rate = settings.rate;
644
628
  }
@@ -1,5 +1,9 @@
1
1
  import { tokenizeAlignmentText, tokenizeSpeechSource, } from "./math-alignment/speech-tokenizer.js";
2
2
  import { normalizeTextForSpeech } from "./text-processing.js";
3
+ // A spoken token equal to a visible token; phrase targets score below it.
4
+ const EXACT_TOKEN_SCORE = 10;
5
+ // Below this, an anchored chunk highlights its region instead of spans.
6
+ const MIN_ANCHOR_SPAN_CONFIDENCE = 0.12;
3
7
  // LIMITATION (i18n): the phrase table is English, so multi-word operator
4
8
  // phrases ("plus or minus", "divided by") only align for English speech; other
5
9
  // locales degrade to coarse region highlighting.
@@ -29,7 +33,7 @@ const createCandidates = (spokenTokens, visibleTokens) => {
29
33
  spokenStartToken: spokenIndex,
30
34
  spokenEndToken: spokenIndex + 1,
31
35
  visibleToken: visibleIndex,
32
- score: 10,
36
+ score: EXACT_TOKEN_SCORE,
33
37
  });
34
38
  }
35
39
  }
@@ -57,6 +61,9 @@ const createCandidates = (spokenTokens, visibleTokens) => {
57
61
  }
58
62
  return candidates;
59
63
  };
64
+ // The highest-scoring chain of candidates monotonic in both spoken and visible
65
+ // order (O(n²) DP). A gap whose spoken and visible token counts differ by more
66
+ // than 2 pays the excess.
60
67
  const computeAnchors = (spokenTokens, visibleTokens) => {
61
68
  const candidates = createCandidates(spokenTokens, visibleTokens).sort((left, right) => spokenTokens[left.spokenStartToken].start -
62
69
  spokenTokens[right.spokenStartToken].start ||
@@ -116,7 +123,7 @@ const scoreConfidence = (anchors, spokenTokens, visibleTokens) => {
116
123
  Math.max(1, Math.min(spokenTokens.length, visibleTokens.length));
117
124
  const averageScore = anchors.reduce((total, anchor) => total + anchor.score, 0) /
118
125
  anchors.length /
119
- 10;
126
+ EXACT_TOKEN_SCORE;
120
127
  return Math.min(1, tokenCoverage * averageScore);
121
128
  };
122
129
  export const createCatalogSpanAlignment = (args) => {
@@ -130,17 +137,17 @@ export const createCatalogSpanAlignment = (args) => {
130
137
  const confidence = scoreConfidence(anchors, spokenTokens, visibleTokens);
131
138
  const isExact = speech.spokenText === visibleText && speech.spokenText.length > 0;
132
139
  const hasShortSingleExactAnchor = anchors.length === 1 &&
133
- anchors[0].score === 10 &&
140
+ anchors[0].score === EXACT_TOKEN_SCORE &&
134
141
  spokenTokens.length <= 2 &&
135
142
  visibleTokens.length <= 2;
143
+ const anchorsAreTrusted = (anchors.length >= 2 || hasShortSingleExactAnchor) &&
144
+ confidence >= MIN_ANCHOR_SPAN_CONFIDENCE;
136
145
  const playbackMode = speech.unsupportedSemantic
137
146
  ? "region-fallback"
138
147
  : isExact
139
148
  ? "exact-word"
140
- : anchors.length >= 2 || hasShortSingleExactAnchor
141
- ? confidence >= 0.12
142
- ? "anchor-span"
143
- : "region-fallback"
149
+ : anchorsAreTrusted
150
+ ? "anchor-span"
144
151
  : "region-fallback";
145
152
  return {
146
153
  speech,
@@ -17,8 +17,6 @@
17
17
  * global, at import or at call time.
18
18
  */
19
19
  export declare const isShadowRootNode: (node: Node | null | undefined) => node is ShadowRoot;
20
- /** Toolbars, tool elements and debug panels: chrome around content, never content. */
21
- export declare const isToolkitChromeElement: (element: Element) => boolean;
22
20
  /** `node`'s children in the flat tree, with skipped elements left out. */
23
21
  export declare const flatTreeChildNodes: (node: Node) => Node[];
24
22
  /**
@@ -35,11 +33,6 @@ export declare const flatTextContent: (root: Node) => string;
35
33
  /** Elements under `root` matching `selector`, in rendering order. */
36
34
  export declare const flatQuerySelectorAll: (root: Node, selector: string) => Element[];
37
35
  export declare const flatQuerySelector: (root: Node, selector: string) => Element | null;
38
- /**
39
- * `node`'s parent in the flat tree: the slot rendering it, else its parent, with
40
- * a shadow root standing for its host.
41
- */
42
- export declare const flatTreeParentNode: (node: Node) => Node | null;
43
36
  export declare const flatTreeParentElement: (node: Node) => Element | null;
44
37
  /** The nearest element at or above `node` in the flat tree that matches `selector`. */
45
38
  export declare const flatTreeClosest: (node: Node, selector: string) => Element | null;
@@ -35,7 +35,7 @@ export const isShadowRootNode = (node) => !!node &&
35
35
  node.nodeType === DOCUMENT_FRAGMENT_NODE &&
36
36
  node.host?.nodeType === ELEMENT_NODE;
37
37
  /** Toolbars, tool elements and debug panels: chrome around content, never content. */
38
- export const isToolkitChromeElement = (element) => {
38
+ const isToolkitChromeElement = (element) => {
39
39
  const tag = element.localName?.toLowerCase() || "";
40
40
  if (CHROME_TAGS.has(tag))
41
41
  return true;
@@ -143,7 +143,7 @@ export const flatQuerySelector = (root, selector) => {
143
143
  * `node`'s parent in the flat tree: the slot rendering it, else its parent, with
144
144
  * a shadow root standing for its host.
145
145
  */
146
- export const flatTreeParentNode = (node) => {
146
+ const flatTreeParentNode = (node) => {
147
147
  const slot = node.assignedSlot;
148
148
  if (slot)
149
149
  return slot;
@@ -3,8 +3,8 @@ import type { DomAnchor, GeneratedSpeechChunk } from "./types.js";
3
3
  export interface PlanToChunksOptions {
4
4
  /**
5
5
  * Playback format:
6
- * - "plain" (default): plain spoken text for every chunk (Phase A, the
7
- * stable generated-speech behavior).
6
+ * - "plain" (default): plain spoken text for every chunk (the stable
7
+ * generated-speech behavior).
8
8
  * - "ssml": math chunks with SRE SSML send that SSML to the provider (with
9
9
  * a plain fallback attached); prose chunks always stay plain.
10
10
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generated-speech module (PIE-623): runtime speech composition for math items
2
+ * Generated-speech module: runtime speech composition for math items
3
3
  * that ship without authored SSML / `accessibilityCatalogs`.
4
4
  *
5
5
  * Layering:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generated-speech module (PIE-623): runtime speech composition for math items
2
+ * Generated-speech module: runtime speech composition for math items
3
3
  * that ship without authored SSML / `accessibilityCatalogs`.
4
4
  *
5
5
  * Layering:
@@ -2,7 +2,7 @@
2
2
  * Generated-speech plan model (PURE core).
3
3
  *
4
4
  * This module is the speech-composition core for items that contain math but
5
- * ship without authored SSML / `accessibilityCatalogs` (PIE-623). It is
5
+ * ship without authored SSML / `accessibilityCatalogs`. It is
6
6
  * deliberately free of any imports from `TTSService`, the highlight renderer,
7
7
  * `HighlightCoordinator`, Svelte, or custom-element entrypoints
8
8
  * (`scripts/check-speech-composition-purity.mjs` enforces this). DOM primitive
@@ -2,7 +2,7 @@
2
2
  * Generated-speech plan model (PURE core).
3
3
  *
4
4
  * This module is the speech-composition core for items that contain math but
5
- * ship without authored SSML / `accessibilityCatalogs` (PIE-623). It is
5
+ * ship without authored SSML / `accessibilityCatalogs`. It is
6
6
  * deliberately free of any imports from `TTSService`, the highlight renderer,
7
7
  * `HighlightCoordinator`, Svelte, or custom-element entrypoints
8
8
  * (`scripts/check-speech-composition-purity.mjs` enforces this). DOM primitive
@@ -67,11 +67,10 @@ const TEXT_NODE = 3;
67
67
  // the last token — painting it would be a likely false positive, and "false
68
68
  // positives are worse than coarse highlighting." A single-text-node prose range
69
69
  // is kept: it underlines one token cleanly and usefully fills in any token the
70
- // math resolver missed. (The highlight coordinator no longer escalates word
71
- // ranges to the whole <math> / <mjx-container>, so this is now a precision
72
- // policy rather than flash-suppression — see HighlightCoordinator
73
- // WORD_FALLBACK_SELECTOR.)
74
- const wouldEscalateInsideTokenMath = (planned, target, candidates) => {
70
+ // math resolver missed. The word layer never escalates a range to the enclosing
71
+ // <math>/<mjx-container> (HighlightCoordinator WORD_FALLBACK_SELECTOR), so this
72
+ // is a precision policy.
73
+ const isMultiNodeRangeInsideTokenMath = (planned, target, candidates) => {
75
74
  if (!target || target.type !== "range")
76
75
  return false;
77
76
  const { startContainer, endContainer } = target.range;
@@ -128,8 +127,7 @@ export const createTTSHighlightPlan = (args) => {
128
127
  const planned = plannedChunksById.get(chunkId);
129
128
  if (!planned)
130
129
  return missingChunkDecision();
131
- const alignments = planned.chunk.mathAlignments;
132
- const soleElement = alignments.length === 1 ? alignments[0].element : null;
130
+ const soleElement = soleEquationElement(planned.chunk.mathAlignments);
133
131
  const capability = soleElement
134
132
  ? planned.mathCapabilityByElement.get(soleElement)
135
133
  : null;
@@ -164,9 +162,9 @@ export const createTTSHighlightPlan = (args) => {
164
162
  const normalized = normalizeBoundaryEvent(chunk, event);
165
163
  const proseTarget = resolveProseBoundaryTarget(chunk, normalized);
166
164
  const mathCandidates = mathCandidatesForTarget(proseTarget, chunk);
167
- const resolvedMathTarget = (!proseTarget || mathCandidates.length > 0
165
+ const resolvedMathTarget = !proseTarget || mathCandidates.length > 0
168
166
  ? resolveMathBoundaryTarget(chunk, normalized, event, mathCandidates, planned.renderedMathTargetResolver)
169
- : null) || null;
167
+ : null;
170
168
  const mathTarget = resolvedMathTarget?.quality === "semantic-token"
171
169
  ? resolvedMathTarget
172
170
  : (renderHiddenProseTarget(planned, proseTarget) ??
@@ -195,15 +193,15 @@ export const createTTSHighlightPlan = (args) => {
195
193
  }
196
194
  // (2) A spoken word that maps to visible prose. The prose anchor is
197
195
  // precise; a coarse math expression fallback must never override it.
198
- // Exception: a multi-node prose range inside a token-mode equation
199
- // would escalate to the whole expression, so it is rejected here and
196
+ // Exception: a multi-node prose range inside a token-mode equation is
197
+ // an untrustworthy glyph-level match, so it is rejected here and
200
198
  // falls through to the token hold below. When per-token math
201
199
  // highlighting is disabled, any prose range that lands inside a
202
200
  // formula is likewise rejected so the formula stays a single block.
203
201
  if (proseTarget &&
204
202
  !isHiddenRange(proseTarget) &&
205
203
  (mathTokenHighlighting
206
- ? !wouldEscalateInsideTokenMath(planned, proseTarget, mathCandidates)
204
+ ? !isMultiNodeRangeInsideTokenMath(planned, proseTarget, mathCandidates)
207
205
  : mathCandidates.length === 0)) {
208
206
  return createHighlightDecision({
209
207
  semanticTarget: proseTarget,
@@ -1,3 +1,7 @@
1
+ /**
2
+ * What the host knows about the content being highlighted. `scopeElement`
3
+ * falls back to the content element being read.
4
+ */
1
5
  export interface TTSHighlightContext {
2
6
  scopeElement?: HTMLElement | null;
3
7
  itemId?: string;
@@ -6,12 +10,20 @@ export interface TTSHighlightContext {
6
10
  contentKind?: string;
7
11
  regionPolicy?: string;
8
12
  }
13
+ /**
14
+ * Host remap of TTS highlight targets. A null or undefined result keeps the
15
+ * native targets, and so does a result outside `scopeElement` or a throw. For
16
+ * sentences, one out-of-scope or non-Range/Element entry keeps all native
17
+ * ranges.
18
+ */
9
19
  export interface TTSHighlightTargetResolver {
10
20
  resolveWordRange?(range: Range, context: TTSHighlightContext): Range | null | undefined;
11
21
  resolveSentenceRanges?(ranges: Range[], context: TTSHighlightContext): Array<Range | HTMLElement> | null | undefined;
12
22
  }
23
+ /** A resolver with the context it is called with. */
13
24
  export interface TTSHighlightTargetResolverRuntime {
14
25
  context: TTSHighlightContext;
15
26
  resolver?: TTSHighlightTargetResolver | null;
16
27
  }
28
+ /** Read at every highlight; a throw counts as no resolver. */
17
29
  export type TTSHighlightTargetResolverProvider = () => TTSHighlightTargetResolverRuntime | null | undefined;
@@ -74,18 +74,16 @@ const TEXT_CHUNK_SOURCE_ROLES = new Set([
74
74
  ]);
75
75
  const resolveTextChunkSourceElement = (textNode, root) => {
76
76
  let current = flatTreeParentElement(textNode);
77
- let best = null;
78
77
  while (current && current !== root) {
79
78
  const role = (current.getAttribute("role") || "").toLowerCase();
80
79
  const tagName = current.tagName.toUpperCase();
81
80
  if (TEXT_CHUNK_SOURCE_TAGS.has(tagName) ||
82
81
  TEXT_CHUNK_SOURCE_ROLES.has(role)) {
83
- best = current;
84
- break;
82
+ return current;
85
83
  }
86
84
  current = flatTreeParentElement(current);
87
85
  }
88
- return best || root;
86
+ return root;
89
87
  };
90
88
  const hasMathCandidate = (element) => Boolean(typeof element.querySelector === "function" &&
91
89
  flatQuerySelector(element, "math, [data-mathml], .MathJax, mjx-container, mjx-assistive-mml")) ||
@@ -158,13 +156,12 @@ const appendCollectedText = (acc, collected) => {
158
156
  for (let i = 0; i < collected.text.length; i++) {
159
157
  const character = collected.text[i];
160
158
  const isWhitespace = /\s/.test(character);
161
- const mapping = collected.map.get(i) || null;
162
- const syntheticNode = mapping?.node || acc.lastMapped?.node || null;
163
- const syntheticOffset = mapping?.offset ?? acc.lastMapped?.offset ?? 0;
164
- const normalizedMapping = mapping
159
+ const mapping = collected.map.get(i);
160
+ // An unmapped character borrows the last mapped position.
161
+ const characterMapping = mapping
165
162
  ? { node: mapping.node, offset: mapping.offset }
166
- : syntheticNode
167
- ? { node: syntheticNode, offset: syntheticOffset }
163
+ : acc.lastMapped
164
+ ? { node: acc.lastMapped.node, offset: acc.lastMapped.offset }
168
165
  : null;
169
166
  if (acc.inLeadingWhitespace) {
170
167
  if (isWhitespace)
@@ -173,29 +170,16 @@ const appendCollectedText = (acc, collected) => {
173
170
  }
174
171
  if (isWhitespace) {
175
172
  if (!acc.lastCharWasWhitespace) {
176
- appendCharacter(acc, " ", normalizedMapping);
173
+ appendCharacter(acc, " ", characterMapping);
177
174
  }
178
175
  acc.lastCharWasWhitespace = true;
179
176
  continue;
180
177
  }
181
178
  if (i === 0 && shouldInsertBoundarySpace(acc, character)) {
182
- appendCharacter(acc, " ", normalizedMapping);
179
+ appendCharacter(acc, " ", characterMapping);
183
180
  }
184
- appendCharacter(acc, character, normalizedMapping);
181
+ appendCharacter(acc, character, characterMapping);
185
182
  acc.lastCharWasWhitespace = false;
186
- if (mapping) {
187
- acc.map.set(acc.position - 1, {
188
- node: mapping.node,
189
- offset: mapping.offset,
190
- });
191
- acc.lastMapped = { node: mapping.node, offset: mapping.offset };
192
- }
193
- else if (syntheticNode) {
194
- acc.map.set(acc.position - 1, {
195
- node: syntheticNode,
196
- offset: syntheticOffset,
197
- });
198
- }
199
183
  }
200
184
  };
201
185
  const textFallbackFromMathML = (mathml) => {
@@ -376,9 +360,6 @@ const collectMathAware = (root, options, range) => {
376
360
  trimTrailingWhitespace(acc);
377
361
  flushTextChunk();
378
362
  const visibleText = acc.chars.join("");
379
- while (acc.map.has(visibleText.length)) {
380
- acc.map.delete(visibleText.length);
381
- }
382
363
  return {
383
364
  visibleText,
384
365
  map: acc.map,
@@ -14,7 +14,7 @@ export interface ResolveMathSpeechOptions {
14
14
  mathSpeech?: SREMathSpeechOptions;
15
15
  /**
16
16
  * Also produce SRE's SSML rendering (markup: "ssml") for the equation, for
17
- * the runtime generated-SSML path (PIE-623). Only populated when the input
17
+ * the runtime generated-SSML path. Only populated when the input
18
18
  * is a single math chunk and SRE succeeds. The plain `speechText` is always
19
19
  * computed with markup: "none" so the plain path is unaffected.
20
20
  */
@@ -3,6 +3,9 @@ import { setSreStartupLocaleSource, sreLocaleSource } from "./sre-locales.js";
3
3
  import { normalizeTextForSpeech } from "./text-processing.js";
4
4
  // Debug lines need PIE_TTS_DEBUG=1 or globalThis.__PIE_TTS_DEBUG__ = true.
5
5
  const logger = createPieLogger("tts-math-speech", isTtsDebugEnabled);
6
+ // SRE configures one global engine, so each setupEngine + toSpeech pair runs to
7
+ // completion before the next starts; concurrent reads would otherwise speak
8
+ // under each other's settings.
6
9
  let sreOperationQueue = Promise.resolve();
7
10
  const normalizeLocale = (language) => (language || "en").split("-")[0].toLowerCase() || "en";
8
11
  /**
@@ -1 +1,8 @@
1
+ /**
2
+ * MathML re-serialized from an allow-list, with a MathML xmlns on the root.
3
+ * Null for empty or unparseable input, no DOMParser, a non-<math> root, a
4
+ * disallowed element or a node other than element/text/comment, or an xmlns
5
+ * other than MathML's. Comments, attributes off the allow-list and values
6
+ * containing javascript: or data:text/html are dropped.
7
+ */
1
8
  export declare const canonicalizeMathML: (input: string) => string | null;
@@ -1,4 +1,5 @@
1
1
  const MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML";
2
+ const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
2
3
  const ALLOWED_MATHML_ELEMENTS = new Set([
3
4
  "math",
4
5
  "maction",
@@ -109,7 +110,7 @@ const isAllowedMathElement = (element) => {
109
110
  const namespace = element.namespaceURI;
110
111
  return (!namespace ||
111
112
  namespace === MATHML_NAMESPACE ||
112
- namespace === "http://www.w3.org/1999/xhtml");
113
+ namespace === XHTML_NAMESPACE);
113
114
  };
114
115
  const serializeMathNode = (node) => {
115
116
  if (node.nodeType === Node.TEXT_NODE) {
@@ -153,6 +154,13 @@ const parseMathML = (input) => {
153
154
  }
154
155
  return null;
155
156
  };
157
+ /**
158
+ * MathML re-serialized from an allow-list, with a MathML xmlns on the root.
159
+ * Null for empty or unparseable input, no DOMParser, a non-<math> root, a
160
+ * disallowed element or a node other than element/text/comment, or an xmlns
161
+ * other than MathML's. Comments, attributes off the allow-list and values
162
+ * containing javascript: or data:text/html are dropped.
163
+ */
156
164
  export const canonicalizeMathML = (input) => {
157
165
  const trimmed = String(input || "").trim();
158
166
  if (!trimmed)
@@ -7,7 +7,11 @@ import type { BoundarySpacingMode } from "./text-processing.js";
7
7
  * each value at runtime.
8
8
  */
9
9
  export interface ToolkitTTSProviderOptions extends TTSProviderOptions {
10
- /** Highlight words or sentences while reading. Defaults by provider capability. */
10
+ /**
11
+ * Highlight words or sentences while reading. Defaults by provider
12
+ * capability. A playback that sends no word boundaries highlights its
13
+ * sentence in either mode.
14
+ */
11
15
  highlightMode?: "word" | "sentence";
12
16
  /** Locale for text processing; the toolkit sets it per speak. */
13
17
  locale?: string;
@@ -38,4 +42,11 @@ export interface ToolkitTTSProviderOptions extends TTSProviderOptions {
38
42
  /** A provider configuration as the toolkit reads it. */
39
43
  export interface ToolkitTTSConfig extends TTSConfig {
40
44
  providerOptions?: ToolkitTTSProviderOptions;
45
+ /**
46
+ * Highlight math token by token; `false` highlights each formula as one
47
+ * block. The highlight pipeline reads it, and providers ignore it.
48
+ *
49
+ * @default true
50
+ */
51
+ mathTokenHighlighting?: boolean;
41
52
  }
@@ -8,7 +8,7 @@ import { normalizeTextForSpeech } from "../text-processing.js";
8
8
  * stripping used by:
9
9
  * - the catalog span aligner (`catalog-span-alignment.ts`),
10
10
  * - the math speech tokenizer (`math-alignment/speech-tokenizer.ts`),
11
- * - the runtime generated-speech path (PIE-623).
11
+ * - the runtime generated-speech path.
12
12
  *
13
13
  * Keeping one implementation guarantees provider word-boundary offsets (which
14
14
  * are into the raw SSML string) map to the same spoken text on every path.
@@ -1,10 +1,3 @@
1
- /**
2
- * The `code` a speak's rejection carries when speech could not start, as
3
- * distinct from a failure while speaking. Read by shape: several copies of the
4
- * toolkit can share a page, so an error class from one fails `instanceof` in
5
- * another.
6
- */
7
- export declare const TTS_START_FAILED_CODE = "tts-start-failed";
8
1
  /** Whether a speak rejected because speech could not start. */
9
2
  export declare function isTTSStartFailure(error: unknown): boolean;
10
3
  /** Marks `cause` as a start failure, keeping its message and stack. */
@@ -4,7 +4,7 @@
4
4
  * toolkit can share a page, so an error class from one fails `instanceof` in
5
5
  * another.
6
6
  */
7
- export const TTS_START_FAILED_CODE = "tts-start-failed";
7
+ const TTS_START_FAILED_CODE = "tts-start-failed";
8
8
  /** Whether a speak rejected because speech could not start. */
9
9
  export function isTTSStartFailure(error) {
10
10
  return (typeof error === "object" &&
@@ -6,6 +6,11 @@ export interface SentenceSegmentationOptions {
6
6
  locale?: string;
7
7
  useSentenceSegmenter?: boolean;
8
8
  }
9
+ /**
10
+ * `text` split into sentences at their offsets in it, by Intl.Segmenter unless
11
+ * the policy disables it or it is missing. The segmenter path drops
12
+ * whitespace-only segments; the regex fallback keeps every match.
13
+ */
9
14
  export declare const segmentSentences: (text: string, options?: SentenceSegmentationOptions) => SentenceSegment[];
10
15
  /**
11
16
  * `text` in pieces of at most `maxLength` characters: whole sentences while they
@@ -1,11 +1,14 @@
1
+ /**
2
+ * `text` split into sentences at their offsets in it, by Intl.Segmenter unless
3
+ * the policy disables it or it is missing. The segmenter path drops
4
+ * whitespace-only segments; the regex fallback keeps every match.
5
+ */
1
6
  export const segmentSentences = (text, options) => {
2
- const useSentenceSegmenter = options?.useSentenceSegmenter !== false;
3
- try {
4
- if (!useSentenceSegmenter) {
5
- throw new Error("Segmenter disabled by policy");
6
- }
7
- const Segmenter = globalThis.Intl?.Segmenter;
8
- if (typeof Segmenter === "function") {
7
+ const Segmenter = options?.useSentenceSegmenter === false
8
+ ? undefined
9
+ : globalThis.Intl?.Segmenter;
10
+ if (typeof Segmenter === "function") {
11
+ try {
9
12
  const segmenter = new Segmenter(options?.locale, {
10
13
  granularity: "sentence",
11
14
  });
@@ -18,9 +21,9 @@ export const segmentSentences = (text, options) => {
18
21
  if (parsed.length > 0)
19
22
  return parsed;
20
23
  }
21
- }
22
- catch {
23
- // Fall through to regex segmentation.
24
+ catch {
25
+ // Fall through to regex segmentation.
26
+ }
24
27
  }
25
28
  const sentenceRegex = /[^.!?]+(?:[.!?]+|$)/g;
26
29
  const raw = text.match(sentenceRegex) || [text];