@tradik/xslt-processor 1.1.1 → 1.3.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 (122) hide show
  1. package/LICENSE.md +1 -1
  2. package/README.md +102 -757
  3. package/bin/lib/decode.js +15 -0
  4. package/bin/lib/dom.js +177 -0
  5. package/bin/lib/loaders.js +127 -0
  6. package/bin/lib/options.js +17 -0
  7. package/bin/lib/output.js +114 -0
  8. package/bin/lib/paths.js +3 -3
  9. package/bin/lib/transform.js +124 -33
  10. package/bin/xslt.js +26 -27
  11. package/dist/xslt-processor.browser.js +8784 -2720
  12. package/dist/xslt-processor.browser.js.map +4 -4
  13. package/dist/xslt-processor.browser.min.js +13 -6
  14. package/dist/xslt-processor.browser.min.js.map +4 -4
  15. package/dist/xslt-processor.cjs +8789 -2723
  16. package/dist/xslt-processor.cjs.map +4 -4
  17. package/dist/xslt-processor.d.cts +380 -21
  18. package/dist/xslt-processor.d.ts +380 -21
  19. package/dist/xslt-processor.js +8770 -2722
  20. package/dist/xslt-processor.js.map +4 -4
  21. package/package.json +51 -11
  22. package/src/XSLTProcessor.js +343 -66
  23. package/src/async/abort.js +63 -0
  24. package/src/async/documentUris.js +128 -0
  25. package/src/async/loaders.js +134 -0
  26. package/src/async/preload.js +159 -0
  27. package/src/async/processor.js +206 -0
  28. package/src/async/stream.js +125 -0
  29. package/src/bridge/engine.js +221 -0
  30. package/src/bridge/loader.js +78 -0
  31. package/src/bridge/results.js +75 -0
  32. package/src/bridge/version.js +63 -0
  33. package/src/index.js +16 -4
  34. package/src/io/decode.js +140 -0
  35. package/src/io/readSource.js +167 -0
  36. package/src/xpath/axes.js +562 -0
  37. package/src/xpath/documentOrder.js +270 -0
  38. package/src/xpath/evaluator.js +475 -355
  39. package/src/xpath/index.js +8 -2
  40. package/src/xpath/namespaceNodes.js +172 -0
  41. package/src/xpath/nodeSetFunctions.js +169 -0
  42. package/src/xpath/parser.js +30 -5
  43. package/src/xpath/strings.js +183 -0
  44. package/src/xpath/tokenizer.js +37 -23
  45. package/src/xslt/attributeSets.js +95 -0
  46. package/src/xslt/avt.js +103 -0
  47. package/src/xslt/computedNames.js +91 -0
  48. package/src/xslt/copying.js +212 -0
  49. package/src/xslt/declarationNames.js +80 -0
  50. package/src/xslt/domParsing.js +95 -0
  51. package/src/xslt/elements.js +1 -1
  52. package/src/xslt/engine/bindings.js +195 -0
  53. package/src/xslt/engine/context.js +105 -0
  54. package/src/xslt/engine/controlFlow.js +145 -0
  55. package/src/xslt/engine/copyInstructions.js +133 -0
  56. package/src/xslt/engine/declarations.js +233 -0
  57. package/src/xslt/engine/functionSupport.js +103 -0
  58. package/src/xslt/engine/methods.js +33 -0
  59. package/src/xslt/engine/nodeConstruction.js +187 -0
  60. package/src/xslt/engine/numbering.js +104 -0
  61. package/src/xslt/engine/outputDeclaration.js +77 -0
  62. package/src/xslt/engine/sequenceConstructor.js +228 -0
  63. package/src/xslt/engine/stylesheetLoading.js +208 -0
  64. package/src/xslt/engine/templateInvocation.js +253 -0
  65. package/src/xslt/engine/templateRules.js +243 -0
  66. package/src/xslt/engine/textInstructions.js +171 -0
  67. package/src/xslt/engine/topLevel.js +130 -0
  68. package/src/xslt/engine/transformation.js +263 -0
  69. package/src/xslt/engine/workStack.js +245 -0
  70. package/src/xslt/engine.js +176 -2020
  71. package/src/xslt/exslt/arguments.js +99 -0
  72. package/src/xslt/exslt/calendar.js +120 -0
  73. package/src/xslt/exslt/common.js +44 -0
  74. package/src/xslt/exslt/dateCalc.js +261 -0
  75. package/src/xslt/exslt/dateFormat.js +150 -0
  76. package/src/xslt/exslt/dateParse.js +265 -0
  77. package/src/xslt/exslt/dates.js +259 -0
  78. package/src/xslt/exslt/duration.js +207 -0
  79. package/src/xslt/exslt/dynamic.js +59 -0
  80. package/src/xslt/exslt/index.js +59 -0
  81. package/src/xslt/exslt/math.js +177 -0
  82. package/src/xslt/exslt/sets.js +96 -0
  83. package/src/xslt/exslt/stringOps.js +163 -0
  84. package/src/xslt/exslt/strings.js +147 -0
  85. package/src/xslt/exslt/uri.js +92 -0
  86. package/src/xslt/formatNumber.js +22 -9
  87. package/src/xslt/forwardsCompatible.js +75 -0
  88. package/src/xslt/functions.js +94 -15
  89. package/src/xslt/index.js +7 -1
  90. package/src/xslt/keys.js +51 -28
  91. package/src/xslt/literalResult.js +63 -7
  92. package/src/xslt/matchScope.js +116 -0
  93. package/src/xslt/number.js +171 -78
  94. package/src/xslt/numberFormat.js +124 -26
  95. package/src/xslt/outputNames.js +58 -0
  96. package/src/xslt/patternCompiler.js +175 -0
  97. package/src/xslt/patterns.js +324 -0
  98. package/src/xslt/qname.js +90 -0
  99. package/src/xslt/resultDocument.js +98 -0
  100. package/src/xslt/resultNamespaces.js +219 -0
  101. package/src/xslt/resultTree.js +143 -6
  102. package/src/xslt/serializer/baseWriter.js +173 -66
  103. package/src/xslt/serializer/chunks.js +120 -0
  104. package/src/xslt/serializer/constants.js +14 -0
  105. package/src/xslt/serializer/encoding.js +327 -0
  106. package/src/xslt/serializer/escape.js +49 -12
  107. package/src/xslt/serializer/frames.js +168 -0
  108. package/src/xslt/serializer/htmlDoctype.js +102 -0
  109. package/src/xslt/serializer/htmlEntities.js +77 -0
  110. package/src/xslt/serializer/htmlSerializer.js +123 -25
  111. package/src/xslt/serializer/settings.js +89 -13
  112. package/src/xslt/serializer/textSerializer.js +58 -10
  113. package/src/xslt/serializer/xhtmlDocument.js +103 -0
  114. package/src/xslt/serializer/xmlSerializer.js +113 -13
  115. package/src/xslt/serializer.js +50 -17
  116. package/src/xslt/sort.js +151 -0
  117. package/src/xslt/spaceNameTests.js +115 -0
  118. package/src/xslt/stylesheetChecks.js +206 -0
  119. package/src/xslt/stylesheetNamespaces.js +266 -0
  120. package/src/xslt/variables.js +152 -0
  121. package/src/xslt/whitespace.js +43 -27
  122. package/LICENSE +0 -29
@@ -1,178 +1,271 @@
1
1
  /**
2
- * `xsl:number` counting and number-to-string conversion.
2
+ * `xsl:number` counting (XSLT 1.0 section 7.7).
3
3
  *
4
4
  * Counting is kept independent from the engine: callers pass a `matcher`
5
5
  * callback that answers "does this node match this XSLT pattern", which keeps
6
- * this module free of any XPath dependency and easy to test in isolation.
6
+ * this module free of the XPath evaluator and easy to test in isolation.
7
+ *
8
+ * Numbering every node of a long list would be quadratic if each call counted
9
+ * from scratch, so a call may be given a memo (one per instruction and
10
+ * transformation, see {@link isMemoizable}) remembering the numbers already
11
+ * computed: a later call stops at the nearest numbered node. The tree is
12
+ * walked with previousSibling/lastChild/parentNode only.
7
13
  */
8
14
 
9
15
  "use strict";
10
16
 
17
+ import { isParserArtifact } from "../xpath/axes.js";
18
+
11
19
  /** Node types that participate in `xsl:number` counting. */
12
20
  const COUNTABLE_NODE_TYPES = new Set([1, 3, 4, 7, 8]);
13
21
 
14
22
  /**
15
- * Default `count` pattern behaviour: match nodes of the same type and name.
23
+ * Whether a node takes part in counting: an element, text, processing
24
+ * instruction or comment of the data model (not a parser artifact, see
25
+ * axes.js).
26
+ *
27
+ * @param {Node} node - Any node
28
+ * @returns {boolean} True for countable nodes
29
+ */
30
+ function isCountable(node) {
31
+ return COUNTABLE_NODE_TYPES.has(node.nodeType) && !isParserArtifact(node);
32
+ }
33
+
34
+ /**
35
+ * The XPath node kind of a DOM node: CDATA sections are text nodes.
36
+ *
37
+ * @param {Node} node - Any node
38
+ * @returns {number} The DOM node type, 3 for CDATA sections
39
+ */
40
+ function nodeKind(node) {
41
+ return node.nodeType === 4 ? 3 : node.nodeType;
42
+ }
43
+
44
+ /**
45
+ * Default `count` pattern: nodes of the same kind as the numbered node and,
46
+ * for elements and attributes, the same expanded name; for processing
47
+ * instructions, the same target.
16
48
  *
17
49
  * @param {Node} candidate - The node being considered
18
50
  * @param {Node} node - The node `xsl:number` is numbering
19
- * @returns {boolean} True when the candidate is of the same kind
51
+ * @returns {boolean} True when the candidate is counted
20
52
  */
21
53
  function matchesDefaultCount(candidate, node) {
22
- if (candidate.nodeType !== node.nodeType) return false;
23
- if (candidate.nodeType === 1) return candidate.nodeName === node.nodeName;
24
- return true;
54
+ if (nodeKind(candidate) !== nodeKind(node)) return false;
55
+ switch (node.nodeType) {
56
+ case 1:
57
+ case 2:
58
+ return (
59
+ candidate.localName === node.localName &&
60
+ (candidate.namespaceURI ?? null) === (node.namespaceURI ?? null)
61
+ );
62
+ case 7:
63
+ return candidate.target === node.target;
64
+ default:
65
+ return true;
66
+ }
25
67
  }
26
68
 
27
69
  /**
28
- * Build the predicate used to decide whether a node is counted.
70
+ * Key telling apart the node kinds the default `count` pattern selects, so
71
+ * memoized numbers are only reused for the same kind.
29
72
  *
30
- * @param {Node} node - The node being numbered
31
- * @param {string|null} count - The `count` pattern, if any
32
- * @param {(node: Node, pattern: string) => boolean} matcher - Pattern matcher
33
- * @returns {(candidate: Node) => boolean} The predicate
73
+ * @param {Node} node - The numbered node
74
+ * @returns {string} e.g. `1|urn:x|item`
34
75
  */
35
- function createCountPredicate(node, count, matcher) {
36
- if (count) return (candidate) => matcher(candidate, count);
37
- return (candidate) => matchesDefaultCount(candidate, node);
76
+ function defaultCountKey(node) {
77
+ if (node.nodeType === 7) return `7|${node.target}`;
78
+ if (node.nodeType === 1 || node.nodeType === 2) {
79
+ return `${node.nodeType}|${node.namespaceURI ?? ""}|${node.localName}`;
80
+ }
81
+ return String(nodeKind(node));
38
82
  }
39
83
 
40
84
  /**
41
- * Build the predicate marking `from` boundaries.
85
+ * Whether the numbers of an instruction may be memoized: its patterns must
86
+ * not depend on variables or on the current node, whose values may differ
87
+ * between invocations of the same instruction.
42
88
  *
43
- * @param {string|null} from - The `from` pattern, if any
44
- * @param {(node: Node, pattern: string) => boolean} matcher - Pattern matcher
45
- * @returns {(candidate: Node) => boolean} The predicate, always false without `from`
89
+ * @param {string|null} count - The `count` pattern
90
+ * @param {string|null} from - The `from` pattern
91
+ * @returns {boolean} True when counting only depends on the source tree
92
+ *
93
+ * @example
94
+ * isMemoizable("item", null); // true
95
+ * isMemoizable("item[@k=$k]", null); // false
46
96
  */
47
- function createFromPredicate(from, matcher) {
48
- if (!from) return () => false;
49
- return (candidate) => matcher(candidate, from);
97
+ export function isMemoizable(count, from) {
98
+ return !/\$|current\s*\(/.test(`${count ?? ""} ${from ?? ""}`);
50
99
  }
51
100
 
52
101
  /**
53
- * Count preceding siblings of a node that satisfy the predicate.
102
+ * Count a node's preceding siblings satisfying the predicate, stopping at the
103
+ * nearest one whose position is memoized.
54
104
  *
55
- * @param {Node} node - The node whose position is computed
105
+ * @param {Node} node - The (counted) node whose position is computed
56
106
  * @param {(candidate: Node) => boolean} isCounted - Counting predicate
107
+ * @param {WeakMap<Node, number>|null} positions - Memoized positions
57
108
  * @returns {number} The 1-based position
58
109
  */
59
- function siblingPosition(node, isCounted) {
110
+ function siblingPosition(node, isCounted, positions) {
111
+ const own = positions?.get(node);
112
+ if (own !== undefined) return own;
113
+
60
114
  let position = 1;
61
- let sibling = node.previousSibling;
62
- while (sibling) {
63
- if (COUNTABLE_NODE_TYPES.has(sibling.nodeType) && isCounted(sibling)) {
115
+ for (let sibling = node.previousSibling; sibling;) {
116
+ const known = positions?.get(sibling);
117
+ if (known !== undefined) {
118
+ position += known;
119
+ break;
120
+ }
121
+ if (isCountable(sibling) && isCounted(sibling)) {
64
122
  position++;
65
123
  }
66
124
  sibling = sibling.previousSibling;
67
125
  }
126
+ positions?.set(node, position);
68
127
  return position;
69
128
  }
70
129
 
71
130
  /**
72
- * Collect nodes in document order up to and including a target node.
131
+ * Whether a node hangs off an element without being its child: an
132
+ * attribute or a namespace node, whose parent is `ownerElement`.
73
133
  *
74
- * @param {Node} target - The node at which traversal stops
75
- * @returns {Node[]} Nodes in document order, ending with the target
134
+ * @param {Node} node - Any node
135
+ * @returns {boolean} True for attribute and namespace nodes
76
136
  */
77
- function nodesUpToTarget(target) {
78
- const root = target.ownerDocument || target;
79
- const result = [];
80
- const stack = [root];
81
-
82
- while (stack.length > 0) {
83
- const current = stack.pop();
84
- result.push(current);
85
- if (current === target) break;
86
- const children = current.childNodes;
87
- if (children) {
88
- for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
89
- }
90
- }
91
-
92
- return result;
137
+ function isAttachedNode(node) {
138
+ return node.nodeType === 2 || node.nodeType === 13;
93
139
  }
94
140
 
95
141
  /**
96
- * Count a node according to `level="single"`.
142
+ * The node before another one in document order (its preceding node or its
143
+ * parent).
97
144
  *
98
- * @param {Node} node - The node being numbered
99
- * @param {(candidate: Node) => boolean} isCounted - Counting predicate
100
- * @param {(candidate: Node) => boolean} isFrom - Boundary predicate
101
- * @returns {number[]} A single number, or an empty list when nothing matches
145
+ * @param {Node} node - A child node
146
+ * @returns {Node|null} The previous node, null at the root
102
147
  */
103
- function countSingle(node, isCounted, isFrom) {
104
- let current = node;
105
- while (current && current.nodeType !== 9) {
106
- if (isFrom(current)) return [];
107
- if (isCounted(current)) return [siblingPosition(current, isCounted)];
108
- current = current.parentNode;
109
- }
110
- return [];
148
+ function previousInDocumentOrder(node) {
149
+ let previous = node.previousSibling;
150
+ if (!previous) return node.parentNode;
151
+ while (previous.lastChild) previous = previous.lastChild;
152
+ return previous;
111
153
  }
112
154
 
113
155
  /**
114
- * Count a node according to `level="multiple"`.
156
+ * Count the ancestors-or-self of a node according to `level="single"` or
157
+ * `level="multiple"`.
115
158
  *
116
159
  * @param {Node} node - The node being numbered
160
+ * @param {boolean} multiple - Whether every counted ancestor is numbered
117
161
  * @param {(candidate: Node) => boolean} isCounted - Counting predicate
118
162
  * @param {(candidate: Node) => boolean} isFrom - Boundary predicate
163
+ * @param {WeakMap<Node, number>|null} positions - Memoized positions
119
164
  * @returns {number[]} Numbers from the outermost ancestor inwards
120
165
  */
121
- function countMultiple(node, isCounted, isFrom) {
166
+ function countAncestors(node, multiple, isCounted, isFrom, positions) {
122
167
  const numbers = [];
123
168
  let current = node;
124
169
 
125
170
  while (current && current.nodeType !== 9) {
126
171
  if (isFrom(current)) break;
127
172
  if (isCounted(current)) {
128
- numbers.unshift(siblingPosition(current, isCounted));
173
+ numbers.unshift(siblingPosition(current, isCounted, positions));
174
+ if (!multiple) break;
129
175
  }
130
- current = current.parentNode;
176
+ current = isAttachedNode(current)
177
+ ? current.ownerElement
178
+ : current.parentNode;
131
179
  }
132
180
 
133
181
  return numbers;
134
182
  }
135
183
 
136
184
  /**
137
- * Count a node according to `level="any"`.
185
+ * Count a node according to `level="any"`: walk backwards in document order
186
+ * until a `from` node, the root, or a node whose total is memoized. An
187
+ * attribute or namespace node counts itself, then its element and the nodes
188
+ * before it: other attributes are neither preceding nor ancestor nodes (as
189
+ * in libxslt).
138
190
  *
139
191
  * @param {Node} node - The node being numbered
140
192
  * @param {(candidate: Node) => boolean} isCounted - Counting predicate
141
193
  * @param {(candidate: Node) => boolean} isFrom - Boundary predicate
194
+ * @param {WeakMap<Node, number>|null} totals - Memoized totals
142
195
  * @returns {number[]} A single number, or an empty list when nothing matches
143
196
  */
144
- function countAny(node, isCounted, isFrom) {
197
+ function countAny(node, isCounted, isFrom, totals) {
145
198
  let total = 0;
199
+ let current = node;
200
+ if (isAttachedNode(node)) {
201
+ if (isFrom(node)) return [];
202
+ if (isCounted(node)) total++;
203
+ current = node.ownerElement;
204
+ }
146
205
 
147
- for (const candidate of nodesUpToTarget(node)) {
148
- if (!COUNTABLE_NODE_TYPES.has(candidate.nodeType)) continue;
149
- if (isFrom(candidate)) {
150
- total = 0;
151
- continue;
206
+ while (current && current.nodeType !== 9) {
207
+ const known = totals?.get(current);
208
+ if (known !== undefined) {
209
+ total += known;
210
+ break;
211
+ }
212
+ if (isCountable(current)) {
213
+ if (isFrom(current)) break;
214
+ if (isCounted(current)) total++;
152
215
  }
153
- if (isCounted(candidate)) total++;
216
+ current = previousInDocumentOrder(current);
154
217
  }
155
218
 
219
+ totals?.set(node, total);
156
220
  return total > 0 ? [total] : [];
157
221
  }
158
222
 
223
+ /**
224
+ * The memo tables of one kind of counted node.
225
+ *
226
+ * @param {Map<string, {positions: WeakMap, totals: WeakMap}>|null} memo - The instruction's memo
227
+ * @param {string} key - The counted kind
228
+ * @returns {{positions: WeakMap, totals: WeakMap}|null} The tables, null without memo
229
+ */
230
+ function memoTables(memo, key) {
231
+ if (!memo) return null;
232
+ let tables = memo.get(key);
233
+ if (!tables) {
234
+ tables = { positions: new WeakMap(), totals: new WeakMap() };
235
+ memo.set(key, tables);
236
+ }
237
+ return tables;
238
+ }
239
+
159
240
  /**
160
241
  * Compute the number sequence for an `xsl:number` instruction.
161
242
  *
162
243
  * @param {Node} node - The current node
163
244
  * @param {{level?: string, count?: string|null, from?: string|null}} options - Instruction attributes
164
245
  * @param {(node: Node, pattern: string) => boolean} matcher - XSLT pattern matcher
246
+ * @param {Map|null} [memo] - Memo of the instruction for the current
247
+ * transformation (a Map owned by the caller), or null to count from scratch
165
248
  * @returns {number[]} The computed numbers, outermost first
166
249
  *
167
250
  * @example
168
251
  * countXsltNumber(item, { level: 'any' }, matcher); // [2]
169
252
  */
170
- export function countXsltNumber(node, options, matcher) {
253
+ export function countXsltNumber(node, options, matcher, memo = null) {
171
254
  const { level = "single", count = null, from = null } = options;
172
- const isCounted = createCountPredicate(node, count, matcher);
173
- const isFrom = createFromPredicate(from, matcher);
255
+ const isCounted = count
256
+ ? (candidate) => matcher(candidate, count)
257
+ : (candidate) => matchesDefaultCount(candidate, node);
258
+ const isFrom = from ? (candidate) => matcher(candidate, from) : () => false;
259
+ const tables = memoTables(memo, count ? "" : defaultCountKey(node));
174
260
 
175
- if (level === "any") return countAny(node, isCounted, isFrom);
176
- if (level === "multiple") return countMultiple(node, isCounted, isFrom);
177
- return countSingle(node, isCounted, isFrom);
261
+ if (level === "any") {
262
+ return countAny(node, isCounted, isFrom, tables?.totals ?? null);
263
+ }
264
+ return countAncestors(
265
+ node,
266
+ level === "multiple",
267
+ isCounted,
268
+ isFrom,
269
+ tables?.positions ?? null,
270
+ );
178
271
  }
@@ -2,9 +2,15 @@
2
2
  * `xsl:number` number-to-string conversion.
3
3
  *
4
4
  * Renders the number sequence produced by {@link countXsltNumber} using the
5
- * `format` attribute of `xsl:number`: numeric tokens (`1`, `01`), alphabetic
6
- * tokens (`a`, `A`) and Roman numerals (`i`, `I`), together with the prefix,
7
- * separators and suffix taken from the format string itself.
5
+ * `format` attribute of `xsl:number`: numeric tokens (`1`, `01`, and the
6
+ * same in any Unicode digit family, e.g. `٠١`), alphabetic tokens (`a`, `A`)
7
+ * and Roman numerals (`i`, `I`), together with the prefix, separators and
8
+ * suffix taken from the format string itself.
9
+ *
10
+ * Extreme values follow libxslt: a negative number is formatted as 0, NaN
11
+ * and Infinity as by `string()`, alphabetic and Roman tokens use decimals
12
+ * below 1 (and Roman ones above 5000), and every conversion takes O(log n)
13
+ * steps, so a huge value cannot stall the transformation.
8
14
  */
9
15
 
10
16
  "use strict";
@@ -46,6 +52,9 @@ const ROMAN_NUMERALS = Object.freeze([
46
52
  ["I", 1],
47
53
  ]);
48
54
 
55
+ /** Largest number written with Roman numerals, as in libxslt. */
56
+ const MAX_ROMAN = 5000;
57
+
49
58
  /**
50
59
  * Convert a positive integer to a Roman numeral.
51
60
  *
@@ -60,41 +69,124 @@ export function toRoman(value) {
60
69
  let result = "";
61
70
 
62
71
  for (const [numeral, amount] of ROMAN_NUMERALS) {
63
- while (remaining >= amount) {
64
- result += numeral;
65
- remaining -= amount;
66
- }
72
+ result += numeral.repeat(Math.floor(remaining / amount));
73
+ remaining %= amount;
67
74
  }
68
75
 
69
76
  return result;
70
77
  }
71
78
 
79
+ /**
80
+ * Insert a grouping separator every `size` digits, counting from the right.
81
+ *
82
+ * @param {string} digits - The decimal digits
83
+ * @param {{separator?: string, size?: number}} grouping - The grouping settings
84
+ * @returns {string} The grouped digits
85
+ */
86
+ function groupDigits(digits, { separator, size }) {
87
+ if (!separator || Number.isNaN(size) || size <= 0) return digits;
88
+
89
+ let result = "";
90
+ for (let end = digits.length; end > 0; end -= size) {
91
+ const group = digits.slice(Math.max(0, end - size), end);
92
+ result = result ? `${group}${separator}${result}` : group;
93
+ }
94
+ return result;
95
+ }
96
+
97
+ /** A Unicode decimal digit (general category Nd). */
98
+ const DECIMAL_DIGIT = /^\p{Nd}$/u;
99
+
100
+ /**
101
+ * Whether a code point is a Unicode decimal digit.
102
+ *
103
+ * @param {number} codePoint - Any code point
104
+ * @returns {boolean} True for characters of category Nd
105
+ */
106
+ function isDecimalDigit(codePoint) {
107
+ return DECIMAL_DIGIT.test(String.fromCodePoint(codePoint));
108
+ }
109
+
110
+ /**
111
+ * The zero of the digit family of a decimal format token: a token whose
112
+ * last character has the digit value 1 and whose other characters are the
113
+ * zero of that family (XSLT 1.0 section 7.7.1), e.g. `1`, `01`, `٠١`.
114
+ * Digit families are runs of ten code points, which may follow each other
115
+ * (the mathematical digits), so the value is counted from the run start.
116
+ *
117
+ * @param {string} token - A format token
118
+ * @returns {number|null} The code point of the family's zero, or null
119
+ */
120
+ function decimalTokenZero(token) {
121
+ const digits = Array.from(token, (char) => char.codePointAt(0));
122
+ const one = digits.at(-1);
123
+ if (!isDecimalDigit(one)) return null;
124
+ let start = one;
125
+ while (isDecimalDigit(start - 1)) start--;
126
+ const zero = one - 1;
127
+ if ((one - start) % 10 !== 1) return null;
128
+ return digits.slice(0, -1).every((digit) => digit === zero) ? zero : null;
129
+ }
130
+
131
+ /**
132
+ * The decimal digits of a non-negative integer, without exponent notation.
133
+ *
134
+ * @param {number} value - A finite, non-negative integer
135
+ * @returns {string} Its ASCII decimal digits
136
+ */
137
+ function decimalDigits(value) {
138
+ return Number.isSafeInteger(value) ? String(value) : BigInt(value).toString();
139
+ }
140
+
141
+ /**
142
+ * Write a number with decimal digits of a family, padded with zeros to a
143
+ * minimum width and grouped.
144
+ *
145
+ * @param {number} value - A finite, non-negative integer
146
+ * @param {number} zero - Code point of the family's zero
147
+ * @param {number} width - Minimum number of digits
148
+ * @param {{separator?: string, size?: number}} grouping - Digit grouping
149
+ * @returns {string} The rendered number
150
+ */
151
+ function formatDecimal(value, zero, width, grouping) {
152
+ const ascii = decimalDigits(value).padStart(width, "0");
153
+ const digits =
154
+ zero === 0x30
155
+ ? ascii
156
+ : Array.from(ascii, (digit) =>
157
+ String.fromCodePoint(zero + Number(digit)),
158
+ ).join("");
159
+ return groupDigits(digits, grouping);
160
+ }
161
+
72
162
  /**
73
163
  * Render one number with a single `xsl:number` format token.
74
164
  *
75
165
  * @param {number} value - The number to render
76
166
  * @param {string} token - The format token, e.g. `1`, `01`, `a`, `I`
167
+ * @param {{separator?: string, size?: number}} grouping - Digit grouping
77
168
  * @returns {string} The rendered number
78
169
  */
79
- function formatToken(value, token) {
170
+ function formatToken(value, token, grouping) {
171
+ if (Number.isNaN(value) || value === Infinity) return String(value);
172
+ // Negative numbers are an error that libxslt recovers from with 0
173
+ const number = value < 0 ? 0 : Math.round(value);
174
+
80
175
  if (/^\d+$/.test(token)) {
81
- return String(value).padStart(token.length, "0");
176
+ return formatDecimal(number, 0x30, token.length, grouping);
177
+ }
178
+ const zero = decimalTokenZero(token);
179
+ if (zero !== null) {
180
+ return formatDecimal(number, zero, Array.from(token).length, grouping);
82
181
  }
83
182
 
84
- if (value <= 0) return String(value);
85
-
86
- switch (token) {
87
- case "a":
88
- return toAlphabetic(value, false);
89
- case "A":
90
- return toAlphabetic(value, true);
91
- case "i":
92
- return toRoman(value).toLowerCase();
93
- case "I":
94
- return toRoman(value);
95
- default:
96
- return String(value);
183
+ const alphabetic = token === "a" || token === "A";
184
+ const roman = token === "i" || token === "I";
185
+ if (number < 1 || (roman && number > MAX_ROMAN) || (!alphabetic && !roman)) {
186
+ return decimalDigits(number);
97
187
  }
188
+ if (alphabetic) return toAlphabetic(number, token === "A");
189
+ return token === "I" ? toRoman(number) : toRoman(number).toLowerCase();
98
190
  }
99
191
 
100
192
  /**
@@ -104,8 +196,8 @@ function formatToken(value, token) {
104
196
  * @returns {{prefix: string, suffix: string, tokens: string[], separators: string[]}} The parsed format
105
197
  */
106
198
  function parseFormat(format) {
107
- const parts = format.match(/[a-zA-Z0-9]+|[^a-zA-Z0-9]+/g) || [];
108
- const isToken = (part) => /^[a-zA-Z0-9]+$/.test(part);
199
+ const parts = format.match(/[\p{L}\p{N}]+|[^\p{L}\p{N}]+/gu) || [];
200
+ const isToken = (part) => /^[\p{L}\p{N}]+$/u.test(part);
109
201
 
110
202
  const tokens = [];
111
203
  const separators = [];
@@ -130,14 +222,20 @@ function parseFormat(format) {
130
222
  /**
131
223
  * Format a number sequence produced by {@link countXsltNumber}.
132
224
  *
225
+ * Decimal tokens are grouped when both `grouping.separator` and a positive
226
+ * `grouping.size` are given (the `grouping-separator` and `grouping-size`
227
+ * attributes).
228
+ *
133
229
  * @param {number[]} numbers - The numbers, outermost first
134
230
  * @param {string} [format] - The `format` attribute value
231
+ * @param {{separator?: string, size?: number}} [grouping] - Digit grouping
135
232
  * @returns {string} The formatted string, empty when there is nothing to number
136
233
  *
137
234
  * @example
138
235
  * formatXsltNumber([2, 3], '1.1'); // '2.3'
236
+ * formatXsltNumber([1234567], '1', { separator: ',', size: 3 }); // '1,234,567'
139
237
  */
140
- export function formatXsltNumber(numbers, format = "1") {
238
+ export function formatXsltNumber(numbers, format = "1", grouping = {}) {
141
239
  if (numbers.length === 0) return "";
142
240
 
143
241
  const { prefix, suffix, tokens, separators } = parseFormat(format);
@@ -148,7 +246,7 @@ export function formatXsltNumber(numbers, format = "1") {
148
246
  const separator = separators[index - 1] ?? separators.at(-1) ?? ".";
149
247
  result += separator;
150
248
  }
151
- result += formatToken(value, tokens[index] ?? tokens.at(-1));
249
+ result += formatToken(value, tokens[index] ?? tokens.at(-1), grouping);
152
250
  });
153
251
 
154
252
  return result + suffix;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * QName-valued attributes of `xsl:output` (XSLT 1.0 section 16).
3
+ *
4
+ * `cdata-section-elements` lists QNames that are expanded with the namespace
5
+ * declarations in scope on the `xsl:output` element, the default namespace
6
+ * included for unprefixed names (section 16.1). The serializer then compares
7
+ * expanded names, so the prefixes used in the result do not matter.
8
+ *
9
+ * @module xslt/outputNames
10
+ */
11
+
12
+ "use strict";
13
+
14
+ import { isQName } from "./qname.js";
15
+ import { splitQName } from "./resultNamespaces.js";
16
+ import { resolvePrefix } from "./stylesheetNamespaces.js";
17
+
18
+ /**
19
+ * @typedef {{namespaceUri: (string|null), localName: string}} ExpandedName
20
+ */
21
+
22
+ /**
23
+ * Expand the QNames of a `cdata-section-elements` attribute and add them to
24
+ * the names already declared (several xsl:output elements are merged, so the
25
+ * lists are united). Invalid names and undeclared prefixes are reported and
26
+ * skipped.
27
+ *
28
+ * @param {string} value - Whitespace separated QNames
29
+ * @param {Object<string, string>} scope - Namespaces in scope on xsl:output
30
+ * @param {ExpandedName[]} declared - Names declared by earlier xsl:output elements
31
+ * @param {(message: string) => void} warn - Reports a name that is skipped
32
+ * @returns {ExpandedName[]} The union, without duplicates
33
+ *
34
+ * @example
35
+ * cdataSectionNames("p:c d", { p: "urn:p" }, [], console.warn);
36
+ * // [{ namespaceUri: "urn:p", localName: "c" },
37
+ * // { namespaceUri: null, localName: "d" }]
38
+ */
39
+ export function cdataSectionNames(value, scope, declared, warn) {
40
+ const names = [...declared];
41
+ const seen = new Set(names.map((n) => `{${n.namespaceUri}}${n.localName}`));
42
+
43
+ for (const qname of value.split(/[ \t\r\n]+/).filter(Boolean)) {
44
+ const { prefix, localName } = splitQName(qname);
45
+ const namespaceUri = resolvePrefix(scope, prefix);
46
+ if (!isQName(qname) || (prefix && !namespaceUri)) {
47
+ warn(
48
+ `xsl:output cdata-section-elements: "${qname}" is not a QName with a declared prefix and is ignored`,
49
+ );
50
+ continue;
51
+ }
52
+ const key = `{${namespaceUri}}${localName}`;
53
+ if (seen.has(key)) continue;
54
+ seen.add(key);
55
+ names.push({ namespaceUri, localName });
56
+ }
57
+ return names;
58
+ }