@docx4j/core-ts 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -113,6 +113,11 @@ body.replaceText('(\\d+) pages', '$1 pp.', { matchRegExp: true });
113
113
  body.replaceText('(quick) (fox)', '\\2 \\1', { matchWildcards: true });
114
114
  ```
115
115
 
116
+ `range.hyperlink = 'https://example.com/'` (or `'#bookmark'` for a place in the document) makes the
117
+ range a link, styled as Word styles one: its runs take the `Hyperlink` character style, over any
118
+ character style they had, and lose their direct colour; `''` removes the links the range touches,
119
+ style and all. A document that lacks the `Hyperlink` definition gets it when it is saved.
120
+
116
121
  A style the document lacks is spliced in from docx4j's defaults, with the styles it is based on and
117
122
  linked to: `await pkg.styles.ensure(['FootnoteText', 'FootnoteReference'])` returns the ids it
118
123
  added, and a document that already has them keeps its styles part byte for byte.
@@ -226,7 +231,9 @@ body.acceptAll(); // or rejectAll(), or accept()/reject() one at a time
226
231
  ```
227
232
 
228
233
  `getTrackedChanges()` is also on `Paragraph` and `Range`. Text and searches read the accepted
229
- view throughout, so an agent sees the document as it will read once the changes are taken.
234
+ view throughout, so an agent sees the document as it will read once the changes are taken. A moved
235
+ paragraph is listed as its two halves, and accepting or rejecting either resolves the whole move,
236
+ as Word's Review tab does.
230
237
 
231
238
  To record one caller's edits without touching the document's own setting - an agent working in a
232
239
  document whose `w:trackRevisions` belongs to its owner - use `withTracking`. Nothing is written
@@ -527,11 +527,8 @@ export class Paragraph {
527
527
  const value = seg.text.substring(0, from) + middle + seg.text.substring(to);
528
528
  if (value === '')
529
529
  toRemove.push(seg);
530
- else {
531
- t.value = value;
532
- if (/^\s|\s$|\s\s/.test(value))
533
- t.space = 'preserve';
534
- }
530
+ else
531
+ setText(t, value);
535
532
  }
536
533
  else
537
534
  toRemove.push(seg);
@@ -554,9 +551,7 @@ export class Paragraph {
554
551
  if (target) {
555
552
  const t = target.item.value;
556
553
  const at = start - target.start;
557
- t.value = target.text.substring(0, at) + text + target.text.substring(at);
558
- if (/^\s|\s$|\s\s/.test(t.value))
559
- t.space = 'preserve';
554
+ setText(t, target.text.substring(0, at) + text + target.text.substring(at));
560
555
  }
561
556
  else {
562
557
  // no w:t to extend: a new run next to the segment at `start`, with that run's formatting
@@ -613,9 +608,7 @@ export class Paragraph {
613
608
  const t = seg.item.value;
614
609
  const head = seg.text.substring(0, at);
615
610
  const tail = seg.text.substring(at);
616
- t.value = head;
617
- if (/^\s|\s$|\s\s/.test(head))
618
- t.space = 'preserve';
611
+ setText(t, head);
619
612
  const rest = seg.owner.splice(seg.index + 1);
620
613
  const second = runOf([textItem(tail), ...rest], seg.run.rPr ? deepCopy(seg.run.rPr) : undefined);
621
614
  seg.runOwner.splice(seg.runIndex + 1, 0, second);
@@ -807,9 +800,7 @@ export class Paragraph {
807
800
  continue;
808
801
  if (seg.editable) {
809
802
  const t = seg.item.value;
810
- t.value = side === 'after' ? seg.text + text : text + seg.text;
811
- if (/^\s|\s$|\s\s/.test(t.value))
812
- t.space = 'preserve';
803
+ setText(t, side === 'after' ? seg.text + text : text + seg.text);
813
804
  }
814
805
  else {
815
806
  const run = runOf([textItem(text)], copyRPr(seg.run.rPr));
@@ -927,3 +918,17 @@ function outlineLevelOf(pPr) {
927
918
  const level = (_a = pPr === null || pPr === void 0 ? void 0 : pPr.outlineLvl) === null || _a === void 0 ? void 0 : _a.val;
928
919
  return level === undefined ? 10 : level + 1;
929
920
  }
921
+ /**
922
+ * Sets a `w:t`'s text with `xml:space="preserve"` exactly when the text needs it - whitespace at
923
+ * either end, or two whitespace characters together - which is how the builders' `t()` and Word
924
+ * write it. Setting the attribute and never taking it off left a text shortened from "Font styles "
925
+ * to "Font X" still preserving, the same text but not the same bytes as a `w:t` made afresh
926
+ * (CR-002 section 32 item 2).
927
+ */
928
+ function setText(t, value) {
929
+ t.value = value;
930
+ if (/^\s|\s$|\s\s/.test(value))
931
+ t.space = 'preserve';
932
+ else
933
+ delete t.space;
934
+ }
@@ -90,14 +90,43 @@ export declare class Range {
90
90
  * Setting removes the hyperlinks the range already carries and wraps its runs in one
91
91
  * `w:hyperlink`, splitting runs at the boundaries as `font` and `insertContentControl` do;
92
92
  * setting `""` removes them and wraps nothing. An external address becomes a relationship of
93
- * this range's part, which is why the part must exist (CR-002 section 20).
93
+ * this range's part, which is why the part must exist (CR-002 section 20). Only the hyperlinks
94
+ * the range touches are removed; before 0.2.1 every one in the paragraph was (section 25).
95
+ *
96
+ * The runs are styled as Word styles them, measured through Office JS (`test/README.md` check
97
+ * 15, CR-002 section 25): each run a link wraps takes the `Hyperlink` character style, over any
98
+ * character style it had, and loses its direct colour, keeping the rest of its direct formatting;
99
+ * a link removed takes the style off its runs again. The `Hyperlink` definition is added to the
100
+ * styles part when the document lacks it, at the latest when the package is saved - a setter
101
+ * cannot await it - so `await pkg.styles.ensure('Hyperlink')` first where an effective read
102
+ * before the save must see it. Under change tracking the restyle is a formatting revision, as a
103
+ * `font` write is.
94
104
  */
95
105
  get hyperlink(): string;
96
106
  set hyperlink(value: string);
97
- /** The `w:hyperlink` this range's first run sits in, if any. */
107
+ /** The first `w:hyperlink` this range touches, if any. */
98
108
  private hyperlinkHolder;
99
- /** Unwraps every `w:hyperlink` the range touches, keeping its runs where they are. */
109
+ /**
110
+ * Unwraps every `w:hyperlink` the range touches, keeping its runs where they are and taking the
111
+ * `Hyperlink` style off them. Until 0.2.1 this unwrapped every hyperlink of the paragraph, so
112
+ * linking one word unlinked the others (CR-002 section 25).
113
+ */
100
114
  private removeHyperlinks;
115
+ /**
116
+ * Whether this range touches a hyperlink's text: overlaps it, or for an empty range lies in it
117
+ * (from its first character up to, not including, its end). A hyperlink holding no text touches
118
+ * nothing.
119
+ */
120
+ private touches;
121
+ private touchesSpan;
122
+ /** The text a hyperlink holds, as [start, end) in the paragraph, or undefined when it holds none. */
123
+ private spanOf;
124
+ /**
125
+ * The `Hyperlink` character style on every run of a hyperlink, in place of any character style
126
+ * and with the direct colour removed, or off again when the link goes: what Word does (CR-002
127
+ * section 25). A run whose properties are then empty loses its `w:rPr`, as in Word's output.
128
+ */
129
+ private restyle;
101
130
  /** The target of a relationship of this range's part, for the getter. */
102
131
  private relationshipTarget;
103
132
  /** An external relationship of this range's part for the address, and its id. */
@@ -1,9 +1,9 @@
1
1
  import { Font } from './Font.mjs';
2
2
  import { runFontSelectorOf } from '../fonts/lookup.mjs';
3
- import { typeNameOf, linkParents, runItemsOf } from './tree.mjs';
3
+ import { typeNameOf, linkParents, runItemsOf, runsOf } from './tree.mjs';
4
4
  import { Docx4JException } from '../../opc/exceptions.mjs';
5
5
  import { ContentControl } from './ContentControl.mjs';
6
- import { sdt as sdtOf, nextSdtId } from '@docx4j/generated-objects-ts/builders/wml';
6
+ import { sdt as sdtOf, nextSdtId, applyRunOptions } from '@docx4j/generated-objects-ts/builders/wml';
7
7
  import { hyperlink as hyperlinkOf } from '@docx4j/generated-objects-ts/el/org_docx4j_wml';
8
8
  import { Namespaces } from '../../parts/Namespaces.mjs';
9
9
  import { controlIdScope, sdtKindFor } from '../customxml/insert.mjs';
@@ -247,7 +247,17 @@ export class Range {
247
247
  * Setting removes the hyperlinks the range already carries and wraps its runs in one
248
248
  * `w:hyperlink`, splitting runs at the boundaries as `font` and `insertContentControl` do;
249
249
  * setting `""` removes them and wraps nothing. An external address becomes a relationship of
250
- * this range's part, which is why the part must exist (CR-002 section 20).
250
+ * this range's part, which is why the part must exist (CR-002 section 20). Only the hyperlinks
251
+ * the range touches are removed; before 0.2.1 every one in the paragraph was (section 25).
252
+ *
253
+ * The runs are styled as Word styles them, measured through Office JS (`test/README.md` check
254
+ * 15, CR-002 section 25): each run a link wraps takes the `Hyperlink` character style, over any
255
+ * character style it had, and loses its direct colour, keeping the rest of its direct formatting;
256
+ * a link removed takes the style off its runs again. The `Hyperlink` definition is added to the
257
+ * styles part when the document lacks it, at the latest when the package is saved - a setter
258
+ * cannot await it - so `await pkg.styles.ensure('Hyperlink')` first where an effective read
259
+ * before the save must see it. Under change tracking the restyle is a formatting revision, as a
260
+ * `font` write is.
251
261
  */
252
262
  get hyperlink() {
253
263
  var _a;
@@ -259,8 +269,24 @@ export class Range {
259
269
  return anchor === '' ? address : `${address}#${anchor}`;
260
270
  }
261
271
  set hyperlink(value) {
262
- var _a;
272
+ var _a, _b, _c;
263
273
  const paragraph = this.paragraph;
274
+ // A range that crosses the edge of a link takes the whole of that link into the new one; a range
275
+ // inside a link replaces it with a link over the range alone. Measured in Word through Office JS
276
+ // (test/README.md check 16, cases F and G; CR-002 section 25): "alpha beta" linked and then
277
+ // "beta gamma" is one link over "alpha beta gamma", where "alpha beta gamma" linked and then
278
+ // "beta" leaves "alpha " and " gamma" unlinked.
279
+ let start = this.start;
280
+ let end = this.end;
281
+ for (const holder of hyperlinkHolders(paragraph)) {
282
+ const span = this.spanOf(holder);
283
+ if (span === undefined || !this.touchesSpan(span))
284
+ continue;
285
+ if (span[0] <= this.start && this.end <= span[1])
286
+ continue;
287
+ start = Math.min(start, span[0]);
288
+ end = Math.max(end, span[1]);
289
+ }
264
290
  this.removeHyperlinks();
265
291
  if (value === '')
266
292
  return;
@@ -277,13 +303,13 @@ export class Range {
277
303
  holder.value.anchor = location;
278
304
  if (address !== '')
279
305
  holder.value.id = this.addHyperlinkRelationship(address);
280
- if (this.start === this.end) {
281
- paragraph.insertItemsAt(this.start, [holder]);
306
+ if (start === end) {
307
+ paragraph.insertItemsAt(start, [holder]);
282
308
  return;
283
309
  }
284
- paragraph.splitAt(this.start);
285
- paragraph.splitAt(this.end);
286
- const segments = paragraph.segments().filter((seg) => seg.start >= this.start && seg.end <= this.end);
310
+ paragraph.splitAt(start);
311
+ paragraph.splitAt(end);
312
+ const segments = paragraph.segments().filter((seg) => seg.start >= start && seg.end <= end);
287
313
  if (segments.length === 0)
288
314
  throw new Docx4JException('This range covers no run');
289
315
  const owner = segments[0].runOwner;
@@ -300,35 +326,79 @@ export class Range {
300
326
  holder.value.content = items;
301
327
  owner.splice(at, items.length, holder);
302
328
  linkParents(holder, (_a = segments[0].run.PARENT) !== null && _a !== void 0 ? _a : paragraph.p);
329
+ this.restyle(holder.value, true);
330
+ (_c = (_b = paragraph.parentBody.package_) === null || _b === void 0 ? void 0 : _b.requireStyles) === null || _c === void 0 ? void 0 : _c.call(_b, [HYPERLINK_STYLE]);
303
331
  }
304
- /** The `w:hyperlink` this range's first run sits in, if any. */
332
+ /** The first `w:hyperlink` this range touches, if any. */
305
333
  hyperlinkHolder() {
306
- for (const segment of this.paragraph.segments()) {
307
- if (segment.end <= this.start && this.start !== this.end)
308
- continue;
309
- if (segment.start >= this.end && this.start !== this.end)
310
- break;
311
- const parent = segment.run.PARENT;
312
- const found = hyperlinkHolders(this.paragraph).find((h) => h.value === parent);
313
- if (found)
314
- return found;
315
- }
316
- return undefined;
334
+ return hyperlinkHolders(this.paragraph).find((holder) => this.touches(holder));
317
335
  }
318
- /** Unwraps every `w:hyperlink` the range touches, keeping its runs where they are. */
336
+ /**
337
+ * Unwraps every `w:hyperlink` the range touches, keeping its runs where they are and taking the
338
+ * `Hyperlink` style off them. Until 0.2.1 this unwrapped every hyperlink of the paragraph, so
339
+ * linking one word unlinked the others (CR-002 section 25).
340
+ */
319
341
  removeHyperlinks() {
320
342
  var _a, _b, _c;
321
- for (const holder of hyperlinkHolders(this.paragraph)) {
343
+ for (const holder of hyperlinkHolders(this.paragraph).filter((h) => this.touches(h))) {
322
344
  const owner = containerOf(holder, this.paragraph);
323
345
  const at = owner.indexOf(holder);
324
346
  if (at === -1)
325
347
  continue;
348
+ this.restyle(holder.value, false);
326
349
  owner.splice(at, 1, ...((_a = holder.value.content) !== null && _a !== void 0 ? _a : []));
327
350
  for (const item of ((_b = holder.value.content) !== null && _b !== void 0 ? _b : [])) {
328
351
  linkParents(item, (_c = holder.PARENT) !== null && _c !== void 0 ? _c : this.paragraph.p);
329
352
  }
330
353
  }
331
354
  }
355
+ /**
356
+ * Whether this range touches a hyperlink's text: overlaps it, or for an empty range lies in it
357
+ * (from its first character up to, not including, its end). A hyperlink holding no text touches
358
+ * nothing.
359
+ */
360
+ touches(holder) {
361
+ const span = this.spanOf(holder);
362
+ return span !== undefined && this.touchesSpan(span);
363
+ }
364
+ touchesSpan([start, end]) {
365
+ return this.start === this.end ? start <= this.start && this.start < end : start < this.end && end > this.start;
366
+ }
367
+ /** The text a hyperlink holds, as [start, end) in the paragraph, or undefined when it holds none. */
368
+ spanOf(holder) {
369
+ let start;
370
+ let end = 0;
371
+ for (const segment of this.paragraph.segments()) {
372
+ if (!within(segment.run, holder.value, this.paragraph.p))
373
+ continue;
374
+ start !== null && start !== void 0 ? start : (start = segment.start);
375
+ end = segment.end;
376
+ }
377
+ return start === undefined ? undefined : [start, end];
378
+ }
379
+ /**
380
+ * The `Hyperlink` character style on every run of a hyperlink, in place of any character style
381
+ * and with the direct colour removed, or off again when the link goes: what Word does (CR-002
382
+ * section 25). A run whose properties are then empty loses its `w:rPr`, as in Word's output.
383
+ */
384
+ restyle(holder, linked) {
385
+ var _a, _b, _c;
386
+ const tracking = this.paragraph.fontTracking();
387
+ const runs = new Set([...runsOf(holder), ...runsOf(holder, { view: 'original' })].map((r) => r.value));
388
+ for (const run of runs) {
389
+ if (!linked && ((_b = (_a = run.rPr) === null || _a === void 0 ? void 0 : _a.rStyle) === null || _b === void 0 ? void 0 : _b.val) !== HYPERLINK_STYLE)
390
+ continue;
391
+ (_c = run.rPr) !== null && _c !== void 0 ? _c : (run.rPr = { TYPE_NAME: 'org_docx4j_wml.RPr' });
392
+ // the properties as they are now become w:rPrChange, unless this run is our own insertion
393
+ if (tracking && !tracking.ownInsertions.has(run))
394
+ tracking.tracker.recordRPrChange(run.rPr);
395
+ applyRunOptions(run.rPr, linked ? { style: HYPERLINK_STYLE, color: '' } : { style: '' });
396
+ if (Object.keys(run.rPr).every((key) => key === 'TYPE_NAME'))
397
+ delete run.rPr;
398
+ else
399
+ linkParents(run.rPr, run);
400
+ }
401
+ }
332
402
  /** The target of a relationship of this range's part, for the getter. */
333
403
  relationshipTarget(id) {
334
404
  var _a, _b, _c;
@@ -343,6 +413,16 @@ export class Range {
343
413
  return part.getRelationshipsPart(true).addExternalRelationship(Namespaces.HYPERLINK, address).id;
344
414
  }
345
415
  }
416
+ /** The character style Word gives the runs of a hyperlink it makes. */
417
+ const HYPERLINK_STYLE = 'Hyperlink';
418
+ /** Whether `object` is `ancestor` or below it, following `PARENT` no further than `stop`. */
419
+ function within(object, ancestor, stop) {
420
+ for (let o = object; o !== undefined && o !== stop; o = o.PARENT) {
421
+ if (o === ancestor)
422
+ return true;
423
+ }
424
+ return false;
425
+ }
346
426
  /** Every `w:hyperlink` of a paragraph, outermost first (they do not nest in practice). */
347
427
  function hyperlinkHolders(paragraph) {
348
428
  const out = [];
@@ -70,9 +70,15 @@ export declare class TrackedChange {
70
70
  get text(): string;
71
71
  /** The range the change covers; a deletion is a collapsed range where it sits in the accepted text. */
72
72
  getRange(): Range | undefined;
73
- /** Keeps the change: a `w:ins` is unwrapped, a `w:del` removed, a deleted mark joins the paragraphs. */
73
+ /**
74
+ * Keeps the change: a `w:ins` is unwrapped, a `w:del` removed, a deleted mark joins the paragraphs.
75
+ * Either half of a move keeps the whole move, as Word's Review tab does (CR-002 section 29).
76
+ */
74
77
  accept(): void;
75
- /** Puts back what was there: a `w:ins` is removed, a `w:del` restored, a `w:rPrChange` re-applied. */
78
+ /**
79
+ * Puts back what was there: a `w:ins` is removed, a `w:del` restored, a `w:rPrChange` re-applied.
80
+ * Either half of a move rejects the whole move, as Word's Review tab does (CR-002 section 29).
81
+ */
76
82
  reject(): void;
77
83
  private requireParagraph;
78
84
  }
@@ -86,7 +92,7 @@ export declare class TrackedChange {
86
92
  * with: the paragraph then goes, its content joining the one before. With neither neighbour the
87
93
  * mark is simply dropped.
88
94
  */
89
- export declare function joinWithNext(paragraph: Paragraph, fallbackToPrevious?: boolean): void;
95
+ export declare function joinWithNext(paragraph: Pick<Paragraph, 'container' | 'element' | 'p'>, fallbackToPrevious?: boolean): void;
90
96
  /** The tracked changes of one paragraph, in document order. */
91
97
  export declare function trackedChangesOfParagraph(paragraph: Paragraph): TrackedChange[];
92
98
  /**
@@ -73,12 +73,17 @@ export class TrackedChange {
73
73
  }
74
74
  return p.getRange('Whole');
75
75
  }
76
- /** Keeps the change: a `w:ins` is unwrapped, a `w:del` removed, a deleted mark joins the paragraphs. */
76
+ /**
77
+ * Keeps the change: a `w:ins` is unwrapped, a `w:del` removed, a deleted mark joins the paragraphs.
78
+ * Either half of a move keeps the whole move, as Word's Review tab does (CR-002 section 29).
79
+ */
77
80
  accept() {
78
81
  var _a, _b, _c;
79
82
  const t = this.target;
80
83
  switch (t.kind) {
81
84
  case 'run':
85
+ if ((t.revision === 'moveFrom' || t.revision === 'moveTo') && resolveMove(this, 'accept'))
86
+ return;
82
87
  if (t.revision === 'ins' || t.revision === 'moveTo')
83
88
  unwrap(t);
84
89
  else
@@ -111,12 +116,17 @@ export class TrackedChange {
111
116
  return;
112
117
  }
113
118
  }
114
- /** Puts back what was there: a `w:ins` is removed, a `w:del` restored, a `w:rPrChange` re-applied. */
119
+ /**
120
+ * Puts back what was there: a `w:ins` is removed, a `w:del` restored, a `w:rPrChange` re-applied.
121
+ * Either half of a move rejects the whole move, as Word's Review tab does (CR-002 section 29).
122
+ */
115
123
  reject() {
116
124
  var _a;
117
125
  const t = this.target;
118
126
  switch (t.kind) {
119
127
  case 'run':
128
+ if ((t.revision === 'moveFrom' || t.revision === 'moveTo') && resolveMove(this, 'reject'))
129
+ return;
120
130
  if (t.revision === 'ins' || t.revision === 'moveTo')
121
131
  remove(t.owner, t.element);
122
132
  else
@@ -298,6 +308,129 @@ function acceptedOffsetOf(p, target) {
298
308
  visit(runItemsOf(p));
299
309
  return found >= 0 ? found : pos;
300
310
  }
311
+ const RANGE_MARKERS = {
312
+ moveFromRangeStart: { kind: 'start', side: 'from' }, moveFromRangeEnd: { kind: 'end', side: 'from' },
313
+ moveToRangeStart: { kind: 'start', side: 'to' }, moveToRangeEnd: { kind: 'end', side: 'to' },
314
+ };
315
+ /**
316
+ * Accepts or rejects the whole move a run-level half belongs to, as Word's Review tab does from
317
+ * either half (`test/README.md` check 18, Word 16.0.20326.20158; Office JS cannot list a move at
318
+ * all there, check 17). Word pairs the halves by the `w:name` on their range starts
319
+ * (`w:moveFromRangeStart`, `w:moveToRangeStart`); `w:moveFrom` and `w:moveTo` carry none, so a half
320
+ * belongs to the move whose range of its side is open where it stands. Accepting keeps the
321
+ * destination and removes the source, paragraph mark and all; rejecting the other way round; the
322
+ * four range markers go either way. Returns false for a half in no named range - markup without
323
+ * range markers, or a half already resolved - which the caller then treats on its own, as before.
324
+ */
325
+ function resolveMove(change, action) {
326
+ var _a, _b, _c;
327
+ const t = change.target;
328
+ if (t.kind !== 'run' || !change.paragraph)
329
+ return false;
330
+ const pieces = movePiecesOf(storyOf(change.paragraph.p));
331
+ const half = pieces.find((piece) => piece.kind === 'half' && piece.element === t.element);
332
+ if (!half || half.kind !== 'half' || half.names.length === 0)
333
+ return false;
334
+ const name = half.names[0];
335
+ const ids = new Set(pieces.filter((piece) => piece.kind === 'start' && piece.name === name).map((piece) => piece.id));
336
+ const mine = pieces.filter((piece) => (piece.kind === 'start' && piece.name === name) || (piece.kind === 'end' && ids.has(piece.id))
337
+ || ((piece.kind === 'half' || piece.kind === 'mark') && piece.names.includes(name)));
338
+ const kept = action === 'accept' ? 'to' : 'from';
339
+ // the markers first: a range end between two paragraphs would stop the paragraph join below
340
+ for (const piece of mine)
341
+ if (piece.kind === 'start' || piece.kind === 'end')
342
+ remove(piece.owner, piece.element);
343
+ for (const piece of mine) {
344
+ if (piece.kind !== 'half')
345
+ continue;
346
+ const target = { kind: 'run', revision: piece.side === 'from' ? 'moveFrom' : 'moveTo', element: piece.element, owner: piece.owner, value: piece.element.value };
347
+ if (piece.side === kept) {
348
+ if (piece.side === 'from')
349
+ restoreDeleted(target);
350
+ else
351
+ unwrap(target);
352
+ }
353
+ else
354
+ remove(piece.owner, piece.element);
355
+ }
356
+ // the paragraph marks last, last first, so that a join never disturbs one still to do
357
+ const marks = mine.filter((piece) => piece.kind === 'mark');
358
+ for (let i = marks.length - 1; i >= 0; i--) {
359
+ const mark = marks[i];
360
+ const p = mark.paragraph.value;
361
+ if (mark.side === kept) {
362
+ const rPr = (_a = p.pPr) === null || _a === void 0 ? void 0 : _a.rPr;
363
+ if (rPr)
364
+ delete rPr[mark.side === 'from' ? 'moveFrom' : 'moveTo'];
365
+ pruneParagraphProperties(p);
366
+ }
367
+ else if (((_b = p.content) !== null && _b !== void 0 ? _b : []).length === 0) {
368
+ remove(mark.container, mark.paragraph); // the moved paragraph, emptied: gone, as in Word
369
+ }
370
+ else {
371
+ const rPr = (_c = p.pPr) === null || _c === void 0 ? void 0 : _c.rPr;
372
+ if (rPr)
373
+ delete rPr[mark.side === 'from' ? 'moveFrom' : 'moveTo'];
374
+ joinWithNext({ container: mark.container, element: mark.paragraph, p }, mark.side === 'to');
375
+ }
376
+ }
377
+ return true;
378
+ }
379
+ /** The story a paragraph is in: the outermost array of block content above it (a body, a header, a note). */
380
+ function storyOf(p) {
381
+ let story;
382
+ for (let o = p; o !== undefined; o = o.PARENT) {
383
+ const content = o.content;
384
+ if (Array.isArray(content) && o.TYPE_NAME !== 'org_docx4j_wml.P')
385
+ story = content;
386
+ }
387
+ return story !== null && story !== void 0 ? story : [];
388
+ }
389
+ /** Every piece of move markup in a story, in document order, each half and mark with the names of the ranges open over it. */
390
+ function movePiecesOf(story) {
391
+ const out = [];
392
+ const open = { from: new Map(), to: new Map() };
393
+ const visit = (items) => {
394
+ var _a, _b, _c, _d, _e, _f;
395
+ for (const el of items) {
396
+ const marker = RANGE_MARKERS[(_b = (_a = el.name) === null || _a === void 0 ? void 0 : _a.localPart) !== null && _b !== void 0 ? _b : ''];
397
+ const value = el.value;
398
+ if (marker && value) {
399
+ const id = Number(value.id);
400
+ if (marker.kind === 'start') {
401
+ open[marker.side].set(id, (_c = value.name) !== null && _c !== void 0 ? _c : '');
402
+ out.push({ kind: 'start', side: marker.side, id, name: (_d = value.name) !== null && _d !== void 0 ? _d : '', element: el, owner: items });
403
+ }
404
+ else {
405
+ open[marker.side].delete(id);
406
+ out.push({ kind: 'end', side: marker.side, id, element: el, owner: items });
407
+ }
408
+ continue;
409
+ }
410
+ const revision = revisionKindOf(el);
411
+ if (revision === 'moveFrom' || revision === 'moveTo') {
412
+ const side = revision === 'moveFrom' ? 'from' : 'to';
413
+ out.push({ kind: 'half', side, element: el, owner: items, names: [...open[side].values()] });
414
+ }
415
+ const v = el.value;
416
+ if (typeof v !== 'object' || v === null)
417
+ continue;
418
+ const children = (_e = childrenOf(v)) !== null && _e !== void 0 ? _e : runItemsOf(v);
419
+ if (children)
420
+ visit(children);
421
+ if (typeNameOf(el) === 'org_docx4j_wml.P') {
422
+ const rPr = (_f = v.pPr) === null || _f === void 0 ? void 0 : _f.rPr;
423
+ for (const side of ['from', 'to']) {
424
+ if (rPr === null || rPr === void 0 ? void 0 : rPr[side === 'from' ? 'moveFrom' : 'moveTo']) {
425
+ out.push({ kind: 'mark', side, paragraph: el, container: items, names: [...open[side].values()] });
426
+ }
427
+ }
428
+ }
429
+ }
430
+ };
431
+ visit(story);
432
+ return out;
433
+ }
301
434
  // --- collecting ----------------------------------------------------------------------------
302
435
  /** The tracked changes of one paragraph, in document order. */
303
436
  export function trackedChangesOfParagraph(paragraph) {
@@ -32,7 +32,7 @@ export interface BoundSnapshot {
32
32
  * on Enter - so no cache is needed, which is what CR-006 section 2 was uncertain about.
33
33
  */
34
34
  export declare function bindSnapshot<T>(element: Jsonix.TypedNamedValue<T>): Promise<BoundSnapshot>;
35
- /** Turns selected nodes into `Bound`s against a snapshot. */
35
+ /** Turns selected nodes into `Bound`s against a snapshot, with one path cache for them all. */
36
36
  export declare function boundsOf(nodes: readonly Node[], snapshot: BoundSnapshot): Bound[];
37
37
  /**
38
38
  * The node's path from the document element, in the prefixed form the part itself uses, with a
@@ -30,14 +30,15 @@ export async function bindSnapshot(element) {
30
30
  });
31
31
  return { root, objects };
32
32
  }
33
- /** Turns selected nodes into `Bound`s against a snapshot. */
33
+ /** Turns selected nodes into `Bound`s against a snapshot, with one path cache for them all. */
34
34
  export function boundsOf(nodes, snapshot) {
35
+ const paths = new Paths();
35
36
  return nodes.map((node) => {
36
37
  const owner = owningElement(node);
37
38
  const bound = {
38
39
  node,
39
40
  object: owner === undefined ? undefined : snapshot.objects.get(owner),
40
- path: pathOf(node),
41
+ path: paths.of(node),
41
42
  };
42
43
  if (node.nodeType === 2)
43
44
  bound.attribute = node.name;
@@ -66,29 +67,61 @@ function owningElement(node) {
66
67
  * than a short one, and docx4j's harness records the same form.
67
68
  */
68
69
  export function pathOf(node) {
69
- if (node.nodeType === 2) {
70
- const owner = node.ownerElement;
71
- return `${owner === null ? '' : pathOf(owner)}/@${node.name}`;
70
+ return new Paths().of(node);
71
+ }
72
+ /**
73
+ * Paths for the nodes of one snapshot. Each parent's children are counted once, on first need,
74
+ * and each element's path is kept for its descendants, so a hit list costs one pass over the
75
+ * parents it touches. Counting a node's preceding siblings afresh for every node and every
76
+ * ancestor was quadratic in a parent's children: the 6,972 hits of `//w:p[w:pPr/w:pStyle]` over a
77
+ * 7,088-paragraph body took 21 s in Chromium, where the marshal took 0.5 s (CR-006 section 10).
78
+ */
79
+ class Paths {
80
+ constructor() {
81
+ /** For a parent, the position of each element child among its siblings of the same name. */
82
+ this.positions = new Map();
83
+ this.elementPaths = new Map();
72
84
  }
73
- if (node.nodeType === 3 || node.nodeType === 4) {
74
- const parent = node.parentNode;
75
- return `${parent === null ? '' : pathOf(parent)}/text()`;
85
+ of(node) {
86
+ if (node.nodeType === 2) {
87
+ const owner = node.ownerElement;
88
+ return `${owner === null ? '' : this.of(owner)}/@${node.name}`;
89
+ }
90
+ if (node.nodeType === 3 || node.nodeType === 4) {
91
+ const parent = node.parentNode;
92
+ return `${parent === null ? '' : this.of(parent)}/text()`;
93
+ }
94
+ if (node.nodeType !== 1)
95
+ return '';
96
+ const known = this.elementPaths.get(node);
97
+ if (known !== undefined)
98
+ return known;
99
+ const element = node;
100
+ const parent = element.parentNode;
101
+ const path = parent === null || parent.nodeType !== 1
102
+ ? `/${element.nodeName}`
103
+ : `${this.of(parent)}/${element.nodeName}[${this.position(parent, element)}]`;
104
+ this.elementPaths.set(node, path);
105
+ return path;
76
106
  }
77
- if (node.nodeType !== 1)
78
- return '';
79
- const element = node;
80
- const parent = element.parentNode;
81
- if (parent === null || parent.nodeType !== 1)
82
- return `/${element.nodeName}`;
83
- let index = 0;
84
- for (let sibling = parent.firstChild; sibling; sibling = sibling.nextSibling) {
85
- if (sibling.nodeType === 1 && sibling.nodeName === element.nodeName) {
86
- index++;
87
- if (sibling === element)
88
- break;
107
+ position(parent, element) {
108
+ var _a, _b;
109
+ let positions = this.positions.get(parent);
110
+ if (positions === undefined) {
111
+ positions = new Map();
112
+ const counts = new Map();
113
+ for (let sibling = parent.firstChild; sibling; sibling = sibling.nextSibling) {
114
+ if (sibling.nodeType !== 1)
115
+ continue;
116
+ const name = sibling.nodeName;
117
+ const n = ((_a = counts.get(name)) !== null && _a !== void 0 ? _a : 0) + 1;
118
+ counts.set(name, n);
119
+ positions.set(sibling, n);
120
+ }
121
+ this.positions.set(parent, positions);
89
122
  }
123
+ return (_b = positions.get(element)) !== null && _b !== void 0 ? _b : 0;
90
124
  }
91
- return `${pathOf(parent)}/${element.nodeName}[${index}]`;
92
125
  }
93
126
  function isElement(node) {
94
127
  return typeof node === 'object' && node !== null && node.nodeType === 1;
@@ -13,7 +13,7 @@ export { contentOf, isFlatOpc, rewriteRelationshipIds, type OoxmlOptions } from
13
13
  export { type SearchOptions, searchPattern, findAll, matchesOf, expandReplacement } from './search.mjs';
14
14
  export { segmentsOf, runsOf, runItemsOf, childrenOf, rowsOf, cellsOf, BLOCK_LEVEL_TYPES, SDT_TYPES, runOf, paragraphOf, textOfView, revisionKindOf, type TextSegment, type Located, type TextView, type TextViewOptions, type RevisionKind, type RevisionHolder } from './tree.mjs';
15
15
  export { TrackedChange, trackedChangesOfParagraph, trackedChangesOfRow, joinWithNext, type TrackedChangeType, type TrackedChangeTarget } from './TrackedChange.mjs';
16
- export { ChangeTracker, trackerOf, calendarOf, dateOf, copyRPr, markDeleted, markInserted, toDeletedText, toRestoredText, restoreRPr, restorePPr, trackInsertedParagraph, trackInsertedTable, wrapNewRuns, paraRPrOf, rowPrOf, pruneParagraphProperties, type ChangeTrackingMode, type TrackingHost, type TrackingScope, } from './tracking.mjs';
16
+ export { ChangeTracker, trackerOf, calendarOf, dateOf, copyRPr, markDeleted, markInserted, highestAnnotationId, MARKUP_TYPES, toDeletedText, toRestoredText, restoreRPr, restorePPr, trackInsertedParagraph, trackInsertedTable, wrapNewRuns, paraRPrOf, rowPrOf, pruneParagraphProperties, type ChangeTrackingMode, type TrackingHost, type TrackingScope, } from './tracking.mjs';
17
17
  export { wml, wmlOne, p, r, t, br, tab, tbl, tr, tc, sdt, sdtPr, nextSdtId, sdtProperty, sdtKindOf, inlinePicture, rPrToElements, rPrFromElements, textOf, walk, walkAll, find, linkParents, applyRunOptions, readRunOptions, UNDERLINE_TO_WML, isElement, typeNameOf, W_NS, type Element, type Wrapper, type WmlOptions, type Raw, type Interpolated, type RunOptions, type RunFormatting, type ParagraphOptions, type TableOptions, type CellOptions, type SdtKind, type SdtForm, type SdtOptions } from '@docx4j/generated-objects-ts/builders/wml';
18
18
  export { StylesFacade, type StylePackageLike } from './stylesFacade.mjs';
19
19
  export { bindSnapshot, boundsOf, pathOf, type Bound, type BoundSnapshot } from './binder.mjs';
@@ -16,7 +16,7 @@ export { searchPattern, findAll, matchesOf, expandReplacement } from './search.m
16
16
  export { segmentsOf, runsOf, runItemsOf, childrenOf, rowsOf, cellsOf, BLOCK_LEVEL_TYPES, SDT_TYPES, runOf, paragraphOf, textOfView, revisionKindOf } from './tree.mjs';
17
17
  // change tracking (CR-002 phase F)
18
18
  export { TrackedChange, trackedChangesOfParagraph, trackedChangesOfRow, joinWithNext } from './TrackedChange.mjs';
19
- export { ChangeTracker, trackerOf, calendarOf, dateOf, copyRPr, markDeleted, markInserted, toDeletedText, toRestoredText, restoreRPr, restorePPr, trackInsertedParagraph, trackInsertedTable, wrapNewRuns, paraRPrOf, rowPrOf, pruneParagraphProperties, } from './tracking.mjs';
19
+ export { ChangeTracker, trackerOf, calendarOf, dateOf, copyRPr, markDeleted, markInserted, highestAnnotationId, MARKUP_TYPES, toDeletedText, toRestoredText, restoreRPr, restorePPr, trackInsertedParagraph, trackInsertedTable, wrapNewRuns, paraRPrOf, rowPrOf, pruneParagraphProperties, } from './tracking.mjs';
20
20
  // The objects package's builders, re-exported so that one import serves the content API and the tree it works on.
21
21
  export { wml, wmlOne, p, r, t, br, tab, tbl, tr, tc, sdt, sdtPr, nextSdtId, sdtProperty, sdtKindOf, inlinePicture, rPrToElements, rPrFromElements, textOf, walk, walkAll, find, linkParents, applyRunOptions, readRunOptions, UNDERLINE_TO_WML, isElement, typeNameOf, W_NS } from '@docx4j/generated-objects-ts/builders/wml';
22
22
  export { StylesFacade } from './stylesFacade.mjs';
@@ -26,6 +26,14 @@ export declare function findAll(text: string, pattern: RegExp): [number, number]
26
26
  * Every match of a pattern in a text, with its capture groups: what `replaceText` needs to expand
27
27
  * `$1` and Word's `\1`. An empty match advances by one rather than looping forever, as `findAll`
28
28
  * has always done.
29
+ *
30
+ * A match that starts or ends inside a grapheme cluster is dropped: half of an emoji's surrogate
31
+ * pair (a wildcard `?` or class matches one UTF-16 unit), or an Arabic letter without the
32
+ * combining mark after it (`\p{L}+` stops before the mark). Such a range is not text a person can
33
+ * see, and replacing it splits a character - half a surrogate pair is saved as U+FFFD (CR-002
34
+ * section 32 item 1, found by the editor's agreement suite). The clusters are `Intl.Segmenter`'s,
35
+ * computed only for a text with a code unit at or above U+0300, below which every code unit is a
36
+ * cluster of its own, so Latin text pays nothing; a runtime without `Intl.Segmenter` keeps every match.
29
37
  */
30
38
  export declare function matchesOf(text: string, pattern: RegExp): RegExpExecArray[];
31
39
  /**
@@ -94,6 +94,14 @@ export function findAll(text, pattern) {
94
94
  * Every match of a pattern in a text, with its capture groups: what `replaceText` needs to expand
95
95
  * `$1` and Word's `\1`. An empty match advances by one rather than looping forever, as `findAll`
96
96
  * has always done.
97
+ *
98
+ * A match that starts or ends inside a grapheme cluster is dropped: half of an emoji's surrogate
99
+ * pair (a wildcard `?` or class matches one UTF-16 unit), or an Arabic letter without the
100
+ * combining mark after it (`\p{L}+` stops before the mark). Such a range is not text a person can
101
+ * see, and replacing it splits a character - half a surrogate pair is saved as U+FFFD (CR-002
102
+ * section 32 item 1, found by the editor's agreement suite). The clusters are `Intl.Segmenter`'s,
103
+ * computed only for a text with a code unit at or above U+0300, below which every code unit is a
104
+ * cluster of its own, so Latin text pays nothing; a runtime without `Intl.Segmenter` keeps every match.
97
105
  */
98
106
  export function matchesOf(text, pattern) {
99
107
  const out = [];
@@ -105,7 +113,20 @@ export function matchesOf(text, pattern) {
105
113
  }
106
114
  out.push(m);
107
115
  }
108
- return out;
116
+ const boundaries = out.length > 0 ? graphemeBoundaries(text) : undefined;
117
+ return boundaries === undefined ? out : out.filter((m) => boundaries.has(m.index) && boundaries.has(m.index + m[0].length));
118
+ }
119
+ /** The code-unit offsets where a grapheme cluster starts, and the text's end; undefined where every unit is its own cluster. */
120
+ function graphemeBoundaries(text) {
121
+ if (!/[\u0300-\uffff]/.test(text))
122
+ return undefined;
123
+ const Segmenter = Intl.Segmenter;
124
+ if (Segmenter === undefined)
125
+ return undefined;
126
+ const boundaries = new Set([text.length]);
127
+ for (const segment of new Segmenter(undefined, { granularity: 'grapheme' }).segment(text))
128
+ boundaries.add(segment.index);
129
+ return boundaries;
109
130
  }
110
131
  /**
111
132
  * A replacement with its group references expanded against one match: `$1` to `$9`, `$&` and `$$`
@@ -75,6 +75,12 @@ export declare class WordprocessingMLPackage extends OpcPackage implements Track
75
75
  */
76
76
  private trackingScopes;
77
77
  private beforeScopes;
78
+ /**
79
+ * Style ids a synchronous setter has given runs, to be defined in the styles part by the time the
80
+ * package is saved: `Range.hyperlink` writes `w:rStyle w:val="Hyperlink"` and cannot await
81
+ * `styles.ensure` (CR-002 section 25). `saveTo` ensures them; nothing is read before then.
82
+ */
83
+ private requiredStyles;
78
84
  /**
79
85
  * The parts `seedAnnotationIds()` has read, with what it read: `true` for a part still holding its
80
86
  * source, whose bytes cannot change, and otherwise the bytes that were set, which stay the same
@@ -226,9 +232,27 @@ export declare class WordprocessingMLPackage extends OpcPackage implements Track
226
232
  * bytes cannot change; bytes that were set are read again when they are set again.
227
233
  */
228
234
  seedAnnotationIds(): Promise<number>;
235
+ /**
236
+ * Records the highest annotation id of a part the caller has read itself, so that
237
+ * `seedAnnotationIds()` does not read it again. An editor that imports the main document part into
238
+ * a model of its own knows that part's ids already, and seeding would parse it a second time -
239
+ * 1.06 s for a 255-page document (CR-002 section 34). The floor rises to `highest` at once, and
240
+ * the part counts as read for the bytes it holds now: set its bytes again and the next seed reads
241
+ * it afresh; unmarshal it and the counter's own scan of it covers it.
242
+ *
243
+ * The caller vouches for the number: one lower than the part's highest id lets a new revision
244
+ * reuse an id. Asynchronous only because a part whose bytes were set is keyed on those bytes.
245
+ */
246
+ noteAnnotationIds(part: XmlPart<unknown>, highest: number): Promise<void>;
229
247
  /** TrackingHost: the trees a new revision id must be above, which is every part already unmarshalled. */
230
248
  markupRoots(): object[];
231
- /** A clone carries the identity, the tracking date, the theme setting and the XPath engine. */
249
+ /**
250
+ * Asks for style definitions a synchronous setter has referenced; they are ensured on save, as
251
+ * `pkg.styles.ensure(ids)` would, and a document that already has them keeps its styles part
252
+ * byte for byte (CR-002 section 25). Await `styles.ensure` instead to have them at once.
253
+ */
254
+ requireStyles(ids: Iterable<string>): void;
255
+ /** A clone carries the identity, the tracking date, the theme setting, the XPath engine and the styles still to ensure. */
232
256
  protected copyPackageSettingsTo(target: OpcPackage): void;
233
257
  saveTo<R>(sink: PartSink<R>): Promise<R>;
234
258
  /** The paragraph at an address anywhere in the document: 'body/3', 'header:rId5/0', a paraId, or a text match. */
@@ -75,6 +75,12 @@ export class WordprocessingMLPackage extends OpcPackage {
75
75
  * applies again, and when none is left the package is as it was (CR-002 section 31).
76
76
  */
77
77
  this.trackingScopes = [];
78
+ /**
79
+ * Style ids a synchronous setter has given runs, to be defined in the styles part by the time the
80
+ * package is saved: `Range.hyperlink` writes `w:rStyle w:val="Hyperlink"` and cannot await
81
+ * `styles.ensure` (CR-002 section 25). `saveTo` ensures them; nothing is read before then.
82
+ */
83
+ this.requiredStyles = new Set();
78
84
  /**
79
85
  * The parts `seedAnnotationIds()` has read, with what it read: `true` for a part still holding its
80
86
  * source, whose bytes cannot change, and otherwise the bytes that were set, which stay the same
@@ -442,6 +448,28 @@ export class WordprocessingMLPackage extends OpcPackage {
442
448
  }
443
449
  return this.annotationIdFloor;
444
450
  }
451
+ /**
452
+ * Records the highest annotation id of a part the caller has read itself, so that
453
+ * `seedAnnotationIds()` does not read it again. An editor that imports the main document part into
454
+ * a model of its own knows that part's ids already, and seeding would parse it a second time -
455
+ * 1.06 s for a 255-page document (CR-002 section 34). The floor rises to `highest` at once, and
456
+ * the part counts as read for the bytes it holds now: set its bytes again and the next seed reads
457
+ * it afresh; unmarshal it and the counter's own scan of it covers it.
458
+ *
459
+ * The caller vouches for the number: one lower than the part's highest id lets a new revision
460
+ * reuse an id. Asynchronous only because a part whose bytes were set is keyed on those bytes.
461
+ */
462
+ async noteAnnotationIds(part, highest) {
463
+ if (!Number.isInteger(highest) || highest < 0) {
464
+ throw new Docx4JException(`The highest annotation id must be a non-negative integer, not ${String(highest)}`);
465
+ }
466
+ if (this.parts.get(part.partName.name) !== part) {
467
+ throw new Docx4JException(`${part.partName.name} is not a part of this package`);
468
+ }
469
+ this.annotationIdFloor = Math.max(this.annotationIdFloor, highest);
470
+ if (!part.isUnmarshalled)
471
+ this.seededParts.set(part, part.isUntouched ? true : await part.getBytes());
472
+ }
445
473
  /** TrackingHost: the trees a new revision id must be above, which is every part already unmarshalled. */
446
474
  markupRoots() {
447
475
  const out = [];
@@ -454,10 +482,20 @@ export class WordprocessingMLPackage extends OpcPackage {
454
482
  }
455
483
  return out;
456
484
  }
457
- /** A clone carries the identity, the tracking date, the theme setting and the XPath engine. */
485
+ /**
486
+ * Asks for style definitions a synchronous setter has referenced; they are ensured on save, as
487
+ * `pkg.styles.ensure(ids)` would, and a document that already has them keeps its styles part
488
+ * byte for byte (CR-002 section 25). Await `styles.ensure` instead to have them at once.
489
+ */
490
+ requireStyles(ids) {
491
+ for (const id of ids)
492
+ this.requiredStyles.add(id);
493
+ }
494
+ /** A clone carries the identity, the tracking date, the theme setting, the XPath engine and the styles still to ensure. */
458
495
  copyPackageSettingsTo(target) {
459
496
  if (!(target instanceof WordprocessingMLPackage))
460
497
  return;
498
+ target.requireStyles(this.requiredStyles);
461
499
  target.author = { ...this.author };
462
500
  target.trackedChangeDate = this.trackedChangeDate;
463
501
  target.fonts.defaultTheme = this.fonts.defaultTheme;
@@ -467,6 +505,10 @@ export class WordprocessingMLPackage extends OpcPackage {
467
505
  var _a;
468
506
  if (this.trackingPending)
469
507
  await this.setChangeTrackingMode((_a = this.trackingMode) !== null && _a !== void 0 ? _a : 'Off');
508
+ if (this.requiredStyles.size > 0) {
509
+ await this.styles.ensure([...this.requiredStyles]);
510
+ this.requiredStyles.clear();
511
+ }
470
512
  return super.saveTo(sink);
471
513
  }
472
514
  /** The paragraph at an address anywhere in the document: 'body/3', 'header:rId5/0', a paraId, or a text match. */
@@ -1,6 +1,15 @@
1
1
  /** The declaration docx4j writes on every XML part. */
2
2
  export declare const XML_DECLARATION = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>";
3
- /** Parses XML text to a DOM document; throws on malformed input. */
3
+ /**
4
+ * Parses XML text to a DOM document; throws on malformed input, in a browser as in Node.
5
+ *
6
+ * xmldom throws. A browser's `DOMParser` does not: Chromium returns the document up to the error
7
+ * with an XHTML `parsererror` element first in the root, and Firefox a `parsererror` root in its
8
+ * own namespace, and handing that on unmarshalled a truncated part without complaint - a settings
9
+ * part with a stray `<` kept its first child and lost the rest, which a save would have written
10
+ * (CR-002 section 33). Such a document is refused here, with the parser's own message, which
11
+ * carries the line and column in both browsers.
12
+ */
4
13
  export declare function parseXml(text: string): Document;
5
14
  /** Serialises a node (document or element) without an XML declaration. */
6
15
  export declare function serializeXml(node: Node): string;
package/dist/xml/dom.mjs CHANGED
@@ -1,12 +1,56 @@
1
1
  // DOM, text and base64 helpers over the runtime's DOM implementation. In Node the runtime brings
2
2
  // @xmldom/xmldom; in browsers it uses the native DOM. Jsonix.DOM is typed since @docx4j/jsonix 3.2.1.
3
3
  import { Jsonix } from '@docx4j/generated-objects-ts';
4
+ import { Docx4JException } from '../opc/exceptions.mjs';
4
5
  const DOM = Jsonix.DOM;
5
6
  /** The declaration docx4j writes on every XML part. */
6
7
  export const XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
7
- /** Parses XML text to a DOM document; throws on malformed input. */
8
+ /**
9
+ * Parses XML text to a DOM document; throws on malformed input, in a browser as in Node.
10
+ *
11
+ * xmldom throws. A browser's `DOMParser` does not: Chromium returns the document up to the error
12
+ * with an XHTML `parsererror` element first in the root, and Firefox a `parsererror` root in its
13
+ * own namespace, and handing that on unmarshalled a truncated part without complaint - a settings
14
+ * part with a stray `<` kept its first child and lost the rest, which a save would have written
15
+ * (CR-002 section 33). Such a document is refused here, with the parser's own message, which
16
+ * carries the line and column in both browsers.
17
+ */
8
18
  export function parseXml(text) {
9
- return DOM.parse(text);
19
+ const doc = DOM.parse(text);
20
+ const error = parserErrorOf(doc);
21
+ if (error !== undefined)
22
+ throw new Docx4JException(`Malformed XML: ${error}`);
23
+ return doc;
24
+ }
25
+ /** The namespaces a browser puts its `parsererror` element in: Chromium and WebKit's, Firefox's. */
26
+ const PARSER_ERROR_NAMESPACES = new Set([
27
+ 'http://www.w3.org/1999/xhtml',
28
+ 'http://www.mozilla.org/newlayout/xml/parsererror.xml',
29
+ ]);
30
+ /**
31
+ * The message of a browser's `parsererror` document - the root, or the root's first element child,
32
+ * named `parsererror` in one of the parser-error namespaces - or undefined for a document that
33
+ * parsed. Chromium's message is the `div` in its element; its `h3`s are boilerplate.
34
+ */
35
+ function parserErrorOf(doc) {
36
+ var _a, _b;
37
+ const root = doc.documentElement;
38
+ if (!root)
39
+ return undefined;
40
+ let first = null;
41
+ for (let child = root.firstChild; child; child = child.nextSibling) {
42
+ if (child.nodeType === 1) {
43
+ first = child;
44
+ break;
45
+ }
46
+ }
47
+ for (const element of [root, first]) {
48
+ if (element === null || element.localName !== 'parsererror' || !PARSER_ERROR_NAMESPACES.has((_a = element.namespaceURI) !== null && _a !== void 0 ? _a : ''))
49
+ continue;
50
+ const div = element.getElementsByTagNameNS('http://www.w3.org/1999/xhtml', 'div')[0];
51
+ return ((_b = (div !== null && div !== void 0 ? div : element).textContent) !== null && _b !== void 0 ? _b : '').replace(/\s+/g, ' ').trim() || 'the parser reported an error';
52
+ }
53
+ return undefined;
10
54
  }
11
55
  /** Serialises a node (document or element) without an XML declaration. */
12
56
  export function serializeXml(node) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docx4j/core-ts",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "docx4j-core for TypeScript: Open Packaging (docx/pptx/xlsx zip and flat OPC), typed parts and relationships, style, numbering and font resolution over @docx4j/generated-objects-ts. Node, browsers, Word add-ins.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",