@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,162 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { buildFontRegistry } from "../fonts/registry";
4
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
5
+ import { createReadOnlyView } from "./view";
6
+ import { docxToPm } from "../pm/docx_to_pm";
7
+ import { parseStyleTable } from "../parse/styles";
8
+ import type { Section } from "../model/sections";
9
+ import type { DocxDocument } from "../model/types";
10
+
11
+ const fontRegistry = buildFontRegistry({
12
+ embeddedFonts: [],
13
+ workspaceFonts: [],
14
+ });
15
+
16
+ const defaultSections: Section[] = [
17
+ {
18
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
19
+ blockStartIndex: 0,
20
+ blockEndIndex: 0,
21
+ },
22
+ ];
23
+
24
+ const STYLES_XML = `<?xml version="1.0"?>
25
+ <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
26
+ <w:docDefaults>
27
+ <w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri"/><w:sz w:val="22"/></w:rPr></w:rPrDefault>
28
+ </w:docDefaults>
29
+ <w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
30
+ <w:style w:type="paragraph" w:styleId="Heading1">
31
+ <w:name w:val="heading 1"/>
32
+ <w:basedOn w:val="Normal"/>
33
+ <w:pPr><w:outlineLvl w:val="0"/><w:spacing w:before="240" w:after="120"/></w:pPr>
34
+ <w:rPr><w:b/><w:sz w:val="32"/><w:color w:val="2E74B5"/></w:rPr>
35
+ </w:style>
36
+ </w:styles>`;
37
+
38
+ function styleTable() {
39
+ return parseStyleTable(
40
+ new Map([["word/styles.xml", new TextEncoder().encode(STYLES_XML)]]),
41
+ );
42
+ }
43
+
44
+ let host: HTMLElement;
45
+ beforeEach(() => {
46
+ document.body.innerHTML = "";
47
+ host = document.createElement("div");
48
+ document.body.appendChild(host);
49
+ });
50
+
51
+ function buildDocxWith(blocks: DocxDocument["body"]["children"]): DocxDocument {
52
+ return {
53
+ parts: new Map(),
54
+ documentAttrs: {},
55
+ body: { children: blocks },
56
+ };
57
+ }
58
+
59
+ describe("paragraphView — resolved styles", () => {
60
+ it("applies heading run baseline (size + bold + color) to paragraph wrapper", () => {
61
+ const docx = buildDocxWith([
62
+ {
63
+ kind: "paragraph",
64
+ properties: [
65
+ { "w:pStyle": [], ":@": { "@_w:val": "Heading1" } },
66
+ ],
67
+ content: [
68
+ { kind: "run", properties: null, content: [{ kind: "text", value: "Title", preserveSpace: false }] },
69
+ ],
70
+ },
71
+ ]);
72
+ const pm = docxToPm(docx, { styleTable: styleTable() });
73
+ const view = createReadOnlyView(host, pm, { fontRegistry, sections: defaultSections });
74
+
75
+ const p = host.querySelector("p.docx-paragraph") as HTMLElement;
76
+ expect(p).not.toBeNull();
77
+ const style = p.getAttribute("style") ?? "";
78
+ expect(style).toContain("font-size: 21.33px");
79
+ expect(style).toContain("font-weight: 700");
80
+ expect(style).toContain("color: #2E74B5");
81
+ expect(p.getAttribute("data-style")).toBe("Heading1");
82
+ expect(p.getAttribute("data-outline")).toBe("0");
83
+ view.destroy();
84
+ });
85
+
86
+ it("applies paragraph spacing from style chain", () => {
87
+ const docx = buildDocxWith([
88
+ {
89
+ kind: "paragraph",
90
+ properties: [{ "w:pStyle": [], ":@": { "@_w:val": "Heading1" } }],
91
+ content: [],
92
+ },
93
+ ]);
94
+ const pm = docxToPm(docx, { styleTable: styleTable() });
95
+ const view = createReadOnlyView(host, pm, { fontRegistry, sections: defaultSections });
96
+
97
+ const p = host.querySelector("p.docx-paragraph") as HTMLElement;
98
+ const style = p.getAttribute("style") ?? "";
99
+ expect(style).toContain("margin-top: 16px");
100
+ expect(style).toContain("margin-bottom: 8px");
101
+ view.destroy();
102
+ });
103
+
104
+ it("applies direct alignment override on top of style chain", () => {
105
+ const docx = buildDocxWith([
106
+ {
107
+ kind: "paragraph",
108
+ properties: [
109
+ { "w:pStyle": [], ":@": { "@_w:val": "Heading1" } },
110
+ { "w:jc": [], ":@": { "@_w:val": "center" } },
111
+ ],
112
+ content: [],
113
+ },
114
+ ]);
115
+ const pm = docxToPm(docx, { styleTable: styleTable() });
116
+ const view = createReadOnlyView(host, pm, { fontRegistry, sections: defaultSections });
117
+
118
+ const p = host.querySelector("p.docx-paragraph") as HTMLElement;
119
+ expect((p.getAttribute("style") ?? "")).toContain("text-align: center");
120
+ view.destroy();
121
+ });
122
+
123
+ it("applies indent (left + firstLine) in pixels via dxa conversion", () => {
124
+ const docx = buildDocxWith([
125
+ {
126
+ kind: "paragraph",
127
+ properties: [
128
+ {
129
+ "w:ind": [],
130
+ ":@": { "@_w:left": "720", "@_w:firstLine": "360" },
131
+ },
132
+ ],
133
+ content: [],
134
+ },
135
+ ]);
136
+ const pm = docxToPm(docx, { styleTable: styleTable() });
137
+ const view = createReadOnlyView(host, pm, { fontRegistry, sections: defaultSections });
138
+
139
+ const p = host.querySelector("p.docx-paragraph") as HTMLElement;
140
+ const style = p.getAttribute("style") ?? "";
141
+ expect(style).toContain("padding-left: 48px");
142
+ expect(style).toContain("text-indent: 24px");
143
+ view.destroy();
144
+ });
145
+
146
+ it("falls back to no resolved style when no styleTable is provided", () => {
147
+ const docx = buildDocxWith([
148
+ {
149
+ kind: "paragraph",
150
+ properties: [{ "w:jc": [], ":@": { "@_w:val": "right" } }],
151
+ content: [],
152
+ },
153
+ ]);
154
+ const pm = docxToPm(docx);
155
+ const view = createReadOnlyView(host, pm, { fontRegistry, sections: defaultSections });
156
+
157
+ const p = host.querySelector("p.docx-paragraph") as HTMLElement;
158
+ expect(p).not.toBeNull();
159
+ expect((p.getAttribute("style") ?? "")).not.toContain("text-align");
160
+ view.destroy();
161
+ });
162
+ });
@@ -0,0 +1,141 @@
1
+ import type { Node as PMNode } from "prosemirror-model";
2
+ import type { NodeView } from "prosemirror-view";
3
+ import type {
4
+ ParagraphProperties,
5
+ RunProperties,
6
+ } from "../model/properties";
7
+ import {
8
+ resolveThemeColor,
9
+ type ThemeTable,
10
+ } from "../model/theme";
11
+ import { dxaToPx, halfPointsToPx } from "./units";
12
+
13
+ function resolveRunColor(
14
+ props: RunProperties,
15
+ theme: ThemeTable | null,
16
+ ): string | null {
17
+ if (props.color !== null) return `#${props.color}`;
18
+ if (props.themeColor !== null && theme) {
19
+ const hex = resolveThemeColor(
20
+ theme,
21
+ props.themeColor,
22
+ props.themeTint,
23
+ props.themeShade,
24
+ );
25
+ if (hex) return `#${hex}`;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function alignmentCss(align: ParagraphProperties["alignment"]): string | null {
31
+ if (align === null) return null;
32
+ if (align === "both") return "justify";
33
+ if (align === "start") return "left";
34
+ if (align === "end") return "right";
35
+ return align;
36
+ }
37
+
38
+ function fontFamilyCss(props: RunProperties): string | null {
39
+ const family = props.fontAscii ?? props.fontHAnsi ?? props.fontEastAsia;
40
+ if (!family) return null;
41
+ return `"${family}"`;
42
+ }
43
+
44
+ function buildParagraphStyle(
45
+ resolvedParagraph: ParagraphProperties | null,
46
+ resolvedBaseRun: RunProperties | null,
47
+ outlineLevel: number | null,
48
+ theme: ThemeTable | null,
49
+ ): string {
50
+ const parts: string[] = [];
51
+
52
+ if (resolvedParagraph) {
53
+ const align = alignmentCss(resolvedParagraph.alignment);
54
+ if (align) parts.push(`text-align: ${align}`);
55
+
56
+ if (resolvedParagraph.indent) {
57
+ const left = resolvedParagraph.indent.left ?? 0;
58
+ const right = resolvedParagraph.indent.right ?? 0;
59
+ const firstLine = resolvedParagraph.indent.firstLine;
60
+ const hanging = resolvedParagraph.indent.hanging;
61
+ if (left !== 0) parts.push(`padding-left: ${dxaToPx(left)}px`);
62
+ if (right !== 0) parts.push(`padding-right: ${dxaToPx(right)}px`);
63
+ if (firstLine !== null && firstLine !== undefined) {
64
+ parts.push(`text-indent: ${dxaToPx(firstLine)}px`);
65
+ } else if (hanging !== null && hanging !== undefined) {
66
+ parts.push(`text-indent: ${dxaToPx(-hanging)}px`);
67
+ }
68
+ }
69
+
70
+ if (resolvedParagraph.spacing) {
71
+ const before = resolvedParagraph.spacing.before;
72
+ const after = resolvedParagraph.spacing.after;
73
+ if (before !== null) parts.push(`margin-top: ${dxaToPx(before)}px`);
74
+ if (after !== null) parts.push(`margin-bottom: ${dxaToPx(after)}px`);
75
+ const line = resolvedParagraph.spacing.line;
76
+ const rule = resolvedParagraph.spacing.lineRule;
77
+ if (line !== null) {
78
+ if (rule === "exact" || rule === "atLeast") {
79
+ parts.push(`line-height: ${dxaToPx(line)}px`);
80
+ } else {
81
+ parts.push(`line-height: ${(line / 240).toFixed(3)}`);
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ if (resolvedBaseRun) {
88
+ const family = fontFamilyCss(resolvedBaseRun);
89
+ if (family) parts.push(`font-family: ${family}`);
90
+ if (resolvedBaseRun.size !== null) {
91
+ const px = halfPointsToPx(resolvedBaseRun.size);
92
+ parts.push(`font-size: ${Number.isInteger(px) ? px : px.toFixed(2)}px`);
93
+ }
94
+ if (resolvedBaseRun.bold === true) parts.push("font-weight: 700");
95
+ if (resolvedBaseRun.italic === true) parts.push("font-style: italic");
96
+ const color = resolveRunColor(resolvedBaseRun, theme);
97
+ if (color) parts.push(`color: ${color}`);
98
+ }
99
+
100
+ if (outlineLevel !== null && outlineLevel !== undefined) {
101
+ parts.push(`--docx-outline-level: ${outlineLevel}`);
102
+ }
103
+
104
+ return parts.join("; ");
105
+ }
106
+
107
+ export function buildParagraphView(theme: ThemeTable | null) {
108
+ return (node: PMNode): NodeView => paragraphViewImpl(node, theme);
109
+ }
110
+
111
+ export function paragraphView(node: PMNode): NodeView {
112
+ return paragraphViewImpl(node, null);
113
+ }
114
+
115
+ function paragraphViewImpl(
116
+ node: PMNode,
117
+ theme: ThemeTable | null,
118
+ ): NodeView {
119
+ const dom = document.createElement("p");
120
+ dom.className = "docx-paragraph";
121
+
122
+ const props = node.attrs.properties as ParagraphProperties | null;
123
+ const resolved = node.attrs.resolvedProperties as ParagraphProperties | null;
124
+ const resolvedBaseRun = node.attrs.resolvedBaseRun as RunProperties | null;
125
+ const outlineLevel =
126
+ resolved?.outlineLevel ?? props?.outlineLevel ?? null;
127
+
128
+ const style = buildParagraphStyle(resolved, resolvedBaseRun, outlineLevel, theme);
129
+ if (style.length > 0) dom.setAttribute("style", style);
130
+
131
+ if (resolved?.styleId) {
132
+ dom.setAttribute("data-style", resolved.styleId);
133
+ } else if (props?.styleId) {
134
+ dom.setAttribute("data-style", props.styleId);
135
+ }
136
+ if (outlineLevel !== null && outlineLevel !== undefined) {
137
+ dom.setAttribute("data-outline", String(outlineLevel));
138
+ }
139
+
140
+ return { dom, contentDOM: dom };
141
+ }
@@ -0,0 +1,110 @@
1
+ import type { SectionProperties } from "../model/sections";
2
+ import { dxaToPx, PX_PER_INCH } from "./units";
3
+
4
+ export type RulerHandle = {
5
+ element: HTMLElement;
6
+ destroy(): void;
7
+ };
8
+
9
+ export function buildRuler(props: SectionProperties): RulerHandle {
10
+ const widthPx = dxaToPx(props.pageSize.width);
11
+ const leftMarginPx = dxaToPx(props.margins.left);
12
+ const rightMarginPx = dxaToPx(props.margins.right);
13
+
14
+ const ruler = document.createElement("div");
15
+ ruler.className = "docx-ruler";
16
+ ruler.setAttribute("data-docx-ruler", "true");
17
+ ruler.style.width = `${widthPx}px`;
18
+
19
+ // Margin gutters (gray) and body area (white) — reflects the OOXML margins.
20
+ const leftGutter = document.createElement("div");
21
+ leftGutter.className = "docx-ruler__gutter docx-ruler__gutter--left";
22
+ leftGutter.style.width = `${leftMarginPx}px`;
23
+ ruler.appendChild(leftGutter);
24
+
25
+ const body = document.createElement("div");
26
+ body.className = "docx-ruler__body";
27
+ body.style.left = `${leftMarginPx}px`;
28
+ body.style.right = `${rightMarginPx}px`;
29
+ ruler.appendChild(body);
30
+
31
+ const rightGutter = document.createElement("div");
32
+ rightGutter.className = "docx-ruler__gutter docx-ruler__gutter--right";
33
+ rightGutter.style.width = `${rightMarginPx}px`;
34
+ rightGutter.style.right = "0";
35
+ ruler.appendChild(rightGutter);
36
+
37
+ // Tick marks every quarter inch, numbers every inch (relative to body start).
38
+ const bodyWidthPx = widthPx - leftMarginPx - rightMarginPx;
39
+ const inchCount = Math.floor(bodyWidthPx / PX_PER_INCH);
40
+ for (let i = 0; i <= inchCount; i++) {
41
+ for (let q = 0; q < 4; q++) {
42
+ const tickPx = i * PX_PER_INCH + (q * PX_PER_INCH) / 4;
43
+ if (tickPx > bodyWidthPx) break;
44
+ const tick = document.createElement("div");
45
+ tick.className =
46
+ q === 0 ? "docx-ruler__tick docx-ruler__tick--major" : "docx-ruler__tick";
47
+ tick.style.left = `${tickPx}px`;
48
+ body.appendChild(tick);
49
+ if (q === 0 && i > 0) {
50
+ const label = document.createElement("span");
51
+ label.className = "docx-ruler__label";
52
+ label.style.left = `${tickPx}px`;
53
+ label.textContent = String(i);
54
+ body.appendChild(label);
55
+ }
56
+ }
57
+ }
58
+
59
+ // Indent triangles intentionally absent — the visual handles aren't yet
60
+ // wired to drag-to-indent commands. Adding a `cursor: ew-resize` element
61
+ // without a working drag handler is a UX lie, so we ship the ruler as
62
+ // measurement-only until Phase 3 wires drag-to-indent.
63
+
64
+ return {
65
+ element: ruler,
66
+ destroy() {
67
+ ruler.remove();
68
+ },
69
+ };
70
+ }
71
+
72
+ export const RULER_CSS = `
73
+ .docx-ruler {
74
+ position: relative;
75
+ height: 24px;
76
+ margin: 0 auto 4px;
77
+ font: 10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
78
+ color: #5f6368;
79
+ user-select: none;
80
+ }
81
+ .docx-ruler__gutter {
82
+ position: absolute;
83
+ top: 0;
84
+ bottom: 0;
85
+ background: #dadce0;
86
+ }
87
+ .docx-ruler__gutter--left { left: 0; }
88
+ .docx-ruler__body {
89
+ position: absolute;
90
+ top: 0;
91
+ bottom: 0;
92
+ background: #ffffff;
93
+ border-top: 1px solid #dadce0;
94
+ border-bottom: 1px solid #dadce0;
95
+ }
96
+ .docx-ruler__tick {
97
+ position: absolute;
98
+ bottom: 0;
99
+ width: 1px;
100
+ height: 4px;
101
+ background: #80868b;
102
+ }
103
+ .docx-ruler__tick--major { height: 8px; background: #5f6368; }
104
+ .docx-ruler__label {
105
+ position: absolute;
106
+ top: 4px;
107
+ transform: translateX(-50%);
108
+ pointer-events: none;
109
+ }
110
+ `;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Ref-counted global style injection. Plugins that mount floating DOM into
3
+ * `document.body` share styles via this helper so that:
4
+ * - Multiple editor instances on the same page don't duplicate the style tag.
5
+ * - The style tag is removed when the last consumer destroys.
6
+ *
7
+ * Caller flow:
8
+ * const release = registerGlobalStyle("docx-bubble-style", BUBBLE_CSS);
9
+ * // …
10
+ * release(); // on plugin destroy
11
+ */
12
+ const REFCOUNTS = new Map<string, number>();
13
+
14
+ export function registerGlobalStyle(key: string, css: string): () => void {
15
+ const attr = `data-${key}`;
16
+ const existing = document.querySelector(`[${attr}]`);
17
+ REFCOUNTS.set(key, (REFCOUNTS.get(key) ?? 0) + 1);
18
+ if (!existing) {
19
+ const el = document.createElement("style");
20
+ el.setAttribute(attr, "true");
21
+ el.textContent = css;
22
+ document.head.appendChild(el);
23
+ }
24
+ return () => {
25
+ const next = (REFCOUNTS.get(key) ?? 1) - 1;
26
+ if (next <= 0) {
27
+ REFCOUNTS.delete(key);
28
+ document.querySelector(`[${attr}]`)?.remove();
29
+ } else {
30
+ REFCOUNTS.set(key, next);
31
+ }
32
+ };
33
+ }
@@ -0,0 +1,171 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { parseXml } from "@lotics/ooxml/xml";
4
+ import { renderTable, isTableXml } from "./table_dom";
5
+ import { docxSchema } from "../pm/schema";
6
+ import { buildFontRegistry } from "../fonts/registry";
7
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
8
+ import { createReadOnlyView } from "./view";
9
+
10
+ const NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"`;
11
+
12
+ let host: HTMLElement;
13
+ beforeEach(() => {
14
+ document.body.innerHTML = "";
15
+ host = document.createElement("div");
16
+ document.body.appendChild(host);
17
+ });
18
+
19
+ describe("renderTable — basic structure", () => {
20
+ it("renders <w:tbl> with rows and cells into a real <table>", () => {
21
+ const tbl = parseXml(`
22
+ <w:tbl ${NS}>
23
+ <w:tr>
24
+ <w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>
25
+ <w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>
26
+ </w:tr>
27
+ <w:tr>
28
+ <w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc>
29
+ <w:tc><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc>
30
+ </w:tr>
31
+ </w:tbl>`)[0];
32
+ const dom = renderTable(tbl);
33
+ expect(dom.tagName.toLowerCase()).toBe("table");
34
+ const rows = dom.querySelectorAll("tr");
35
+ expect(rows).toHaveLength(2);
36
+ expect(rows[0].cells).toHaveLength(2);
37
+ expect(rows[0].cells[0].textContent).toContain("A1");
38
+ expect(rows[1].cells[1].textContent).toContain("B2");
39
+ });
40
+
41
+ it("applies gridSpan as colspan", () => {
42
+ const tbl = parseXml(`
43
+ <w:tbl ${NS}>
44
+ <w:tr>
45
+ <w:tc>
46
+ <w:tcPr><w:gridSpan w:val="2"/></w:tcPr>
47
+ <w:p><w:r><w:t>spanning</w:t></w:r></w:p>
48
+ </w:tc>
49
+ </w:tr>
50
+ </w:tbl>`)[0];
51
+ const dom = renderTable(tbl);
52
+ const cell = dom.querySelector("td")!;
53
+ expect(cell.getAttribute("colspan")).toBe("2");
54
+ });
55
+
56
+ it("converts vMerge restart + continue into rowspan on the originating cell", () => {
57
+ const tbl = parseXml(`
58
+ <w:tbl ${NS}>
59
+ <w:tr>
60
+ <w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>top</w:t></w:r></w:p></w:tc>
61
+ <w:tc><w:p><w:r><w:t>r1c2</w:t></w:r></w:p></w:tc>
62
+ </w:tr>
63
+ <w:tr>
64
+ <w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc>
65
+ <w:tc><w:p><w:r><w:t>r2c2</w:t></w:r></w:p></w:tc>
66
+ </w:tr>
67
+ <w:tr>
68
+ <w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc>
69
+ <w:tc><w:p><w:r><w:t>r3c2</w:t></w:r></w:p></w:tc>
70
+ </w:tr>
71
+ </w:tbl>`)[0];
72
+ const dom = renderTable(tbl);
73
+ const rows = dom.querySelectorAll("tr");
74
+ expect(rows[0].cells[0].getAttribute("rowspan")).toBe("3");
75
+ expect(rows[0].cells[0].textContent).toContain("top");
76
+ expect(rows[1].cells).toHaveLength(1);
77
+ expect(rows[1].cells[0].textContent).toContain("r2c2");
78
+ expect(rows[2].cells[0].textContent).toContain("r3c2");
79
+ });
80
+
81
+ it("applies cell border styles from <w:tcBorders>", () => {
82
+ const tbl = parseXml(`
83
+ <w:tbl ${NS}>
84
+ <w:tr>
85
+ <w:tc>
86
+ <w:tcPr>
87
+ <w:tcBorders>
88
+ <w:top w:val="single" w:sz="8" w:color="333333"/>
89
+ <w:bottom w:val="double" w:sz="16" w:color="000000"/>
90
+ </w:tcBorders>
91
+ </w:tcPr>
92
+ <w:p><w:r><w:t>x</w:t></w:r></w:p>
93
+ </w:tc>
94
+ </w:tr>
95
+ </w:tbl>`)[0];
96
+ const dom = renderTable(tbl);
97
+ const cell = dom.querySelector("td")!;
98
+ const style = cell.getAttribute("style") ?? "";
99
+ expect(style).toMatch(/border-top:\s*1pt\s+solid\s+#333333/);
100
+ expect(style).toMatch(/border-bottom:\s*2pt\s+double/);
101
+ });
102
+
103
+ it("renders bold and italic runs inside cells", () => {
104
+ const tbl = parseXml(`
105
+ <w:tbl ${NS}>
106
+ <w:tr><w:tc>
107
+ <w:p><w:r><w:rPr><w:b/></w:rPr><w:t>bold</w:t></w:r></w:p>
108
+ <w:p><w:r><w:rPr><w:i/></w:rPr><w:t>italic</w:t></w:r></w:p>
109
+ </w:tc></w:tr>
110
+ </w:tbl>`)[0];
111
+ const dom = renderTable(tbl);
112
+ expect(dom.querySelector("strong")?.textContent).toBe("bold");
113
+ expect(dom.querySelector("em")?.textContent).toBe("italic");
114
+ });
115
+
116
+ it("renders nested tables inside cells", () => {
117
+ const tbl = parseXml(`
118
+ <w:tbl ${NS}>
119
+ <w:tr><w:tc>
120
+ <w:tbl>
121
+ <w:tr><w:tc><w:p><w:r><w:t>inner</w:t></w:r></w:p></w:tc></w:tr>
122
+ </w:tbl>
123
+ </w:tc></w:tr>
124
+ </w:tbl>`)[0];
125
+ const dom = renderTable(tbl);
126
+ const tables = dom.querySelectorAll("table");
127
+ expect(tables).toHaveLength(1);
128
+ expect(tables[0].textContent).toContain("inner");
129
+ });
130
+
131
+ it("isTableXml correctly identifies w:tbl elements", () => {
132
+ const tbl = parseXml(`<w:tbl ${NS}/>`)[0];
133
+ const notTbl = parseXml(`<w:p ${NS}/>`)[0];
134
+ expect(isTableXml(tbl)).toBe(true);
135
+ expect(isTableXml(notTbl)).toBe(false);
136
+ expect(isTableXml(null)).toBe(false);
137
+ expect(isTableXml(undefined)).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe("opaque_block nodeView routes w:tbl through renderTable", () => {
142
+ it("renders a table inside an opaque_block in the EditorView", () => {
143
+ const tbl = parseXml(`
144
+ <w:tbl ${NS}>
145
+ <w:tr>
146
+ <w:tc><w:p><w:r><w:t>X</w:t></w:r></w:p></w:tc>
147
+ <w:tc><w:p><w:r><w:t>Y</w:t></w:r></w:p></w:tc>
148
+ </w:tr>
149
+ </w:tbl>`)[0];
150
+ const { doc, paragraph, opaque_block } = docxSchema.nodes;
151
+ const pmDoc = doc.create({}, [
152
+ paragraph.create({}, []),
153
+ opaque_block.create({ xml: tbl }),
154
+ ]);
155
+ const view = createReadOnlyView(host, pmDoc, {
156
+ fontRegistry: buildFontRegistry({ embeddedFonts: [], workspaceFonts: [] }),
157
+ sections: [
158
+ {
159
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
160
+ blockStartIndex: 0,
161
+ blockEndIndex: 0,
162
+ },
163
+ ],
164
+ });
165
+ const tables = host.querySelectorAll("table.docx-table");
166
+ expect(tables).toHaveLength(1);
167
+ expect(tables[0].textContent).toContain("X");
168
+ expect(tables[0].textContent).toContain("Y");
169
+ view.destroy();
170
+ });
171
+ });