@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.
@@ -1,6 +1,6 @@
1
1
  import { deepCopy, deepCopyAsSync } from '@docx4j/generated-objects-ts';
2
2
  import { createCTTrackChange, createCTRPrChange, createCTRPrChangeRPr, createCTPPrChange, createDelText, createPPr, createParaRPr, createTrPr, } from '@docx4j/generated-objects-ts/factory/org_docx4j_wml';
3
- import { rPrToElements } from '@docx4j/generated-objects-ts/builders/wml';
3
+ import { rPrToElements, rPrFromElements } from '@docx4j/generated-objects-ts/builders/wml';
4
4
  import { Docx4JException } from '../../opc/exceptions.mjs';
5
5
  import { W_NS, linkParents, walk, typeNameOf, revisionKindOf } from './tree.mjs';
6
6
  /**
@@ -39,24 +39,25 @@ export function trackerOf(pkg) {
39
39
  return pkg === null || pkg === void 0 ? void 0 : pkg.changeTracker;
40
40
  }
41
41
  /**
42
- * Now, with the seconds and milliseconds dropped, which is what Word stamps a revision with.
43
- * Not cosmetic: Word groups adjacent revisions by author **and** timestamp, so a per-second stamp
44
- * makes one change of every keystroke run and an editor's typing unreviewable (CR-002 section 28.2).
45
- * A caller that sets `trackedChangeDate` gets exactly the date it asked for, truncated or not.
42
+ * Now, with the seconds and milliseconds dropped, which is what Word stamps a revision with
43
+ * (CR-002 section 28.2). A caller that sets `trackedChangeDate` gets exactly the date it asked for,
44
+ * truncated or not. (Section 28.2 gave a second reason, that Word groups adjacent revisions by their
45
+ * timestamp; check 23 found otherwise for Office JS's list, where an hour apart is still one change,
46
+ * CR-002 section 29.)
46
47
  */
47
48
  export function nowToTheMinute() {
48
49
  const now = new Date();
49
50
  now.setUTCSeconds(0, 0);
50
51
  return now;
51
52
  }
52
- /** An `XmlCalendar` in UTC, as Word writes `w:date` (an xsd:dateTime ending in `Z`). */
53
+ /** An `XmlCalendar` in UTC, ending in `Z`: `w16du:dateUtc`, or any xsd:dateTime meant as UTC. */
53
54
  export function calendarOf(date) {
54
55
  return {
55
56
  year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate(),
56
57
  hour: date.getUTCHours(), minute: date.getUTCMinutes(), second: date.getUTCSeconds(), timezone: 0,
57
58
  };
58
59
  }
59
- /** The `Date` of a `w:date`, or undefined when there is none. */
60
+ /** The `Date` of an xsd:dateTime read as it says - a missing offset as UTC - or undefined when there is none. */
60
61
  export function dateOf(cal) {
61
62
  var _a, _b, _c, _d, _e, _f;
62
63
  if (!cal || cal.year === undefined)
@@ -64,6 +65,63 @@ export function dateOf(cal) {
64
65
  const ms = Date.UTC(cal.year, ((_a = cal.month) !== null && _a !== void 0 ? _a : 1) - 1, (_b = cal.day) !== null && _b !== void 0 ? _b : 1, (_c = cal.hour) !== null && _c !== void 0 ? _c : 0, (_d = cal.minute) !== null && _d !== void 0 ? _d : 0, (_e = cal.second) !== null && _e !== void 0 ? _e : 0);
65
66
  return new Date(ms - ((_f = cal.timezone) !== null && _f !== void 0 ? _f : 0) * 60000);
66
67
  }
68
+ /**
69
+ * A revision's `w:date` as Word writes it: the local wall-clock time, with a `Z` that does not mean
70
+ * UTC (CR-002 section 29, check 23: Office JS read `01:00:00Z` as 15:00 UTC the day before, at
71
+ * UTC+10; Word's own files carry the true UTC beside it in `w16du:dateUtc`). "Local" is this
72
+ * process's time zone, as it is the add-in's for Office JS.
73
+ */
74
+ export function wordCalendarOf(date) {
75
+ return {
76
+ year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(),
77
+ hour: date.getHours(), minute: date.getMinutes(), second: date.getSeconds(), timezone: 0,
78
+ };
79
+ }
80
+ /**
81
+ * A revision's date as Word means it: `w16du:dateUtc` when there is one, which is UTC; else
82
+ * `w:date` read as local wall-clock time, its `Z` notwithstanding, as Office JS reads it (check 23).
83
+ * A `w:date` with a non-zero offset, which Word does not write, is read as it says.
84
+ */
85
+ export function revisionDateOf(value) {
86
+ var _a, _b, _c, _d, _e, _f;
87
+ if (((_a = value.dateUtc) === null || _a === void 0 ? void 0 : _a.year) !== undefined)
88
+ return dateOf(value.dateUtc);
89
+ const cal = value.date;
90
+ if (!cal || cal.year === undefined)
91
+ return undefined;
92
+ if (cal.timezone !== undefined && cal.timezone !== 0)
93
+ return dateOf(cal);
94
+ return new Date(cal.year, ((_b = cal.month) !== null && _b !== void 0 ? _b : 1) - 1, (_c = cal.day) !== null && _c !== void 0 ? _c : 1, (_d = cal.hour) !== null && _d !== void 0 ? _d : 0, (_e = cal.minute) !== null && _e !== void 0 ? _e : 0, (_f = cal.second) !== null && _f !== void 0 ? _f : 0);
95
+ }
96
+ /** Properties compared as values: keys sorted, `PARENT`, `TYPE_NAME` and absent values left out. */
97
+ export function canonical(value) {
98
+ return JSON.stringify(value, (key, v) => {
99
+ if (key === 'PARENT' || key === 'TYPE_NAME' || v === undefined)
100
+ return undefined;
101
+ if (v && typeof v === 'object' && !Array.isArray(v))
102
+ return Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]));
103
+ return v;
104
+ });
105
+ }
106
+ /**
107
+ * Makes sure the part's root lists `w16du` in `mc:Ignorable`, appending it when absent, so that
108
+ * `w16du:dateUtc` may be written in it; the facade then declares the prefix. This is docx4j's
109
+ * pattern - the writer of the content adds its prefix (`Paginate.declareW14Ignorable`) - and Jason's
110
+ * decision (CR-002 section 29); objects CR-008 would do it for every writer. False when there is no
111
+ * unmarshalled root to put it on. Every part a `Body` is over (document, header, footer, notes,
112
+ * comments) has a root that binds `mc:Ignorable`.
113
+ */
114
+ function declareDateUtc(part) {
115
+ var _a;
116
+ const contents = part === null || part === void 0 ? void 0 : part.contents;
117
+ if (!contents || typeof contents !== 'object')
118
+ return false;
119
+ const root = (contents.value && typeof contents.value === 'object' ? contents.value : contents);
120
+ const prefixes = ((_a = root.ignorable) !== null && _a !== void 0 ? _a : '').split(/\s+/).filter((prefix) => prefix.length > 0);
121
+ if (!prefixes.includes('w16du'))
122
+ root.ignorable = [...prefixes, 'w16du'].join(' ');
123
+ return true;
124
+ }
67
125
  function element(localPart, value) {
68
126
  return { name: { namespaceURI: W_NS, localPart }, value };
69
127
  }
@@ -76,9 +134,27 @@ function element(localPart, value) {
76
134
  * The name follows Office JS's vocabulary (`Word.ChangeTrackingMode`).
77
135
  */
78
136
  export class ChangeTracker {
79
- constructor(host, mode) {
137
+ constructor(host, mode,
138
+ /** The part this tracker writes into, when known: it decides whether `w16du:dateUtc` is written. */
139
+ part) {
80
140
  this.host = host;
81
141
  this.mode = mode;
142
+ this.part = part;
143
+ this.perPart = new WeakMap();
144
+ }
145
+ /**
146
+ * This tracker for revisions written into one part (a `Body` asks for its own). The id counter is
147
+ * the host's, so every tracker over a document draws ids from one space.
148
+ */
149
+ forPart(part) {
150
+ if (!part || part === this.part)
151
+ return this;
152
+ let tracker = this.perPart.get(part);
153
+ if (!tracker) {
154
+ tracker = new ChangeTracker(this.host, this.mode, part);
155
+ this.perPart.set(part, tracker);
156
+ }
157
+ return tracker;
82
158
  }
83
159
  /** The `w:author` a new revision carries: the package author's name. */
84
160
  get author() {
@@ -106,10 +182,20 @@ export class ChangeTracker {
106
182
  host.annotationIdCounter = counter;
107
183
  return counter;
108
184
  }
109
- /** The `w:id`, `w:author` and `w:date` a new revision carries. */
185
+ /**
186
+ * The `w:id`, `w:author` and `w:date` a new revision carries, `w:date` in Word's local wall-clock
187
+ * convention (`wordCalendarOf`), and `w16du:dateUtc`, the true UTC, as Word writes them both. The
188
+ * part's root is made to list `w16du` in `mc:Ignorable` first (`declareDateUtc`): a consumer that
189
+ * does not know `w16du` must reject it unless it is declared ignorable. A tracker bound to no part -
190
+ * the package's own - writes `w:date` alone (CR-002 section 29).
191
+ */
110
192
  markup() {
111
193
  var _a;
112
- return { id: this.nextId(), author: this.author, date: calendarOf((_a = this.host.trackedChangeDate) !== null && _a !== void 0 ? _a : nowToTheMinute()) };
194
+ const when = (_a = this.host.trackedChangeDate) !== null && _a !== void 0 ? _a : nowToTheMinute();
195
+ const markup = { id: this.nextId(), author: this.author, date: wordCalendarOf(when) };
196
+ if (declareDateUtc(this.part))
197
+ markup.dateUtc = calendarOf(when);
198
+ return markup;
113
199
  }
114
200
  /** A `w:ins` / `w:del` attribute set: the id, this package's author and the date. */
115
201
  trackChange() {
@@ -147,6 +233,26 @@ export class ChangeTracker {
147
233
  rPr.rPrChange = createCTRPrChange({ ...this.markup(), rPr: original });
148
234
  linkParents(rPr.rPrChange, rPr);
149
235
  }
236
+ /**
237
+ * After a tracked formatting write: the run's `w:rPrChange` goes when its properties are back to
238
+ * what it recorded, since Word keeps no formatting change that changes nothing, whoever changed the
239
+ * formatting back (`test/README.md` check 24: bold then unbold by the same author, and by another; a
240
+ * character style applied then cleared; CR-002 section 29). An `w:rPr` then left empty goes too, as
241
+ * Word writes none.
242
+ */
243
+ settleRPrChange(run) {
244
+ var _a, _b;
245
+ const rPr = run.rPr;
246
+ const change = rPr === null || rPr === void 0 ? void 0 : rPr.rPrChange;
247
+ if (!rPr || !change)
248
+ return;
249
+ const { rPrChange: _recorded, ...current } = rPr;
250
+ if (canonical(current) !== canonical(rPrFromElements((_b = (_a = change.rPr) === null || _a === void 0 ? void 0 : _a.egrPrBase) !== null && _b !== void 0 ? _b : [])))
251
+ return;
252
+ delete rPr.rPrChange;
253
+ if (Object.keys(rPr).every((key) => key === 'TYPE_NAME'))
254
+ delete run.rPr;
255
+ }
150
256
  /** Records the paragraph's properties as they are now in `w:pPrChange`, once. */
151
257
  recordPPrChange(pPr) {
152
258
  if (pPr.pPrChange)
@@ -232,6 +232,18 @@ export declare class WordprocessingMLPackage extends OpcPackage implements Track
232
232
  * bytes cannot change; bytes that were set are read again when they are set again.
233
233
  */
234
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>;
235
247
  /** TrackingHost: the trees a new revision id must be above, which is every part already unmarshalled. */
236
248
  markupRoots(): object[];
237
249
  /**
@@ -448,6 +448,28 @@ export class WordprocessingMLPackage extends OpcPackage {
448
448
  }
449
449
  return this.annotationIdFloor;
450
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
+ }
451
473
  /** TrackingHost: the trees a new revision id must be above, which is every part already unmarshalled. */
452
474
  markupRoots() {
453
475
  const out = [];
@@ -3,8 +3,10 @@ import * as w15Factory from '@docx4j/generated-objects-ts/factory/org_docx4j_w15
3
3
  import * as w16cidFactory from '@docx4j/generated-objects-ts/factory/org_docx4j_w16cid';
4
4
  import { linkParents } from '@docx4j/generated-objects-ts/builders/wml';
5
5
  import { Docx4JException } from '../../opc/exceptions.mjs';
6
+ import { parseXml } from '../../xml/dom.mjs';
6
7
  import { MainDocumentPart, CommentsPart, CommentsExtendedPart, CommentsIdsPart, PeoplePart } from './index.mjs';
7
8
  import { COMMENT_REFERENCE_STYLE, COMMENT_TEXT_STYLE, setCommentPartsAccess, dateToCalendar, } from '../../model/content/comments.mjs';
9
+ import { dateOf } from '../../model/content/tracking.mjs';
8
10
  // Loaded for its side effect: the Comment views register themselves with the content API.
9
11
  import '../../model/content/Comment.mjs';
10
12
  /** The comment parts of one document. */
@@ -14,6 +16,7 @@ class DocumentCommentParts {
14
16
  this.commentsPart = commentsPart;
15
17
  this.comments = comments;
16
18
  this.author = author;
19
+ this.datesUtc = new Map();
17
20
  this.loadedAll = false;
18
21
  }
19
22
  ensureCommentsEx() {
@@ -66,8 +69,52 @@ async function partsFor(main, commentsPart, author) {
66
69
  parts.commentsEx = await main.commentsExtendedPart.getContents();
67
70
  if (main.peoplePart)
68
71
  parts.people = await main.peoplePart.getContents();
72
+ await readDatesUtc(main, parts.datesUtc);
69
73
  return parts;
70
74
  }
75
+ const W16CID_NS = 'http://schemas.microsoft.com/office/word/2016/wordml/cid';
76
+ const W16CEX_NS = 'http://schemas.microsoft.com/office/word/2018/wordml/cex';
77
+ /**
78
+ * Each comment's `w16cex:dateUtc`, by the paragraph id `w16cid:commentId` pairs its durable id with.
79
+ * From the parts' contents when they are unmarshalled, else from their XML, which leaves them
80
+ * untouched: no view needs them otherwise, and an unmarshalled part is re-marshalled on save.
81
+ */
82
+ async function readDatesUtc(main, into) {
83
+ var _a, _b, _c;
84
+ const idsPart = main.commentsIdsPart;
85
+ const extensiblePart = main.commentsExtensiblePart;
86
+ if (!idsPart || !extensiblePart)
87
+ return;
88
+ const paraIdOf = new Map();
89
+ if (idsPart.isUnmarshalled) {
90
+ for (const e of (_a = idsPart.contents.commentId) !== null && _a !== void 0 ? _a : [])
91
+ if (e.durableId && e.paraId)
92
+ paraIdOf.set(e.durableId.toUpperCase(), e.paraId.toUpperCase());
93
+ }
94
+ else {
95
+ for (const e of Array.from(parseXml(await idsPart.getXml()).getElementsByTagNameNS(W16CID_NS, 'commentId'))) {
96
+ const durableId = e.getAttributeNS(W16CID_NS, 'durableId');
97
+ const paraId = e.getAttributeNS(W16CID_NS, 'paraId');
98
+ if (durableId && paraId)
99
+ paraIdOf.set(durableId.toUpperCase(), paraId.toUpperCase());
100
+ }
101
+ }
102
+ const add = (durableId, date) => {
103
+ const paraId = durableId ? paraIdOf.get(durableId.toUpperCase()) : undefined;
104
+ if (paraId && date && !Number.isNaN(date.getTime()))
105
+ into.set(paraId, date);
106
+ };
107
+ if (extensiblePart.isUnmarshalled) {
108
+ for (const e of (_b = extensiblePart.contents.commentExtensible) !== null && _b !== void 0 ? _b : [])
109
+ add(e.durableId, dateOf(e.dateUtc));
110
+ }
111
+ else {
112
+ for (const e of Array.from(parseXml(await extensiblePart.getXml()).getElementsByTagNameNS(W16CEX_NS, 'commentExtensible'))) {
113
+ const value = e.getAttributeNS(W16CEX_NS, 'dateUtc');
114
+ add((_c = e.getAttributeNS(W16CEX_NS, 'durableId')) !== null && _c !== void 0 ? _c : undefined, value ? new Date(value) : undefined);
115
+ }
116
+ }
117
+ }
71
118
  async function ensurePeople(main) {
72
119
  if (main.peoplePart)
73
120
  return main.peoplePart.getContents();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docx4j/core-ts",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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",