@lotics/docx 0.1.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.
Files changed (107) hide show
  1. package/package.json +40 -0
  2. package/src/fixtures/.gitkeep +0 -0
  3. package/src/fixtures/lotics_generated_contract.docx +0 -0
  4. package/src/fonts/bundled.ts +123 -0
  5. package/src/fonts/registry.test.ts +233 -0
  6. package/src/fonts/registry.ts +219 -0
  7. package/src/fonts/types.ts +83 -0
  8. package/src/index.ts +16 -0
  9. package/src/layout/engine.test.ts +430 -0
  10. package/src/layout/engine.ts +566 -0
  11. package/src/layout/page_geometry.ts +43 -0
  12. package/src/layout/types.ts +159 -0
  13. package/src/load.test.ts +144 -0
  14. package/src/load.ts +142 -0
  15. package/src/model/default_numbering.ts +101 -0
  16. package/src/model/default_styles.ts +201 -0
  17. package/src/model/numbering_table.ts +52 -0
  18. package/src/model/properties.ts +328 -0
  19. package/src/model/sections.ts +94 -0
  20. package/src/model/style_resolution.test.ts +219 -0
  21. package/src/model/style_resolution.ts +113 -0
  22. package/src/model/style_table.ts +22 -0
  23. package/src/model/theme.ts +156 -0
  24. package/src/model/types.ts +55 -0
  25. package/src/parse/drawing.ts +157 -0
  26. package/src/parse/font_table.ts +132 -0
  27. package/src/parse/footnotes.ts +60 -0
  28. package/src/parse/header_footer.test.ts +264 -0
  29. package/src/parse/header_footer.ts +66 -0
  30. package/src/parse/numbering.ts +187 -0
  31. package/src/parse/parser.ts +184 -0
  32. package/src/parse/relationships.ts +83 -0
  33. package/src/parse/sections.test.ts +192 -0
  34. package/src/parse/sections.ts +182 -0
  35. package/src/parse/styles.ts +149 -0
  36. package/src/parse/theme.test.ts +86 -0
  37. package/src/parse/theme.ts +112 -0
  38. package/src/pm/bubble_menu.ts +117 -0
  39. package/src/pm/commands.test.ts +185 -0
  40. package/src/pm/commands.ts +697 -0
  41. package/src/pm/commands_insert.test.ts +183 -0
  42. package/src/pm/docx_to_pm.test.ts +330 -0
  43. package/src/pm/docx_to_pm.ts +643 -0
  44. package/src/pm/drag_handle.ts +166 -0
  45. package/src/pm/format_painter.test.ts +91 -0
  46. package/src/pm/format_painter.ts +109 -0
  47. package/src/pm/header_footer_doc.ts +24 -0
  48. package/src/pm/hyperlinks.test.ts +234 -0
  49. package/src/pm/image_registry.test.ts +81 -0
  50. package/src/pm/image_registry.ts +100 -0
  51. package/src/pm/images.test.ts +257 -0
  52. package/src/pm/link_popover.ts +159 -0
  53. package/src/pm/mark_commands.ts +60 -0
  54. package/src/pm/marks.ts +169 -0
  55. package/src/pm/nodes.ts +258 -0
  56. package/src/pm/numbering.test.ts +210 -0
  57. package/src/pm/numbering_plugin.test.ts +71 -0
  58. package/src/pm/numbering_plugin.ts +96 -0
  59. package/src/pm/outline.ts +41 -0
  60. package/src/pm/page_break.test.ts +80 -0
  61. package/src/pm/page_layout.test.ts +87 -0
  62. package/src/pm/pagination_plugin.test.ts +155 -0
  63. package/src/pm/pagination_plugin.ts +590 -0
  64. package/src/pm/phase5.test.ts +271 -0
  65. package/src/pm/phase6.test.ts +215 -0
  66. package/src/pm/placeholder_plugin.ts +24 -0
  67. package/src/pm/plugins.ts +91 -0
  68. package/src/pm/pm_to_docx.ts +0 -0
  69. package/src/pm/roundtrip.test.ts +332 -0
  70. package/src/pm/schema.test.ts +188 -0
  71. package/src/pm/schema.ts +79 -0
  72. package/src/pm/search.ts +46 -0
  73. package/src/pm/table_attrs.ts +48 -0
  74. package/src/pm/table_borders.test.ts +117 -0
  75. package/src/pm/table_borders.ts +130 -0
  76. package/src/pm/table_convert.test.ts +221 -0
  77. package/src/pm/table_convert.ts +541 -0
  78. package/src/pm/table_decorations.ts +132 -0
  79. package/src/pm/table_handles.ts +163 -0
  80. package/src/pm/template_marker.ts +47 -0
  81. package/src/pm/template_plugin.ts +65 -0
  82. package/src/pm/templates.test.ts +162 -0
  83. package/src/render/clipboard.test.ts +115 -0
  84. package/src/render/clipboard.ts +200 -0
  85. package/src/render/editable_view.test.ts +173 -0
  86. package/src/render/footnotes_view.ts +94 -0
  87. package/src/render/header_footer_view.ts +95 -0
  88. package/src/render/link_mark_view.ts +26 -0
  89. package/src/render/media_resolver.ts +61 -0
  90. package/src/render/node_views.ts +296 -0
  91. package/src/render/numbering_counter.ts +149 -0
  92. package/src/render/page_chrome.test.ts +262 -0
  93. package/src/render/page_chrome.ts +343 -0
  94. package/src/render/page_styles.ts +234 -0
  95. package/src/render/paragraph_view.test.ts +162 -0
  96. package/src/render/paragraph_view.ts +141 -0
  97. package/src/render/ruler.ts +110 -0
  98. package/src/render/style_registry.ts +33 -0
  99. package/src/render/table_dom.test.ts +171 -0
  100. package/src/render/table_dom.ts +288 -0
  101. package/src/render/units.ts +18 -0
  102. package/src/render/view.test.ts +165 -0
  103. package/src/render/view.ts +607 -0
  104. package/src/roundtrip.test.ts +179 -0
  105. package/src/serialize/default_parts.ts +128 -0
  106. package/src/serialize/header_footer_pm.ts +82 -0
  107. package/src/serialize/serializer.ts +114 -0
@@ -0,0 +1,332 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { type XmlElement } from "@lotics/ooxml/xml";
3
+ import {
4
+ createBlankDocx,
5
+ saveDocxToBuffer,
6
+ appendElement,
7
+ } from "@lotics/ooxml/document";
8
+ import { buildRun } from "@lotics/ooxml/builders";
9
+ import { parseDocx } from "../parse/parser";
10
+ import { serializeDocx } from "../serialize/serializer";
11
+ import { docxToPm } from "./docx_to_pm";
12
+ import { pmToDocx } from "./pm_to_docx";
13
+ import type { DocxDocument } from "../model/types";
14
+
15
+ function paragraph(children: XmlElement[]): XmlElement {
16
+ return { "w:p": children };
17
+ }
18
+
19
+ async function buildSampleDocx(): Promise<Uint8Array> {
20
+ const doc = createBlankDocx();
21
+ appendElement(doc, paragraph([buildRun("Hello world")]));
22
+ appendElement(
23
+ doc,
24
+ paragraph([
25
+ buildRun("plain "),
26
+ buildRun("bold", { bold: true }),
27
+ buildRun(" "),
28
+ buildRun("italic", { italic: true }),
29
+ buildRun(" "),
30
+ buildRun("both", { bold: true, italic: true }),
31
+ ]),
32
+ );
33
+ appendElement(doc, paragraph([]));
34
+ appendElement(
35
+ doc,
36
+ paragraph([buildRun(" spaces preserved ")]),
37
+ );
38
+ const buffer = await saveDocxToBuffer(doc);
39
+ return new Uint8Array(buffer);
40
+ }
41
+
42
+ function flattenText(docx: DocxDocument): string {
43
+ let out = "";
44
+ for (const block of docx.body.children) {
45
+ if (block.kind !== "paragraph") continue;
46
+ for (const inline of block.content) {
47
+ if (inline.kind !== "run") continue;
48
+ for (const child of inline.content) {
49
+ if (child.kind === "text") out += child.value;
50
+ }
51
+ }
52
+ out += "\n";
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function blockKinds(docx: DocxDocument): string[] {
58
+ return docx.body.children.map((b) => b.kind);
59
+ }
60
+
61
+ describe("docxToPm → pmToDocx roundtrip", () => {
62
+ it("preserves block structure and text content", async () => {
63
+ const original = await parseDocx(await buildSampleDocx());
64
+ const pm = docxToPm(original);
65
+ const reconstructed = pmToDocx(pm, {
66
+ parts: original.parts,
67
+ documentAttrs: original.documentAttrs,
68
+ });
69
+ expect(blockKinds(reconstructed)).toEqual(blockKinds(original));
70
+ expect(flattenText(reconstructed)).toBe(flattenText(original));
71
+ });
72
+
73
+ it("preserves bold/italic marks via re-emission to <w:b/>/<w:i/>", async () => {
74
+ const original = await parseDocx(await buildSampleDocx());
75
+ const pm = docxToPm(original);
76
+ const reconstructed = pmToDocx(pm, {
77
+ parts: original.parts,
78
+ documentAttrs: original.documentAttrs,
79
+ });
80
+
81
+ const findText = (text: string) => {
82
+ for (const block of reconstructed.body.children) {
83
+ if (block.kind !== "paragraph") continue;
84
+ for (const inline of block.content) {
85
+ if (inline.kind !== "run") continue;
86
+ for (const child of inline.content) {
87
+ if (child.kind === "text" && child.value === text) {
88
+ return inline;
89
+ }
90
+ }
91
+ }
92
+ }
93
+ return null;
94
+ };
95
+
96
+ const boldRun = findText("bold");
97
+ expect(boldRun).not.toBeNull();
98
+ if (boldRun?.kind === "run") {
99
+ const tags = (boldRun.properties ?? []).map(
100
+ (el) => Object.keys(el).find((k) => k !== ":@")!,
101
+ );
102
+ expect(tags).toContain("w:b");
103
+ }
104
+
105
+ const bothRun = findText("both");
106
+ if (bothRun?.kind === "run") {
107
+ const tags = (bothRun.properties ?? []).map(
108
+ (el) => Object.keys(el).find((k) => k !== ":@")!,
109
+ );
110
+ expect(tags).toContain("w:b");
111
+ expect(tags).toContain("w:i");
112
+ }
113
+ });
114
+
115
+ it("merges PM text segments with identical marks into single runs", async () => {
116
+ const original = await parseDocx(await buildSampleDocx());
117
+ const pm = docxToPm(original);
118
+ const reconstructed = pmToDocx(pm, {
119
+ parts: original.parts,
120
+ documentAttrs: original.documentAttrs,
121
+ });
122
+
123
+ const para = reconstructed.body.children[1];
124
+ expect(para.kind).toBe("paragraph");
125
+ if (para.kind !== "paragraph") return;
126
+
127
+ let runCount = 0;
128
+ for (const inline of para.content) {
129
+ if (inline.kind === "run") runCount += 1;
130
+ }
131
+ expect(runCount).toBeGreaterThanOrEqual(5);
132
+ });
133
+
134
+ it("preserves preserveSpace flag on text with leading/trailing whitespace", async () => {
135
+ const original = await parseDocx(await buildSampleDocx());
136
+ const pm = docxToPm(original);
137
+ const reconstructed = pmToDocx(pm, {
138
+ parts: original.parts,
139
+ documentAttrs: original.documentAttrs,
140
+ });
141
+
142
+ let foundSpaced = false;
143
+ for (const block of reconstructed.body.children) {
144
+ if (block.kind !== "paragraph") continue;
145
+ for (const inline of block.content) {
146
+ if (inline.kind !== "run") continue;
147
+ for (const child of inline.content) {
148
+ if (
149
+ child.kind === "text" &&
150
+ child.value === " spaces preserved "
151
+ ) {
152
+ expect(child.preserveSpace).toBe(true);
153
+ foundSpaced = true;
154
+ }
155
+ }
156
+ }
157
+ }
158
+ expect(foundSpaced).toBe(true);
159
+ });
160
+
161
+ it("produces serializable output that re-parses to an equivalent model", async () => {
162
+ const original = await parseDocx(await buildSampleDocx());
163
+ const pm = docxToPm(original);
164
+ const reconstructed = pmToDocx(pm, {
165
+ parts: original.parts,
166
+ documentAttrs: original.documentAttrs,
167
+ });
168
+
169
+ const bytes = await serializeDocx(reconstructed);
170
+ const reparsed = await parseDocx(bytes);
171
+
172
+ expect(blockKinds(reparsed)).toEqual(blockKinds(original));
173
+ expect(flattenText(reparsed)).toBe(flattenText(original));
174
+ });
175
+
176
+ it("preserves the body-level final sectPr through PM and back", async () => {
177
+ const original = await parseDocx(await buildSampleDocx());
178
+ const pm = docxToPm(original);
179
+ const reconstructed = pmToDocx(pm, {
180
+ parts: original.parts,
181
+ documentAttrs: original.documentAttrs,
182
+ });
183
+
184
+ const lastBlock =
185
+ reconstructed.body.children[reconstructed.body.children.length - 1];
186
+ expect(lastBlock.kind).toBe("body_sect_pr");
187
+ });
188
+ });
189
+
190
+ describe("pmToDocx — opaque pass-through", () => {
191
+ it("preserves opaque_block content verbatim", async () => {
192
+ const fakeXml = {
193
+ "w:sdt": [{ "w:sdtPr": [] }],
194
+ ":@": { "@_w:foo": "bar" },
195
+ };
196
+ const docx: DocxDocument = {
197
+ parts: new Map(),
198
+ documentAttrs: {},
199
+ body: {
200
+ children: [
201
+ { kind: "paragraph", properties: null, content: [] },
202
+ { kind: "opaque_block", xml: fakeXml },
203
+ ],
204
+ },
205
+ };
206
+ const pm = docxToPm(docx);
207
+ const reconstructed = pmToDocx(pm, {
208
+ parts: docx.parts,
209
+ documentAttrs: docx.documentAttrs,
210
+ });
211
+ const opaque = reconstructed.body.children.find(
212
+ (b) => b.kind === "opaque_block",
213
+ );
214
+ expect(opaque).toBeDefined();
215
+ if (opaque?.kind === "opaque_block") {
216
+ expect(opaque.xml).toEqual(fakeXml);
217
+ }
218
+ });
219
+
220
+ it("preserves opaque_inline content verbatim inside a paragraph", async () => {
221
+ const fldSimple = {
222
+ "w:fldSimple": [{ "w:r": [{ "w:t": [{ "#text": "3" }] }] }],
223
+ ":@": { "@_w:instr": " PAGE " },
224
+ };
225
+ const docx: DocxDocument = {
226
+ parts: new Map(),
227
+ documentAttrs: {},
228
+ body: {
229
+ children: [
230
+ {
231
+ kind: "paragraph",
232
+ properties: null,
233
+ content: [{ kind: "opaque_inline", xml: fldSimple }],
234
+ },
235
+ ],
236
+ },
237
+ };
238
+ const pm = docxToPm(docx);
239
+ const reconstructed = pmToDocx(pm, {
240
+ parts: docx.parts,
241
+ documentAttrs: docx.documentAttrs,
242
+ });
243
+ const para = reconstructed.body.children[0];
244
+ if (para.kind !== "paragraph") throw new Error("expected paragraph");
245
+ const run = para.content[0];
246
+ if (run?.kind !== "run") throw new Error("expected run");
247
+ const child = run.content[0];
248
+ expect(child.kind).toBe("opaque_run_child");
249
+ if (child.kind === "opaque_run_child") {
250
+ expect(child.xml).toEqual(fldSimple);
251
+ }
252
+ });
253
+
254
+ it("preserves unknown paragraph properties (unknownProperties bucket)", async () => {
255
+ const divIdEl = {
256
+ "w:divId": [],
257
+ ":@": { "@_w:val": "12345" },
258
+ };
259
+ const docx: DocxDocument = {
260
+ parts: new Map(),
261
+ documentAttrs: {},
262
+ body: {
263
+ children: [
264
+ {
265
+ kind: "paragraph",
266
+ properties: [
267
+ { "w:pStyle": [], ":@": { "@_w:val": "Normal" } },
268
+ divIdEl,
269
+ ],
270
+ content: [],
271
+ },
272
+ ],
273
+ },
274
+ };
275
+ const pm = docxToPm(docx);
276
+ const reconstructed = pmToDocx(pm, {
277
+ parts: docx.parts,
278
+ documentAttrs: docx.documentAttrs,
279
+ });
280
+ const para = reconstructed.body.children[0];
281
+ if (para.kind !== "paragraph") throw new Error("expected paragraph");
282
+ expect(para.properties).not.toBeNull();
283
+ const tags = (para.properties ?? []).map(
284
+ (el) => Object.keys(el).find((k) => k !== ":@")!,
285
+ );
286
+ expect(tags).toContain("w:pStyle");
287
+ expect(tags).toContain("w:divId");
288
+ });
289
+ });
290
+
291
+ describe("pmToDocx — sections", () => {
292
+ it("emits inline sectPr inside the preceding paragraph's pPr", () => {
293
+ const docx: DocxDocument = {
294
+ parts: new Map(),
295
+ documentAttrs: {},
296
+ body: {
297
+ children: [
298
+ { kind: "paragraph", properties: null, content: [] },
299
+ {
300
+ kind: "paragraph",
301
+ properties: [
302
+ {
303
+ "w:sectPr": [{ "w:type": [], ":@": { "@_w:val": "continuous" } }],
304
+ },
305
+ ],
306
+ content: [],
307
+ },
308
+ { kind: "paragraph", properties: null, content: [] },
309
+ {
310
+ kind: "body_sect_pr",
311
+ xml: { "w:sectPr": [{ "w:pgSz": [], ":@": { "@_w:w": "11906", "@_w:h": "16838" } }] },
312
+ },
313
+ ],
314
+ },
315
+ };
316
+ const pm = docxToPm(docx);
317
+ const reconstructed = pmToDocx(pm, {
318
+ parts: docx.parts,
319
+ documentAttrs: docx.documentAttrs,
320
+ });
321
+
322
+ const inlineParagraph = reconstructed.body.children[1];
323
+ if (inlineParagraph.kind !== "paragraph") throw new Error("expected paragraph");
324
+ const tags = (inlineParagraph.properties ?? []).map(
325
+ (el) => Object.keys(el).find((k) => k !== ":@")!,
326
+ );
327
+ expect(tags).toContain("w:sectPr");
328
+
329
+ const finalBlock = reconstructed.body.children[reconstructed.body.children.length - 1];
330
+ expect(finalBlock.kind).toBe("body_sect_pr");
331
+ });
332
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { docxSchema } from "./schema";
3
+ import { EMPTY_PARAGRAPH_PROPERTIES } from "../model/properties";
4
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
5
+
6
+ describe("docx ProseMirror schema", () => {
7
+ it("has all expected node types", () => {
8
+ const expected = [
9
+ "doc",
10
+ "paragraph",
11
+ "section_break",
12
+ "opaque_block",
13
+ "text",
14
+ "hard_break",
15
+ "image_inline",
16
+ "opaque_inline",
17
+ "field_inline",
18
+ "bookmark_start",
19
+ "bookmark_end",
20
+ "table",
21
+ "table_row",
22
+ "table_cell",
23
+ "table_header",
24
+ ];
25
+ for (const name of expected) {
26
+ expect(docxSchema.nodes[name], `missing node: ${name}`).toBeDefined();
27
+ }
28
+ });
29
+
30
+ it("has all expected marks", () => {
31
+ const expected = [
32
+ "bold",
33
+ "italic",
34
+ "underline",
35
+ "strike",
36
+ "doubleStrike",
37
+ "caps",
38
+ "smallCaps",
39
+ "superscript",
40
+ "subscript",
41
+ "color",
42
+ "highlight",
43
+ "font",
44
+ "size",
45
+ "styleRef",
46
+ "link",
47
+ "insertion",
48
+ "deletion",
49
+ ];
50
+ for (const name of expected) {
51
+ expect(docxSchema.marks[name], `missing mark: ${name}`).toBeDefined();
52
+ }
53
+ });
54
+
55
+ it("constructs a paragraph with bold + italic + colored text", () => {
56
+ const { paragraph, text } = docxSchema.nodes;
57
+ const { bold, italic, color } = docxSchema.marks;
58
+
59
+ const node = paragraph.create(
60
+ {
61
+ properties: { ...EMPTY_PARAGRAPH_PROPERTIES, alignment: "center" },
62
+ unknownProperties: [],
63
+ },
64
+ [
65
+ text.schema.text("Hello ", [bold.create()]),
66
+ text.schema.text("World", [italic.create(), color.create({ value: "FF0000" })]),
67
+ ],
68
+ );
69
+
70
+ expect(node.type.name).toBe("paragraph");
71
+ expect(node.attrs.properties.alignment).toBe("center");
72
+ expect(node.childCount).toBe(2);
73
+ expect(node.child(0).marks.map((m) => m.type.name)).toEqual(["bold"]);
74
+ expect(node.child(1).marks.map((m) => m.type.name)).toEqual(["italic", "color"]);
75
+ expect(node.child(1).marks[1].attrs.value).toBe("FF0000");
76
+ });
77
+
78
+ it("permits opaque_inline carrying arbitrary attrs (XML pass-through)", () => {
79
+ const { paragraph, opaque_inline, text } = docxSchema.nodes;
80
+ const opaque = opaque_inline.create({
81
+ xml: { "w:fldSimple": [], ":@": { "@_w:instr": " PAGE " } },
82
+ });
83
+ const node = paragraph.create({}, [
84
+ text.schema.text("before "),
85
+ opaque,
86
+ text.schema.text(" after"),
87
+ ]);
88
+ expect(node.childCount).toBe(3);
89
+ expect(node.child(1).type.name).toBe("opaque_inline");
90
+ expect(node.child(1).attrs.xml).toMatchObject({
91
+ "w:fldSimple": [],
92
+ });
93
+ });
94
+
95
+ it("permits opaque_block carrying arbitrary attrs", () => {
96
+ const { doc, paragraph, opaque_block } = docxSchema.nodes;
97
+ const node = doc.create({}, [
98
+ paragraph.create({}, []),
99
+ opaque_block.create({ xml: { "w:tbl": [] } }),
100
+ ]);
101
+ expect(node.childCount).toBe(2);
102
+ expect(node.child(1).attrs.xml).toEqual({ "w:tbl": [] });
103
+ });
104
+
105
+ it("constructs a section_break with SectionProperties", () => {
106
+ const { doc, paragraph, section_break } = docxSchema.nodes;
107
+ const node = doc.create({}, [
108
+ paragraph.create({}, []),
109
+ section_break.create({
110
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
111
+ isFinal: true,
112
+ }),
113
+ ]);
114
+ expect(node.child(1).attrs.properties.pageSize.width).toBe(12240);
115
+ expect(node.child(1).attrs.isFinal).toBe(true);
116
+ });
117
+
118
+ it("survives JSON serialization roundtrip with opaque attrs intact", () => {
119
+ const { doc, paragraph, opaque_inline, text } = docxSchema.nodes;
120
+ const original = doc.create({}, [
121
+ paragraph.create({}, [
122
+ text.schema.text("a"),
123
+ opaque_inline.create({
124
+ xml: { "w:tab": [], ":@": { "@_w:val": "clear" } },
125
+ }),
126
+ ]),
127
+ ]);
128
+ const json = original.toJSON();
129
+ const restored = docxSchema.nodeFromJSON(json);
130
+ expect(restored.eq(original)).toBe(true);
131
+ expect((restored.child(0).child(1).attrs.xml as Record<string, unknown>)).toEqual({
132
+ "w:tab": [],
133
+ ":@": { "@_w:val": "clear" },
134
+ });
135
+ });
136
+
137
+ it("constructs a table with rows, cells, and content", () => {
138
+ const { doc, paragraph, table, table_row, table_cell, text } =
139
+ docxSchema.nodes;
140
+ const cell = (s: string) =>
141
+ table_cell.create({}, [paragraph.create({}, [text.schema.text(s)])]);
142
+ const node = doc.create({}, [
143
+ table.create({}, [
144
+ table_row.create({}, [cell("a"), cell("b")]),
145
+ table_row.create({}, [cell("c"), cell("d")]),
146
+ ]),
147
+ ]);
148
+ expect(node.child(0).childCount).toBe(2);
149
+ expect(node.child(0).child(0).childCount).toBe(2);
150
+ });
151
+
152
+ it("preserves font mark attrs through JSON roundtrip", () => {
153
+ const { paragraph, text } = docxSchema.nodes;
154
+ const { font, size } = docxSchema.marks;
155
+ const node = paragraph.create({}, [
156
+ text.schema.text("typed", [
157
+ font.create({
158
+ ascii: "Calibri",
159
+ hAnsi: "Calibri",
160
+ eastAsia: null,
161
+ complexScript: null,
162
+ }),
163
+ size.create({ halfPoints: 24 }),
164
+ ]),
165
+ ]);
166
+ const restored = docxSchema.nodeFromJSON(node.toJSON());
167
+ expect(restored.eq(node)).toBe(true);
168
+ const restoredMarks = restored.child(0).marks;
169
+ expect(restoredMarks[0].attrs.ascii).toBe("Calibri");
170
+ expect(restoredMarks[1].attrs.halfPoints).toBe(24);
171
+ });
172
+
173
+ it("declares mutual exclusion between superscript and subscript", () => {
174
+ const { superscript, subscript } = docxSchema.marks;
175
+ const sup = superscript.create();
176
+ const sub = subscript.create();
177
+ const merged = sub.addToSet([sup]);
178
+ const names = merged.map((m) => m.type.name);
179
+ expect(names).toEqual(["subscript"]);
180
+ });
181
+
182
+ it("rejects nesting block content directly inside paragraph (createChecked)", () => {
183
+ const { paragraph } = docxSchema.nodes;
184
+ expect(() =>
185
+ paragraph.createChecked({}, [paragraph.create({}, [])]),
186
+ ).toThrow();
187
+ });
188
+ });
@@ -0,0 +1,79 @@
1
+ import { Schema } from "prosemirror-model";
2
+ import { tableNodes } from "prosemirror-tables";
3
+ import { nodeSpecs } from "./nodes";
4
+ import { markSpecs } from "./marks";
5
+
6
+ function passthrough(name: string) {
7
+ return {
8
+ default: null,
9
+ getFromDOM: (el: HTMLElement) => {
10
+ const raw = el.getAttribute(`data-${name}`);
11
+ if (!raw) return null;
12
+ try {
13
+ return JSON.parse(raw) as unknown;
14
+ } catch {
15
+ return null;
16
+ }
17
+ },
18
+ setDOMAttr: (value: unknown, attrs: Record<string, unknown>) => {
19
+ if (value !== null && value !== undefined) {
20
+ attrs[`data-${name}`] = JSON.stringify(value);
21
+ }
22
+ },
23
+ };
24
+ }
25
+
26
+ const tableNodeBundle = tableNodes({
27
+ tableGroup: "block",
28
+ cellContent: "block+",
29
+ cellAttributes: {
30
+ background: {
31
+ default: null,
32
+ getFromDOM: (el) => (el as HTMLElement).style.backgroundColor || null,
33
+ setDOMAttr: (value, attrs) => {
34
+ if (value !== null) {
35
+ attrs.style = `${(attrs.style as string | undefined) ?? ""}background-color: ${value as string};`;
36
+ }
37
+ },
38
+ },
39
+ gridSpan: passthrough("grid-span"),
40
+ vMerge: passthrough("v-merge"),
41
+ borders: passthrough("borders"),
42
+ shading: passthrough("shading"),
43
+ vAlign: passthrough("v-align"),
44
+ cellWidth: passthrough("cell-width"),
45
+ },
46
+ });
47
+
48
+ const tableSpec = {
49
+ ...tableNodeBundle.table,
50
+ attrs: {
51
+ ...tableNodeBundle.table.attrs,
52
+ tableProperties: { default: null },
53
+ columnGrid: { default: null },
54
+ },
55
+ };
56
+
57
+ const tableRowSpec = {
58
+ ...tableNodeBundle.table_row,
59
+ attrs: {
60
+ ...tableNodeBundle.table_row.attrs,
61
+ rowProperties: { default: null },
62
+ },
63
+ };
64
+
65
+ const tables = {
66
+ ...tableNodeBundle,
67
+ table: tableSpec,
68
+ table_row: tableRowSpec,
69
+ };
70
+
71
+ export const docxSchema = new Schema({
72
+ nodes: {
73
+ ...nodeSpecs,
74
+ ...tables,
75
+ },
76
+ marks: markSpecs,
77
+ });
78
+
79
+ export type DocxSchema = typeof docxSchema;
@@ -0,0 +1,46 @@
1
+ import {
2
+ SearchQuery,
3
+ findNext,
4
+ findPrev,
5
+ replaceAll,
6
+ replaceCurrent,
7
+ replaceNext,
8
+ search,
9
+ setSearchState,
10
+ } from "prosemirror-search";
11
+ import type { EditorView } from "prosemirror-view";
12
+ import type { Plugin } from "prosemirror-state";
13
+
14
+ export type SearchOptions = {
15
+ caseSensitive?: boolean;
16
+ wholeWord?: boolean;
17
+ regexp?: boolean;
18
+ replace?: string;
19
+ };
20
+
21
+ export function searchPlugin(): Plugin {
22
+ return search();
23
+ }
24
+
25
+ export function setQuery(view: EditorView, text: string, opts: SearchOptions = {}): void {
26
+ const query = new SearchQuery({
27
+ search: text,
28
+ caseSensitive: opts.caseSensitive ?? false,
29
+ wholeWord: opts.wholeWord ?? false,
30
+ regexp: opts.regexp ?? false,
31
+ replace: opts.replace ?? "",
32
+ });
33
+ view.dispatch(setSearchState(view.state.tr, query));
34
+ }
35
+
36
+ export function clearQuery(view: EditorView): void {
37
+ view.dispatch(setSearchState(view.state.tr, new SearchQuery({ search: "" })));
38
+ }
39
+
40
+ export const searchCommands = {
41
+ findNext,
42
+ findPrev,
43
+ replaceNext,
44
+ replaceCurrent,
45
+ replaceAll,
46
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Typed views over OOXML-aware table cell passthrough attrs.
3
+ *
4
+ * The PM schema declares these as opaque (any JSON is allowed). Code that
5
+ * reads or writes them goes through these helpers so the typed shape lives
6
+ * in one place.
7
+ */
8
+
9
+ export type TableBorder = {
10
+ style: string;
11
+ color: string | null;
12
+ sizePts: number;
13
+ };
14
+
15
+ export type TableCellBorders = {
16
+ top?: TableBorder;
17
+ right?: TableBorder;
18
+ bottom?: TableBorder;
19
+ left?: TableBorder;
20
+ };
21
+
22
+ export type TableCellShading = {
23
+ fill: string;
24
+ pattern?: string;
25
+ color?: string | null;
26
+ };
27
+
28
+ export type TableCellWidth = {
29
+ value: number;
30
+ type: "dxa" | "pct" | "auto" | "nil";
31
+ };
32
+
33
+ export type VMergeKind = "restart" | "continue";
34
+
35
+ export type VAlignKind = "top" | "center" | "bottom";
36
+
37
+ export type DocxCellAttrs = {
38
+ colspan?: number;
39
+ rowspan?: number;
40
+ colwidth?: number[] | null;
41
+ background?: string | null;
42
+ gridSpan?: number | null;
43
+ vMerge?: VMergeKind | null;
44
+ borders?: TableCellBorders | null;
45
+ shading?: TableCellShading | null;
46
+ vAlign?: VAlignKind | null;
47
+ cellWidth?: TableCellWidth | null;
48
+ };