@docx4j/core-ts 0.2.0 → 0.2.1

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.
@@ -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,40 @@ 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
+ /**
122
+ * The `Hyperlink` character style on every run of a hyperlink, in place of any character style
123
+ * and with the direct colour removed, or off again when the link goes: what Word does (CR-002
124
+ * section 25). A run whose properties are then empty loses its `w:rPr`, as in Word's output.
125
+ */
126
+ private restyle;
101
127
  /** The target of a relationship of this range's part, for the getter. */
102
128
  private relationshipTarget;
103
129
  /** 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,7 +269,7 @@ 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;
264
274
  this.removeHyperlinks();
265
275
  if (value === '')
@@ -300,35 +310,73 @@ export class Range {
300
310
  holder.value.content = items;
301
311
  owner.splice(at, items.length, holder);
302
312
  linkParents(holder, (_a = segments[0].run.PARENT) !== null && _a !== void 0 ? _a : paragraph.p);
313
+ this.restyle(holder.value, true);
314
+ (_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
315
  }
304
- /** The `w:hyperlink` this range's first run sits in, if any. */
316
+ /** The first `w:hyperlink` this range touches, if any. */
305
317
  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;
318
+ return hyperlinkHolders(this.paragraph).find((holder) => this.touches(holder));
317
319
  }
318
- /** Unwraps every `w:hyperlink` the range touches, keeping its runs where they are. */
320
+ /**
321
+ * Unwraps every `w:hyperlink` the range touches, keeping its runs where they are and taking the
322
+ * `Hyperlink` style off them. Until 0.2.1 this unwrapped every hyperlink of the paragraph, so
323
+ * linking one word unlinked the others (CR-002 section 25).
324
+ */
319
325
  removeHyperlinks() {
320
326
  var _a, _b, _c;
321
- for (const holder of hyperlinkHolders(this.paragraph)) {
327
+ for (const holder of hyperlinkHolders(this.paragraph).filter((h) => this.touches(h))) {
322
328
  const owner = containerOf(holder, this.paragraph);
323
329
  const at = owner.indexOf(holder);
324
330
  if (at === -1)
325
331
  continue;
332
+ this.restyle(holder.value, false);
326
333
  owner.splice(at, 1, ...((_a = holder.value.content) !== null && _a !== void 0 ? _a : []));
327
334
  for (const item of ((_b = holder.value.content) !== null && _b !== void 0 ? _b : [])) {
328
335
  linkParents(item, (_c = holder.PARENT) !== null && _c !== void 0 ? _c : this.paragraph.p);
329
336
  }
330
337
  }
331
338
  }
339
+ /**
340
+ * Whether this range touches a hyperlink's text: overlaps it, or for an empty range lies in it
341
+ * (from its first character up to, not including, its end). A hyperlink holding no text touches
342
+ * nothing.
343
+ */
344
+ touches(holder) {
345
+ let start;
346
+ let end = 0;
347
+ for (const segment of this.paragraph.segments()) {
348
+ if (!within(segment.run, holder.value, this.paragraph.p))
349
+ continue;
350
+ start !== null && start !== void 0 ? start : (start = segment.start);
351
+ end = segment.end;
352
+ }
353
+ if (start === undefined)
354
+ return false;
355
+ return this.start === this.end ? start <= this.start && this.start < end : start < this.end && end > this.start;
356
+ }
357
+ /**
358
+ * The `Hyperlink` character style on every run of a hyperlink, in place of any character style
359
+ * and with the direct colour removed, or off again when the link goes: what Word does (CR-002
360
+ * section 25). A run whose properties are then empty loses its `w:rPr`, as in Word's output.
361
+ */
362
+ restyle(holder, linked) {
363
+ var _a, _b, _c;
364
+ const tracking = this.paragraph.fontTracking();
365
+ const runs = new Set([...runsOf(holder), ...runsOf(holder, { view: 'original' })].map((r) => r.value));
366
+ for (const run of runs) {
367
+ if (!linked && ((_b = (_a = run.rPr) === null || _a === void 0 ? void 0 : _a.rStyle) === null || _b === void 0 ? void 0 : _b.val) !== HYPERLINK_STYLE)
368
+ continue;
369
+ (_c = run.rPr) !== null && _c !== void 0 ? _c : (run.rPr = { TYPE_NAME: 'org_docx4j_wml.RPr' });
370
+ // the properties as they are now become w:rPrChange, unless this run is our own insertion
371
+ if (tracking && !tracking.ownInsertions.has(run))
372
+ tracking.tracker.recordRPrChange(run.rPr);
373
+ applyRunOptions(run.rPr, linked ? { style: HYPERLINK_STYLE, color: '' } : { style: '' });
374
+ if (Object.keys(run.rPr).every((key) => key === 'TYPE_NAME'))
375
+ delete run.rPr;
376
+ else
377
+ linkParents(run.rPr, run);
378
+ }
379
+ }
332
380
  /** The target of a relationship of this range's part, for the getter. */
333
381
  relationshipTarget(id) {
334
382
  var _a, _b, _c;
@@ -343,6 +391,16 @@ export class Range {
343
391
  return part.getRelationshipsPart(true).addExternalRelationship(Namespaces.HYPERLINK, address).id;
344
392
  }
345
393
  }
394
+ /** The character style Word gives the runs of a hyperlink it makes. */
395
+ const HYPERLINK_STYLE = 'Hyperlink';
396
+ /** Whether `object` is `ancestor` or below it, following `PARENT` no further than `stop`. */
397
+ function within(object, ancestor, stop) {
398
+ for (let o = object; o !== undefined && o !== stop; o = o.PARENT) {
399
+ if (o === ancestor)
400
+ return true;
401
+ }
402
+ return false;
403
+ }
346
404
  /** Every `w:hyperlink` of a paragraph, outermost first (they do not nest in practice). */
347
405
  function hyperlinkHolders(paragraph) {
348
406
  const out = [];
@@ -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;
@@ -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
@@ -228,7 +234,13 @@ export declare class WordprocessingMLPackage extends OpcPackage implements Track
228
234
  seedAnnotationIds(): Promise<number>;
229
235
  /** TrackingHost: the trees a new revision id must be above, which is every part already unmarshalled. */
230
236
  markupRoots(): object[];
231
- /** A clone carries the identity, the tracking date, the theme setting and the XPath engine. */
237
+ /**
238
+ * Asks for style definitions a synchronous setter has referenced; they are ensured on save, as
239
+ * `pkg.styles.ensure(ids)` would, and a document that already has them keeps its styles part
240
+ * byte for byte (CR-002 section 25). Await `styles.ensure` instead to have them at once.
241
+ */
242
+ requireStyles(ids: Iterable<string>): void;
243
+ /** A clone carries the identity, the tracking date, the theme setting, the XPath engine and the styles still to ensure. */
232
244
  protected copyPackageSettingsTo(target: OpcPackage): void;
233
245
  saveTo<R>(sink: PartSink<R>): Promise<R>;
234
246
  /** 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
@@ -454,10 +460,20 @@ export class WordprocessingMLPackage extends OpcPackage {
454
460
  }
455
461
  return out;
456
462
  }
457
- /** A clone carries the identity, the tracking date, the theme setting and the XPath engine. */
463
+ /**
464
+ * Asks for style definitions a synchronous setter has referenced; they are ensured on save, as
465
+ * `pkg.styles.ensure(ids)` would, and a document that already has them keeps its styles part
466
+ * byte for byte (CR-002 section 25). Await `styles.ensure` instead to have them at once.
467
+ */
468
+ requireStyles(ids) {
469
+ for (const id of ids)
470
+ this.requiredStyles.add(id);
471
+ }
472
+ /** A clone carries the identity, the tracking date, the theme setting, the XPath engine and the styles still to ensure. */
458
473
  copyPackageSettingsTo(target) {
459
474
  if (!(target instanceof WordprocessingMLPackage))
460
475
  return;
476
+ target.requireStyles(this.requiredStyles);
461
477
  target.author = { ...this.author };
462
478
  target.trackedChangeDate = this.trackedChangeDate;
463
479
  target.fonts.defaultTheme = this.fonts.defaultTheme;
@@ -467,6 +483,10 @@ export class WordprocessingMLPackage extends OpcPackage {
467
483
  var _a;
468
484
  if (this.trackingPending)
469
485
  await this.setChangeTrackingMode((_a = this.trackingMode) !== null && _a !== void 0 ? _a : 'Off');
486
+ if (this.requiredStyles.size > 0) {
487
+ await this.styles.ensure([...this.requiredStyles]);
488
+ this.requiredStyles.clear();
489
+ }
470
490
  return super.saveTo(sink);
471
491
  }
472
492
  /** 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.1",
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",