ssml-builder-js 2.14.0 → 2.15.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.
package/README.md CHANGED
@@ -240,7 +240,7 @@ const diagnostics = validateAzureSsml(ssml, {
240
240
  });
241
241
  ```
242
242
 
243
- `audio`、`mstts:backgroundaudio`、`lexicon`、`mstts:voiceconversion` の URL は `urlValidator`(または `customUrlValidator`)へ渡せます。Promise を返す検証関数で DNS 解決後のプライベート IP 遮断などを実装できます。
243
+ `audio`、`mstts:backgroundaudio`、`lexicon`、`mstts:voiceconversion` の URL は `urlValidator`(または `customUrlValidator`)へ渡せます。コールバックは `(url, context, signal)` を受け取り、Promise を返す検証関数で DNS 解決後のプライベート IP 遮断などを実装できます。検証キャッシュはタグ・属性・URL ごとに分離されます。
244
244
 
245
245
  `maxXmlDepth` は `<speak>` を深さ 1 として XML の過剰なネストを検出します。長文は `splitSsmlDocument` で `<p>`/`<s>` と親の `voice`、`prosody` コンテキストを保ったまま分割できます。
246
246
 
@@ -342,6 +342,7 @@ export function App() {
342
342
  - `customInsertions` / `additionalInsertions`: カスタム SSML 挿入定義。`customInsertions` は同じ ID の標準定義を置き換え、`additionalInsertions` は標準定義へ追加します
343
343
  - `customInspectors`: Visual Editor のタグ名ごとに Inspector を差し替えるレンダラー
344
344
  - `renderVoiceSelector`: 音声セレクターのカスタムレンダラー。`voiceCatalog` と `voiceLocale`、`voiceRegion`、`voiceStyle` で候補を絞り込めます。プレビュー音声にはバッジ情報も渡されます
345
+ - `voiceModel`(または `model`): 選択中の Azure 音声モデル。音声カタログの対応モデル、SSML タグ、スタイル、ロケールとの不整合を Visual Editor にリアルタイム表示します
345
346
  - `className` / `style`: エディター全体のクラス名とインラインスタイル
346
347
  - `toolbarClassName` / `toolbarStyle`: ツールバーのクラス名とインラインスタイル
347
348
  - `displayClassName` / `displayStyle`: 本文表示エリアのクラス名とインラインスタイル
@@ -708,7 +709,7 @@ const diagnostics = validateAzureSsml(ssml, {
708
709
  });
709
710
  ```
710
711
 
711
- URLs in `audio`, `mstts:backgroundaudio`, `lexicon`, and `mstts:voiceconversion` can be passed to `urlValidator` (or `customUrlValidator`). The callback may be asynchronous, making it suitable for host-side DNS/private-IP and SSRF policy checks.
712
+ URLs in `audio`, `mstts:backgroundaudio`, `lexicon`, and `mstts:voiceconversion` can be passed to `urlValidator` (or `customUrlValidator`). The callback receives `(url, context, signal)` and may be asynchronous, making it suitable for host-side DNS/private-IP and SSRF policy checks. Validation cache entries are isolated by tag, attribute, and URL.
712
713
 
713
714
  The catalog is represented by `AzureVoiceDefinition` with `name`, `locale`, optional `secondaryLocales`, `styles`, `supportedTags`, `unsupportedTags`, `models`, `regions`, and `status`. Pass `voiceDefinitions` (or `voiceCatalog`) to supplement or override the built-in catalog with an external definition. A tag that violates `supportedTags` or `unsupportedTags` produces `azure-unsupported-tag-for-voice` with error severity. `customVoiceStyleMap` remains supported for backward compatibility and overrides styles for the named voice. Diagnostics distinguish an unregistered voice (`azure-unknown-voice`, controlled by `unknownVoicePolicy`), an unsupported style on a registered voice (`azure-unsupported-style`), and a locale mismatch (`azure-locale-mismatch`). The `<mstts:audioduration value="10s"/>` element accepts positive `ms` or `s` values and `hh:mm:ss[.fff]` clock values.
714
715
 
@@ -834,6 +835,7 @@ export function App() {
834
835
  - `customInsertions` / `additionalInsertions`: Custom SSML insertion definitions. `customInsertions` replaces a built-in definition with the same ID, while `additionalInsertions` adds definitions to the built-ins
835
836
  - `customInspectors`: Custom renderers keyed by element type or serialized tag name for the Visual Editor
836
837
  - `renderVoiceSelector`: Custom voice selector renderer. Supply `voiceCatalog` and optional `voiceLocale`, `voiceRegion`, or `voiceStyle` filters; preview status is included in each voice entry
838
+ - `voiceModel` (or `model`): Selected Azure voice model. The Visual Editor reports live mismatches between the catalog's supported models, SSML tags, styles, and locale
837
839
  - `className` / `style`: A class name and inline styles for the editor container
838
840
  - `toolbarClassName` / `toolbarStyle`: A class name and inline styles for the toolbar
839
841
  - `displayClassName` / `displayStyle`: A class name and inline styles for the text display area
@@ -911,13 +913,13 @@ result.bookmarks; // { name, audioOffsetMs }[]
911
913
 
912
914
  長文を分割して合成する場合は `synthesizeSsmlChunks` または `AzureTtsClient.synthesizeChunks` を使います。音声バイナリを連結し、`boundaries`、`visemes`、`bookmarks` のオフセットを累積 `durationMs` 分だけ補正します。`synthesizeSsmlSafe(client, ssml, { validation })` は検証エラー時に Azure API を呼び出さず、`status: "validation-error"` / `"azure-api-error"` / `"success"` の結果を返します。
913
915
 
914
- v2.14.0 では `synthesizeSsmlChunksSafe(client, chunks, options)` が全チャンクを事前検証し、エラー時に `ChunkValidationError.chunkIndex` を返します。`onProgress` には `chunkIndex`、`originalTextRange`、`status`、`durationMs`、`error` が含まれます。`mergeAudioBuffers(buffers, format)` PCM WAV のヘッダーを再構築し、MP3 ID3 タグを除去して結合します。Ogg/WebM など安全に連結できない形式は `UnsupportedMergeFormatError` になります。同期イベントには `chunkIndex`、`sourceNodePath`、`originalTextRange`、`chunkAudioOffsetMs` が付与されます。
916
+ v2.15.0 では `synthesizeSsmlChunksSafe(client, chunks, options)` が全チャンクを事前検証し、`outputFormat`、`signal`、`timeoutMs`、`sourceNodePath` を各合成へ伝播します。`mergeAudioBuffers(buffers, { format })` `mergeSynthesisResults(results, { format })` は形式指定を必須とし、結合結果には `mimeType` が含まれます。Ogg/WebM などは `customMerger` で外部 Muxer に委譲できます。エラーは `validation-error`、`azure-api-error`、`merge-error`、`unsupported-format-error`、`cancelled`、`timeout` の判別可能な `kind` を持ちます。同期イベントは個別の `sourceNodePath` と `originalTextRange` にマッピングされ、URL 検証コールバックには `AbortSignal` が渡されます。
915
917
 
916
918
  `validateAzureSsml` の `urlValidation` オプションは URL の重複排除、キャッシュ、`concurrency`、`signal`、`timeoutMs` を制御します。
917
919
 
918
920
  For long documents, use `synthesizeSsmlChunks` or `AzureTtsClient.synthesizeChunks`; `onProgress` reports completed chunks while audio and synchronization offsets are merged. `synthesizeSsmlSafe(client, ssml, { validation })` validates before synthesis and returns a discriminated result without calling Azure when static validation fails.
919
921
 
920
- In v2.14.0, `synthesizeSsmlChunksSafe(client, chunks, options)` validates every chunk before contacting Azure and returns a `ChunkValidationError` with its `chunkIndex` when validation fails. `onProgress` events include `chunkIndex`, `originalTextRange`, `status`, `durationMs`, and `error`. `mergeAudioBuffers(buffers, format)` rebuilds PCM WAV headers and strips ID3 tags from MP3 streams; formats such as Ogg and WebM throw `UnsupportedMergeFormatError` because they require re-multiplexing. Synchronization events retain `chunkIndex`, `sourceNodePath`, `originalTextRange`, and `chunkAudioOffsetMs`.
922
+ In v2.15.0, `synthesizeSsmlChunksSafe(client, chunks, options)` validates every chunk before contacting Azure and propagates `outputFormat`, `signal`, `timeoutMs`, and `sourceNodePath` to each synthesis. `mergeAudioBuffers(buffers, { format })` and `mergeSynthesisResults(results, { format })` require an explicit format and merged results expose `mimeType`. Ogg and WebM can be delegated to an external Muxer through `customMerger`. Errors have discriminated `kind` values: `validation-error`, `azure-api-error`, `merge-error`, `unsupported-format-error`, `cancelled`, and `timeout`. Synchronization events receive individual `sourceNodePath` and `originalTextRange` mappings, and URL validators receive an `AbortSignal`.
921
923
 
922
924
  The `urlValidation` option of `validateAzureSsml` provides URL deduplication, in-memory caching, bounded `concurrency`, `signal`, and `timeoutMs` controls.
923
925
 
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  buildSsml,
3
3
  parseSsml
4
- } from "./chunk-QFIBPCO4.mjs";
4
+ } from "./chunk-WXFLUCLR.mjs";
5
5
  import {
6
6
  __export
7
7
  } from "./chunk-6S5ODO6A.mjs";
@@ -3112,4 +3112,4 @@ export {
3112
3112
  findSsmlHoverTarget,
3113
3113
  formatSsmlHover
3114
3114
  };
3115
- //# sourceMappingURL=chunk-UZSCXPC5.mjs.map
3115
+ //# sourceMappingURL=chunk-BDY2Q2JL.mjs.map
@@ -931,6 +931,236 @@ function buildPartialSsml(textOrOptions, context) {
931
931
  return serializePartialSsml(textOrOptions.text, textOrOptions);
932
932
  }
933
933
 
934
+ // packages/ssml-core/src/textNodes.ts
935
+ function decodeXmlText(value) {
936
+ return value.replace(/&(?:amp|apos|gt|lt|quot);|&#(?:x[\da-f]+|\d+);/gi, (entity) => {
937
+ if (entity === "&amp;") return "&";
938
+ if (entity === "&apos;") return "'";
939
+ if (entity === "&gt;") return ">";
940
+ if (entity === "&lt;") return "<";
941
+ if (entity === "&quot;") return '"';
942
+ const hexadecimal = entity.toLowerCase().startsWith("&#x");
943
+ const digits = entity.slice(hexadecimal ? 3 : 2, -1);
944
+ return String.fromCodePoint(Number.parseInt(digits, hexadecimal ? 16 : 10));
945
+ });
946
+ }
947
+ function encodeXmlText(value) {
948
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
949
+ }
950
+ function decodeXmlAttribute(value) {
951
+ return decodeXmlText(value);
952
+ }
953
+ function findTagEnd(source, start) {
954
+ let quote = "";
955
+ for (let index = start; index < source.length; index += 1) {
956
+ const character = source[index];
957
+ if (quote) {
958
+ if (character === quote) quote = "";
959
+ } else if (character === '"' || character === "'") {
960
+ quote = character;
961
+ } else if (character === ">") {
962
+ return index;
963
+ }
964
+ }
965
+ return source.length - 1;
966
+ }
967
+ function readTagName(tag) {
968
+ const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
969
+ return match?.[1];
970
+ }
971
+ function readTagAttributes(tag, name) {
972
+ const attributes = {};
973
+ const nameStart = tag.indexOf(name);
974
+ const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
975
+ const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
976
+ for (const match of attributeSource.matchAll(attributePattern)) {
977
+ attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
978
+ }
979
+ return attributes;
980
+ }
981
+ function collectTextNodes(source) {
982
+ const nodes = [];
983
+ const elements = [];
984
+ let index = 0;
985
+ const addText = (start, end, rawText, sourceStart = start, sourceEnd = end) => {
986
+ if (!rawText) return;
987
+ const path = elements.map((element) => element.name);
988
+ const parent = elements[elements.length - 1];
989
+ nodes.push({
990
+ context: {
991
+ ancestorTags: path.slice(0, -1),
992
+ parentAttributes: { ...parent?.attributes ?? {} },
993
+ parentTag: parent?.name ?? "",
994
+ path
995
+ },
996
+ decodedText: decodeXmlText(rawText),
997
+ end,
998
+ sourceEnd,
999
+ sourceStart,
1000
+ start
1001
+ });
1002
+ };
1003
+ while (index < source.length) {
1004
+ if (source[index] !== "<") {
1005
+ const nextTag = source.indexOf("<", index);
1006
+ const end2 = nextTag === -1 ? source.length : nextTag;
1007
+ addText(index, end2, source.slice(index, end2));
1008
+ index = end2;
1009
+ continue;
1010
+ }
1011
+ if (source.startsWith("<!--", index)) {
1012
+ const end2 = source.indexOf("-->", index + 4);
1013
+ index = end2 === -1 ? source.length : end2 + 3;
1014
+ continue;
1015
+ }
1016
+ if (source.startsWith("<![CDATA[", index)) {
1017
+ const contentStart = index + 9;
1018
+ const end2 = source.indexOf("]]>", contentStart);
1019
+ const contentEnd = end2 === -1 ? source.length : end2;
1020
+ addText(
1021
+ contentStart,
1022
+ contentEnd,
1023
+ source.slice(contentStart, contentEnd),
1024
+ index,
1025
+ end2 === -1 ? source.length : end2 + 3
1026
+ );
1027
+ index = end2 === -1 ? source.length : end2 + 3;
1028
+ continue;
1029
+ }
1030
+ if (source.startsWith("<?", index)) {
1031
+ const end2 = source.indexOf("?>", index + 2);
1032
+ index = end2 === -1 ? source.length : end2 + 2;
1033
+ continue;
1034
+ }
1035
+ if (source.startsWith("</", index)) {
1036
+ const end2 = findTagEnd(source, index + 2);
1037
+ elements.pop();
1038
+ index = end2 + 1;
1039
+ continue;
1040
+ }
1041
+ const end = findTagEnd(source, index + 1);
1042
+ const tag = source.slice(index, end + 1);
1043
+ const name = readTagName(tag);
1044
+ if (name && !/\/\s*>$/.test(tag)) elements.push({ attributes: readTagAttributes(tag, name), name });
1045
+ index = end + 1;
1046
+ }
1047
+ return nodes;
1048
+ }
1049
+ function collectSourceMap(source) {
1050
+ const segments = [];
1051
+ const markers = [];
1052
+ const elements = [];
1053
+ let textOffset = 0;
1054
+ let index = 0;
1055
+ const textParts = [];
1056
+ const addText = (value) => {
1057
+ if (!value) return;
1058
+ const parent = elements[elements.length - 1];
1059
+ if (parent) parent.nextChildIndex += 1;
1060
+ const sourceNodePath = parent?.path ?? ["speak"];
1061
+ const start = textOffset;
1062
+ textOffset += value.length;
1063
+ textParts.push(value);
1064
+ segments.push({ text: value, range: { start, end: textOffset }, sourceNodePath: [...sourceNodePath] });
1065
+ };
1066
+ while (index < source.length) {
1067
+ if (source[index] !== "<") {
1068
+ const end2 = source.indexOf("<", index);
1069
+ const textEnd = end2 === -1 ? source.length : end2;
1070
+ addText(decodeXmlText(source.slice(index, textEnd)));
1071
+ index = textEnd;
1072
+ continue;
1073
+ }
1074
+ if (source.startsWith("<!--", index)) {
1075
+ const end2 = source.indexOf("-->", index + 4);
1076
+ index = end2 === -1 ? source.length : end2 + 3;
1077
+ continue;
1078
+ }
1079
+ if (source.startsWith("<![CDATA[", index)) {
1080
+ const contentStart = index + 9;
1081
+ const end2 = source.indexOf("]]>", contentStart);
1082
+ const contentEnd = end2 === -1 ? source.length : end2;
1083
+ addText(source.slice(contentStart, contentEnd));
1084
+ index = end2 === -1 ? source.length : end2 + 3;
1085
+ continue;
1086
+ }
1087
+ if (source.startsWith("<?", index)) {
1088
+ const end2 = source.indexOf("?>", index + 2);
1089
+ index = end2 === -1 ? source.length : end2 + 2;
1090
+ continue;
1091
+ }
1092
+ const end = findTagEnd(source, index + 1);
1093
+ const rawTag = source.slice(index, end + 1);
1094
+ if (rawTag.startsWith("</")) {
1095
+ elements.pop();
1096
+ index = end + 1;
1097
+ continue;
1098
+ }
1099
+ const name = readTagName(rawTag);
1100
+ if (!name) {
1101
+ index = end + 1;
1102
+ continue;
1103
+ }
1104
+ const parent = elements[elements.length - 1];
1105
+ const childIndex = parent?.nextChildIndex ?? 0;
1106
+ if (parent) parent.nextChildIndex += 1;
1107
+ const path = parent ? [...parent.path, `${name}[${childIndex}]`] : [name];
1108
+ const attributes = readTagAttributes(rawTag, name);
1109
+ const normalizedName = name.toLowerCase();
1110
+ if (normalizedName === "mark" || normalizedName === "bookmark") {
1111
+ const markerName = attributes[normalizedName === "mark" ? "name" : "mark"];
1112
+ if (markerName) {
1113
+ markers.push({
1114
+ kind: normalizedName,
1115
+ name: markerName,
1116
+ originalTextRange: { start: textOffset, end: textOffset },
1117
+ sourceNodePath: [...path]
1118
+ });
1119
+ }
1120
+ }
1121
+ if (!/\/\s*>$/.test(rawTag)) elements.push({ name, path, nextChildIndex: 0 });
1122
+ index = end + 1;
1123
+ }
1124
+ return { text: textParts.join(""), segments, markers };
1125
+ }
1126
+ function getSsmlSourceMap(ssml) {
1127
+ parseSsml(ssml);
1128
+ return collectSourceMap(ssml);
1129
+ }
1130
+ function extractSsmlText(ssml) {
1131
+ parseSsml(ssml);
1132
+ return collectTextNodes(ssml).map((node) => node.decodedText);
1133
+ }
1134
+ async function mapSsmlTextNodes(ssml, transform, options = {}) {
1135
+ parseSsml(ssml);
1136
+ const nodes = collectTextNodes(ssml);
1137
+ const skipTags = new Set((options.skipTags ?? ["phoneme", "say-as", "sub"]).map((tag) => tag.toLowerCase()));
1138
+ const replacements = await Promise.all(
1139
+ nodes.map(async (node) => {
1140
+ const context = {
1141
+ ancestorTags: [...node.context.ancestorTags],
1142
+ parentAttributes: { ...node.context.parentAttributes },
1143
+ parentTag: node.context.parentTag,
1144
+ path: [...node.context.path]
1145
+ };
1146
+ const shouldTransform = !skipTags.has(context.parentTag.toLowerCase()) && (options.filter?.(context) ?? true);
1147
+ if (!shouldTransform) return ssml.slice(node.sourceStart, node.sourceEnd);
1148
+ const transformed = await transform(node.decodedText, context);
1149
+ if (typeof transformed !== "string") {
1150
+ throw new TypeError("SSML text node transform must return a string");
1151
+ }
1152
+ return transformed === node.decodedText ? ssml.slice(node.sourceStart, node.sourceEnd) : encodeXmlText(transformed);
1153
+ })
1154
+ );
1155
+ let result = "";
1156
+ let cursor = 0;
1157
+ nodes.forEach((node, nodeIndex) => {
1158
+ result += ssml.slice(cursor, node.sourceStart) + replacements[nodeIndex];
1159
+ cursor = node.sourceEnd;
1160
+ });
1161
+ return result + ssml.slice(cursor);
1162
+ }
1163
+
934
1164
  // packages/ssml-core/src/split.ts
935
1165
  var DEFAULT_MAX_LENGTH = 1e4;
936
1166
  function cloneElement(element, children) {
@@ -1062,7 +1292,7 @@ function findSourceNodePath(nodes, targetOffset) {
1062
1292
  });
1063
1293
  return foundPath ?? firstPath;
1064
1294
  }
1065
- function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1295
+ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio, sourceMap, includeEndMarkers) {
1066
1296
  const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1067
1297
  const text = nodes.map(textFromNode).join("");
1068
1298
  const marks = [];
@@ -1078,7 +1308,23 @@ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, re
1078
1308
  hasBackgroundAudio: chunkNodes.some(
1079
1309
  (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1080
1310
  ),
1081
- sourceNodePath: findSourceNodePath(document.children ?? [], textStart)
1311
+ sourceNodePath: findSourceNodePath(document.children ?? [], textStart),
1312
+ sourceTextSegments: sourceMap.segments.filter(({ range }) => range.end > textStart && range.start < textStart + text.length).map((segment) => {
1313
+ const start = Math.max(segment.range.start, textStart);
1314
+ const end = Math.min(segment.range.end, textStart + text.length);
1315
+ return {
1316
+ text: segment.text.slice(start - segment.range.start, end - segment.range.start),
1317
+ range: { start, end },
1318
+ sourceNodePath: [...segment.sourceNodePath]
1319
+ };
1320
+ }),
1321
+ sourceMarkers: sourceMap.markers.filter(
1322
+ ({ originalTextRange }) => originalTextRange.start >= textStart && (originalTextRange.start < textStart + text.length || includeEndMarkers && originalTextRange.start === textStart + text.length)
1323
+ ).map((marker) => ({
1324
+ ...marker,
1325
+ originalTextRange: { ...marker.originalTextRange },
1326
+ sourceNodePath: [...marker.sourceNodePath]
1327
+ }))
1082
1328
  };
1083
1329
  }
1084
1330
  function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
@@ -1088,11 +1334,12 @@ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1088
1334
  throw new RangeError("maxLength must be a positive integer");
1089
1335
  }
1090
1336
  const document = parseSsml(ssml);
1337
+ const sourceMap = getSsmlSourceMap(ssml);
1091
1338
  const backgroundAudio = (document.children ?? []).find(
1092
1339
  (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1093
1340
  );
1094
1341
  if (ssml.length <= resolvedMaxLength) {
1095
- return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
1342
+ return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true, sourceMap, true)];
1096
1343
  }
1097
1344
  const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
1098
1345
  const plainDocumentLength = documentWithChildren(document, []).length;
@@ -1116,7 +1363,16 @@ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1116
1363
  }
1117
1364
  if (group.length > 0) chunks.push(group);
1118
1365
  if (chunks.length === 0) {
1119
- const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
1366
+ const result = createChunk(
1367
+ document,
1368
+ [],
1369
+ 0,
1370
+ 0,
1371
+ backgroundAudio,
1372
+ resolvedOptions.replicateBackgroundAudio ?? false,
1373
+ sourceMap,
1374
+ true
1375
+ );
1120
1376
  if (result.ssml.length > resolvedMaxLength) {
1121
1377
  throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1122
1378
  }
@@ -1130,7 +1386,9 @@ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1130
1386
  chunkIndex,
1131
1387
  textStart,
1132
1388
  backgroundAudio,
1133
- resolvedOptions.replicateBackgroundAudio ?? false
1389
+ resolvedOptions.replicateBackgroundAudio ?? false,
1390
+ sourceMap,
1391
+ chunkIndex === chunks.length - 1
1134
1392
  );
1135
1393
  textStart = result.originalTextRange.end;
1136
1394
  return result;
@@ -1153,155 +1411,6 @@ function validateSsml(xmlString) {
1153
1411
  }
1154
1412
  }
1155
1413
 
1156
- // packages/ssml-core/src/textNodes.ts
1157
- function decodeXmlText(value) {
1158
- return value.replace(/&(?:amp|apos|gt|lt|quot);|&#(?:x[\da-f]+|\d+);/gi, (entity) => {
1159
- if (entity === "&amp;") return "&";
1160
- if (entity === "&apos;") return "'";
1161
- if (entity === "&gt;") return ">";
1162
- if (entity === "&lt;") return "<";
1163
- if (entity === "&quot;") return '"';
1164
- const hexadecimal = entity.toLowerCase().startsWith("&#x");
1165
- const digits = entity.slice(hexadecimal ? 3 : 2, -1);
1166
- return String.fromCodePoint(Number.parseInt(digits, hexadecimal ? 16 : 10));
1167
- });
1168
- }
1169
- function encodeXmlText(value) {
1170
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1171
- }
1172
- function decodeXmlAttribute(value) {
1173
- return decodeXmlText(value);
1174
- }
1175
- function findTagEnd(source, start) {
1176
- let quote = "";
1177
- for (let index = start; index < source.length; index += 1) {
1178
- const character = source[index];
1179
- if (quote) {
1180
- if (character === quote) quote = "";
1181
- } else if (character === '"' || character === "'") {
1182
- quote = character;
1183
- } else if (character === ">") {
1184
- return index;
1185
- }
1186
- }
1187
- return source.length - 1;
1188
- }
1189
- function readTagName(tag) {
1190
- const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
1191
- return match?.[1];
1192
- }
1193
- function readTagAttributes(tag, name) {
1194
- const attributes = {};
1195
- const nameStart = tag.indexOf(name);
1196
- const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
1197
- const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
1198
- for (const match of attributeSource.matchAll(attributePattern)) {
1199
- attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
1200
- }
1201
- return attributes;
1202
- }
1203
- function collectTextNodes(source) {
1204
- const nodes = [];
1205
- const elements = [];
1206
- let index = 0;
1207
- const addText = (start, end, rawText, sourceStart = start, sourceEnd = end) => {
1208
- if (!rawText) return;
1209
- const path = elements.map((element) => element.name);
1210
- const parent = elements[elements.length - 1];
1211
- nodes.push({
1212
- context: {
1213
- ancestorTags: path.slice(0, -1),
1214
- parentAttributes: { ...parent?.attributes ?? {} },
1215
- parentTag: parent?.name ?? "",
1216
- path
1217
- },
1218
- decodedText: decodeXmlText(rawText),
1219
- end,
1220
- sourceEnd,
1221
- sourceStart,
1222
- start
1223
- });
1224
- };
1225
- while (index < source.length) {
1226
- if (source[index] !== "<") {
1227
- const nextTag = source.indexOf("<", index);
1228
- const end2 = nextTag === -1 ? source.length : nextTag;
1229
- addText(index, end2, source.slice(index, end2));
1230
- index = end2;
1231
- continue;
1232
- }
1233
- if (source.startsWith("<!--", index)) {
1234
- const end2 = source.indexOf("-->", index + 4);
1235
- index = end2 === -1 ? source.length : end2 + 3;
1236
- continue;
1237
- }
1238
- if (source.startsWith("<![CDATA[", index)) {
1239
- const contentStart = index + 9;
1240
- const end2 = source.indexOf("]]>", contentStart);
1241
- const contentEnd = end2 === -1 ? source.length : end2;
1242
- addText(
1243
- contentStart,
1244
- contentEnd,
1245
- source.slice(contentStart, contentEnd),
1246
- index,
1247
- end2 === -1 ? source.length : end2 + 3
1248
- );
1249
- index = end2 === -1 ? source.length : end2 + 3;
1250
- continue;
1251
- }
1252
- if (source.startsWith("<?", index)) {
1253
- const end2 = source.indexOf("?>", index + 2);
1254
- index = end2 === -1 ? source.length : end2 + 2;
1255
- continue;
1256
- }
1257
- if (source.startsWith("</", index)) {
1258
- const end2 = findTagEnd(source, index + 2);
1259
- elements.pop();
1260
- index = end2 + 1;
1261
- continue;
1262
- }
1263
- const end = findTagEnd(source, index + 1);
1264
- const tag = source.slice(index, end + 1);
1265
- const name = readTagName(tag);
1266
- if (name && !/\/\s*>$/.test(tag)) elements.push({ attributes: readTagAttributes(tag, name), name });
1267
- index = end + 1;
1268
- }
1269
- return nodes;
1270
- }
1271
- function extractSsmlText(ssml) {
1272
- parseSsml(ssml);
1273
- return collectTextNodes(ssml).map((node) => node.decodedText);
1274
- }
1275
- async function mapSsmlTextNodes(ssml, transform, options = {}) {
1276
- parseSsml(ssml);
1277
- const nodes = collectTextNodes(ssml);
1278
- const skipTags = new Set((options.skipTags ?? ["phoneme", "say-as", "sub"]).map((tag) => tag.toLowerCase()));
1279
- const replacements = await Promise.all(
1280
- nodes.map(async (node) => {
1281
- const context = {
1282
- ancestorTags: [...node.context.ancestorTags],
1283
- parentAttributes: { ...node.context.parentAttributes },
1284
- parentTag: node.context.parentTag,
1285
- path: [...node.context.path]
1286
- };
1287
- const shouldTransform = !skipTags.has(context.parentTag.toLowerCase()) && (options.filter?.(context) ?? true);
1288
- if (!shouldTransform) return ssml.slice(node.sourceStart, node.sourceEnd);
1289
- const transformed = await transform(node.decodedText, context);
1290
- if (typeof transformed !== "string") {
1291
- throw new TypeError("SSML text node transform must return a string");
1292
- }
1293
- return transformed === node.decodedText ? ssml.slice(node.sourceStart, node.sourceEnd) : encodeXmlText(transformed);
1294
- })
1295
- );
1296
- let result = "";
1297
- let cursor = 0;
1298
- nodes.forEach((node, nodeIndex) => {
1299
- result += ssml.slice(cursor, node.sourceStart) + replacements[nodeIndex];
1300
- cursor = node.sourceEnd;
1301
- });
1302
- return result + ssml.slice(cursor);
1303
- }
1304
-
1305
1414
  // packages/ssml-core/src/migration.ts
1306
1415
  var DEFAULT_TRANSLATION_SKIP_TAGS = ["phoneme", "say-as", "sayAs", "sub"];
1307
1416
  function elementName2(element) {
@@ -1722,34 +1831,51 @@ function createAzureUrlValidatorRunner(validator, options = {}) {
1722
1831
  const inFlight = /* @__PURE__ */ new Map();
1723
1832
  const waiters = [];
1724
1833
  let active = 0;
1725
- const acquire = async () => {
1834
+ const configuredSignal = options.signal ?? new AbortController().signal;
1835
+ const acquire = async (signal) => {
1836
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1726
1837
  if (active < concurrency) {
1727
1838
  active += 1;
1728
1839
  return;
1729
1840
  }
1730
- await new Promise((resolve) => waiters.push(resolve));
1841
+ await new Promise((resolve, reject) => {
1842
+ let waiter;
1843
+ const abortHandler = () => {
1844
+ const index = waiters.indexOf(waiter);
1845
+ if (index >= 0) waiters.splice(index, 1);
1846
+ signal.removeEventListener("abort", abortHandler);
1847
+ reject(new Error("URL validation was aborted."));
1848
+ };
1849
+ signal.addEventListener("abort", abortHandler, { once: true });
1850
+ waiter = () => {
1851
+ signal.removeEventListener("abort", abortHandler);
1852
+ resolve();
1853
+ };
1854
+ waiters.push(waiter);
1855
+ });
1731
1856
  active += 1;
1732
1857
  };
1733
1858
  const release = () => {
1734
1859
  active -= 1;
1735
1860
  waiters.shift()?.();
1736
1861
  };
1737
- const check = async (url, context) => {
1738
- if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1739
- const cached = cache.get(url);
1862
+ const check = async (url, context, signal = configuredSignal) => {
1863
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1864
+ const key = `${context.tag}:${context.attribute}:${url}`;
1865
+ const cached = cache.get(key);
1740
1866
  if (cached !== void 0) return cached;
1741
- const existing = inFlight.get(url);
1867
+ const existing = inFlight.get(key);
1742
1868
  if (existing) return existing;
1743
1869
  const promise = (async () => {
1744
- await acquire();
1870
+ await acquire(signal);
1745
1871
  try {
1746
- if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1747
- const validation = Promise.resolve(validator(url, context));
1872
+ if (signal.aborted) throw new Error("URL validation was aborted.");
1873
+ const validation = Promise.resolve(validator(url, context, signal));
1748
1874
  let timer;
1749
1875
  let abortHandler;
1750
1876
  const cancellation = new Promise((_resolve, reject) => {
1751
1877
  abortHandler = () => reject(new Error("URL validation was aborted."));
1752
- options.signal?.addEventListener("abort", abortHandler, { once: true });
1878
+ signal.addEventListener("abort", abortHandler, { once: true });
1753
1879
  if (options.timeoutMs !== void 0 && options.timeoutMs > 0) {
1754
1880
  timer = setTimeout(
1755
1881
  () => reject(new Error(`URL validation timed out after ${options.timeoutMs} ms.`)),
@@ -1759,24 +1885,24 @@ function createAzureUrlValidatorRunner(validator, options = {}) {
1759
1885
  });
1760
1886
  try {
1761
1887
  const result = await (timer || abortHandler ? Promise.race([validation, cancellation]) : validation);
1762
- cache.set(url, result);
1888
+ cache.set(key, result);
1763
1889
  return result;
1764
1890
  } finally {
1765
1891
  if (timer) clearTimeout(timer);
1766
- if (abortHandler) options.signal?.removeEventListener("abort", abortHandler);
1892
+ if (abortHandler) signal.removeEventListener("abort", abortHandler);
1767
1893
  }
1768
1894
  } finally {
1769
1895
  release();
1770
1896
  }
1771
1897
  })();
1772
- inFlight.set(url, promise);
1898
+ inFlight.set(key, promise);
1773
1899
  try {
1774
1900
  return await promise;
1775
1901
  } finally {
1776
- inFlight.delete(url);
1902
+ inFlight.delete(key);
1777
1903
  }
1778
1904
  };
1779
- return (url, context) => check(url, context);
1905
+ return (url, context, signal) => check(url, context, signal ?? configuredSignal);
1780
1906
  }
1781
1907
  var ALLOWED_BREAK_STRENGTHS = /* @__PURE__ */ new Set(["none", "x-weak", "weak", "medium", "strong", "x-strong"]);
1782
1908
  var ALLOWED_SAY_AS = /* @__PURE__ */ new Set([
@@ -2432,6 +2558,7 @@ function validateAzureSsml(ssml, options = {}) {
2432
2558
  ...options.urlValidatorSignal ? { signal: options.urlValidatorSignal } : {},
2433
2559
  ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
2434
2560
  });
2561
+ const validationSignal = options.urlValidatorSignal ?? options.urlValidation?.signal ?? new AbortController().signal;
2435
2562
  let tokens;
2436
2563
  try {
2437
2564
  tokens = tokenizeElements(ssml);
@@ -2441,7 +2568,11 @@ function validateAzureSsml(ssml, options = {}) {
2441
2568
  const checks = tokens.flatMap(
2442
2569
  (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2443
2570
  try {
2444
- const result = await boundedValidator(value, { tag: token.name, attribute });
2571
+ const result = await boundedValidator(
2572
+ value,
2573
+ { tag: token.name, attribute, ...options.sourceNodePath ? { sourceNodePath: options.sourceNodePath } : {} },
2574
+ validationSignal
2575
+ );
2445
2576
  const valid = typeof result === "boolean" ? result : result.valid;
2446
2577
  if (!valid) {
2447
2578
  const reason = typeof result === "boolean" ? void 0 : result.reason;
@@ -2487,10 +2618,11 @@ export {
2487
2618
  buildSsml,
2488
2619
  parseSsml,
2489
2620
  buildPartialSsml,
2490
- splitSsmlDocument,
2491
- validateSsml,
2621
+ getSsmlSourceMap,
2492
2622
  extractSsmlText,
2493
2623
  mapSsmlTextNodes,
2624
+ splitSsmlDocument,
2625
+ validateSsml,
2494
2626
  extractSsmlTranslatableText,
2495
2627
  fromPlainTextToSsml,
2496
2628
  validateSsmlStructureIntegrity,
@@ -2502,4 +2634,4 @@ export {
2502
2634
  getAzureVoiceCatalogMetadata,
2503
2635
  getBuiltInVoiceCatalogMetadata
2504
2636
  };
2505
- //# sourceMappingURL=chunk-AQ55MOPU.mjs.map
2637
+ //# sourceMappingURL=chunk-FXUM45ZY.mjs.map