@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,169 @@
1
+ import type { MarkSpec } from "prosemirror-model";
2
+
3
+ const toggle = (tag: string): MarkSpec => ({
4
+ parseDOM: [{ tag }],
5
+ toDOM: () => [tag, 0],
6
+ });
7
+
8
+ export const markSpecs: Record<string, MarkSpec> = {
9
+ bold: toggle("strong"),
10
+ italic: toggle("em"),
11
+ underline: {
12
+ parseDOM: [{ tag: "u" }, { style: "text-decoration=underline" }],
13
+ toDOM: () => ["u", 0],
14
+ },
15
+ strike: {
16
+ parseDOM: [{ tag: "s" }, { tag: "strike" }, { style: "text-decoration=line-through" }],
17
+ toDOM: () => ["s", 0],
18
+ },
19
+ doubleStrike: {
20
+ attrs: {},
21
+ parseDOM: [{ tag: "s.double-strike" }],
22
+ toDOM: () => ["s", { class: "double-strike" }, 0],
23
+ },
24
+ caps: {
25
+ parseDOM: [{ style: "text-transform=uppercase" }],
26
+ toDOM: () => ["span", { style: "text-transform: uppercase" }, 0],
27
+ },
28
+ smallCaps: {
29
+ parseDOM: [{ style: "font-variant=small-caps" }],
30
+ toDOM: () => ["span", { style: "font-variant: small-caps" }, 0],
31
+ },
32
+ superscript: {
33
+ excludes: "superscript subscript",
34
+ parseDOM: [{ tag: "sup" }],
35
+ toDOM: () => ["sup", 0],
36
+ },
37
+ subscript: {
38
+ excludes: "superscript subscript",
39
+ parseDOM: [{ tag: "sub" }],
40
+ toDOM: () => ["sub", 0],
41
+ },
42
+ color: {
43
+ attrs: { value: { default: "" } },
44
+ parseDOM: [
45
+ {
46
+ style: "color",
47
+ getAttrs: (value) => {
48
+ if (typeof value !== "string") return false;
49
+ return { value };
50
+ },
51
+ },
52
+ ],
53
+ toDOM: (mark) => ["span", { style: `color: ${mark.attrs.value as string}` }, 0],
54
+ },
55
+ highlight: {
56
+ attrs: { value: { default: "" } },
57
+ parseDOM: [
58
+ {
59
+ style: "background-color",
60
+ getAttrs: (value) => {
61
+ if (typeof value !== "string") return false;
62
+ return { value };
63
+ },
64
+ },
65
+ ],
66
+ toDOM: (mark) => [
67
+ "span",
68
+ { style: `background-color: ${mark.attrs.value as string}` },
69
+ 0,
70
+ ],
71
+ },
72
+ font: {
73
+ attrs: {
74
+ ascii: { default: null },
75
+ hAnsi: { default: null },
76
+ eastAsia: { default: null },
77
+ complexScript: { default: null },
78
+ },
79
+ parseDOM: [
80
+ {
81
+ style: "font-family",
82
+ getAttrs: (value) => {
83
+ if (typeof value !== "string") return false;
84
+ return { ascii: value, hAnsi: value, eastAsia: null, complexScript: null };
85
+ },
86
+ },
87
+ ],
88
+ toDOM: (mark) => {
89
+ const family = (mark.attrs.ascii as string | null) ?? (mark.attrs.hAnsi as string | null);
90
+ if (!family) return ["span", 0];
91
+ return ["span", { style: `font-family: ${family}` }, 0];
92
+ },
93
+ },
94
+ size: {
95
+ attrs: { halfPoints: { default: 22 } },
96
+ parseDOM: [
97
+ {
98
+ style: "font-size",
99
+ getAttrs: (value) => {
100
+ if (typeof value !== "string") return false;
101
+ const m = /^(\d+(?:\.\d+)?)pt$/.exec(value.trim());
102
+ if (!m) return false;
103
+ return { halfPoints: Math.round(Number.parseFloat(m[1]) * 2) };
104
+ },
105
+ },
106
+ ],
107
+ toDOM: (mark) => {
108
+ const halfPoints = mark.attrs.halfPoints as number;
109
+ return ["span", { style: `font-size: ${halfPoints / 2}pt` }, 0];
110
+ },
111
+ },
112
+ styleRef: {
113
+ attrs: { styleId: { default: "" } },
114
+ parseDOM: [{ tag: "span[data-style-ref]", getAttrs: (el) => ({ styleId: (el as HTMLElement).getAttribute("data-style-ref") ?? "" }) }],
115
+ toDOM: (mark) => ["span", { "data-style-ref": mark.attrs.styleId as string }, 0],
116
+ },
117
+ link: {
118
+ attrs: {
119
+ relationshipId: { default: null },
120
+ anchor: { default: null },
121
+ },
122
+ inclusive: false,
123
+ parseDOM: [
124
+ {
125
+ tag: "a[href]",
126
+ getAttrs: (el) => {
127
+ const href = (el as HTMLElement).getAttribute("href") ?? "";
128
+ if (href.startsWith("#")) {
129
+ return { relationshipId: null, anchor: href.slice(1) };
130
+ }
131
+ return { relationshipId: null, anchor: null };
132
+ },
133
+ },
134
+ ],
135
+ toDOM: (mark) => {
136
+ const anchor = mark.attrs.anchor as string | null;
137
+ const href = anchor ? `#${anchor}` : "#";
138
+ return ["a", { href }, 0];
139
+ },
140
+ },
141
+ insertion: {
142
+ attrs: {
143
+ author: { default: null },
144
+ date: { default: null },
145
+ revisionId: { default: null },
146
+ },
147
+ inclusive: false,
148
+ parseDOM: [{ tag: "ins" }],
149
+ toDOM: () => [
150
+ "ins",
151
+ { class: "docx-tracked-insertion" },
152
+ 0,
153
+ ],
154
+ },
155
+ deletion: {
156
+ attrs: {
157
+ author: { default: null },
158
+ date: { default: null },
159
+ revisionId: { default: null },
160
+ },
161
+ inclusive: false,
162
+ parseDOM: [{ tag: "del" }],
163
+ toDOM: () => [
164
+ "del",
165
+ { class: "docx-tracked-deletion" },
166
+ 0,
167
+ ],
168
+ },
169
+ };
@@ -0,0 +1,258 @@
1
+ import type { NodeSpec } from "prosemirror-model";
2
+ import type { ParagraphProperties } from "../model/properties";
3
+ import type { SectionProperties } from "../model/sections";
4
+
5
+ export type ParagraphAttrs = {
6
+ properties: ParagraphProperties | null;
7
+ unknownProperties: unknown[];
8
+ };
9
+
10
+ export type ImageInlineAttrs = {
11
+ relationshipId: string | null;
12
+ widthEmu: number | null;
13
+ heightEmu: number | null;
14
+ alt: string;
15
+ };
16
+
17
+ export type OpaqueAttrs = {
18
+ xml: unknown;
19
+ };
20
+
21
+ export type SectionBreakAttrs = {
22
+ properties: SectionProperties;
23
+ isFinal: boolean;
24
+ };
25
+
26
+ export type FieldAttrs = {
27
+ instruction: string;
28
+ cachedResult: string;
29
+ };
30
+
31
+ const DEFAULT_PARAGRAPH_ATTRS: ParagraphAttrs = {
32
+ properties: null,
33
+ unknownProperties: [],
34
+ };
35
+
36
+ export const nodeSpecs: Record<string, NodeSpec> = {
37
+ doc: { content: "block+" },
38
+
39
+ paragraph: {
40
+ content: "inline*",
41
+ group: "block",
42
+ attrs: {
43
+ properties: { default: DEFAULT_PARAGRAPH_ATTRS.properties },
44
+ unknownProperties: { default: DEFAULT_PARAGRAPH_ATTRS.unknownProperties },
45
+ resolvedProperties: { default: null },
46
+ resolvedBaseRun: { default: null },
47
+ numberingLabel: { default: null },
48
+ },
49
+ parseDOM: [{ tag: "p" }],
50
+ toDOM: () => ["p", 0],
51
+ },
52
+
53
+ section_break: {
54
+ group: "block",
55
+ atom: true,
56
+ selectable: false,
57
+ attrs: {
58
+ properties: { default: null },
59
+ isFinal: { default: false },
60
+ },
61
+ parseDOM: [{ tag: "div[data-section-break]" }],
62
+ toDOM: () => [
63
+ "div",
64
+ { "data-section-break": "true", "aria-hidden": "true" },
65
+ ],
66
+ },
67
+
68
+ opaque_block: {
69
+ group: "block",
70
+ atom: true,
71
+ attrs: { xml: { default: null } },
72
+ parseDOM: [{ tag: "div[data-opaque-block]" }],
73
+ toDOM: () => ["div", { "data-opaque-block": "true" }],
74
+ },
75
+
76
+ text: { group: "inline" },
77
+
78
+ hard_break: {
79
+ group: "inline",
80
+ inline: true,
81
+ selectable: false,
82
+ attrs: {
83
+ breakType: { default: "line" },
84
+ },
85
+ parseDOM: [{ tag: "br" }],
86
+ toDOM: (node) => {
87
+ const type = node.attrs.breakType as string;
88
+ if (type === "page" || type === "column") {
89
+ return ["span", { class: `docx-${type}-break`, "data-break-type": type }];
90
+ }
91
+ return ["br"];
92
+ },
93
+ },
94
+
95
+ image_inline: {
96
+ group: "inline",
97
+ inline: true,
98
+ atom: true,
99
+ attrs: {
100
+ relationshipId: { default: null },
101
+ widthEmu: { default: null },
102
+ heightEmu: { default: null },
103
+ alt: { default: "" },
104
+ originalXml: { default: null },
105
+ wrap: { default: null },
106
+ floatSide: { default: null },
107
+ behindDoc: { default: false },
108
+ offsetXEmu: { default: null },
109
+ offsetYEmu: { default: null },
110
+ },
111
+ parseDOM: [
112
+ {
113
+ tag: "img[data-rid]",
114
+ getAttrs: (el) => {
115
+ const node = el as HTMLElement;
116
+ return {
117
+ relationshipId: node.getAttribute("data-rid"),
118
+ widthEmu: Number.parseInt(node.getAttribute("data-width-emu") ?? "", 10) || null,
119
+ heightEmu: Number.parseInt(node.getAttribute("data-height-emu") ?? "", 10) || null,
120
+ alt: node.getAttribute("alt") ?? "",
121
+ originalXml: null,
122
+ };
123
+ },
124
+ },
125
+ ],
126
+ toDOM: (node) => [
127
+ "img",
128
+ {
129
+ "data-rid": node.attrs.relationshipId as string | null,
130
+ "data-width-emu": String(node.attrs.widthEmu ?? ""),
131
+ "data-height-emu": String(node.attrs.heightEmu ?? ""),
132
+ alt: node.attrs.alt as string,
133
+ },
134
+ ],
135
+ },
136
+
137
+ opaque_inline: {
138
+ group: "inline",
139
+ inline: true,
140
+ atom: true,
141
+ selectable: false,
142
+ attrs: { xml: { default: null } },
143
+ parseDOM: [{ tag: "span[data-opaque-inline]" }],
144
+ toDOM: () => ["span", { "data-opaque-inline": "true" }],
145
+ },
146
+
147
+ field_inline: {
148
+ group: "inline",
149
+ inline: true,
150
+ atom: true,
151
+ attrs: {
152
+ instruction: { default: "" },
153
+ cachedResult: { default: "" },
154
+ },
155
+ parseDOM: [
156
+ {
157
+ tag: "span[data-field]",
158
+ getAttrs: (el) => ({
159
+ instruction: (el as HTMLElement).getAttribute("data-field") ?? "",
160
+ cachedResult: (el as HTMLElement).textContent ?? "",
161
+ }),
162
+ },
163
+ ],
164
+ toDOM: (node) => [
165
+ "span",
166
+ { "data-field": node.attrs.instruction as string },
167
+ node.attrs.cachedResult as string,
168
+ ],
169
+ },
170
+
171
+ bookmark_start: {
172
+ group: "inline",
173
+ inline: true,
174
+ atom: true,
175
+ selectable: false,
176
+ attrs: {
177
+ bookmarkId: { default: "" },
178
+ name: { default: "" },
179
+ },
180
+ parseDOM: [
181
+ {
182
+ tag: "span[data-bookmark-start]",
183
+ getAttrs: (el) => ({
184
+ bookmarkId:
185
+ (el as HTMLElement).getAttribute("data-bookmark-start") ?? "",
186
+ name: (el as HTMLElement).getAttribute("data-bookmark-name") ?? "",
187
+ }),
188
+ },
189
+ ],
190
+ toDOM: (node) => [
191
+ "span",
192
+ {
193
+ "data-bookmark-start": node.attrs.bookmarkId as string,
194
+ "data-bookmark-name": node.attrs.name as string,
195
+ "aria-hidden": "true",
196
+ },
197
+ ],
198
+ },
199
+
200
+ footnote_ref: {
201
+ group: "inline",
202
+ inline: true,
203
+ atom: true,
204
+ attrs: {
205
+ footnoteId: { default: "" },
206
+ number: { default: 1 },
207
+ },
208
+ parseDOM: [
209
+ {
210
+ tag: "sup[data-footnote]",
211
+ getAttrs: (el) => ({
212
+ footnoteId:
213
+ (el as HTMLElement).getAttribute("data-footnote") ?? "",
214
+ number:
215
+ Number.parseInt(
216
+ (el as HTMLElement).getAttribute("data-footnote-number") ?? "",
217
+ 10,
218
+ ) || 1,
219
+ }),
220
+ },
221
+ ],
222
+ toDOM: (node) => [
223
+ "sup",
224
+ {
225
+ "data-footnote": node.attrs.footnoteId as string,
226
+ "data-footnote-number": String(node.attrs.number),
227
+ class: "docx-footnote-ref",
228
+ },
229
+ String(node.attrs.number as number),
230
+ ],
231
+ },
232
+
233
+ bookmark_end: {
234
+ group: "inline",
235
+ inline: true,
236
+ atom: true,
237
+ selectable: false,
238
+ attrs: {
239
+ bookmarkId: { default: "" },
240
+ },
241
+ parseDOM: [
242
+ {
243
+ tag: "span[data-bookmark-end]",
244
+ getAttrs: (el) => ({
245
+ bookmarkId:
246
+ (el as HTMLElement).getAttribute("data-bookmark-end") ?? "",
247
+ }),
248
+ },
249
+ ],
250
+ toDOM: (node) => [
251
+ "span",
252
+ {
253
+ "data-bookmark-end": node.attrs.bookmarkId as string,
254
+ "aria-hidden": "true",
255
+ },
256
+ ],
257
+ },
258
+ };
@@ -0,0 +1,210 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { parseXml } from "@lotics/ooxml/xml";
4
+ import { parseNumberingTable } from "../parse/numbering";
5
+ import { docxToPm } from "./docx_to_pm";
6
+ import {
7
+ computeLabel,
8
+ createNumberingState,
9
+ } from "../render/numbering_counter";
10
+ import { createReadOnlyView } from "../render/view";
11
+ import { buildFontRegistry } from "../fonts/registry";
12
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
13
+ import { docxSchema as _schema } from "./schema";
14
+ import type { DocxDocument } from "../model/types";
15
+ import type { Section } from "../model/sections";
16
+
17
+ void _schema;
18
+
19
+ const NUMBERING_XML = `<?xml version="1.0"?>
20
+ <w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
21
+ <w:abstractNum w:abstractNumId="0">
22
+ <w:lvl w:ilvl="0">
23
+ <w:start w:val="1"/>
24
+ <w:numFmt w:val="decimal"/>
25
+ <w:lvlText w:val="%1."/>
26
+ </w:lvl>
27
+ <w:lvl w:ilvl="1">
28
+ <w:start w:val="1"/>
29
+ <w:numFmt w:val="lowerLetter"/>
30
+ <w:lvlText w:val="%2)"/>
31
+ </w:lvl>
32
+ </w:abstractNum>
33
+ <w:abstractNum w:abstractNumId="1">
34
+ <w:lvl w:ilvl="0">
35
+ <w:start w:val="1"/>
36
+ <w:numFmt w:val="bullet"/>
37
+ <w:lvlText w:val="•"/>
38
+ </w:lvl>
39
+ </w:abstractNum>
40
+ <w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
41
+ <w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>
42
+ </w:numbering>`;
43
+
44
+ function numberingParts() {
45
+ return new Map([
46
+ ["word/numbering.xml", new TextEncoder().encode(NUMBERING_XML)],
47
+ ]);
48
+ }
49
+
50
+ const NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"`;
51
+
52
+ function pPrWithNumbering(numId: number, ilvl: number) {
53
+ return parseXml(
54
+ `<w:pPr ${NS}><w:numPr><w:ilvl w:val="${ilvl}"/><w:numId w:val="${numId}"/></w:numPr></w:pPr>`,
55
+ )[0];
56
+ }
57
+
58
+ function paraWithNumbering(numId: number, ilvl: number, text: string) {
59
+ const pPr = pPrWithNumbering(numId, ilvl);
60
+ return {
61
+ kind: "paragraph" as const,
62
+ properties: pPr["w:pPr"] as ReturnType<typeof parseXml>,
63
+ content: [
64
+ {
65
+ kind: "run" as const,
66
+ properties: null,
67
+ content: [{ kind: "text" as const, value: text, preserveSpace: false }],
68
+ },
69
+ ],
70
+ };
71
+ }
72
+
73
+ describe("parseNumberingTable", () => {
74
+ it("parses abstractNums and nums", () => {
75
+ const table = parseNumberingTable(numberingParts());
76
+ expect(table.abstractNums.size).toBe(2);
77
+ expect(table.nums.size).toBe(2);
78
+ expect(table.nums.get(1)?.abstractNumId).toBe(0);
79
+ expect(table.abstractNums.get(0)?.levels.get(0)?.numFmt).toBe("decimal");
80
+ expect(table.abstractNums.get(0)?.levels.get(0)?.lvlText).toBe("%1.");
81
+ expect(table.abstractNums.get(1)?.levels.get(0)?.numFmt).toBe("bullet");
82
+ expect(table.abstractNums.get(1)?.levels.get(0)?.lvlText).toBe("•");
83
+ });
84
+
85
+ it("returns empty for missing numbering.xml", () => {
86
+ const table = parseNumberingTable(new Map());
87
+ expect(table.abstractNums.size).toBe(0);
88
+ });
89
+ });
90
+
91
+ describe("computeLabel", () => {
92
+ it("counts decimal level 0", () => {
93
+ const table = parseNumberingTable(numberingParts());
94
+ const state = createNumberingState();
95
+ expect(computeLabel(state, table, 1, 0)).toBe("1.");
96
+ expect(computeLabel(state, table, 1, 0)).toBe("2.");
97
+ expect(computeLabel(state, table, 1, 0)).toBe("3.");
98
+ });
99
+
100
+ it("emits bullet glyph for bullet format", () => {
101
+ const table = parseNumberingTable(numberingParts());
102
+ const state = createNumberingState();
103
+ expect(computeLabel(state, table, 2, 0)).toBe("•");
104
+ expect(computeLabel(state, table, 2, 0)).toBe("•");
105
+ });
106
+
107
+ it("resets deeper levels when outer increments", () => {
108
+ const table = parseNumberingTable(numberingParts());
109
+ const state = createNumberingState();
110
+ expect(computeLabel(state, table, 1, 0)).toBe("1.");
111
+ expect(computeLabel(state, table, 1, 1)).toBe("a)");
112
+ expect(computeLabel(state, table, 1, 1)).toBe("b)");
113
+ expect(computeLabel(state, table, 1, 0)).toBe("2.");
114
+ expect(computeLabel(state, table, 1, 1)).toBe("a)");
115
+ });
116
+ });
117
+
118
+ describe("docxToPm — numbering integration", () => {
119
+ it("attaches numberingLabel to numbered paragraphs", () => {
120
+ const docx: DocxDocument = {
121
+ parts: numberingParts(),
122
+ documentAttrs: {},
123
+ body: {
124
+ children: [
125
+ paraWithNumbering(1, 0, "first"),
126
+ paraWithNumbering(1, 0, "second"),
127
+ ],
128
+ },
129
+ };
130
+ const table = parseNumberingTable(numberingParts());
131
+ const pm = docxToPm(docx, { numberingTable: table });
132
+ expect(pm.child(0).attrs.numberingLabel).toBe("1.");
133
+ expect(pm.child(1).attrs.numberingLabel).toBe("2.");
134
+ });
135
+
136
+ it("does not add labels when numberingTable is not provided", () => {
137
+ const docx: DocxDocument = {
138
+ parts: new Map(),
139
+ documentAttrs: {},
140
+ body: { children: [paraWithNumbering(1, 0, "x")] },
141
+ };
142
+ const pm = docxToPm(docx);
143
+ expect(pm.child(0).attrs.numberingLabel).toBeNull();
144
+ });
145
+
146
+ it("renders the marker before paragraph content", () => {
147
+ let host: HTMLElement;
148
+ document.body.innerHTML = "";
149
+ host = document.createElement("div");
150
+ document.body.appendChild(host);
151
+
152
+ const docx: DocxDocument = {
153
+ parts: numberingParts(),
154
+ documentAttrs: {},
155
+ body: {
156
+ children: [
157
+ paraWithNumbering(1, 0, "first"),
158
+ paraWithNumbering(1, 0, "second"),
159
+ ],
160
+ },
161
+ };
162
+ const table = parseNumberingTable(numberingParts());
163
+ const pm = docxToPm(docx, { numberingTable: table });
164
+ const view = createReadOnlyView(host, pm, {
165
+ fontRegistry: buildFontRegistry({ embeddedFonts: [], workspaceFonts: [] }),
166
+ sections: [
167
+ {
168
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
169
+ blockStartIndex: 0,
170
+ blockEndIndex: 0,
171
+ } satisfies Section,
172
+ ],
173
+ });
174
+ const markers = host.querySelectorAll(".docx-numbering-marker");
175
+ expect(markers).toHaveLength(2);
176
+ expect(markers[0].textContent).toBe("1.");
177
+ expect(markers[1].textContent).toBe("2.");
178
+ view.destroy();
179
+ });
180
+ });
181
+
182
+ describe("docxToPm — backwards compat for second arg", () => {
183
+ beforeEach(() => {
184
+ document.body.innerHTML = "";
185
+ });
186
+
187
+ it("accepts the old StyleTable-only second argument shape", () => {
188
+ const docx: DocxDocument = {
189
+ parts: new Map(),
190
+ documentAttrs: {},
191
+ body: {
192
+ children: [
193
+ {
194
+ kind: "paragraph",
195
+ properties: null,
196
+ content: [
197
+ {
198
+ kind: "run",
199
+ properties: null,
200
+ content: [{ kind: "text", value: "hi", preserveSpace: false }],
201
+ },
202
+ ],
203
+ },
204
+ ],
205
+ },
206
+ };
207
+ const pm = docxToPm(docx);
208
+ expect(pm.firstChild!.firstChild!.text).toBe("hi");
209
+ });
210
+ });
@@ -0,0 +1,71 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { docxSchema } from "./schema";
4
+ import { createReadOnlyView } from "../render/view";
5
+ import { buildFontRegistry } from "../fonts/registry";
6
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
7
+ import type { Section } from "../model/sections";
8
+
9
+ const fontRegistry = buildFontRegistry({
10
+ embeddedFonts: [],
11
+ workspaceFonts: [],
12
+ });
13
+
14
+ const defaultSections: Section[] = [
15
+ {
16
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
17
+ blockStartIndex: 0,
18
+ blockEndIndex: 0,
19
+ },
20
+ ];
21
+
22
+ let host: HTMLElement;
23
+ beforeEach(() => {
24
+ document.body.innerHTML = "";
25
+ host = document.createElement("div");
26
+ document.body.appendChild(host);
27
+ });
28
+
29
+ describe("numberingDecorationPlugin", () => {
30
+ it("renders markers as widget decorations, not as paragraph children", () => {
31
+ const { doc, paragraph } = docxSchema.nodes;
32
+ const node = doc.create({}, [
33
+ paragraph.create(
34
+ { numberingLabel: "1." },
35
+ [docxSchema.text("first")],
36
+ ),
37
+ paragraph.create(
38
+ { numberingLabel: "2." },
39
+ [docxSchema.text("second")],
40
+ ),
41
+ ]);
42
+ const view = createReadOnlyView(host, node, {
43
+ fontRegistry,
44
+ sections: defaultSections,
45
+ });
46
+
47
+ const markers = host.querySelectorAll(".docx-numbering-marker");
48
+ expect(markers).toHaveLength(2);
49
+ expect(markers[0].textContent).toBe("1.");
50
+ expect(markers[1].textContent).toBe("2.");
51
+
52
+ for (const marker of markers) {
53
+ expect(marker.parentElement?.classList.contains("docx-paragraph")).toBe(true);
54
+ expect(marker.getAttribute("aria-hidden")).toBe("true");
55
+ }
56
+ view.destroy();
57
+ });
58
+
59
+ it("paragraphs without a label render no marker", () => {
60
+ const { doc, paragraph } = docxSchema.nodes;
61
+ const node = doc.create({}, [
62
+ paragraph.create({}, [docxSchema.text("plain")]),
63
+ ]);
64
+ const view = createReadOnlyView(host, node, {
65
+ fontRegistry,
66
+ sections: defaultSections,
67
+ });
68
+ expect(host.querySelectorAll(".docx-numbering-marker")).toHaveLength(0);
69
+ view.destroy();
70
+ });
71
+ });