@docx4j/core-ts 0.2.1 → 0.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.
@@ -19,10 +19,26 @@ import { controlIdScope, sdtKindFor } from '../customxml/insert.mjs';
19
19
  import { InlinePicture, addImage, writableWidthEmu } from './InlinePicture.mjs';
20
20
  import { contentOf } from './ooxml.mjs';
21
21
  import { commentApi } from './comments.mjs';
22
- import { copyRPr, markDeleted, toDeletedText } from './tracking.mjs';
22
+ import { ChangeTracker, copyRPr, markDeleted, toDeletedText, highestAnnotationId, canonical, restorePPr } from './tracking.mjs';
23
23
  import { trackedChangesOfParagraph } from './TrackedChange.mjs';
24
24
  import { isListItem, listOf, listOrNullObjectOf, listItemOf, listItemOrNullObjectOf, startNewList, attachToList, detachFromList, } from './List.mjs';
25
25
  const TWIPS_PER_POINT = 20;
26
+ /** Office JS's paragraph formatting read from a `w:pPr`, resolved (`effective`) or direct. */
27
+ function formattingOf(pPr, effective) {
28
+ var _a, _b, _c, _d, _e, _f, _g, _h;
29
+ return {
30
+ alignment: alignmentOf(pPr, effective),
31
+ leftIndent: ((_b = (_a = pPr === null || pPr === void 0 ? void 0 : pPr.ind) === null || _a === void 0 ? void 0 : _a.left) !== null && _b !== void 0 ? _b : 0) / TWIPS_PER_POINT,
32
+ rightIndent: ((_d = (_c = pPr === null || pPr === void 0 ? void 0 : pPr.ind) === null || _c === void 0 ? void 0 : _c.right) !== null && _d !== void 0 ? _d : 0) / TWIPS_PER_POINT,
33
+ firstLineIndent: firstLineIndentOf(pPr),
34
+ spaceBefore: ((_f = (_e = pPr === null || pPr === void 0 ? void 0 : pPr.spacing) === null || _e === void 0 ? void 0 : _e.before) !== null && _f !== void 0 ? _f : 0) / TWIPS_PER_POINT,
35
+ spaceAfter: ((_h = (_g = pPr === null || pPr === void 0 ? void 0 : pPr.spacing) === null || _g === void 0 ? void 0 : _g.after) !== null && _h !== void 0 ? _h : 0) / TWIPS_PER_POINT,
36
+ lineSpacing: lineSpacingOf(pPr),
37
+ outlineLevel: outlineLevelOf(pPr),
38
+ };
39
+ }
40
+ /** The `w:pPr` members `formattingOf` reads; everything else is compared as it is written. */
41
+ const FORMATTING_MEMBERS = new Set(['jc', 'ind', 'spacing', 'outlineLvl']);
26
42
  /**
27
43
  * A subset of Office JS `Word.Paragraph` over a `w:p` in a body, cell, header, footer or
28
44
  * content control. A light view: it holds the element and the array that contains it; nothing
@@ -78,11 +94,11 @@ export class Paragraph {
78
94
  set styleId(id) {
79
95
  // through pPr(), so that a tracked write records w:pPrChange first (CR-002 phase F)
80
96
  const pPr = this.pPr();
81
- if (id === '' || id === 'Normal') {
97
+ if (id === '' || id === 'Normal')
82
98
  delete pPr.pStyle;
83
- return;
84
- }
85
- pPr.pStyle = { val: id };
99
+ else
100
+ pPr.pStyle = { val: id };
101
+ this.settleFormatting();
86
102
  }
87
103
  /**
88
104
  * The style's display name ('Heading 1', 'My Style'), as Office JS: from the styles part's
@@ -125,19 +141,7 @@ export class Paragraph {
125
141
  * Phase B step 2 (an absent property reads 0, 10 or `'Unknown'`).
126
142
  */
127
143
  formatting(options) {
128
- var _a, _b, _c, _d, _e, _f, _g, _h;
129
- const pPr = this.readPPr(options);
130
- const effective = (options === null || options === void 0 ? void 0 : options.direct) !== true;
131
- return {
132
- alignment: alignmentOf(pPr, effective),
133
- leftIndent: ((_b = (_a = pPr === null || pPr === void 0 ? void 0 : pPr.ind) === null || _a === void 0 ? void 0 : _a.left) !== null && _b !== void 0 ? _b : 0) / TWIPS_PER_POINT,
134
- rightIndent: ((_d = (_c = pPr === null || pPr === void 0 ? void 0 : pPr.ind) === null || _c === void 0 ? void 0 : _c.right) !== null && _d !== void 0 ? _d : 0) / TWIPS_PER_POINT,
135
- firstLineIndent: firstLineIndentOf(pPr),
136
- spaceBefore: ((_f = (_e = pPr === null || pPr === void 0 ? void 0 : pPr.spacing) === null || _e === void 0 ? void 0 : _e.before) !== null && _f !== void 0 ? _f : 0) / TWIPS_PER_POINT,
137
- spaceAfter: ((_h = (_g = pPr === null || pPr === void 0 ? void 0 : pPr.spacing) === null || _g === void 0 ? void 0 : _g.after) !== null && _h !== void 0 ? _h : 0) / TWIPS_PER_POINT,
138
- lineSpacing: lineSpacingOf(pPr),
139
- outlineLevel: outlineLevelOf(pPr),
140
- };
144
+ return formattingOf(this.readPPr(options), (options === null || options === void 0 ? void 0 : options.direct) !== true);
141
145
  }
142
146
  /** The effective alignment; an absent `w:jc` after resolution is `'Left'`, as Office JS. */
143
147
  get alignment() {
@@ -146,17 +150,17 @@ export class Paragraph {
146
150
  set alignment(v) {
147
151
  const pPr = this.pPr();
148
152
  const val = v === 'Left' ? 'left' : v === 'Centered' ? 'center' : v === 'Right' ? 'right' : v === 'Justified' ? 'both' : undefined;
149
- if (val === undefined) {
153
+ if (val === undefined)
150
154
  delete pPr.jc;
151
- return;
152
- }
153
- pPr.jc = { val };
155
+ else
156
+ pPr.jc = { val };
157
+ this.settleFormatting();
154
158
  }
155
159
  /** Indents and spacing in points, as Office JS; the effective values (CR-001 Phase B step 2). */
156
160
  get leftIndent() { var _a, _b; return ((_b = (_a = this.effectivePPr.ind) === null || _a === void 0 ? void 0 : _a.left) !== null && _b !== void 0 ? _b : 0) / TWIPS_PER_POINT; }
157
- set leftIndent(pt) { this.ind().left = Math.round(pt * TWIPS_PER_POINT); }
161
+ set leftIndent(pt) { this.ind().left = Math.round(pt * TWIPS_PER_POINT); this.settleFormatting(); }
158
162
  get rightIndent() { var _a, _b; return ((_b = (_a = this.effectivePPr.ind) === null || _a === void 0 ? void 0 : _a.right) !== null && _b !== void 0 ? _b : 0) / TWIPS_PER_POINT; }
159
- set rightIndent(pt) { this.ind().right = Math.round(pt * TWIPS_PER_POINT); }
163
+ set rightIndent(pt) { this.ind().right = Math.round(pt * TWIPS_PER_POINT); this.settleFormatting(); }
160
164
  /** First-line indent; negative for a hanging indent, as Office JS. */
161
165
  get firstLineIndent() {
162
166
  return firstLineIndentOf(this.effectivePPr);
@@ -171,22 +175,23 @@ export class Paragraph {
171
175
  ind.firstLine = Math.round(pt * TWIPS_PER_POINT);
172
176
  delete ind.hanging;
173
177
  }
178
+ this.settleFormatting();
174
179
  }
175
180
  get spaceBefore() { var _a, _b; return ((_b = (_a = this.effectivePPr.spacing) === null || _a === void 0 ? void 0 : _a.before) !== null && _b !== void 0 ? _b : 0) / TWIPS_PER_POINT; }
176
- set spaceBefore(pt) { this.spacing().before = Math.round(pt * TWIPS_PER_POINT); }
181
+ set spaceBefore(pt) { this.spacing().before = Math.round(pt * TWIPS_PER_POINT); this.settleFormatting(); }
177
182
  get spaceAfter() { var _a, _b; return ((_b = (_a = this.effectivePPr.spacing) === null || _a === void 0 ? void 0 : _a.after) !== null && _b !== void 0 ? _b : 0) / TWIPS_PER_POINT; }
178
- set spaceAfter(pt) { this.spacing().after = Math.round(pt * TWIPS_PER_POINT); }
183
+ set spaceAfter(pt) { this.spacing().after = Math.round(pt * TWIPS_PER_POINT); this.settleFormatting(); }
179
184
  /** Line spacing in points: w:line/240 lines of 12pt when the rule is auto, else w:line twips; 0 when not set. */
180
185
  get lineSpacing() {
181
186
  return lineSpacingOf(this.effectivePPr);
182
187
  }
183
- set lineSpacing(pt) { const s = this.spacing(); s.line = Math.round(pt * TWIPS_PER_POINT); s.lineRule = 'exact'; }
188
+ set lineSpacing(pt) { const s = this.spacing(); s.line = Math.round(pt * TWIPS_PER_POINT); s.lineRule = 'exact'; this.settleFormatting(); }
184
189
  /** w:outlineLvl + 1 (1 to 9); 10 for body text, as Office JS. A heading style supplies it. */
185
190
  get outlineLevel() { return outlineLevelOf(this.effectivePPr); }
186
191
  set outlineLevel(level) { const pPr = this.pPr(); if (level >= 10 || level < 1)
187
192
  delete pPr.outlineLvl;
188
193
  else
189
- pPr.outlineLvl = { val: level - 1 }; }
194
+ pPr.outlineLvl = { val: level - 1 }; this.settleFormatting(); }
190
195
  /**
191
196
  * The runs' formatting: reads the first run's *effective* properties, writes direct
192
197
  * formatting to all (extension: `Font` is over runs, not the paragraph mark).
@@ -446,6 +451,40 @@ export class Paragraph {
446
451
  (_b = this.changeTracker) === null || _b === void 0 ? void 0 : _b.recordPPrChange(pPr);
447
452
  return pPr;
448
453
  }
454
+ /**
455
+ * After a tracked write of the paragraph's properties: when the paragraph is as it was, its
456
+ * recorded properties go back as they were and the `w:pPrChange` goes, since Word keeps no
457
+ * formatting change that changes nothing, whoever changed it back (`test/README.md` check 26:
458
+ * centred then left again, by the same author and by another; Increase Indent then Decrease
459
+ * Indent; each left no `w:pPr`; CR-002 section 29). "As it was" is Office JS's paragraph
460
+ * formatting (alignment, indents, spacing, outline level) read resolved from both, the rest of the
461
+ * properties - the style, the list - written the same: Word's Ctrl+L and Decrease Indent removed
462
+ * the direct values where this package's setters write `w:jc="left"` and `w:ind w:left="0"`.
463
+ * Without the package's `PropertyResolver` only properties written the same count.
464
+ *
465
+ * @internal public so that the list views (CR-002 phase H) settle their `w:numPr` writes.
466
+ */
467
+ settleFormatting() {
468
+ var _a, _b;
469
+ const pPr = this.p.pPr;
470
+ const recorded = (_a = pPr === null || pPr === void 0 ? void 0 : pPr.pPrChange) === null || _a === void 0 ? void 0 : _a.pPr;
471
+ if (!pPr || !pPr.pPrChange || !this.changeTracker)
472
+ return;
473
+ const current = {};
474
+ for (const [key, value] of Object.entries(pPr))
475
+ if (key !== 'pPrChange' && key !== 'rPr' && key !== 'sectPr')
476
+ current[key] = value;
477
+ const before = { ...(recorded !== null && recorded !== void 0 ? recorded : {}) };
478
+ let same = canonical(current) === canonical(before);
479
+ if (!same) {
480
+ const rest = (props) => canonical(Object.fromEntries(Object.entries(props).filter(([key]) => !FORMATTING_MEMBERS.has(key))));
481
+ const resolver = (_b = this.parentBody.package_) === null || _b === void 0 ? void 0 : _b.propertyResolverOrUndefined;
482
+ same = resolver !== undefined && rest(current) === rest(before)
483
+ && canonical(formattingOf(resolver.getEffectivePPr(current), true)) === canonical(formattingOf(resolver.getEffectivePPr(before), true));
484
+ }
485
+ if (same)
486
+ restorePPr(this.p, recorded);
487
+ }
449
488
  /**
450
489
  * The paragraph's `w:numPr`, created when absent, through {@link pPr} so that a tracked
451
490
  * write records `w:pPrChange` first.
@@ -495,8 +534,8 @@ export class Paragraph {
495
534
  * else into a new run with the formatting of the nearest run.
496
535
  */
497
536
  splice(start, end, text) {
498
- var _a, _b, _c, _d;
499
- var _e;
537
+ var _a, _b, _c, _d, _e, _f;
538
+ var _g;
500
539
  const length = this.text.length;
501
540
  start = Math.max(0, Math.min(start, length));
502
541
  end = Math.max(start, Math.min(end, length));
@@ -510,6 +549,13 @@ export class Paragraph {
510
549
  }
511
550
  let segs = this.segments();
512
551
  let inserted = false;
552
+ // A replacement that begins inside a revision is a deletion and then the new text typed there, so
553
+ // the new text is plain and splits the revision, as typed text is (test/README.md check 21), rather
554
+ // than joining the revision it replaced part of.
555
+ if (end > start && text.length > 0 && ((_a = segs.find((s) => s.start <= start && start < s.end)) === null || _a === void 0 ? void 0 : _a.revision)) {
556
+ this.splice(start, end, '');
557
+ return this.splice(start, start, text);
558
+ }
513
559
  if (end > start) {
514
560
  // Replace: the new text takes the place (and formatting) of the first replaced character, as Word does.
515
561
  const first = segs.find((s) => s.start <= start && start < s.end);
@@ -546,8 +592,23 @@ export class Paragraph {
546
592
  segs = this.segments();
547
593
  }
548
594
  if (text.length > 0) {
549
- // Insert: extend the w:t at `start` (the one containing it, else the one ending there, else the one starting there)
550
- const target = (_b = (_a = segs.find((s) => s.editable && s.start < start && start < s.end)) !== null && _a !== void 0 ? _a : segs.find((s) => s.editable && s.end === start)) !== null && _b !== void 0 ? _b : segs.find((s) => s.editable && s.start === start);
595
+ // Text typed with tracking off is plain wherever it lands, as Word writes it (test/README.md
596
+ // check 21): inside a revision it splits the revision, and next to one it goes beside it, not
597
+ // in it. Otherwise it extends the w:t at `start` (the one containing it, else the one ending
598
+ // there, else the one starting there).
599
+ const containing = segs.find((s) => s.editable && s.start < start && start < s.end);
600
+ if ((containing === null || containing === void 0 ? void 0 : containing.revision) && this.insertBetweenHalves(start, runOf([textItem(text)], copyRPr(containing.run.rPr)), () => this.nextAnnotationId())) {
601
+ return new Range(this, start, start + text.length);
602
+ }
603
+ const ending = segs.find((s) => s.editable && s.end === start);
604
+ const starting = segs.find((s) => s.editable && s.start === start);
605
+ const target = (_d = (_c = (_b = (containing && !containing.revision ? containing : undefined)) !== null && _b !== void 0 ? _b : (ending && !ending.revision ? ending : undefined)) !== null && _c !== void 0 ? _c : (starting && !starting.revision ? starting : undefined)) !== null && _d !== void 0 ? _d : containing; // a revision that could not be split: extended, as before
606
+ const edge = target ? undefined : ending !== null && ending !== void 0 ? ending : starting;
607
+ if (edge === null || edge === void 0 ? void 0 : edge.revision) {
608
+ const run = runOf([textItem(text)], copyRPr(edge.run.rPr));
609
+ this.insertBesideRevision(edge.revision, run, edge === ending ? 'after' : 'before');
610
+ return new Range(this, start, start + text.length);
611
+ }
551
612
  if (target) {
552
613
  const t = target.item.value;
553
614
  const at = start - target.start;
@@ -558,7 +619,7 @@ export class Paragraph {
558
619
  const before = [...segs].reverse().find((s) => s.end <= start);
559
620
  const after = segs.find((s) => s.start >= start);
560
621
  const neighbour = before !== null && before !== void 0 ? before : after;
561
- const firstRun = (_c = runsOf(this.p)[0]) === null || _c === void 0 ? void 0 : _c.value.rPr;
622
+ const firstRun = (_e = runsOf(this.p)[0]) === null || _e === void 0 ? void 0 : _e.value.rPr;
562
623
  const rPr = (neighbour === null || neighbour === void 0 ? void 0 : neighbour.run.rPr) ? deepCopy(neighbour.run.rPr) : firstRun ? deepCopy(firstRun) : undefined;
563
624
  const run = runOf([textItem(text)], rPr);
564
625
  if (before)
@@ -566,7 +627,7 @@ export class Paragraph {
566
627
  else if (after)
567
628
  after.runOwner.splice(after.runIndex, 0, run);
568
629
  else {
569
- const content = ((_d = (_e = this.p).content) !== null && _d !== void 0 ? _d : (_e.content = []));
630
+ const content = ((_f = (_g = this.p).content) !== null && _f !== void 0 ? _f : (_g.content = []));
570
631
  if (start === 0)
571
632
  content.unshift(run);
572
633
  else
@@ -776,6 +837,71 @@ export class Paragraph {
776
837
  }
777
838
  return made;
778
839
  }
840
+ /**
841
+ * Splits the revision that holds the text at `at` into two - the second half a copy of its
842
+ * attributes under a new `w:id` - and puts `item` between them. Word splits a revision so when
843
+ * text is typed inside it: around the typist's `w:ins` with tracking on, around plain text with it
844
+ * off (`test/README.md` checks 20 and 21). False, with nothing changed but a run split at `at`,
845
+ * where the text there is not directly in the revision (in a hyperlink inside it, say): the caller
846
+ * then does what it did before.
847
+ */
848
+ insertBetweenHalves(at, item, nextId) {
849
+ var _a;
850
+ this.splitAt(at);
851
+ const after = this.segments().find((s) => s.start >= at && s.revision);
852
+ const holder = after === null || after === void 0 ? void 0 : after.revision;
853
+ const index = holder && after ? holder.items.indexOf(after.runOwner[after.runIndex]) : -1;
854
+ if (!holder || index <= 0)
855
+ return false;
856
+ const value = holder.value;
857
+ const itemsKey = Object.keys(value).find((key) => value[key] === holder.items);
858
+ if (!itemsKey)
859
+ return false;
860
+ const copy = {};
861
+ for (const [key, v] of Object.entries(value))
862
+ if (key !== itemsKey)
863
+ copy[key] = v;
864
+ copy.id = nextId();
865
+ copy[itemsKey] = holder.items.splice(index);
866
+ const second = { name: holder.element.name, value: copy };
867
+ const parent = (_a = holder.value.PARENT) !== null && _a !== void 0 ? _a : this.p;
868
+ const at2 = holder.owner.indexOf(holder.element) + 1;
869
+ holder.owner.splice(at2, 0, item, second);
870
+ linkParents(copy[itemsKey], copy);
871
+ linkParents([item, second], parent);
872
+ return true;
873
+ }
874
+ /**
875
+ * Puts `item` beside a revision rather than in it: after it, or before it - and then before the
876
+ * range start of a move whose range opens just there, so that text typed at the start of moved
877
+ * text is not inside the move (`test/README.md` check 20 case 03).
878
+ */
879
+ insertBesideRevision(holder, item, side) {
880
+ var _a, _b, _c;
881
+ let index = holder.owner.indexOf(holder.element) + (side === 'after' ? 1 : 0);
882
+ if (side === 'before' && (holder.kind === 'moveTo' || holder.kind === 'moveFrom')) {
883
+ const marker = holder.kind === 'moveTo' ? 'moveToRangeStart' : 'moveFromRangeStart';
884
+ while (index > 0 && ((_b = (_a = holder.owner[index - 1]) === null || _a === void 0 ? void 0 : _a.name) === null || _b === void 0 ? void 0 : _b.localPart) === marker)
885
+ index--;
886
+ }
887
+ holder.owner.splice(index, 0, item);
888
+ linkParents(item, (_c = holder.value.PARENT) !== null && _c !== void 0 ? _c : this.p);
889
+ }
890
+ /**
891
+ * A fresh annotation id for markup this paragraph writes itself: the package's counter when there
892
+ * is a package that keeps one, since a document has one id space; else one above every id in the
893
+ * story.
894
+ */
895
+ nextAnnotationId() {
896
+ const pkg = this.parentBody.package_;
897
+ if (pkg && typeof pkg.markupRoots === 'function' && 'annotationIdFloor' in pkg) {
898
+ return new ChangeTracker(pkg, 'TrackAll').nextId();
899
+ }
900
+ let root = this.p;
901
+ for (let o = this.p; o !== undefined; o = o.PARENT)
902
+ root = o;
903
+ return highestAnnotationId(root) + 1;
904
+ }
779
905
  /**
780
906
  * Text inserted at `at` as a `w:ins`. A run of this author's own that is already inside a
781
907
  * `w:ins` is extended rather than nested in another one, as Word does. `anchor` is the `w:del`
@@ -813,6 +939,18 @@ export class Paragraph {
813
939
  tracker.assertEditable(neighbour === null || neighbour === void 0 ? void 0 : neighbour.revision);
814
940
  const rPr = copyRPr((_b = neighbour === null || neighbour === void 0 ? void 0 : neighbour.run.rPr) !== null && _b !== void 0 ? _b : (_c = runsOf(this.p)[0]) === null || _c === void 0 ? void 0 : _c.value.rPr);
815
941
  const ins = tracker.ins([runOf([textItem(text)], rPr)]);
942
+ // Typed inside another's revision - a w:ins, a w:moveTo - the revision is split and the typist's
943
+ // w:ins goes between its halves, as Word writes it (test/README.md check 20 case 01, check 21's
944
+ // first run); before, it went to the revision's edge, putting the text in the wrong place.
945
+ if ((before === null || before === void 0 ? void 0 : before.revision) && (after === null || after === void 0 ? void 0 : after.revision) && before.revision.element === after.revision.element
946
+ && this.insertBetweenHalves(at, ins, () => tracker.nextId())) {
947
+ return;
948
+ }
949
+ // at the start of a revision, beside it - and outside a move's range that opens there (case 03)
950
+ if (!before && (after === null || after === void 0 ? void 0 : after.revision)) {
951
+ this.insertBesideRevision(after.revision, ins, 'before');
952
+ return;
953
+ }
816
954
  if (neighbour) {
817
955
  const owner = neighbour.revision ? neighbour.revision.owner : neighbour.runOwner;
818
956
  const item = neighbour.revision ? neighbour.revision.element : neighbour.runOwner[neighbour.runIndex];
@@ -91,7 +91,9 @@ export declare class Range {
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
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).
94
+ * the range touches are removed; before 0.2.1 every one in the paragraph was (section 25). An
95
+ * empty range is refused with a `GeneralException`, as Office JS refuses it (section 25, check 19):
96
+ * to change a link under a caret, set it on the link's whole range.
95
97
  *
96
98
  * The runs are styled as Word styles them, measured through Office JS (`test/README.md` check
97
99
  * 15, CR-002 section 25): each run a link wraps takes the `Hyperlink` character style, over any
@@ -113,11 +115,14 @@ export declare class Range {
113
115
  */
114
116
  private removeHyperlinks;
115
117
  /**
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.
118
+ * Whether this range touches a hyperlink's text: overlaps it, or for an empty range lies in it or
119
+ * at either end of it - Office JS reads a caret just after a link as in that link (check 19, case
120
+ * K; the start edge was not measured). A hyperlink holding no text touches nothing.
119
121
  */
120
122
  private touches;
123
+ private touchesSpan;
124
+ /** The text a hyperlink holds, as [start, end) in the paragraph, or undefined when it holds none. */
125
+ private spanOf;
121
126
  /**
122
127
  * The `Hyperlink` character style on every run of a hyperlink, in place of any character style
123
128
  * and with the direct colour removed, or off again when the link goes: what Word does (CR-002
@@ -3,7 +3,7 @@ import { runFontSelectorOf } from '../fonts/lookup.mjs';
3
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, applyRunOptions } from '@docx4j/generated-objects-ts/builders/wml';
6
+ import { sdt as sdtOf, nextSdtId, applyRunOptions, t as textElement } 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';
@@ -248,7 +248,9 @@ export class Range {
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
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).
251
+ * the range touches are removed; before 0.2.1 every one in the paragraph was (section 25). An
252
+ * empty range is refused with a `GeneralException`, as Office JS refuses it (section 25, check 19):
253
+ * to change a link under a caret, set it on the link's whole range.
252
254
  *
253
255
  * The runs are styled as Word styles them, measured through Office JS (`test/README.md` check
254
256
  * 15, CR-002 section 25): each run a link wraps takes the `Hyperlink` character style, over any
@@ -270,7 +272,30 @@ export class Range {
270
272
  }
271
273
  set hyperlink(value) {
272
274
  var _a, _b, _c;
275
+ // Office JS refuses a hyperlink set on an empty range - inside a link, in plain text, just after a
276
+ // link, and "" alike - with GeneralException, and changes nothing (test/README.md check 19, Word
277
+ // 16.0.20326.20158; CR-002 section 25). Before, the setter removed the link under a caret and
278
+ // left an empty w:hyperlink there.
279
+ if (this.start === this.end) {
280
+ throw new GeneralException('A hyperlink cannot be set on an empty range, as in Office JS: select the text to link, or the whole of a link to change it');
281
+ }
273
282
  const paragraph = this.paragraph;
283
+ // A range that crosses the edge of a link takes the whole of that link into the new one; a range
284
+ // inside a link replaces it with a link over the range alone. Measured in Word through Office JS
285
+ // (test/README.md check 16, cases F and G; CR-002 section 25): "alpha beta" linked and then
286
+ // "beta gamma" is one link over "alpha beta gamma", where "alpha beta gamma" linked and then
287
+ // "beta" leaves "alpha " and " gamma" unlinked.
288
+ let start = this.start;
289
+ let end = this.end;
290
+ for (const holder of hyperlinkHolders(paragraph)) {
291
+ const span = this.spanOf(holder);
292
+ if (span === undefined || !this.touchesSpan(span))
293
+ continue;
294
+ if (span[0] <= this.start && this.end <= span[1])
295
+ continue;
296
+ start = Math.min(start, span[0]);
297
+ end = Math.max(end, span[1]);
298
+ }
274
299
  this.removeHyperlinks();
275
300
  if (value === '')
276
301
  return;
@@ -287,13 +312,9 @@ export class Range {
287
312
  holder.value.anchor = location;
288
313
  if (address !== '')
289
314
  holder.value.id = this.addHyperlinkRelationship(address);
290
- if (this.start === this.end) {
291
- paragraph.insertItemsAt(this.start, [holder]);
292
- return;
293
- }
294
- paragraph.splitAt(this.start);
295
- paragraph.splitAt(this.end);
296
- const segments = paragraph.segments().filter((seg) => seg.start >= this.start && seg.end <= this.end);
315
+ paragraph.splitAt(start);
316
+ paragraph.splitAt(end);
317
+ const segments = paragraph.segments().filter((seg) => seg.start >= start && seg.end <= end);
297
318
  if (segments.length === 0)
298
319
  throw new Docx4JException('This range covers no run');
299
320
  const owner = segments[0].runOwner;
@@ -311,6 +332,7 @@ export class Range {
311
332
  owner.splice(at, items.length, holder);
312
333
  linkParents(holder, (_a = segments[0].run.PARENT) !== null && _a !== void 0 ? _a : paragraph.p);
313
334
  this.restyle(holder.value, true);
335
+ mergeTextRuns(holder.value.content, holder.value);
314
336
  (_c = (_b = paragraph.parentBody.package_) === null || _b === void 0 ? void 0 : _b.requireStyles) === null || _c === void 0 ? void 0 : _c.call(_b, [HYPERLINK_STYLE]);
315
337
  }
316
338
  /** The first `w:hyperlink` this range touches, if any. */
@@ -337,11 +359,19 @@ export class Range {
337
359
  }
338
360
  }
339
361
  /**
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.
362
+ * Whether this range touches a hyperlink's text: overlaps it, or for an empty range lies in it or
363
+ * at either end of it - Office JS reads a caret just after a link as in that link (check 19, case
364
+ * K; the start edge was not measured). A hyperlink holding no text touches nothing.
343
365
  */
344
366
  touches(holder) {
367
+ const span = this.spanOf(holder);
368
+ return span !== undefined && this.touchesSpan(span);
369
+ }
370
+ touchesSpan([start, end]) {
371
+ return this.start === this.end ? start <= this.start && this.start <= end : start < this.end && end > this.start;
372
+ }
373
+ /** The text a hyperlink holds, as [start, end) in the paragraph, or undefined when it holds none. */
374
+ spanOf(holder) {
345
375
  let start;
346
376
  let end = 0;
347
377
  for (const segment of this.paragraph.segments()) {
@@ -350,9 +380,7 @@ export class Range {
350
380
  start !== null && start !== void 0 ? start : (start = segment.start);
351
381
  end = segment.end;
352
382
  }
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;
383
+ return start === undefined ? undefined : [start, end];
356
384
  }
357
385
  /**
358
386
  * The `Hyperlink` character style on every run of a hyperlink, in place of any character style
@@ -371,6 +399,10 @@ export class Range {
371
399
  if (tracking && !tracking.ownInsertions.has(run))
372
400
  tracking.tracker.recordRPrChange(run.rPr);
373
401
  applyRunOptions(run.rPr, linked ? { style: HYPERLINK_STYLE, color: '' } : { style: '' });
402
+ if (tracking)
403
+ tracking.tracker.settleRPrChange(run);
404
+ if (!run.rPr)
405
+ continue;
374
406
  if (Object.keys(run.rPr).every((key) => key === 'TYPE_NAME'))
375
407
  delete run.rPr;
376
408
  else
@@ -391,8 +423,56 @@ export class Range {
391
423
  return part.getRelationshipsPart(true).addExternalRelationship(Namespaces.HYPERLINK, address).id;
392
424
  }
393
425
  }
426
+ /**
427
+ * What Office JS throws where the host refuses an operation it has no answer for: its `code`, so that
428
+ * add-in code branching on `error.code` behaves the same here (check 19).
429
+ */
430
+ class GeneralException extends Docx4JException {
431
+ constructor(message) {
432
+ super(message);
433
+ this.code = 'GeneralException';
434
+ this.name = 'GeneralException';
435
+ }
436
+ }
394
437
  /** The character style Word gives the runs of a hyperlink it makes. */
395
438
  const HYPERLINK_STYLE = 'Hyperlink';
439
+ /**
440
+ * Adjacent runs holding nothing but text and the same properties become one, as Word writes a link
441
+ * it makes: linking "beta gamma" over the end of a link on "alpha beta" gives Word one run
442
+ * "alpha beta gamma" (`test/README.md` check 16, case F), where splitting and restyling left two.
443
+ * Runs whose properties differ at all - a `w:rPrChange` under tracking among them - stay apart.
444
+ */
445
+ function mergeTextRuns(items, parent) {
446
+ var _a, _b;
447
+ for (let i = items.length - 1; i > 0; i--) {
448
+ const [a, b] = [items[i - 1], items[i]];
449
+ if (!isTextRun(a) || !isTextRun(b))
450
+ continue;
451
+ const [ra, rb] = [a.value, b.value];
452
+ if (canonical(ra.rPr) !== canonical(rb.rPr))
453
+ continue;
454
+ const text = [...((_a = ra.content) !== null && _a !== void 0 ? _a : []), ...((_b = rb.content) !== null && _b !== void 0 ? _b : [])].map((item) => { var _a; return (_a = item.value.value) !== null && _a !== void 0 ? _a : ''; }).join('');
455
+ ra.content = [textElement(text)];
456
+ linkParents(ra.content, ra);
457
+ items.splice(i, 1);
458
+ }
459
+ linkParents(items, parent);
460
+ }
461
+ function isTextRun(item) {
462
+ var _a;
463
+ return typeNameOf(item) === 'org_docx4j_wml.R'
464
+ && ((_a = item.value.content) !== null && _a !== void 0 ? _a : []).every((c) => { var _a; return ((_a = c.name) === null || _a === void 0 ? void 0 : _a.localPart) === 't'; });
465
+ }
466
+ /** A value as text with its keys in order and `PARENT` left out, to compare run properties by content. */
467
+ function canonical(value) {
468
+ return JSON.stringify(value, (key, v) => {
469
+ if (key === 'PARENT')
470
+ return undefined;
471
+ if (v === null || typeof v !== 'object' || Array.isArray(v))
472
+ return v;
473
+ return Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]));
474
+ });
475
+ }
396
476
  /** Whether `object` is `ancestor` or below it, following `PARENT` no further than `stop`. */
397
477
  function within(object, ancestor, stop) {
398
478
  for (let o = object; o !== undefined && o !== stop; o = o.PARENT) {