@docx4j/core-ts 0.1.5 → 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.
Files changed (49) hide show
  1. package/README.md +81 -0
  2. package/dist/model/content/Body.d.mts +36 -2
  3. package/dist/model/content/Body.mjs +79 -4
  4. package/dist/model/content/Comment.d.mts +15 -5
  5. package/dist/model/content/Comment.mjs +70 -22
  6. package/dist/model/content/List.d.mts +13 -2
  7. package/dist/model/content/List.mjs +48 -9
  8. package/dist/model/content/Paragraph.d.mts +11 -1
  9. package/dist/model/content/Paragraph.mjs +37 -21
  10. package/dist/model/content/Range.d.mts +36 -5
  11. package/dist/model/content/Range.mjs +97 -26
  12. package/dist/model/content/binder.d.mts +44 -0
  13. package/dist/model/content/binder.mjs +128 -0
  14. package/dist/model/content/comments.d.mts +19 -1
  15. package/dist/model/content/index.d.mts +5 -3
  16. package/dist/model/content/index.mjs +3 -1
  17. package/dist/model/content/search.d.mts +27 -2
  18. package/dist/model/content/search.mjs +59 -3
  19. package/dist/model/content/stylesFacade.d.mts +37 -0
  20. package/dist/model/content/stylesFacade.mjs +114 -0
  21. package/dist/model/content/tracking.d.mts +57 -2
  22. package/dist/model/content/tracking.mjs +47 -15
  23. package/dist/model/customxml/CustomXmlPartCollection.d.mts +7 -0
  24. package/dist/model/customxml/CustomXmlPartCollection.mjs +40 -6
  25. package/dist/model/customxml/xpath.d.mts +18 -3
  26. package/dist/model/customxml/xpath.mjs +18 -3
  27. package/dist/office-js/index.d.mts +3 -1
  28. package/dist/office-js/run.d.mts +12 -2
  29. package/dist/office-js/run.mjs +8 -1
  30. package/dist/office-js/shimmed.d.mts +46 -0
  31. package/dist/office-js/shimmed.mjs +1 -0
  32. package/dist/opc/exceptions.d.mts +25 -0
  33. package/dist/opc/exceptions.mjs +20 -0
  34. package/dist/opc/index.d.mts +1 -1
  35. package/dist/opc/index.mjs +1 -1
  36. package/dist/opc/mce/understood.mjs +37 -8
  37. package/dist/packages/WordprocessingMLPackage.d.mts +105 -3
  38. package/dist/packages/WordprocessingMLPackage.mjs +165 -2
  39. package/dist/parts/XmlPart.d.mts +44 -0
  40. package/dist/parts/XmlPart.mjs +86 -3
  41. package/dist/parts/sml/index.d.mts +9 -8
  42. package/dist/parts/sml/index.mjs +23 -19
  43. package/dist/parts/wml/index.d.mts +10 -2
  44. package/dist/parts/wml/index.mjs +11 -3
  45. package/dist/parts/wml/spliceableStyles.d.mts +1 -0
  46. package/dist/parts/wml/spliceableStyles.mjs +122 -0
  47. package/dist/xml/dom.d.mts +10 -1
  48. package/dist/xml/dom.mjs +46 -2
  49. package/package.json +2 -2
@@ -1,12 +1,37 @@
1
- /** Office JS Word.SearchOptions, the subset honoured here. */
1
+ /** Office JS Word.SearchOptions, the subset honoured here, plus `matchRegExp` (CR-002 section 26). */
2
2
  export interface SearchOptions {
3
3
  matchCase?: boolean;
4
4
  matchWholeWord?: boolean;
5
5
  /** Word wildcards: `?` one character, `*` any run, `[a-z]`, `[!x]`, `<` / `>` word start and end, `@` one or more of the previous. */
6
6
  matchWildcards?: boolean;
7
+ /**
8
+ * The search text is an ECMAScript regular expression, compiled with `u` (extension beyond
9
+ * Office JS, as `replaceText` is; `Word.SearchOptions` has no such member, so it stays out of
10
+ * `Word.supported`). Never `m` or `s`: a match stays inside one paragraph, as every search here
11
+ * does (CR-002 sections 26 and 29). `matchWildcards` as well is an error - they are two
12
+ * languages for the same argument.
13
+ */
14
+ matchRegExp?: boolean;
7
15
  }
8
16
  /** A regular expression for a search text under the options; global so it can iterate matches. */
9
17
  export declare function searchPattern(text: string, options?: SearchOptions): RegExp;
10
18
  export declare function escapeRegExp(s: string): string;
11
- /** All [start, end) matches of a pattern in a text. */
19
+ /**
20
+ * All [start, end) matches of a pattern in a text. Kept as it is - it is a public export, and every
21
+ * caller that only wants spans would have to change - so the groups a replacement needs come from
22
+ * `matchesOf` instead (CR-002 section 26's review note, trap 1).
23
+ */
12
24
  export declare function findAll(text: string, pattern: RegExp): [number, number][];
25
+ /**
26
+ * Every match of a pattern in a text, with its capture groups: what `replaceText` needs to expand
27
+ * `$1` and Word's `\1`. An empty match advances by one rather than looping forever, as `findAll`
28
+ * has always done.
29
+ */
30
+ export declare function matchesOf(text: string, pattern: RegExp): RegExpExecArray[];
31
+ /**
32
+ * A replacement with its group references expanded against one match: `$1` to `$9`, `$&` and `$$`
33
+ * for a regular expression (JavaScript's `String.prototype.replace` rules), and Word's `\1` to
34
+ * `\9` for wildcards (CR-002 section 26). A reference to a group the pattern does not have expands
35
+ * to nothing, as both languages do.
36
+ */
37
+ export declare function expandReplacement(replacement: string, match: RegExpExecArray, options?: SearchOptions): string;
@@ -1,5 +1,19 @@
1
+ import { Docx4JException } from '../../opc/exceptions.mjs';
1
2
  /** A regular expression for a search text under the options; global so it can iterate matches. */
2
3
  export function searchPattern(text, options = {}) {
4
+ if (options.matchRegExp && options.matchWildcards) {
5
+ throw new RangeError('matchRegExp and matchWildcards are two languages for the same argument; choose one');
6
+ }
7
+ // The `u` flag goes on this branch ONLY. Under it several escapes that are merely redundant become
8
+ // syntax errors, and the other two branches write some: the wildcard `[...]` doubles backslashes
9
+ // and `escapeRegExp` escapes `-`. So it must not be moved to the one `new RegExp` below.
10
+ if (options.matchRegExp) {
11
+ // A user expression may have a top-level alternation, so `matchWholeWord` has to wrap it in a
12
+ // group: `(?<!\w)cat|dog(?!\w)` is two alternatives with one guard each and matches `dog` in
13
+ // `dogma`. A non-capturing group keeps the group numbers `$1` refers to.
14
+ const source = options.matchWholeWord ? `(?<![\\w])(?:${text})(?![\\w])` : text;
15
+ return new RegExp(source, options.matchCase ? 'gu' : 'giu');
16
+ }
3
17
  let source;
4
18
  if (options.matchWildcards) {
5
19
  source = '';
@@ -39,6 +53,14 @@ export function searchPattern(text, options = {}) {
39
53
  else if (c === '\\' && i + 1 < text.length) {
40
54
  source += '\\' + text[++i];
41
55
  }
56
+ else if (c === '(' || c === ')') {
57
+ // Word's wildcard grouping, which `\1` to `\9` in a replacement refer to (CR-002 section 26);
58
+ // a literal parenthesis is `\(`, taken by the escape case above. Before section 26 both were
59
+ // escaped to literals, so this changes what a pattern with them finds: `f(x)` matched the
60
+ // text "f(x)" and now matches "fx", as Word's wildcards do. An unbalanced one is refused
61
+ // below (CR-002 section 31).
62
+ source += c;
63
+ }
42
64
  else
43
65
  source += escapeRegExp(c);
44
66
  }
@@ -48,13 +70,32 @@ export function searchPattern(text, options = {}) {
48
70
  }
49
71
  if (options.matchWholeWord)
50
72
  source = `(?<![\\w])${source}(?![\\w])`;
51
- return new RegExp(source, options.matchCase ? 'g' : 'gi');
73
+ try {
74
+ return new RegExp(source, options.matchCase ? 'g' : 'gi');
75
+ }
76
+ catch (cause) {
77
+ // Only a wildcard pattern can fail here - a plain text is escaped whole - and the RegExp's own
78
+ // message quotes the translation, a pattern the caller never wrote.
79
+ throw new Docx4JException(`Not a valid wildcard pattern: ${text}`, { cause });
80
+ }
52
81
  }
53
82
  export function escapeRegExp(s) {
54
83
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
55
84
  }
56
- /** All [start, end) matches of a pattern in a text. */
85
+ /**
86
+ * All [start, end) matches of a pattern in a text. Kept as it is - it is a public export, and every
87
+ * caller that only wants spans would have to change - so the groups a replacement needs come from
88
+ * `matchesOf` instead (CR-002 section 26's review note, trap 1).
89
+ */
57
90
  export function findAll(text, pattern) {
91
+ return matchesOf(text, pattern).map((m) => [m.index, m.index + m[0].length]);
92
+ }
93
+ /**
94
+ * Every match of a pattern in a text, with its capture groups: what `replaceText` needs to expand
95
+ * `$1` and Word's `\1`. An empty match advances by one rather than looping forever, as `findAll`
96
+ * has always done.
97
+ */
98
+ export function matchesOf(text, pattern) {
58
99
  const out = [];
59
100
  pattern.lastIndex = 0;
60
101
  for (let m = pattern.exec(text); m !== null; m = pattern.exec(text)) {
@@ -62,7 +103,22 @@ export function findAll(text, pattern) {
62
103
  pattern.lastIndex++;
63
104
  continue;
64
105
  }
65
- out.push([m.index, m.index + m[0].length]);
106
+ out.push(m);
66
107
  }
67
108
  return out;
68
109
  }
110
+ /**
111
+ * A replacement with its group references expanded against one match: `$1` to `$9`, `$&` and `$$`
112
+ * for a regular expression (JavaScript's `String.prototype.replace` rules), and Word's `\1` to
113
+ * `\9` for wildcards (CR-002 section 26). A reference to a group the pattern does not have expands
114
+ * to nothing, as both languages do.
115
+ */
116
+ export function expandReplacement(replacement, match, options = {}) {
117
+ if (options.matchRegExp) {
118
+ return replacement.replace(/\$(\$|&|[1-9])/g, (_, token) => { var _a; return token === '$' ? '$' : token === '&' ? match[0] : (_a = match[Number(token)]) !== null && _a !== void 0 ? _a : ''; });
119
+ }
120
+ if (options.matchWildcards) {
121
+ return replacement.replace(/\\([1-9])/g, (_, digit) => { var _a; return (_a = match[Number(digit)]) !== null && _a !== void 0 ? _a : ''; });
122
+ }
123
+ return replacement;
124
+ }
@@ -0,0 +1,37 @@
1
+ import type { StyleDefinitionsPart, MainDocumentPart } from '../../parts/wml/index.mjs';
2
+ /** What the facade needs of its package; `WordprocessingMLPackage` satisfies it. */
3
+ export interface StylePackageLike {
4
+ getMainDocumentPart(): MainDocumentPart;
5
+ refreshPropertyResolver(): void;
6
+ /** Creates the styles part, with the defaults, when the document has none. */
7
+ ensureStyleDefinitionsPart(): StyleDefinitionsPart;
8
+ }
9
+ /**
10
+ * The document's style definitions as verbs that need no `Body`: `pkg.styles`.
11
+ *
12
+ * `ensure` is the one verb, and the point of it is the closure: a style is useless without what it
13
+ * is `w:basedOn` and `w:link`ed to, and a caller asking for `FootnoteText` means the four styles
14
+ * that make a footnote look like a footnote, not one.
15
+ */
16
+ export declare class StylesFacade {
17
+ private readonly pkg;
18
+ constructor(pkg: StylePackageLike);
19
+ /**
20
+ * Adds each style the document lacks, from the same defaults `createPackage()` writes, together
21
+ * with the styles it is `w:basedOn` and `w:link`ed to, recursively. Returns the ids actually
22
+ * added, in the order they were written, so a caller can tell whether it changed anything.
23
+ *
24
+ * A style already in the document is left exactly as it is - the document's own definition wins
25
+ * over the default, always, since a consumer must not silently restyle a document it was asked to
26
+ * add a footnote to. An id the defaults do not carry throws a `Docx4JException` naming it: the
27
+ * caller asked for a definition nothing here has, and inventing one would be worse than saying so.
28
+ * The whole closure is worked out before anything is written, so a call that throws has changed
29
+ * nothing (CR-002 section 31).
30
+ *
31
+ * A document that already has every id keeps its styles part **byte for byte**: the part is not
32
+ * unmarshalled at all when its XML names them, the check `ensureCommentStyles` uses, and when that
33
+ * text check misses (another prefix, single quotes) it is read privately rather than unmarshalled,
34
+ * so the part is still written back from its source unless something is added.
35
+ */
36
+ ensure(ids: string | readonly string[]): Promise<string[]>;
37
+ }
@@ -0,0 +1,114 @@
1
+ import { deepCopy, unmarshalNode } from '@docx4j/generated-objects-ts';
2
+ import { linkParents } from './tree.mjs';
3
+ import { parseXml } from '../../xml/dom.mjs';
4
+ import { DEFAULT_STYLES_XML } from '../../parts/wml/defaultStyles.mjs';
5
+ import { SPLICEABLE_STYLES_XML } from '../../parts/wml/spliceableStyles.mjs';
6
+ import { Docx4JException } from '../../opc/exceptions.mjs';
7
+ /**
8
+ * Where a definition is looked up, unmarshalled once and never handed out (a copy is spliced in):
9
+ * the defaults `createPackage()` writes, and then the nine styles docx4j's own `styles.xml` carries
10
+ * **commented out** - which is why a created document has no `FootnoteText` although the file
11
+ * appears to contain one, and why this facade exists (CR-002 section 22.2).
12
+ */
13
+ let sources;
14
+ function styleSources() {
15
+ return (sources !== null && sources !== void 0 ? sources : (sources = (async () => {
16
+ const read = async (xml) => { var _a; return (_a = (await unmarshalNode(parseXml(xml))).value.style) !== null && _a !== void 0 ? _a : []; };
17
+ const defaults = await read(DEFAULT_STYLES_XML);
18
+ return { defaults, all: [...defaults, ...(await read(SPLICEABLE_STYLES_XML))] };
19
+ })()));
20
+ }
21
+ /**
22
+ * The document's style definitions as verbs that need no `Body`: `pkg.styles`.
23
+ *
24
+ * `ensure` is the one verb, and the point of it is the closure: a style is useless without what it
25
+ * is `w:basedOn` and `w:link`ed to, and a caller asking for `FootnoteText` means the four styles
26
+ * that make a footnote look like a footnote, not one.
27
+ */
28
+ export class StylesFacade {
29
+ constructor(pkg) {
30
+ this.pkg = pkg;
31
+ }
32
+ /**
33
+ * Adds each style the document lacks, from the same defaults `createPackage()` writes, together
34
+ * with the styles it is `w:basedOn` and `w:link`ed to, recursively. Returns the ids actually
35
+ * added, in the order they were written, so a caller can tell whether it changed anything.
36
+ *
37
+ * A style already in the document is left exactly as it is - the document's own definition wins
38
+ * over the default, always, since a consumer must not silently restyle a document it was asked to
39
+ * add a footnote to. An id the defaults do not carry throws a `Docx4JException` naming it: the
40
+ * caller asked for a definition nothing here has, and inventing one would be worse than saying so.
41
+ * The whole closure is worked out before anything is written, so a call that throws has changed
42
+ * nothing (CR-002 section 31).
43
+ *
44
+ * A document that already has every id keeps its styles part **byte for byte**: the part is not
45
+ * unmarshalled at all when its XML names them, the check `ensureCommentStyles` uses, and when that
46
+ * text check misses (another prefix, single quotes) it is read privately rather than unmarshalled,
47
+ * so the part is still written back from its source unless something is added.
48
+ */
49
+ async ensure(ids) {
50
+ var _a, _b;
51
+ const wanted = typeof ids === 'string' ? [ids] : [...ids];
52
+ for (const id of wanted) {
53
+ if (typeof id !== 'string' || id === '')
54
+ throw new Docx4JException(`Not a styleId: ${String(id)}`);
55
+ }
56
+ if (wanted.length === 0)
57
+ return [];
58
+ const existing = this.pkg.getMainDocumentPart().styleDefinitionsPart;
59
+ // The cheap path: an untouched part whose XML names them all is left alone, so a document that
60
+ // has them keeps its styles part byte for byte. The comments must go first - docx4j's own
61
+ // default styles carry nine definitions COMMENTED OUT, so a plain `includes` finds
62
+ // `w:styleId="FootnoteText"` in a document that does not have the style and this would answer
63
+ // "nothing to do" for exactly the documents the facade exists for.
64
+ if (existing !== undefined && !existing.isUnmarshalled) {
65
+ const xml = (await existing.getXml()).replace(/<!--[\s\S]*?-->/g, '');
66
+ if (wanted.every((id) => xml.includes(`w:styleId="${id}"`)))
67
+ return [];
68
+ }
69
+ // What the document holds now, read without unmarshalling; a part created below holds the defaults.
70
+ const source = await styleSources();
71
+ const present = existing === undefined ? source.defaults : ((_a = (await existing.readContents()).style) !== null && _a !== void 0 ? _a : []);
72
+ const plan = closureOf(wanted, present, source.all);
73
+ // a document with no styles part gets the defaults, whether or not anything else is needed
74
+ const part = existing !== null && existing !== void 0 ? existing : this.pkg.ensureStyleDefinitionsPart();
75
+ if (plan.length === 0)
76
+ return [];
77
+ const styles = await part.getContents();
78
+ const list = ((_b = styles.style) !== null && _b !== void 0 ? _b : (styles.style = []));
79
+ for (const template of plan) {
80
+ const style = deepCopy(template);
81
+ list.push(style);
82
+ linkParents(style, styles);
83
+ }
84
+ this.pkg.refreshPropertyResolver();
85
+ return plan.map((style) => style.styleId);
86
+ }
87
+ }
88
+ /**
89
+ * The definitions to splice, in the order they are written: each wanted id the document lacks,
90
+ * after what it is `w:basedOn` and `w:link`ed to, recursively. Throws for an id nothing carries.
91
+ */
92
+ function closureOf(wanted, present, source) {
93
+ const plan = [];
94
+ const has = (id) => present.some((s) => s.styleId === id) || plan.some((s) => s.styleId === id);
95
+ const add = (id, seen) => {
96
+ var _a, _b;
97
+ if (has(id) || seen.has(id))
98
+ return;
99
+ seen.add(id);
100
+ const template = source.find((s) => s.styleId === id);
101
+ if (template === undefined) {
102
+ throw new Docx4JException(`No definition to splice for the style ${id}: neither docx4j's default styles nor the nine it comments out carry one. Define it yourself on the styles part.`);
103
+ }
104
+ // what it is based on and linked to must be there too, and before it reads better in the part
105
+ for (const dependency of [(_a = template.basedOn) === null || _a === void 0 ? void 0 : _a.val, (_b = template.link) === null || _b === void 0 ? void 0 : _b.val]) {
106
+ if (typeof dependency === 'string')
107
+ add(dependency, seen);
108
+ }
109
+ plan.push(template);
110
+ };
111
+ for (const id of wanted)
112
+ add(id, new Set());
113
+ return plan;
114
+ }
@@ -3,6 +3,19 @@ import { type Element, type RevisionHolder } from './tree.mjs';
3
3
  import type { Author } from './comments.mjs';
4
4
  /** Office JS `Word.ChangeTrackingMode`. */
5
5
  export type ChangeTrackingMode = 'Off' | 'TrackAll' | 'TrackMineOnly';
6
+ /**
7
+ * `w:id` on these is the one annotation id space (ECMA-376 17.13.5.4): revisions, bookmarks,
8
+ * comment range marks and permissions share it. `w:comment/@w:id` (docx4j `Comments.Comment`,
9
+ * which also extends `CTMarkup`) is a different space and is excluded, so that phase G's
10
+ * comment ids and phase F's revision ids never have to agree.
11
+ */
12
+ export declare const MARKUP_TYPES: ReadonlySet<string>;
13
+ /**
14
+ * The highest annotation id in a tree (`MARKUP_TYPES` only, the one `w:id` space of ECMA-376
15
+ * 17.13.5.4). Shared so that the tracker's scan of the unmarshalled parts and the package's
16
+ * private read of the rest cannot drift apart (CR-002 section 28.2).
17
+ */
18
+ export declare function highestAnnotationId(root: object, from?: number): number;
6
19
  /** What a package offers so that its edits can be tracked; `WordprocessingMLPackage` implements it. */
7
20
  export interface TrackingHost {
8
21
  /**
@@ -15,9 +28,43 @@ export interface TrackingHost {
15
28
  readonly trackedChangeDate?: Date | undefined;
16
29
  /** The trees to scan for the highest annotation id in use: the parts already unmarshalled. */
17
30
  markupRoots(): object[];
31
+ /**
32
+ * A floor for the annotation id, above every id in the parts that were **not** unmarshalled
33
+ * (`seedAnnotationIds()` reads them privately). 0 until it is seeded; `markupRoots()` covers the
34
+ * rest, so the next id is one above the greater of the two (CR-002 section 28.2).
35
+ */
36
+ readonly annotationIdFloor: number;
37
+ /**
38
+ * The last annotation id issued in the document, or `undefined` until the first, when the tracker
39
+ * scans for the highest in use. The host holds it rather than the tracker because a document has
40
+ * one id space and may have several trackers over its life - `withTracking` can make one with
41
+ * another mode - and a tracker with a counter of its own reissues the ids another one gave out
42
+ * (CR-002 section 31).
43
+ */
44
+ annotationIdCounter: number | undefined;
45
+ }
46
+ /**
47
+ * What `WordprocessingMLPackage.withTracking` records for the duration of one call: who, when, and
48
+ * which mode (`TrackAll` unless said otherwise). The document's own `w:trackRevisions` is not
49
+ * touched - an editor's agent records its edits as itself in a room whose setting belongs to the
50
+ * document, not to the caller (CR-002 section 28.1).
51
+ */
52
+ export interface TrackingScope {
53
+ author: Author;
54
+ /** The `w:date` for every revision in the call; `undefined` means now, to the minute. */
55
+ date?: Date | undefined;
56
+ /** `TrackAll` unless given; `Off` runs `fn` with tracking suppressed for the call. */
57
+ mode?: ChangeTrackingMode | undefined;
18
58
  }
19
59
  /** The tracker of a package whose mode is not `Off`; `undefined` when it is (duck typed, no import cycle). */
20
60
  export declare function trackerOf(pkg: unknown): ChangeTracker | undefined;
61
+ /**
62
+ * Now, with the seconds and milliseconds dropped, which is what Word stamps a revision with.
63
+ * Not cosmetic: Word groups adjacent revisions by author **and** timestamp, so a per-second stamp
64
+ * makes one change of every keystroke run and an editor's typing unreviewable (CR-002 section 28.2).
65
+ * A caller that sets `trackedChangeDate` gets exactly the date it asked for, truncated or not.
66
+ */
67
+ export declare function nowToTheMinute(): Date;
21
68
  /** An `XmlCalendar` in UTC, as Word writes `w:date` (an xsd:dateTime ending in `Z`). */
22
69
  export declare function calendarOf(date: Date): wml.XmlCalendar;
23
70
  /** The `Date` of a `w:date`, or undefined when there is none. */
@@ -33,11 +80,19 @@ export declare function dateOf(cal: wml.XmlCalendar | undefined): Date | undefin
33
80
  export declare class ChangeTracker {
34
81
  readonly host: TrackingHost;
35
82
  readonly mode: ChangeTrackingMode;
36
- private counter;
37
83
  constructor(host: TrackingHost, mode: ChangeTrackingMode);
38
84
  /** The `w:author` a new revision carries: the package author's name. */
39
85
  get author(): string;
40
- /** The next annotation id: one above the highest in use in the parts already unmarshalled. */
86
+ /**
87
+ * The next annotation id: one above the highest in use anywhere in the document - the parts
88
+ * already unmarshalled, scanned here, and the rest through the host's `annotationIdFloor`,
89
+ * which `seedAnnotationIds()` fills from their bytes without unmarshalling them (CR-002
90
+ * section 28.2; before that this saw only the unmarshalled parts, so a revision in an unread
91
+ * footnote or comment part did not raise the counter).
92
+ *
93
+ * The scan runs once per document, not per tracker: the counter is the host's. The floor is
94
+ * consulted on every call, so a `seedAnnotationIds()` awaited after the first id still counts.
95
+ */
41
96
  nextId(): number;
42
97
  /** The `w:id`, `w:author` and `w:date` a new revision carries. */
43
98
  markup(): {
@@ -9,7 +9,7 @@ import { W_NS, linkParents, walk, typeNameOf, revisionKindOf } from './tree.mjs'
9
9
  * which also extends `CTMarkup`) is a different space and is excluded, so that phase G's
10
10
  * comment ids and phase F's revision ids never have to agree.
11
11
  */
12
- const MARKUP_TYPES = new Set([
12
+ export const MARKUP_TYPES = new Set([
13
13
  'org_docx4j_wml.CTMarkup', 'org_docx4j_wml.CTBookmark', 'org_docx4j_wml.CTBookmarkRange',
14
14
  'org_docx4j_wml.CTCellMergeTrackChange', 'org_docx4j_wml.CTMarkupRange', 'org_docx4j_wml.CTMathRunTrackChange',
15
15
  'org_docx4j_wml.CTMoveBookmark', 'org_docx4j_wml.CTMoveFromRangeEnd', 'org_docx4j_wml.CTMoveToRangeEnd',
@@ -19,10 +19,36 @@ const MARKUP_TYPES = new Set([
19
19
  'org_docx4j_wml.CTTrackChangeNumbering', 'org_docx4j_wml.CTTrackChangeRange',
20
20
  'org_docx4j_wml.ParaRPrChange', 'org_docx4j_wml.RunDel', 'org_docx4j_wml.RunIns', 'org_docx4j_wml.RunTrackChange',
21
21
  ]);
22
+ /**
23
+ * The highest annotation id in a tree (`MARKUP_TYPES` only, the one `w:id` space of ECMA-376
24
+ * 17.13.5.4). Shared so that the tracker's scan of the unmarshalled parts and the package's
25
+ * private read of the rest cannot drift apart (CR-002 section 28.2).
26
+ */
27
+ export function highestAnnotationId(root, from = 0) {
28
+ let highest = from;
29
+ walk(root, (value) => {
30
+ const tn = value.TYPE_NAME;
31
+ const id = value.id;
32
+ if (tn !== undefined && MARKUP_TYPES.has(tn) && typeof id === 'number' && id > highest)
33
+ highest = id;
34
+ });
35
+ return highest;
36
+ }
22
37
  /** The tracker of a package whose mode is not `Off`; `undefined` when it is (duck typed, no import cycle). */
23
38
  export function trackerOf(pkg) {
24
39
  return pkg === null || pkg === void 0 ? void 0 : pkg.changeTracker;
25
40
  }
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.
46
+ */
47
+ export function nowToTheMinute() {
48
+ const now = new Date();
49
+ now.setUTCSeconds(0, 0);
50
+ return now;
51
+ }
26
52
  /** An `XmlCalendar` in UTC, as Word writes `w:date` (an xsd:dateTime ending in `Z`). */
27
53
  export function calendarOf(date) {
28
54
  return {
@@ -58,26 +84,32 @@ export class ChangeTracker {
58
84
  get author() {
59
85
  return this.host.author.name;
60
86
  }
61
- /** The next annotation id: one above the highest in use in the parts already unmarshalled. */
87
+ /**
88
+ * The next annotation id: one above the highest in use anywhere in the document - the parts
89
+ * already unmarshalled, scanned here, and the rest through the host's `annotationIdFloor`,
90
+ * which `seedAnnotationIds()` fills from their bytes without unmarshalling them (CR-002
91
+ * section 28.2; before that this saw only the unmarshalled parts, so a revision in an unread
92
+ * footnote or comment part did not raise the counter).
93
+ *
94
+ * The scan runs once per document, not per tracker: the counter is the host's. The floor is
95
+ * consulted on every call, so a `seedAnnotationIds()` awaited after the first id still counts.
96
+ */
62
97
  nextId() {
63
- if (this.counter === undefined) {
64
- let highest = 0;
65
- for (const root of this.host.markupRoots()) {
66
- walk(root, (value) => {
67
- const tn = value.TYPE_NAME;
68
- const id = value.id;
69
- if (tn !== undefined && MARKUP_TYPES.has(tn) && typeof id === 'number' && id > highest)
70
- highest = id;
71
- });
72
- }
73
- this.counter = highest;
98
+ const host = this.host;
99
+ let counter = host.annotationIdCounter;
100
+ if (counter === undefined) {
101
+ counter = 0;
102
+ for (const root of host.markupRoots())
103
+ counter = highestAnnotationId(root, counter);
74
104
  }
75
- return ++this.counter;
105
+ counter = Math.max(counter, host.annotationIdFloor) + 1;
106
+ host.annotationIdCounter = counter;
107
+ return counter;
76
108
  }
77
109
  /** The `w:id`, `w:author` and `w:date` a new revision carries. */
78
110
  markup() {
79
111
  var _a;
80
- return { id: this.nextId(), author: this.author, date: calendarOf((_a = this.host.trackedChangeDate) !== null && _a !== void 0 ? _a : new Date()) };
112
+ return { id: this.nextId(), author: this.author, date: calendarOf((_a = this.host.trackedChangeDate) !== null && _a !== void 0 ? _a : nowToTheMinute()) };
81
113
  }
82
114
  /** A `w:ins` / `w:del` attribute set: the id, this package's author and the date. */
83
115
  trackChange() {
@@ -40,9 +40,16 @@ export declare class CustomXmlPartCollection implements CustomXmlPartOwner, Cust
40
40
  * A new custom XML part holding this XML, with its properties part and a fresh itemID, related
41
41
  * from the main document part (docx4j's `AbstractMigrator.addPropertiesPart` recipe: Word
42
42
  * silently drops a custom XML part that only the package relates to).
43
+ *
44
+ * `itemID` and `partName` may be given, both checked unused, for a co-editing room where two
45
+ * sessions allocate at once and the next free name is not the right answer (CR-002 section 24).
46
+ * A clash throws rather than renaming, as a chosen list `numId` does: the caller is asserting
47
+ * something about the room's state, and renaming would break the peer that chose it.
43
48
  */
44
49
  add(xml: string, options?: {
45
50
  schemaRefs?: string[];
51
+ itemID?: string;
52
+ partName?: string;
46
53
  }): CustomXmlPart;
47
54
  /**
48
55
  * Removes a part, its properties part and the relationships (Office JS CustomXmlPart.delete).
@@ -33,7 +33,10 @@ export class CustomXmlPartCollection {
33
33
  if (!this.views.has(part))
34
34
  this.views.set(part, await this.viewFor(part));
35
35
  }
36
- await this.host.xpathEngine.ready();
36
+ // A parsed document, not the global one: whether a native `document.evaluate` can read these
37
+ // trees is a property of the parser that built them (CR-002 section 21).
38
+ const parsed = this.views.size > 0 ? [...this.views.values()][0].document : undefined;
39
+ await this.host.xpathEngine.ready(parsed);
37
40
  return this.items;
38
41
  }
39
42
  /** The parts, in the order they were registered. Call `load()` once first: a DOM has to be parsed. */
@@ -61,20 +64,40 @@ export class CustomXmlPartCollection {
61
64
  * A new custom XML part holding this XML, with its properties part and a fresh itemID, related
62
65
  * from the main document part (docx4j's `AbstractMigrator.addPropertiesPart` recipe: Word
63
66
  * silently drops a custom XML part that only the package relates to).
67
+ *
68
+ * `itemID` and `partName` may be given, both checked unused, for a co-editing room where two
69
+ * sessions allocate at once and the next free name is not the right answer (CR-002 section 24).
70
+ * A clash throws rather than renaming, as a chosen list `numId` does: the caller is asserting
71
+ * something about the room's state, and renaming would break the peer that chose it.
64
72
  */
65
73
  add(xml, options = {}) {
66
- var _a;
74
+ var _a, _b;
67
75
  const document = parseXml(xml);
68
76
  const root = document.documentElement;
69
77
  if (!root)
70
78
  throw new Docx4JException('A custom XML part needs a document element');
71
- const dataPart = new CustomXmlDataStoragePart('/customXml/item1.xml');
79
+ // Everything that can refuse the call runs before a part is made, so a refusal leaves the package
80
+ // as it was (CR-002 section 31: a malformed itemID used to be refused after the data part was
81
+ // related, which left it in the package without a properties part). The itemID is checked
82
+ // against the keys the package indexes its custom XML parts by, which needs no parse; the views
83
+ // do, and `load()` is not a precondition of `add()`.
84
+ const chosenName = options.partName === undefined ? undefined : PartName.of(options.partName);
85
+ if (chosenName && ((_a = this.host.customXmlRelationshipSource().package) === null || _a === void 0 ? void 0 : _a.parts.get(chosenName.name))) {
86
+ throw new Docx4JException(`The part name ${chosenName.name} is already in this package`);
87
+ }
88
+ const itemID = options.itemID === undefined ? `{${uuid().toUpperCase()}}` : normaliseChosenItemID(options.itemID);
89
+ if (options.itemID !== undefined) {
90
+ const key = normaliseId(itemID);
91
+ if ([...this.host.customXmlDataStorageParts.keys()].some((existing) => normaliseId(existing) === key)) {
92
+ throw new Docx4JException(`The itemID ${options.itemID} is already in use by a custom XML part`);
93
+ }
94
+ }
95
+ const dataPart = new CustomXmlDataStoragePart(chosenName !== null && chosenName !== void 0 ? chosenName : '/customXml/item1.xml');
72
96
  dataPart.setDocument(document);
73
- this.host.customXmlRelationshipSource().addTargetPart(dataPart, 'RENAME_IF_NAME_EXISTS');
74
- const itemID = `{${uuid().toUpperCase()}}`;
97
+ this.host.customXmlRelationshipSource().addTargetPart(dataPart, chosenName ? 'OVERWRITE_IF_NAME_EXISTS' : 'RENAME_IF_NAME_EXISTS');
75
98
  const propsName = PartName.of(`/customXml/itemProps${suffixOf(dataPart.partName)}.xml`);
76
99
  const props = new CustomXmlDataStoragePropertiesPart(propsName);
77
- const refs = (_a = options.schemaRefs) !== null && _a !== void 0 ? _a : (root.namespaceURI ? [root.namespaceURI] : []);
100
+ const refs = (_b = options.schemaRefs) !== null && _b !== void 0 ? _b : (root.namespaceURI ? [root.namespaceURI] : []);
78
101
  props.setContents(createDatastoreItem({
79
102
  itemID,
80
103
  ...(refs.length > 0 ? { schemaRefs: { schemaRef: refs.map((uri) => ({ uri })) } } : {}),
@@ -152,6 +175,17 @@ export class CustomXmlPartCollection {
152
175
  }
153
176
  }
154
177
  /** An itemID without braces, lower-cased: how docx4j keys the map and how Office JS compares ids. */
178
+ /**
179
+ * A chosen itemID in the form the properties part carries: braced and upper case, as Word and
180
+ * `uuid()` write it. A caller may pass it either way round (CR-002 section 24).
181
+ */
182
+ function normaliseChosenItemID(id) {
183
+ const bare = id.trim().replace(/^\{|\}$/g, '');
184
+ if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(bare)) {
185
+ throw new Docx4JException(`An itemID must be a GUID, with or without braces, not ${id}`);
186
+ }
187
+ return `{${bare.toUpperCase()}}`;
188
+ }
155
189
  function normaliseId(id) {
156
190
  return id.replace(/[{}]/g, '').toLowerCase();
157
191
  }
@@ -19,8 +19,15 @@ export type BooleanConversionMode = 'java' | 'xpath1' | 'xpath2';
19
19
  * string, number or boolean result. `namespaces` maps prefixes used in the expression to URIs.
20
20
  */
21
21
  export interface XPathEngine {
22
- /** Prepares the engine (loads the `xpath` package in Node); idempotent, and cheap once done. */
23
- ready(): Promise<void>;
22
+ /**
23
+ * Prepares the engine (loads the `xpath` package in Node); idempotent, and cheap once done.
24
+ *
25
+ * `context` is a node from a document the engine will be asked about, when the caller has one:
26
+ * whether a native `document.evaluate` can be used is a property of **that document**, not of
27
+ * the global one, and the two differ in a mixed environment - a jsdom test whose XML is parsed
28
+ * by xmldom has a global `document.evaluate` that cannot evaluate over the parsed tree.
29
+ */
30
+ ready(context?: Node): Promise<void>;
24
31
  /** True when `select` and `selectValue` can be called synchronously. */
25
32
  readonly isReady: boolean;
26
33
  select(expression: string, context: Node, namespaces?: Record<string, string>): Node[];
@@ -55,7 +62,15 @@ export declare class DefaultXPathEngine implements XPathEngine {
55
62
  /** True when the runtime has a native `document.evaluate` (a browser, a Word add-in). */
56
63
  private native;
57
64
  get isReady(): boolean;
58
- ready(): Promise<void>;
65
+ /**
66
+ * @param context a node from a document this engine will be asked about. Given one, readiness is
67
+ * decided from **that document** rather than from the global: a global `document.evaluate` says
68
+ * nothing about a tree some other parser built, and in a mixed environment - jsdom's global
69
+ * with xmldom's parser, which is what a Node test runner gives when the runtime resolves its
70
+ * `node` export condition - the global would claim readiness the parsed tree cannot honour.
71
+ * `pkg.customXmlParts.load()` passes a parsed document for exactly this reason.
72
+ */
73
+ ready(context?: Node): Promise<void>;
59
74
  select(expression: string, context: Node, namespaces?: Record<string, string>): Node[];
60
75
  selectValue(expression: string, context: Node, namespaces?: Record<string, string>): XPathValue;
61
76
  private call;
@@ -67,9 +67,19 @@ export class DefaultXPathEngine {
67
67
  get isReady() {
68
68
  return this.native || this.module !== undefined;
69
69
  }
70
- async ready() {
70
+ /**
71
+ * @param context a node from a document this engine will be asked about. Given one, readiness is
72
+ * decided from **that document** rather than from the global: a global `document.evaluate` says
73
+ * nothing about a tree some other parser built, and in a mixed environment - jsdom's global
74
+ * with xmldom's parser, which is what a Node test runner gives when the runtime resolves its
75
+ * `node` export condition - the global would claim readiness the parsed tree cannot honour.
76
+ * `pkg.customXmlParts.load()` passes a parsed document for exactly this reason.
77
+ */
78
+ async ready(context) {
71
79
  var _a;
72
- if (this.isReady)
80
+ if (this.module !== undefined)
81
+ return;
82
+ if (context === undefined ? this.native : nativeEvaluatorFor(context) !== undefined)
73
83
  return;
74
84
  (_a = this.loading) !== null && _a !== void 0 ? _a : (this.loading = (async () => {
75
85
  const specifier = 'xpath';
@@ -124,7 +134,12 @@ export class DefaultXPathEngine {
124
134
  }
125
135
  call(expression, context, namespaces) {
126
136
  if (!this.module) {
127
- throw new Docx4JException('The XPath engine is not ready yet: await pkg.customXmlParts.load() (or pkg.xpathEngine.ready()) once before selecting nodes');
137
+ throw new Docx4JException(this.native
138
+ ? 'This document cannot be evaluated by the host DOM: it was parsed by another one (jsdom\'s '
139
+ + 'document.evaluate over an xmldom tree, say). Install the optional peer dependency '
140
+ + "'xpath' and await pkg.xpathEngine.ready(node) with a node of this document, or set "
141
+ + 'pkg.xpathEngine to an engine that can read it'
142
+ : 'The XPath engine is not ready yet: await pkg.customXmlParts.load() (or pkg.xpathEngine.ready()) once before selecting nodes');
128
143
  }
129
144
  const select = Object.keys(namespaces).length > 0 ? this.module.useNamespaces(namespaces) : this.module.select;
130
145
  return select(expression, context);
@@ -1,4 +1,4 @@
1
- import { run, RequestContext, type RunOptions, type TrackedObjects } from './run.mjs';
1
+ import { run, RequestContext, type RunOptions, type TrackedObjects, type ShimmedDocument } from './run.mjs';
2
2
  import { Document, DocumentProperties } from './document.mjs';
3
3
  import { supported, SUBSET_MEMBERS } from './supported.mjs';
4
4
  import { ClientResult, unwrap, nullObject, Wrapper, type Collection, type PendingSink } from './proxy.mjs';
@@ -6,6 +6,8 @@ import { NotSupportedError, ItemNotFoundError, ValueNotLoadedError } from './err
6
6
  import { toApiScript, type ApiScriptOptions, type ApiScriptTarget } from './toApiScript.mjs';
7
7
  import { InsertLocation, Alignment, UnderlineType, ChangeTrackingMode, BreakType, ContentControlType, Style, ErrorCodes, SearchOptions, ListLevelType, ListNumbering, ListBullet, type InsertLocationValue, type ChangeTrackingModeValue, type BreakTypeValue, type ContentControlTypeValue, type StyleValue, type AlignmentValue, type UnderlineTypeValue, type ListLevelTypeValue, type ListNumberingValue, type ListBulletValue } from './enums.mjs';
8
8
  export { run, RequestContext, Document, DocumentProperties, ClientResult, unwrap, nullObject, Wrapper, toApiScript, supported, SUBSET_MEMBERS };
9
+ export type { Shimmed, ShimmedObject, ShimmedCollection, Loadable } from './shimmed.mjs';
10
+ export type { ShimmedDocument };
9
11
  export { NotSupportedError, ItemNotFoundError, ValueNotLoadedError };
10
12
  export { InsertLocation, Alignment, UnderlineType, ChangeTrackingMode, BreakType, ContentControlType, Style, ErrorCodes, SearchOptions };
11
13
  export { ListLevelType, ListNumbering, ListBullet };