@portone/docx-editor 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/CONTRIBUTING.md +4 -0
  3. package/dist/docx/commentOnlyChange.d.ts +3 -1
  4. package/dist/docx/commentOnlyChange.js +38 -21
  5. package/dist/docx/comments/constants.d.ts +3 -3
  6. package/dist/docx/comments/constants.js +4 -3
  7. package/dist/docx/comments/grammar.d.ts +80 -0
  8. package/dist/docx/comments/grammar.js +213 -0
  9. package/dist/docx/comments/people.js +12 -14
  10. package/dist/docx/comments/reading.d.ts +5 -3
  11. package/dist/docx/comments/reading.js +23 -20
  12. package/dist/docx/comments/verifying.d.ts +44 -0
  13. package/dist/docx/comments/verifying.js +206 -0
  14. package/dist/docx/comments/writing.js +73 -46
  15. package/dist/docx/exportDocx.js +2 -1
  16. package/dist/docx/formatting/direct.js +3 -6
  17. package/dist/docx/headersFooters.js +8 -10
  18. package/dist/docx/notes.js +3 -5
  19. package/dist/docx/paraProps.js +51 -46
  20. package/dist/docx/propsXml.d.ts +20 -2
  21. package/dist/docx/propsXml.js +72 -32
  22. package/dist/docx/relationships.js +8 -3
  23. package/dist/docx/runProps.js +34 -34
  24. package/dist/docx/sdt.js +16 -14
  25. package/dist/docx/serializeBlock.js +1 -2
  26. package/dist/docx/serializeParagraph.d.ts +2 -1
  27. package/dist/docx/serializeParagraph.js +25 -14
  28. package/dist/docx/serializeTable.js +38 -24
  29. package/dist/docx/storyProjection.d.ts +36 -0
  30. package/dist/docx/storyProjection.js +23 -0
  31. package/dist/docx/tableFormatting/editing.js +49 -73
  32. package/dist/docx/tableTemplate.js +24 -3
  33. package/dist/docx/theme.d.ts +0 -6
  34. package/dist/docx/theme.js +0 -8
  35. package/dist/editor/clipboard/images.js +17 -3
  36. package/dist/editor/clipboard/inlineFormatting.d.ts +7 -0
  37. package/dist/editor/clipboard/inlineFormatting.js +1 -0
  38. package/dist/editor/commands/breakCommands.js +4 -3
  39. package/dist/editor/commands/canRunCommand.d.ts +2 -2
  40. package/dist/editor/commands/canRunCommand.js +1 -1
  41. package/dist/editor/commands/comments/editing.js +3 -6
  42. package/dist/editor/commands/historyCommands.js +2 -1
  43. package/dist/editor/commands/lockCommands.js +10 -1
  44. package/dist/editor/commands/tabCommands.js +4 -3
  45. package/dist/editor/createEditor.js +3 -9
  46. package/dist/editor/externalClipboard.js +166 -4
  47. package/dist/editor/insertImage.js +4 -3
  48. package/dist/editor/plugins/documentProtection.d.ts +3 -3
  49. package/dist/editor/plugins/lockedContent.d.ts +4 -4
  50. package/dist/editor/plugins/lockedContent.js +1 -1
  51. package/dist/numbering/listTemplate.js +32 -10
  52. package/dist/ooxml/element.d.ts +52 -0
  53. package/dist/ooxml/element.js +49 -0
  54. package/dist/ooxml/fragment.d.ts +53 -0
  55. package/dist/ooxml/fragment.js +76 -0
  56. package/dist/ooxml/image.js +4 -3
  57. package/dist/ooxml/names.d.ts +27 -0
  58. package/dist/ooxml/names.js +29 -0
  59. package/dist/ooxml/precedence.d.ts +31 -0
  60. package/dist/ooxml/precedence.js +51 -0
  61. package/dist/ooxml/xml.d.ts +31 -0
  62. package/dist/ooxml/xml.js +27 -0
  63. package/dist/page/blockKinds.d.ts +42 -0
  64. package/dist/page/blockKinds.js +0 -0
  65. package/dist/page/measureBlocks.d.ts +14 -1
  66. package/dist/page/measureBlocks.js +31 -11
  67. package/dist/page/pageDecorations.d.ts +16 -0
  68. package/dist/page/pageDecorations.js +1 -0
  69. package/dist/page/pageLayout.d.ts +8 -43
  70. package/dist/page/pageLayout.js +28 -51
  71. package/dist/page/tableMeasurements.d.ts +10 -2
  72. package/dist/page/tableMeasurements.js +20 -16
  73. package/dist/page/usePageLayout.d.ts +15 -0
  74. package/dist/page/usePageLayout.js +30 -2
  75. package/dist/schema/attrRoles.d.ts +35 -0
  76. package/dist/schema/attrRoles.js +106 -0
  77. package/dist/schema/docxSchema.d.ts +6 -0
  78. package/dist/schema/docxSchema.js +212 -101
  79. package/dist/schema/editGuard.d.ts +99 -0
  80. package/dist/schema/editGuard.js +43 -0
  81. package/dist/schema/guards.d.ts +68 -0
  82. package/dist/schema/guards.js +82 -0
  83. package/dist/schema/locks.d.ts +8 -35
  84. package/dist/schema/locks.js +29 -64
  85. package/dist/schema/preservedGuards.d.ts +30 -0
  86. package/dist/schema/preservedGuards.js +50 -0
  87. package/dist/schema/protection.d.ts +10 -3
  88. package/dist/schema/protection.js +2 -1
  89. package/dist/schema/sourceEquality.d.ts +27 -0
  90. package/dist/schema/sourceEquality.js +65 -0
  91. package/dist/table/cellFormatting.js +2 -1
  92. package/dist/table/commands.d.ts +2 -2
  93. package/dist/table/commands.js +1 -1
  94. package/dist/table/index.d.ts +2 -2
  95. package/dist/table/merge.d.ts +2 -2
  96. package/dist/table/merge.js +1 -1
  97. package/package.json +4 -1
  98. package/dist/editor/plugins/bookmarkProtection.d.ts +0 -3
  99. package/dist/editor/plugins/bookmarkProtection.js +0 -28
  100. package/dist/editor/plugins/noteProtection.d.ts +0 -3
  101. package/dist/editor/plugins/noteProtection.js +0 -25
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # @portone/docx-editor
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#53](https://github.com/portone-io/docx-editor/pull/53) [`d264803`](https://github.com/portone-io/docx-editor/commit/d264803381c3272ae31e1af1faa7fd158b95a81f) Thanks [@Deea222](https://github.com/Deea222)! - Stop text copied out of the editor from carrying the document's private data.
8
+
9
+ A copy was drawn the way the editor draws itself, so the HTML it left on the clipboard held the paragraph and run XML, the name and recorded identity of a comment's author, what the comment and its replies say, and the body of a footnote.
10
+ All of it landed in whatever application it was pasted into, and in `comment` mode a reader who may not change the body could take the body out this way.
11
+ A copy now carries only what a reader of it needs, and the paragraph style it names is the style's id rather than the whole `w:pPr`.
12
+ A link goes out as a link, so it can be followed where it lands and comes back as one when it is pasted here again.
13
+
14
+ The plain text beside it says what was copied: a tab is a tab, a line break is a line, a page break is a form feed, and a table's cells stand apart by a tab and its rows by a line, so a table pasted into a spreadsheet arrives as a table.
15
+ A cell holding a line break is the exception, since the line inside it reads as the start of the next row.
16
+
17
+ - [#50](https://github.com/portone-io/docx-editor/pull/50) [`41d82c5`](https://github.com/portone-io/docx-editor/commit/41d82c5bee74597baedd813c43c8dcfcdfe74fbf) Thanks [@Deea222](https://github.com/Deea222)! - Stop `onlyCommentsChangedBy` from excusing a part a submission relates as a comment part.
18
+
19
+ The three comment parts are left out of the byte comparison, and which parts those were came from the submitted file's own relationships.
20
+ A file could relate a second comments, extended comments or people part at any part it liked and have that part go uncompared, so a rewritten styles part, a settings part pointing at a template off the package, replaced image bytes or forged document properties were all reported as a change to nothing but comments.
21
+ The parts left out are now the ones the reader opened, a comment part may be related once, one related for the first time has to be a part the submission brought with it, and a relationship part naming one id twice is turned down.
22
+
23
+ A file that relates an extended comments part but carries no comments part now has that part read, so writing the first comment into it writes that part rather than a second one beside it.
24
+ Two extended parts related at once was a file whose settled threads a reader would lose, since only the first of them is read.
25
+
26
+ This affects 0.2.0. A server that accepted files on this verdict should upgrade and run the check again over what it accepted, where such a file now answers `part-changed` naming the part, or `relationship-changed` naming the relationship part.
27
+
28
+ - [#56](https://github.com/portone-io/docx-editor/pull/56) [`467f6f5`](https://github.com/portone-io/docx-editor/commit/467f6f5781dbf6eb6739dbf3369155c7287ee608) Thanks [@Deea222](https://github.com/Deea222)! - Commands and `canRunCommand` now report false where a bookmark marker or a note reference would be removed, instead of reporting true and changing nothing.
29
+
30
+ A refusal over one of those markers also ends an open IME composition, the way a refusal over a locked control already did.
31
+
32
+ - [#59](https://github.com/portone-io/docx-editor/pull/59) [`6d7b97f`](https://github.com/portone-io/docx-editor/commit/6d7b97fdb89e0aba7ac13057b503ec510a5550c1) Thanks [@Deea222](https://github.com/Deea222)! - Formatting XML is now written through shared primitives that consistently escape attribute values while preserving existing formatting behavior.
33
+
34
+ - [#58](https://github.com/portone-io/docx-editor/pull/58) [`d9edb75`](https://github.com/portone-io/docx-editor/commit/d9edb75fa45e1a6cc81e5741a5242cf500107091) Thanks [@Deea222](https://github.com/Deea222)! - Pagination measures every block as a list of break candidates; no visible change
35
+
36
+ - [#55](https://github.com/portone-io/docx-editor/pull/55) [`7110baa`](https://github.com/portone-io/docx-editor/commit/7110baae68b75323720a39374c6df58db4367896) Thanks [@Deea222](https://github.com/Deea222)! - Turn down a raw OOXML fragment that does not hold its shape as it enters the document, rather than writing it into the exported file.
37
+
38
+ Keep imported formatting when a document uses an inherited default namespace or an alternative WordprocessingML prefix. Reject nested namespace rebinding that could disconnect hyperlinks from their targets on export.
39
+
40
+ The editor draws the original XML of a paragraph, a run, a table, an image or an annotation into the page as a `data-` attribute, so that it can read the live DOM back after an IME composition or a browser edit. A fragment written into one of those attributes from outside, by a consumer plugin or through `view.pasteHTML`, is now held to what that attribute goes back out as: a whole element under an expected name, an opening tag's attributes, or an opening tag the writer closes itself. One that does not hold it is turned down along with the rule reading it, so the content settles one level plainer - a paragraph keeps its text and loses its properties, a run loses its mark, a content control or a hyperlink loses its wrapper - instead of a `data-ppr` reading `</w:p><w:p>...` writing a second paragraph into the exported body, or one that never closed reaching export and failing the whole document with `malformed-xml`.
41
+
42
+ - [#54](https://github.com/portone-io/docx-editor/pull/54) [`8aea477`](https://github.com/portone-io/docx-editor/commit/8aea477dceab5462c15903b25b29c738980aff47) Thanks [@Deea222](https://github.com/Deea222)! - An untouched table opened in the editor is written back byte for byte, its `w:tblGridChange` included.
43
+
44
+ Export decided whether a block was untouched by comparing it with the one import produced, attrs and all.
45
+ Opening a document works some of those attrs out again from the formatting around them - a table's shared cell borders among them - so a table nobody had touched compared unequal and was rebuilt, and the rebuild dropped the markup the writer does not model.
46
+
47
+ Each attr now declares whether the writer writes from it, whether the editor works it out for the screen, or whether it identifies something in the open document, and the comparison ignores the ones worked out for the screen.
48
+ Exporting through `createEditorState` is held to the same byte identity as exporting straight from `importDocx`.
49
+
50
+ - [#48](https://github.com/portone-io/docx-editor/pull/48) [`de84006`](https://github.com/portone-io/docx-editor/commit/de840061dd60f3b652273410bc2d0a85b9451ad3) Thanks [@Deea222](https://github.com/Deea222)! - Stop `onlyCommentsChangedBy` from refusing a comment written into a table cell.
51
+
52
+ A commented table is rebuilt on the way out, so it came back worded the way this editor words it while the original carried the wording its producer chose, and the two were compared word for word.
53
+ The story is now compared as this editor writes it back, so two blocks this editor would write alike are read alike: the attribute order inside a `w:tcW` or a `w:tblW`, a percentage width written as `100%` or as `5000`, runs a producer split that say the same text, and a table's `w:tblGridChange`.
54
+ Formatting properties keep the line breaks, comments and text a producer wrote between them, so rewriting a run's bold, a paragraph's alignment or a cell of a rebuilt table no longer drops them.
55
+ A rebuilt table keeps what stood inside its width and span properties too.
56
+
57
+ - [#52](https://github.com/portone-io/docx-editor/pull/52) [`991ba48`](https://github.com/portone-io/docx-editor/commit/991ba48501e20e60bfc69b3c76862f4a26123b3e) Thanks [@Deea222](https://github.com/Deea222)! - Read the comment parts entry by entry in `onlyCommentsChangedBy`.
58
+
59
+ The three parts a comment is written across are the ones a comment edit may rewrite, so the package comparison passes over their bytes.
60
+ Nothing read them afterwards, which let a submission carry a field pointing at a remote image inside a comment body, a comment attributed to a third author that nothing refers to, or markup wrapped around a body, and still be answered as a file where only comments changed.
61
+ Each entry now has to arrive as it was, or be one this editor writes for an author who could have written it, and an entry nothing refers to has to stay as it was.
62
+ A file that fails is refused as `part-changed` naming the comment part.
63
+
64
+ Settling or replying to a comment that arrived with the file no longer rewrites its entry as plain text.
65
+ The entry keeps what it said, and its last paragraph gains the `w14:paraId` the thread state is written against.
66
+ The extended comments part carries an entry only for a comment that has thread state.
67
+
3
68
  ## 0.2.0
4
69
 
5
70
  ### Minor Changes
package/CONTRIBUTING.md CHANGED
@@ -48,12 +48,16 @@ pnpm test:e2e
48
48
 
49
49
  [Testing](https://github.com/portone-io/docx-editor/blob/main/docs/testing.md) explains the scope and prerequisites of every check.
50
50
 
51
+ A command added to `./commands` or `./table` needs a writer probe in `src/docx/__testing__/writerProbes.ts` with a `check` of its immediate effect, so its export is validated before another command can overwrite it, or an entry in `NOT_A_WRITER` giving the reason it reaches no writer.
52
+
51
53
  ## Changesets
52
54
 
53
55
  If you changed anything under `src/`, run `pnpm changeset` and commit the file it writes.
54
56
  It becomes the CHANGELOG entry, so write it for someone reading release notes rather than the diff.
55
57
  Choose `patch` unless the change adds to or breaks the public API.
56
58
  Those are `minor` while the package is below 1.0.
59
+ A declaration added, removed, or rewritten in `etc/*.api.md` after running `pnpm api:update` is what that looks like; a line that only gains or loses an `(undocumented)` marker is not.
60
+ Commit the regenerated report in the same commit as the changeset.
57
61
 
58
62
  By contributing, you agree that your contribution is licensed under the Apache License 2.0 used by this project.
59
63
 
@@ -29,7 +29,9 @@ export type CommentOnlyVerdict = {
29
29
  *
30
30
  * Every part of the package has to arrive as it left, save for the three a comment is written
31
31
  * across and the relationship and content type they are declared with; the document story itself
32
- * has to read as it did, comments aside. A comment carrying no recorded identity is everyone's to
32
+ * has to read as it did, comments aside. Those three parts are read entry by entry instead
33
+ * (`./comments/verifying`), since a comment edit is free to rewrite them and something has to say
34
+ * what it may have written there. A comment carrying no recorded identity is everyone's to
33
35
  * edit here as it is in the editor (`schema/protection`), while a comment that appeared has to
34
36
  * carry this identity: a file can claim any author, and the editor's own hand in writing it is
35
37
  * not there to vouch for it. An identity already recorded is nobody's to rewrite.
@@ -1,10 +1,10 @@
1
1
  // src/docx/commentOnlyChange.ts
2
2
  import { decodeUtf8, elementChildren, parseXml } from "../ooxml/xml.js";
3
3
  import {
4
- changesOnlyComments,
5
4
  commentAdditionsBy,
6
5
  commentEditsOwned,
7
- commentIdentitiesKept
6
+ commentIdentitiesKept,
7
+ withoutComments
8
8
  } from "../schema/protection.js";
9
9
  import {
10
10
  COMMENTS_CONTENT_TYPE,
@@ -15,12 +15,16 @@ import {
15
15
  PEOPLE_CONTENT_TYPE,
16
16
  PEOPLE_REL_TYPE
17
17
  } from "./comments/constants.js";
18
+ import { commentPartsKept } from "./comments/verifying.js";
18
19
  import { importDocx } from "./importDocx.js";
19
20
  import {
20
21
  readRelationships,
21
- relsPathOf,
22
- resolveTarget
22
+ relsPathOf
23
23
  } from "./relationships.js";
24
+ import {
25
+ comparableStory,
26
+ isModelledBlock
27
+ } from "./storyProjection.js";
24
28
  var COMMENT_REL_TYPES = [
25
29
  COMMENTS_REL_TYPE,
26
30
  COMMENTS_EXTENDED_REL_TYPE,
@@ -35,32 +39,32 @@ var refused = (reason) => ({ ok: false, reason });
35
39
  function sameBytes(before, after) {
36
40
  return before.length === after.length && before.every((byte, index) => byte === after[index]);
37
41
  }
42
+ function commentPartsOf(session) {
43
+ const { partPath, extendedPartPath, people } = session.comments;
44
+ return [partPath, extendedPartPath, people.partPath];
45
+ }
38
46
  function commentPartPaths(session) {
39
- const related = readRelationships(
40
- session.parts,
41
- relsPathOf(session.mainPartPath)
42
- ).filter(
43
- (entry) => !entry.external && COMMENT_REL_TYPES.includes(entry.type)
44
- );
45
47
  return new Set(
46
- related.map((entry) => resolveTarget(session.mainPartPath, entry.target))
48
+ commentPartsOf(session).filter((path) => path !== null)
47
49
  );
48
50
  }
49
51
  function aroundTheStory(session) {
50
- const preserved = session.blocks.filter(
51
- (block) => block.node.type.name !== "paragraph" && block.node.type.name !== "table"
52
- ).map((block) => block.xml).join("");
52
+ const preserved = session.blocks.filter((block) => !isModelledBlock(block.node)).map((block) => block.xml).join("");
53
53
  return session.documentPrefix + preserved + session.documentSuffix;
54
54
  }
55
55
  function relationshipsKept(before, after) {
56
+ if (new Set(before.map((entry) => entry.id)).size !== before.length || new Set(after.map((entry) => entry.id)).size !== after.length) {
57
+ return false;
58
+ }
56
59
  const now = new Map(after.map((entry) => [entry.id, entry]));
57
60
  const kept = before.every((entry) => {
58
61
  const current = now.get(entry.id);
59
62
  return current !== void 0 && current.type === entry.type && current.target === entry.target && current.external === entry.external;
60
63
  });
61
64
  const ids = new Set(before.map((entry) => entry.id));
62
- return kept && after.every(
63
- (entry) => ids.has(entry.id) || COMMENT_REL_TYPES.includes(entry.type)
65
+ const parts = after.filter((entry) => !entry.external);
66
+ return kept && after.filter((entry) => !ids.has(entry.id)).every(
67
+ (entry) => COMMENT_REL_TYPES.includes(entry.type) && !entry.external && parts.filter((other) => other.type === entry.type).length === 1
64
68
  );
65
69
  }
66
70
  function contentTypes(bytes) {
@@ -83,6 +87,12 @@ function contentTypesKept(before, after) {
83
87
  }
84
88
  return true;
85
89
  }
90
+ function gainedCommentPartsAreNew(before, after) {
91
+ const had = commentPartsOf(before);
92
+ return commentPartsOf(after).every(
93
+ (path, kind) => path === null || path === had[kind] || !before.parts.has(path)
94
+ );
95
+ }
86
96
  function packageKept(before, after) {
87
97
  if (before.mainPartPath !== after.mainPartPath) {
88
98
  return { ok: false, reason: "part-changed", part: before.mainPartPath };
@@ -106,7 +116,7 @@ function packageKept(before, after) {
106
116
  if (!relationshipsKept(
107
117
  readRelationships(before.parts, relsPath),
108
118
  readRelationships(after.parts, relsPath)
109
- )) {
119
+ ) || !gainedCommentPartsAreNew(before, after)) {
110
120
  return { ok: false, reason: "relationship-changed", part: relsPath };
111
121
  }
112
122
  if (!contentTypesKept(
@@ -120,12 +130,17 @@ function packageKept(before, after) {
120
130
  }
121
131
  return { ok: true };
122
132
  }
133
+ function sameBody(before, after) {
134
+ const was = comparableStory(before, withoutComments);
135
+ const now = comparableStory(after, withoutComments);
136
+ return was !== null && now !== null && was.length === now.length && was.every((block, at) => block === now[at]);
137
+ }
123
138
  function storyKept(before, after, authorId, editableComments) {
124
- if (!changesOnlyComments(before, after)) return refused("body-changed");
125
- if (!commentIdentitiesKept(before, after) || !commentAdditionsBy(before, after, authorId)) {
139
+ if (!sameBody(before, after)) return refused("body-changed");
140
+ if (!commentIdentitiesKept(before.doc, after.doc) || !commentAdditionsBy(before.doc, after.doc, authorId)) {
126
141
  return refused("comment-author-forged");
127
142
  }
128
- if (!commentEditsOwned(before, after, {
143
+ if (!commentEditsOwned(before.doc, after.doc, {
129
144
  protection: "comments",
130
145
  authorId,
131
146
  editableComments
@@ -138,7 +153,9 @@ function onlyCommentsChangedBy(original, submitted, authorId, { editableComments
138
153
  const before = importDocx(original);
139
154
  const after = importDocx(submitted);
140
155
  const packaged = packageKept(before.session, after.session);
141
- return packaged.ok ? storyKept(before.doc, after.doc, authorId, editableComments) : packaged;
156
+ if (!packaged.ok) return packaged;
157
+ const story = storyKept(before, after, authorId, editableComments);
158
+ return story.ok ? commentPartsKept(before, after, authorId, editableComments) : story;
142
159
  }
143
160
  export {
144
161
  onlyCommentsChangedBy
@@ -3,9 +3,9 @@ export declare const COMMENTS_EXTENDED_REL_TYPE = "http://schemas.microsoft.com/
3
3
  export declare const COMMENTS_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml";
4
4
  export declare const COMMENTS_EXTENDED_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml";
5
5
  export declare const CONTENT_TYPES_PATH = "[Content_Types].xml";
6
- export declare const W14_NS = "http://schemas.microsoft.com/office/word/2010/wordml";
7
- export declare const W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml";
8
- export declare const MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006";
6
+ export declare const W14_NS: "http://schemas.microsoft.com/office/word/2010/wordml";
7
+ export declare const W15_NS: "http://schemas.microsoft.com/office/word/2012/wordml";
8
+ export declare const MC_NS: "http://schemas.openxmlformats.org/markup-compatibility/2006";
9
9
  export declare const PEOPLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/people";
10
10
  export declare const PEOPLE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml";
11
11
  /**
@@ -1,13 +1,14 @@
1
1
  // src/docx/comments/constants.ts
2
+ import { NAMESPACES } from "../../ooxml/names.js";
2
3
  import { R_NS } from "../../ooxml/xml.js";
3
4
  var COMMENTS_REL_TYPE = `${R_NS}/comments`;
4
5
  var COMMENTS_EXTENDED_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended";
5
6
  var COMMENTS_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml";
6
7
  var COMMENTS_EXTENDED_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml";
7
8
  var CONTENT_TYPES_PATH = "[Content_Types].xml";
8
- var W14_NS = "http://schemas.microsoft.com/office/word/2010/wordml";
9
- var W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml";
10
- var MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006";
9
+ var W14_NS = NAMESPACES.w14;
10
+ var W15_NS = NAMESPACES.w15;
11
+ var MC_NS = NAMESPACES.mc;
11
12
  var PEOPLE_REL_TYPE = "http://schemas.microsoft.com/office/2011/relationships/people";
12
13
  var PEOPLE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml";
13
14
  var COMMENT_AUTHOR_PROVIDER = "portone-docx-editor";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The shapes this editor writes the three comment parts in, and the reading of those shapes.
3
+ *
4
+ * Each entry is written here and judged here, so the two halves cannot drift apart: a body written
5
+ * by hand can be told from one this editor put out only where the writer and the reader agree on
6
+ * what it puts out. `./reading` keeps a lenient flattener for display, which takes whatever a
7
+ * producer wrote and shows what it can; this one judges an entry that came back and says no to
8
+ * everything else.
9
+ */
10
+ import { type XmlAttr } from "../../ooxml/element";
11
+ import type { CommentReferenceData, CommentReplyData } from "./model";
12
+ /** The attributes this editor writes on a `w:comment`. The thread key goes on the body's paragraph */
13
+ export declare const COMMENT_ATTRIBUTES: ReadonlySet<string>;
14
+ /** The attributes this editor writes on a `w15:commentEx` */
15
+ export declare const COMMENT_EX_ATTRIBUTES: ReadonlySet<string>;
16
+ /**
17
+ * Whether the element carries no attribute outside this set.
18
+ *
19
+ * A namespace declaration is not an attribute of the element in this sense: it says where the
20
+ * names come from rather than anything about the entry, and where a producer puts one is its own
21
+ * business.
22
+ */
23
+ export declare function attributesWithin(el: Element, allowed: ReadonlySet<string>): boolean;
24
+ /** The body of a comment, as a paragraph of one run holding the text and the breaks in it */
25
+ export declare function renderCommentBody(text: string, paraId: string | null): string;
26
+ /**
27
+ * The paragraph a thread key belongs on, which is the last one of the body.
28
+ *
29
+ * A body may hold a paragraph of another vocabulary, a picture's DrawingML `a:p` among them, and a
30
+ * key put on that says nothing about the thread. The namespace decides, since a prefix means only
31
+ * what the element it sits on binds it to, and a document may bind the same prefix twice over.
32
+ */
33
+ export declare function lastBodyParagraph(comment: Element): Element | null;
34
+ /**
35
+ * The entries a part arrived holding, under the id each carries.
36
+ *
37
+ * The writer asks the document which paragraph a key goes on rather than asking the text, because
38
+ * only the document knows what a prefix means where it is written.
39
+ */
40
+ export declare function arrivedEntries(partXml: string | null): ReadonlyMap<string, Element>;
41
+ /**
42
+ * The entry with the thread key on its body's last paragraph, and unchanged where it has one.
43
+ *
44
+ * Settling a thread or replying to it hangs the state off that key, and an entry that arrived
45
+ * without one has to gain it. Writing the entry afresh instead would put back only what this
46
+ * editor models, so a body holding more than plain text would lose it to a change nobody asked
47
+ * for and nobody made.
48
+ *
49
+ * `arrived` is that entry as the document has it, which is what says which paragraph is a
50
+ * WordprocessingML one and whether it already carries a key under some prefix. The text is only
51
+ * asked where that paragraph is: its opening tag is the one at the same place among the tags named
52
+ * `p`, counted the same way in both.
53
+ *
54
+ * The `w14` prefix is declared on the part rather than here: a part holding any thread state
55
+ * declares it on its root along with the compatibility markup that goes with it (`./writing`).
56
+ */
57
+ export declare function withThreadKey(commentXml: string, paraId: string, arrived: Element | null): string;
58
+ /**
59
+ * The text of a comment written in that shape, and null for an entry holding anything else: a
60
+ * field, a second run, markup a producer wrapped it in, an attribute this editor does not write.
61
+ *
62
+ * An entry that arrived and was not edited is compared as it stands rather than read here, so
63
+ * saying no to a shape this editor would not have written turns down only a rewrite.
64
+ */
65
+ /** Whether this editor's writer could have put out this thread state */
66
+ export declare function wellFormedCommentExtension(entry: Element): boolean;
67
+ /** Whether this editor's writer could have put out this recorded identity */
68
+ export declare function wellFormedPerson(entry: Element): boolean;
69
+ /** The identity a recorded person stands for, and null for an entry recording none */
70
+ export declare function recordedIdentity(person: Element): string | null;
71
+ export declare function readStrictCommentBody(comment: Element): string | null;
72
+ /** The thread state of one comment, as this editor writes it into the extended part */
73
+ export declare function renderCommentExtension(comment: CommentReferenceData | CommentReplyData): string;
74
+ /**
75
+ * The identity this editor records for an author, as it writes it into the people part.
76
+ *
77
+ * The prefix and the declaration come from the caller, which is writing into a part whose root
78
+ * already binds them or is being written from nothing.
79
+ */
80
+ export declare function renderPerson(author: string, userId: string, prefix: string, declaration: XmlAttr | null): string;
@@ -0,0 +1,213 @@
1
+ // src/docx/comments/grammar.ts
2
+ import {
3
+ attrsText,
4
+ elementXml,
5
+ xmlnsAttr
6
+ } from "../../ooxml/element.js";
7
+ import { qualify, wName } from "../../ooxml/names.js";
8
+ import {
9
+ attributeByLocalName,
10
+ elementChildren,
11
+ escapeXml,
12
+ parseXml,
13
+ W_NS
14
+ } from "../../ooxml/xml.js";
15
+ import { COMMENT_AUTHOR_PROVIDER, W14_NS, W15_NS } from "./constants.js";
16
+ var ELEMENT_NODE = 1;
17
+ var TEXT_NODE = 3;
18
+ var XMLNS_NS = "http://www.w3.org/2000/xmlns/";
19
+ var XML_NS = "http://www.w3.org/XML/1998/namespace";
20
+ var nameKey = (namespace, localName) => `${namespace ?? ""} ${localName}`;
21
+ var COMMENT_ATTRIBUTES = /* @__PURE__ */ new Set([
22
+ nameKey(W_NS, "id"),
23
+ nameKey(W_NS, "author"),
24
+ nameKey(W_NS, "date"),
25
+ nameKey(W_NS, "initials")
26
+ ]);
27
+ var COMMENT_EX_ATTRIBUTES = /* @__PURE__ */ new Set([
28
+ nameKey(W15_NS, "paraId"),
29
+ nameKey(W15_NS, "paraIdParent"),
30
+ nameKey(W15_NS, "done")
31
+ ]);
32
+ var PARAGRAPH_ATTRIBUTES = /* @__PURE__ */ new Set([
33
+ nameKey(W14_NS, "paraId")
34
+ ]);
35
+ var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set([
36
+ nameKey(XML_NS, "space")
37
+ ]);
38
+ var PERSON_ATTRIBUTES = /* @__PURE__ */ new Set([
39
+ nameKey(W15_NS, "author")
40
+ ]);
41
+ var PRESENCE_ATTRIBUTES = /* @__PURE__ */ new Set([
42
+ nameKey(W15_NS, "providerId"),
43
+ nameKey(W15_NS, "userId")
44
+ ]);
45
+ var NO_ATTRIBUTES = /* @__PURE__ */ new Set();
46
+ var THREAD_KEY = /^[0-9A-Fa-f]{8}$/;
47
+ function threadKeyOrNone(el, localName) {
48
+ const value = attributeByLocalName(el, localName);
49
+ return value === null || THREAD_KEY.test(value);
50
+ }
51
+ function attributesWithin(el, allowed) {
52
+ return Array.from(el.attributes).every(
53
+ (attr) => attr.namespaceURI === XMLNS_NS || attr.name === "xmlns" || allowed.has(nameKey(attr.namespaceURI, attr.localName))
54
+ );
55
+ }
56
+ function isNamed(el, namespace, localName) {
57
+ return el.namespaceURI === namespace && el.localName === localName;
58
+ }
59
+ function holdsElementsOnly(el) {
60
+ return Array.from(el.childNodes).every(
61
+ (node) => node.nodeType === ELEMENT_NODE
62
+ );
63
+ }
64
+ function holdsTextOnly(el) {
65
+ return Array.from(el.childNodes).every((node) => node.nodeType === TEXT_NODE);
66
+ }
67
+ function renderCommentBody(text, paraId) {
68
+ const lines = text.split("\n");
69
+ const pieces = [];
70
+ lines.forEach((line, index) => {
71
+ if (index > 0) pieces.push(elementXml(wName("br"), []));
72
+ if (line.length > 0 || lines.length === 1) {
73
+ pieces.push(
74
+ elementXml(wName("t"), [["xml:space", "preserve"]], [escapeXml(line)])
75
+ );
76
+ }
77
+ });
78
+ const key = paraId === null ? [] : [xmlnsAttr("w14"), [qualify("w14", "paraId"), paraId]];
79
+ return elementXml(wName("p"), key, [elementXml(wName("r"), [], pieces)]);
80
+ }
81
+ var PARAGRAPH_OR_SKIPPED = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<([^\s<>/:="']+:)?p(?=[\s/>])[^>]*>/g;
82
+ function lastBodyParagraph(comment) {
83
+ const paragraphs = comment.getElementsByTagNameNS(W_NS, "p");
84
+ return paragraphs.length === 0 ? null : paragraphs[paragraphs.length - 1];
85
+ }
86
+ function namedParagraphs(comment) {
87
+ return Array.from(comment.getElementsByTagName("*")).filter(
88
+ (element) => element.localName === "p"
89
+ );
90
+ }
91
+ function arrivedEntries(partXml) {
92
+ const entries = /* @__PURE__ */ new Map();
93
+ if (partXml === null) return entries;
94
+ let root;
95
+ try {
96
+ root = parseXml(partXml).documentElement;
97
+ } catch {
98
+ return entries;
99
+ }
100
+ for (const entry of elementChildren(root)) {
101
+ if (entry.namespaceURI !== W_NS || entry.localName !== "comment") continue;
102
+ const id = attributeByLocalName(entry, "id");
103
+ if (id !== null && !entries.has(id)) entries.set(id, entry);
104
+ }
105
+ return entries;
106
+ }
107
+ function withThreadKey(commentXml, paraId, arrived) {
108
+ if (arrived === null) return commentXml;
109
+ const target = lastBodyParagraph(arrived);
110
+ if (target === null) return commentXml;
111
+ if (target.getAttributeNS(W14_NS, "paraId") !== null || target.hasAttribute("w14:paraId")) {
112
+ return commentXml;
113
+ }
114
+ const at = namedParagraphs(arrived).indexOf(target);
115
+ const openings = Array.from(commentXml.matchAll(PARAGRAPH_OR_SKIPPED)).filter(
116
+ (match) => !match[0].startsWith("<!")
117
+ );
118
+ const opening = openings[at];
119
+ if (at === -1 || opening === void 0 || opening.index === void 0) {
120
+ return commentXml;
121
+ }
122
+ const selfClosing = opening[0].endsWith("/>");
123
+ const keyed = opening[0].slice(0, selfClosing ? -2 : -1) + ` ${attrsText([[qualify("w14", "paraId"), paraId]])}` + (selfClosing ? "/>" : ">");
124
+ return commentXml.slice(0, opening.index) + keyed + commentXml.slice(opening.index + opening[0].length);
125
+ }
126
+ function readRunText(run) {
127
+ if (!holdsElementsOnly(run)) return null;
128
+ const pieces = [];
129
+ for (const child of Array.from(run.children)) {
130
+ if (isNamed(child, W_NS, "br")) {
131
+ if (!attributesWithin(child, NO_ATTRIBUTES) || child.childNodes.length > 0) {
132
+ return null;
133
+ }
134
+ pieces.push("\n");
135
+ continue;
136
+ }
137
+ if (!isNamed(child, W_NS, "t")) return null;
138
+ if (!attributesWithin(child, TEXT_ATTRIBUTES) || child.getAttributeNS(XML_NS, "space") !== "preserve" || !holdsTextOnly(child)) {
139
+ return null;
140
+ }
141
+ pieces.push(child.textContent ?? "");
142
+ }
143
+ return pieces.join("");
144
+ }
145
+ function wellFormedCommentExtension(entry) {
146
+ return attributesWithin(entry, COMMENT_EX_ATTRIBUTES) && entry.childNodes.length === 0 && threadKeyOrNone(entry, "paraId") && threadKeyOrNone(entry, "paraIdParent") && // The writer says a thread is settled or open, and nothing else `ST_OnOff` would take
147
+ ["0", "1", null].includes(attributeByLocalName(entry, "done"));
148
+ }
149
+ function wellFormedPerson(entry) {
150
+ if (!attributesWithin(entry, PERSON_ATTRIBUTES)) return false;
151
+ if (!holdsElementsOnly(entry)) return false;
152
+ const children = Array.from(entry.children);
153
+ if (children.length !== 1) return false;
154
+ const [presence] = children;
155
+ return isNamed(presence, W15_NS, "presenceInfo") && attributesWithin(presence, PRESENCE_ATTRIBUTES) && attributeByLocalName(presence, "providerId") === COMMENT_AUTHOR_PROVIDER && attributeByLocalName(presence, "userId") !== null && presence.childNodes.length === 0;
156
+ }
157
+ function recordedIdentity(person) {
158
+ const [presence] = Array.from(person.children);
159
+ return presence === void 0 ? null : attributeByLocalName(presence, "userId");
160
+ }
161
+ function readStrictCommentBody(comment) {
162
+ if (!holdsElementsOnly(comment)) return null;
163
+ const paragraphs = Array.from(comment.children);
164
+ if (paragraphs.length !== 1) return null;
165
+ const [paragraph] = paragraphs;
166
+ if (!isNamed(paragraph, W_NS, "p")) return null;
167
+ if (!attributesWithin(paragraph, PARAGRAPH_ATTRIBUTES)) return null;
168
+ if (!holdsElementsOnly(paragraph)) return null;
169
+ const runs = Array.from(paragraph.children);
170
+ if (runs.length !== 1) return null;
171
+ const [run] = runs;
172
+ if (!isNamed(run, W_NS, "r")) return null;
173
+ if (!attributesWithin(run, NO_ATTRIBUTES)) return null;
174
+ return readRunText(run);
175
+ }
176
+ function renderCommentExtension(comment) {
177
+ if (comment.extensionXml !== null) return comment.extensionXml;
178
+ const parent = "parentParaId" in comment ? [[qualify("w15", "paraIdParent"), comment.parentParaId]] : [];
179
+ const done = "resolved" in comment ? [[qualify("w15", "done"), comment.resolved ? "1" : "0"]] : [];
180
+ return elementXml(qualify("w15", "commentEx"), [
181
+ [qualify("w15", "paraId"), comment.paraId],
182
+ ...parent,
183
+ ...done
184
+ ]);
185
+ }
186
+ function renderPerson(author, userId, prefix, declaration) {
187
+ const declared = declaration === null ? [] : [declaration];
188
+ return elementXml(
189
+ `${prefix}person`,
190
+ [...declared, [`${prefix}author`, author]],
191
+ [
192
+ elementXml(`${prefix}presenceInfo`, [
193
+ [`${prefix}providerId`, COMMENT_AUTHOR_PROVIDER],
194
+ [`${prefix}userId`, userId]
195
+ ])
196
+ ]
197
+ );
198
+ }
199
+ export {
200
+ COMMENT_ATTRIBUTES,
201
+ COMMENT_EX_ATTRIBUTES,
202
+ arrivedEntries,
203
+ attributesWithin,
204
+ lastBodyParagraph,
205
+ readStrictCommentBody,
206
+ recordedIdentity,
207
+ renderCommentBody,
208
+ renderCommentExtension,
209
+ renderPerson,
210
+ wellFormedCommentExtension,
211
+ wellFormedPerson,
212
+ withThreadKey
213
+ };
@@ -1,11 +1,13 @@
1
1
  // src/docx/comments/people.ts
2
+ import { xmlnsAttr } from "../../ooxml/element.js";
2
3
  import { DocxExportError } from "../../ooxml/errors.js";
4
+ import { xmlnsDecl } from "../../ooxml/names.js";
3
5
  import {
6
+ attributeByLocalName,
4
7
  childByLocalName,
5
8
  decodeUtf8,
6
9
  elementChildren,
7
10
  encodeUtf8,
8
- escapeXml,
9
11
  parseXml
10
12
  } from "../../ooxml/xml.js";
11
13
  import {
@@ -22,15 +24,13 @@ import {
22
24
  W15_NS
23
25
  } from "./constants.js";
24
26
  import { withContentType } from "./contentTypes.js";
27
+ import { renderPerson } from "./grammar.js";
25
28
  var NO_PEOPLE = {
26
29
  partPath: null,
27
30
  xml: null,
28
31
  hadBom: false,
29
32
  byAuthor: /* @__PURE__ */ new Map()
30
33
  };
31
- function attribute(el, localName) {
32
- return Array.from(el.attributes).find((entry) => entry.localName === localName)?.value ?? null;
33
- }
34
34
  function readPeople(parts, mainPartPath) {
35
35
  const relationship = readRelationships(parts, relsPathOf(mainPartPath)).find(
36
36
  (entry) => entry.type === PEOPLE_REL_TYPE && !entry.external
@@ -45,14 +45,15 @@ function readPeople(parts, mainPartPath) {
45
45
  const byAuthor = /* @__PURE__ */ new Map();
46
46
  for (const el of elementChildren(root)) {
47
47
  if (el.localName !== "person") continue;
48
- const author = attribute(el, "author");
48
+ const author = attributeByLocalName(el, "author");
49
49
  if (author === null) continue;
50
50
  byAuthor.set(author, null);
51
51
  const presence = childByLocalName(el, "presenceInfo");
52
52
  if (presence === null) continue;
53
- if (attribute(presence, "providerId") !== COMMENT_AUTHOR_PROVIDER) continue;
53
+ if (attributeByLocalName(presence, "providerId") !== COMMENT_AUTHOR_PROVIDER)
54
+ continue;
54
55
  const ids = ourIds.get(author) ?? /* @__PURE__ */ new Set();
55
- ids.add(attribute(presence, "userId"));
56
+ ids.add(attributeByLocalName(presence, "userId"));
56
57
  ourIds.set(author, ids);
57
58
  }
58
59
  for (const [author, ids] of ourIds) {
@@ -64,9 +65,6 @@ function readPeople(parts, mainPartPath) {
64
65
  function commentAuthorId(people, author) {
65
66
  return people.byAuthor.get(author) ?? null;
66
67
  }
67
- function personXml(author, userId, prefix, declaration) {
68
- return `<${prefix}person${declaration} ${prefix}author="${escapeXml(author)}"><${prefix}presenceInfo ${prefix}providerId="${COMMENT_AUTHOR_PROVIDER}" ${prefix}userId="${escapeXml(userId)}"/></${prefix}person>`;
69
- }
70
68
  function unrecordedAuthors(bodies, people) {
71
69
  const unrecorded = /* @__PURE__ */ new Map();
72
70
  for (const body of bodies) {
@@ -129,9 +127,9 @@ function peopleXml(people, added) {
129
127
  if (xml === null) {
130
128
  const persons2 = Array.from(
131
129
  added,
132
- ([author, userId]) => personXml(author, userId, "w15:", "")
130
+ ([author, userId]) => renderPerson(author, userId, "w15:", null)
133
131
  );
134
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w15:people xmlns:w15="${W15_NS}">${persons2.join("")}</w15:people>`;
132
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w15:people ${xmlnsDecl("w15")}>${persons2.join("")}</w15:people>`;
135
133
  }
136
134
  const root = parseXml(xml).documentElement;
137
135
  if (root.localName !== "people") {
@@ -140,11 +138,11 @@ function peopleXml(people, added) {
140
138
  const prefix = w15Prefix(root);
141
139
  const persons = Array.from(
142
140
  added,
143
- ([author, userId]) => personXml(
141
+ ([author, userId]) => renderPerson(
144
142
  author,
145
143
  userId,
146
144
  prefix ?? "w15:",
147
- prefix === null ? ` xmlns:w15="${W15_NS}"` : ""
145
+ prefix === null ? xmlnsAttr("w15") : null
148
146
  )
149
147
  ).join("");
150
148
  const start = rootTagStart(xml);