@docx4j/core-ts 0.3.0 → 0.3.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.
@@ -11,8 +11,8 @@ import { InlinePicture, addImage, writableWidthEmu } from './InlinePicture.mjs';
11
11
  import { contentOf } from './ooxml.mjs';
12
12
  import { expandReplacement } from './search.mjs';
13
13
  import { commentApi } from './comments.mjs';
14
- import { trackerOf, trackInsertedParagraph, trackInsertedTable } from './tracking.mjs';
15
- import { trackedChangesOfRow, trackedChangeTokensOfParagraph, groupTouching, BREAK, tablePropertiesChangeOf, cellChangeOf, bodySectionChangeOf, resolveUnlisted } from './TrackedChange.mjs';
14
+ import { trackerOf, trackInsertedBlock, markHolderInserted } from './tracking.mjs';
15
+ import { trackedChangesOfRow, trackedChangeTokensOfParagraph, groupTouching, BREAK, tablePropertiesChangeOf, cellChangeOf, bodySectionChangeOf, resolveUnlisted, holderInsertionAt } from './TrackedChange.mjs';
16
16
  import { listsOf, listLabelsOf } from './List.mjs';
17
17
  /**
18
18
  * A subset of Office JS `Word.Body` over any container with block-level content: the main
@@ -301,14 +301,12 @@ export class Body {
301
301
  for (const el of elements) {
302
302
  linkParents(el, owner);
303
303
  const tn = typeNameOf(el);
304
- if (tn === 'org_docx4j_wml.P') {
304
+ if (tn === 'org_docx4j_wml.P')
305
305
  this.assignParaId(el.value);
306
- if (tracker)
307
- trackInsertedParagraph(tracker, el.value);
308
- }
309
- else if (tn === 'org_docx4j_wml.Tbl' && tracker) {
310
- trackInsertedTable(tracker, el.value);
311
- }
306
+ // everything it holds is an insertion: the runs in its links and inline controls, the rows and
307
+ // nested tables of a table, a block control's content and the control itself (CR-002 section 37)
308
+ if (tracker)
309
+ trackInsertedBlock(tracker, el, container);
312
310
  }
313
311
  const first = elements[0];
314
312
  return typeNameOf(first) === 'org_docx4j_wml.P' ? new Paragraph(first, container, this) : { element: first, container };
@@ -387,22 +385,35 @@ export class Body {
387
385
  getTrackedChanges() {
388
386
  const out = [];
389
387
  const visit = (items, into) => {
390
- var _a;
391
- for (const el of items) {
388
+ var _a, _b;
389
+ for (let i = 0; i < items.length; i++) {
390
+ const el = items[i];
392
391
  const tn = typeNameOf(el);
393
392
  if (tn === 'org_docx4j_wml.P') {
394
393
  into.push(...trackedChangeTokensOfParagraph(new Paragraph(el, items, this)));
395
394
  continue;
396
395
  }
396
+ // a block control or custom XML element that is itself an insertion (check 30): one piece,
397
+ // grouping with the insertions around and inside it, as Word lists them
398
+ const inserted = holderInsertionAt(items, i, undefined);
399
+ if (inserted) {
400
+ into.push(inserted);
401
+ visit((_a = childrenOf(items[i + 1].value)) !== null && _a !== void 0 ? _a : [], into);
402
+ i += 2;
403
+ continue;
404
+ }
397
405
  const v = el.value;
398
406
  if (typeof v !== 'object' || v === null)
399
407
  continue;
400
408
  if (tn === 'org_docx4j_wml.Tbl') {
401
- into.push(BREAK);
402
- // the table's property records, one change where the table stands (CR-002 section 35)
409
+ // A table is no break of its own: an inserted or deleted row groups with the rows and the
410
+ // inserted text touching it, as Office JS lists them (check 31: a three-row table between two
411
+ // inserted paragraphs is one change; rows added at a table's end with a paragraph typed after
412
+ // it, one; a paragraph whose mark is plain before rows added at the start, two). Its property
413
+ // records are one change of their own where the table stands (CR-002 section 35), kept apart.
403
414
  const tableChange = tablePropertiesChangeOf(el);
404
415
  if (tableChange)
405
- into.push(tableChange, BREAK);
416
+ into.push(BREAK, tableChange, BREAK);
406
417
  for (const row of rowsOf(v)) {
407
418
  // A row that is itself a revision reports as one change, as Office JS does: the
408
419
  // markup its cells carry (Word marks every run and mark of a deleted or inserted row)
@@ -412,13 +423,18 @@ export class Body {
412
423
  const cellChange = cellChangeOf(cell.element, cell.container, row.element.value);
413
424
  if (cellChange)
414
425
  tokens.push(cellChange, BREAK);
415
- visit((_a = childrenOf(cell.element.value)) !== null && _a !== void 0 ? _a : [], tokens);
426
+ visit((_b = childrenOf(cell.element.value)) !== null && _b !== void 0 ? _b : [], tokens);
416
427
  tokens.push(BREAK);
417
428
  }
418
429
  const inner = groupTouching(tokens);
419
- const rowChanges = trackedChangesOfRow(row.element, row.container, inner);
420
- for (const change of rowChanges.length > 0 ? rowChanges : inner)
421
- into.push(change, BREAK);
430
+ const rowChanges = trackedChangesOfRow(row.element, row.container, inner, { element: el, container: items });
431
+ if (rowChanges.length > 0)
432
+ into.push(...rowChanges);
433
+ else {
434
+ into.push(BREAK);
435
+ for (const change of inner)
436
+ into.push(change, BREAK);
437
+ }
422
438
  }
423
439
  continue;
424
440
  }
@@ -732,6 +748,10 @@ export class Body {
732
748
  this.content.length = 0;
733
749
  this.content.push(sdt);
734
750
  linkParents(sdt, this.container);
751
+ // under tracking the control itself is the insertion, its content what it was (check 30)
752
+ const tracker = this.changeTracker;
753
+ if (tracker)
754
+ markHolderInserted(tracker, sdt, this.content, () => { });
735
755
  return new ContentControl(sdt, this.content, this);
736
756
  }
737
757
  }
@@ -104,4 +104,10 @@ export declare function addCommentEntry(body: Body, entry: CommentEntry): Promis
104
104
  * order, replies nested under their parent (a reply is in the array only when its parent is not).
105
105
  */
106
106
  export declare function commentsOf(scope: Body | Paragraph | Range): Promise<Comment[]>;
107
+ /**
108
+ * Comments whose anchored text went with a rejected insertion or an accepted deletion (check 32;
109
+ * `TrackedChange`'s `removeRevision`) had their markers removed then; their entries go here, with
110
+ * their replies, when the comments are next read or the package is saved (`WordprocessingMLPackage.saveTo`).
111
+ */
112
+ export declare function flushPendingCommentRemovals(body: Body): Promise<void>;
107
113
  export type { CommentParts, Author } from './comments.mjs';
@@ -301,11 +301,10 @@ function parentOf(element) {
301
301
  return element.value.PARENT;
302
302
  }
303
303
  /**
304
- * Where a marker for this segment's run goes: beside the run, but outside the `w:ins` or `w:del`
305
- * the run sits in, so that a comment made while change tracking is on is a comment and not an
306
- * insertion of its own (CR-002 section 13). Accepting or rejecting the revision then leaves the
307
- * comment where it is; the cost is that a comment on part of an insertion widens to the whole of
308
- * it, which is what Word shows anyway once the insertion is accepted.
304
+ * Where a marker for this segment's run goes when it is not put inside the revision the run sits
305
+ * in (`placeAround`): beside the run, but outside the `w:ins` or `w:del`, so that a comment made
306
+ * while change tracking is on is a comment and not an insertion of its own (CR-002 section 13).
307
+ * Accepting or rejecting the revision then leaves the comment where it is.
309
308
  */
310
309
  function markerSite(seg, fallback, after) {
311
310
  var _a;
@@ -339,10 +338,29 @@ function placeAround(range, id) {
339
338
  return;
340
339
  }
341
340
  const reference = referenceRun(id);
342
- const after = markerSite(covered[covered.length - 1], paragraph.p, true);
343
- insertAt(after.owner, after.index, [end, reference], after.parent);
344
- const before = markerSite(covered[0], paragraph.p, false);
345
- insertAt(before.owner, before.index, [start], before.parent);
341
+ const first = covered[0];
342
+ const last = covered[covered.length - 1];
343
+ // A comment on part of an insertion, as Word writes it (the editor's measure-word2010.docx and
344
+ // measure-word15.docx, 2026-09-30; CR-002 section 38): the range start inside the w:ins before the
345
+ // first commented run, the w:ins closed at the range's end - the rest of it a second w:ins with a
346
+ // new id - and the end marker and the plain reference run after it. So the anchor stays narrow
347
+ // and the reference is no insertion. Before, both markers went outside the w:ins, which widened
348
+ // the anchor to the whole insertion. A deletion or a move keeps the markers outside (a deletion's
349
+ // form is not measured; a move's halves are not to be split).
350
+ const inInsertion = (seg) => { var _a; return ((_a = seg.revision) === null || _a === void 0 ? void 0 : _a.kind) === 'ins'; };
351
+ if (inInsertion(last)) {
352
+ paragraph.splitHolderAround(last.revision, last.runIndex + 1, [end, reference], () => paragraph.nextAnnotationId());
353
+ }
354
+ else {
355
+ const after = markerSite(last, paragraph.p, true);
356
+ insertAt(after.owner, after.index, [end, reference], after.parent);
357
+ }
358
+ if (inInsertion(first))
359
+ insertAt(first.runOwner, first.runIndex, [start], first.revision.value);
360
+ else {
361
+ const before = markerSite(first, paragraph.p, false);
362
+ insertAt(before.owner, before.index, [start], before.parent);
363
+ }
346
364
  }
347
365
  /** Writes the markers of a reply next to its parent's, as Word nests them. */
348
366
  function placeAfter(body, parentId, id) {
@@ -500,12 +518,59 @@ function paragraphsOfContent(content) {
500
518
  * order, replies nested under their parent (a reply is in the array only when its parent is not).
501
519
  */
502
520
  export async function commentsOf(scope) {
503
- var _a, _b;
504
521
  const paragraph = scope instanceof Range ? scope.paragraph : scope instanceof Paragraph ? scope : undefined;
505
522
  const body = paragraph ? paragraph.parentBody : scope;
506
523
  const parts = await commentPartsAccess().load(body);
507
524
  if (!parts)
508
525
  return [];
526
+ await flushPendingRemovals(body, parts);
527
+ const views = viewsOf(parts, body);
528
+ const ids = scope instanceof Range ? idsInRange(scope) : paragraph ? unique(markersOfParagraph(paragraph.element, paragraph.container).map((m) => m.id)) : commentIdsOf(body.container);
529
+ const inScope = new Set(ids);
530
+ const out = [];
531
+ for (const id of ids) {
532
+ const view = views.get(id);
533
+ if (!view)
534
+ continue;
535
+ if (view.parent && inScope.has(view.parent.id))
536
+ continue;
537
+ out.push(view);
538
+ }
539
+ return out;
540
+ }
541
+ /**
542
+ * Comments whose anchored text went with a rejected insertion or an accepted deletion (check 32;
543
+ * `TrackedChange`'s `removeRevision`) had their markers removed then; their entries go here, with
544
+ * their replies, when the comments are next read or the package is saved (`WordprocessingMLPackage.saveTo`).
545
+ */
546
+ export async function flushPendingCommentRemovals(body) {
547
+ var _a;
548
+ const pkg = body.package_;
549
+ if (!((_a = pkg === null || pkg === void 0 ? void 0 : pkg.pendingCommentRemovals) === null || _a === void 0 ? void 0 : _a.size))
550
+ return;
551
+ const parts = await commentPartsAccess().load(body);
552
+ if (!parts) {
553
+ pkg.pendingCommentRemovals.clear();
554
+ return;
555
+ }
556
+ await flushPendingRemovals(body, parts);
557
+ }
558
+ async function flushPendingRemovals(body, parts) {
559
+ const pkg = body.package_;
560
+ const pending = pkg === null || pkg === void 0 ? void 0 : pkg.pendingCommentRemovals;
561
+ if (!(pending === null || pending === void 0 ? void 0 : pending.size))
562
+ return;
563
+ const views = viewsOf(parts, body);
564
+ for (const id of [...pending]) {
565
+ const view = views.get(id);
566
+ if (view)
567
+ await view.delete();
568
+ pending.delete(id);
569
+ }
570
+ }
571
+ /** A `Comment` view per entry in the parts, replies linked to their parents. */
572
+ function viewsOf(parts, body) {
573
+ var _a, _b;
509
574
  const views = new Map();
510
575
  for (const comment of (_a = parts.comments.comment) !== null && _a !== void 0 ? _a : []) {
511
576
  if (typeof comment.id === 'number' && !views.has(comment.id))
@@ -523,18 +588,7 @@ export async function commentsOf(scope) {
523
588
  if (parent && parent !== view)
524
589
  parent.addReply(view);
525
590
  }
526
- const ids = scope instanceof Range ? idsInRange(scope) : paragraph ? unique(markersOfParagraph(paragraph.element, paragraph.container).map((m) => m.id)) : commentIdsOf(body.container);
527
- const inScope = new Set(ids);
528
- const out = [];
529
- for (const id of ids) {
530
- const view = views.get(id);
531
- if (!view)
532
- continue;
533
- if (view.parent && inScope.has(view.parent.id))
534
- continue;
535
- out.push(view);
536
- }
537
- return out;
591
+ return views;
538
592
  }
539
593
  /** The comments a range touches: a comment whose markers overlap [start, end]. */
540
594
  function idsInRange(range) {
@@ -94,6 +94,12 @@ export declare class ContentControl {
94
94
  private parentParagraph;
95
95
  /** The offsets a run-level control covers in its paragraph. */
96
96
  private runSpan;
97
+ /**
98
+ * Text into a run control that holds no run: a new run in its content, in a `w:ins` under
99
+ * tracking (Word: check 30, new-control-typed), placed before the control's trailing insertion
100
+ * marker when it is itself an insertion.
101
+ */
102
+ private insertIntoEmpty;
97
103
  /** The control's data binding (Office JS `xmlMapping`), backed by `w:dataBinding`. */
98
104
  get xmlMapping(): XmlMapping;
99
105
  /**
@@ -7,8 +7,8 @@ import { marshalString, deepCopy } from '@docx4j/generated-objects-ts';
7
7
  import { XmlMapping } from '../customxml/XmlMapping.mjs';
8
8
  import { CheckboxContentControl, DatePickerContentControl, ListContentControl, PictureContentControl, RepeatingSectionContentControl, GroupContentControl, checkboxRun, CHECKBOX_FONT, } from '../customxml/kinds.mjs';
9
9
  import { runsForValue, updateFromControl, PLACEHOLDER_TEXT } from '../customxml/bindings.mjs';
10
- import { sdtProperty, sdtKindOf } from '@docx4j/generated-objects-ts/builders/wml';
11
- import { typeNameOf, childrenOf, textOf, runItemsOf, segmentsOf, linkParents, SDT_TYPES } from './tree.mjs';
10
+ import { sdtProperty, sdtKindOf, t as textItem } from '@docx4j/generated-objects-ts/builders/wml';
11
+ import { typeNameOf, childrenOf, textOf, runItemsOf, segmentsOf, linkParents, runOf, SDT_TYPES } from './tree.mjs';
12
12
  import { Range } from './Range.mjs';
13
13
  import { Table } from './Table.mjs';
14
14
  /** The namespaces of the Word 2010 and 2012 content-control properties. */
@@ -128,6 +128,8 @@ export class ContentControl {
128
128
  if (!span)
129
129
  throw new Docx4JException('This content control is not in a paragraph');
130
130
  const { paragraph, start, end } = span;
131
+ if (span.empty)
132
+ return this.insertIntoEmpty(paragraph, text);
131
133
  if (location === 'Start')
132
134
  return paragraph.splice(start, start, text);
133
135
  if (location === 'End')
@@ -251,7 +253,8 @@ export class ContentControl {
251
253
  };
252
254
  let start;
253
255
  let end = 0;
254
- for (const seg of segmentsOf(paragraph.p)) {
256
+ const segs = segmentsOf(paragraph.p);
257
+ for (const seg of segs) {
255
258
  if (!inside(seg.run))
256
259
  continue;
257
260
  if (start === undefined)
@@ -259,11 +262,54 @@ export class ContentControl {
259
262
  end = seg.end;
260
263
  }
261
264
  if (start === undefined) {
262
- // an empty control: its position is where its runs would be
263
- return { paragraph, start: 0, end: 0 };
265
+ // a control holding no text: its position is the end of the last text before it in the
266
+ // paragraph (before, it was reported at 0, and text put into it went to the paragraph's start)
267
+ let pos = 0;
268
+ let found = false;
269
+ const visit = (items) => {
270
+ for (const el of items !== null && items !== void 0 ? items : []) {
271
+ if (found)
272
+ return;
273
+ if (el === this.element) {
274
+ found = true;
275
+ return;
276
+ }
277
+ const v = el.value;
278
+ if (typeof v !== 'object' || v === null)
279
+ continue;
280
+ if (typeNameOf(el) === 'org_docx4j_wml.R') {
281
+ const last = segs.filter((seg) => seg.run === v).pop();
282
+ if (last)
283
+ pos = last.end;
284
+ }
285
+ else
286
+ visit(runItemsOf(v));
287
+ }
288
+ };
289
+ visit(paragraph.p.content);
290
+ return { paragraph, start: pos, end: pos, empty: true };
264
291
  }
265
292
  return { paragraph, start, end };
266
293
  }
294
+ /**
295
+ * Text into a run control that holds no run: a new run in its content, in a `w:ins` under
296
+ * tracking (Word: check 30, new-control-typed), placed before the control's trailing insertion
297
+ * marker when it is itself an insertion.
298
+ */
299
+ insertIntoEmpty(paragraph, text) {
300
+ var _a, _b;
301
+ var _c;
302
+ const sdt = this.sdt;
303
+ const items = ((_b = (_c = ((_a = sdt.sdtContent) !== null && _a !== void 0 ? _a : (sdt.sdtContent = {}))).content) !== null && _b !== void 0 ? _b : (_c.content = []));
304
+ const run = runOf([textItem(text)]);
305
+ const tracker = paragraph.changeTracker;
306
+ const item = tracker ? tracker.ins([run]) : run;
307
+ const at = items.findIndex((el) => { var _a; return ((_a = el.name) === null || _a === void 0 ? void 0 : _a.localPart) === 'customXmlInsRangeStart'; });
308
+ items.splice(at < 0 ? items.length : at, 0, item);
309
+ linkParents(item, sdt.sdtContent);
310
+ const span = this.runSpan();
311
+ return new Range(paragraph, span.start, span.end);
312
+ }
267
313
  // --- CR-002 phase E: the XML mapping, the typed kinds and the w:sdtPr properties ---
268
314
  /** The control's data binding (Office JS `xmlMapping`), backed by `w:dataBinding`. */
269
315
  get xmlMapping() {
@@ -106,6 +106,15 @@ export declare class Paragraph {
106
106
  /** The effective alignment; an absent `w:jc` after resolution is `'Left'`, as Office JS. */
107
107
  get alignment(): Alignment;
108
108
  set alignment(v: AlignmentOrUnknown);
109
+ /**
110
+ * The alignment the paragraph resolves to with no `w:jc` of its own: its style's, else the
111
+ * defaults'. Word's Ctrl+L on a centred paragraph whose style is left-aligned removes the `w:jc`
112
+ * rather than writing `w:jc="left"`, with tracking on (check 26) and off (the editor's
113
+ * `measure-word2010.docx` and `measure-word15.docx`, 2026-09-30; CR-002 section 38), so a value the
114
+ * paragraph already inherits is not written. Measured for left over a left-aligned style; taken to
115
+ * hold for any value. Undefined without a resolver, where the value is written as asked.
116
+ */
117
+ private inheritedAlignment;
109
118
  /** Indents and spacing in points, as Office JS; the effective values (CR-001 Phase B step 2). */
110
119
  get leftIndent(): number;
111
120
  set leftIndent(pt: number);
@@ -186,6 +195,17 @@ export declare class Paragraph {
186
195
  */
187
196
  delete(): void;
188
197
  private trackedDelete;
198
+ /**
199
+ * A paragraph mark deleted under tracking gives the next paragraph this paragraph's properties,
200
+ * as a `w:pPrChange` on it recording its own: Word, at Delete at the end of a Heading 2 with a
201
+ * Normal paragraph after it, wrote the next paragraph `w:pStyle Heading2` with
202
+ * `<w:pPrChange><w:pPr/></w:pPrChange>`, and No Markup reads the joined line as a heading
203
+ * throughout (the editor's `measure-word15.docx`, 2026-09-30; CR-002 section 38). The Delete
204
+ * counterpart of check 29's Enter. Nothing where the two paragraphs' properties are the same
205
+ * (check 22's deleted marks). Accepting the mark then joins into a paragraph with these
206
+ * properties, and rejecting puts the next paragraph's back.
207
+ */
208
+ private giveMarkPropertiesToNext;
189
209
  /**
190
210
  * The paragraph's content and its mark marked deleted, the paragraph element itself kept:
191
211
  * what the cells of a deleted table row need (a `w:tc` must keep a block-level child, and Word
@@ -241,7 +261,7 @@ export declare class Paragraph {
241
261
  * is removed, a run left empty is removed. Text is inserted into the w:t at `start`,
242
262
  * else into a new run with the formatting of the nearest run.
243
263
  */
244
- splice(start: number, end: number, text: string): Range;
264
+ splice(start: number, end: number, text: string, prefer?: 'before' | 'after'): Range;
245
265
  /**
246
266
  * Inserts run-level items (runs, hyperlinks, content controls) at a text offset, splitting the
247
267
  * run there when the offset falls inside one. PARENT is linked on what is inserted.
@@ -309,6 +329,14 @@ export declare class Paragraph {
309
329
  * then does what it did before.
310
330
  */
311
331
  private insertBetweenHalves;
332
+ /**
333
+ * Moves a revision's items from `index` on into a copy of it - its attributes, a new `w:id` - and
334
+ * puts `item` between the two; with nothing from `index` on, `item` just goes after the revision.
335
+ * False, with nothing changed, where the revision's items cannot be told apart from its attributes.
336
+ * Internal: `Range.insertContentControl` uses it to put a new control between the halves of the
337
+ * insertion it wraps part of (check 30).
338
+ */
339
+ splitHolderAround(holder: RevisionHolderOf, index: number, item: Element | Element[], nextId: () => number): boolean;
312
340
  /**
313
341
  * Puts `item` beside a revision rather than in it: after it, or before it - and then before the
314
342
  * range start of a move whose range opens just there, so that text typed at the start of moved
@@ -320,13 +348,19 @@ export declare class Paragraph {
320
348
  * is a package that keeps one, since a document has one id space; else one above every id in the
321
349
  * story.
322
350
  */
323
- private nextAnnotationId;
351
+ /** @internal `Comment` uses it to split an insertion at a comment's end. */
352
+ nextAnnotationId(): number;
324
353
  /**
325
354
  * Text inserted at `at` as a `w:ins`. A run of this author's own that is already inside a
326
355
  * `w:ins` is extended rather than nested in another one, as Word does. `anchor` is the `w:del`
327
356
  * of a replacement, which the insertion must follow.
328
357
  */
329
358
  private insertTracked;
359
+ /**
360
+ * An insertion put at `at`, `before` and `after` being the segments either side of it once the runs
361
+ * are split there: never inside another's revision.
362
+ */
363
+ private placeInsertion;
330
364
  /**
331
365
  * Splits runs so that every run holding text in [start, end) holds nothing else, and returns
332
366
  * the segments inside the span. `splitAt` does the `w:t` boundaries; this also moves the items
@@ -338,4 +372,5 @@ export declare class Paragraph {
338
372
  /** Moves the items after `seg` into a run of their own, behind `seg`'s run. */
339
373
  private splitRunAfter;
340
374
  }
375
+ type RevisionHolderOf = NonNullable<TextSegment['revision']>;
341
376
  export { W_NS };