@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,166 @@
1
+ import { Plugin, PluginKey, NodeSelection } from "prosemirror-state";
2
+ import type { EditorView } from "prosemirror-view";
3
+ import { Slice } from "prosemirror-model";
4
+ import { registerGlobalStyle } from "../render/style_registry";
5
+
6
+ const dragHandleKey = new PluginKey("docx-drag-handle");
7
+
8
+ const HANDLE_CSS = `
9
+ .docx-block-drag-handle {
10
+ position: absolute;
11
+ display: none;
12
+ width: 16px;
13
+ height: 22px;
14
+ align-items: center;
15
+ justify-content: center;
16
+ border: none;
17
+ background: transparent;
18
+ color: #80868b;
19
+ cursor: grab;
20
+ border-radius: 4px;
21
+ z-index: 25;
22
+ user-select: none;
23
+ }
24
+ .docx-block-drag-handle:active { cursor: grabbing; }
25
+ .docx-block-drag-handle[data-visible="true"] { display: inline-flex; }
26
+ .docx-block-drag-handle:hover { color: #3c4043; background: #f1f3f4; }
27
+ .docx-block-drag-handle svg {
28
+ width: 14px;
29
+ height: 14px;
30
+ }
31
+ `;
32
+
33
+ const HANDLE_SVG = `
34
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
35
+ <circle cx="9" cy="6" r="1.5"/>
36
+ <circle cx="15" cy="6" r="1.5"/>
37
+ <circle cx="9" cy="12" r="1.5"/>
38
+ <circle cx="15" cy="12" r="1.5"/>
39
+ <circle cx="9" cy="18" r="1.5"/>
40
+ <circle cx="15" cy="18" r="1.5"/>
41
+ </svg>
42
+ `;
43
+
44
+ function installStyle(): () => void {
45
+ return registerGlobalStyle("docx-drag-handle-style", HANDLE_CSS);
46
+ }
47
+
48
+ function findBlockUnderPoint(
49
+ view: EditorView,
50
+ clientX: number,
51
+ clientY: number,
52
+ ): { dom: HTMLElement; pos: number } | null {
53
+ const found = view.posAtCoords({ left: clientX, top: clientY });
54
+ if (!found) return null;
55
+ const start = found.inside >= 0 ? found.inside : found.pos;
56
+ const $pos = view.state.doc.resolve(start);
57
+ // Walk *up* to the deepest block-level container that supports being
58
+ // selected as a NodeSelection — paragraph, table, table_row, list_item,
59
+ // opaque_block. Skip text/leaf/inline nodes. Reach depth 1 if no inner
60
+ // block-level structure is found, so blocks inside table cells are
61
+ // draggable (depth >= 2).
62
+ for (let depth = $pos.depth; depth >= 1; depth--) {
63
+ const node = $pos.node(depth);
64
+ if (!node.isBlock || node.isTextblock === false && depth === 0) continue;
65
+ const pos = $pos.before(depth);
66
+ const dom = view.nodeDOM(pos);
67
+ if (dom instanceof HTMLElement) {
68
+ return { dom, pos };
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ export function dragHandlePlugin(): Plugin {
75
+ return new Plugin({
76
+ key: dragHandleKey,
77
+ view(view) {
78
+ const releaseStyle = installStyle();
79
+
80
+ const handle = document.createElement("button");
81
+ handle.type = "button";
82
+ handle.className = "docx-block-drag-handle";
83
+ handle.setAttribute("data-docx-drag-handle", "true");
84
+ handle.setAttribute("data-visible", "false");
85
+ handle.setAttribute("draggable", "true");
86
+ handle.setAttribute("title", "Drag to move");
87
+ handle.innerHTML = HANDLE_SVG;
88
+ document.body.appendChild(handle);
89
+
90
+ let active: { pos: number; dom: HTMLElement } | null = null;
91
+
92
+ const hide = () => {
93
+ handle.setAttribute("data-visible", "false");
94
+ active = null;
95
+ };
96
+
97
+ const onMove = (e: MouseEvent) => {
98
+ const found = findBlockUnderPoint(view, e.clientX, e.clientY);
99
+ if (!found) {
100
+ hide();
101
+ return;
102
+ }
103
+ active = found;
104
+ const r = found.dom.getBoundingClientRect();
105
+ handle.style.left = `${r.left + window.scrollX - 22}px`;
106
+ handle.style.top = `${r.top + window.scrollY + 2}px`;
107
+ handle.setAttribute("data-visible", "true");
108
+ };
109
+
110
+ const onLeave = (e: MouseEvent) => {
111
+ const target = e.relatedTarget as HTMLElement | null;
112
+ if (target === handle) return;
113
+ if (!target || !view.dom.contains(target)) hide();
114
+ };
115
+
116
+ view.dom.addEventListener("mousemove", onMove);
117
+ view.dom.addEventListener("mouseleave", onLeave);
118
+
119
+ handle.addEventListener("mousedown", (e) => {
120
+ // Select the whole node so the browser drags it, not random text.
121
+ if (!active) return;
122
+ e.preventDefault();
123
+ const tr = view.state.tr.setSelection(
124
+ NodeSelection.create(view.state.doc, active.pos),
125
+ );
126
+ view.dispatch(tr);
127
+ });
128
+
129
+ handle.addEventListener("dragstart", (e) => {
130
+ if (!active) return;
131
+ const node = view.state.doc.nodeAt(active.pos);
132
+ if (!node) return;
133
+ // Hand the slice directly to PM via `view.dragging` so the drop
134
+ // handler reorders nodes (move semantics) instead of falling back
135
+ // to text/html re-parse, which loses node-level attrs and marks.
136
+ const slice = view.state.doc.slice(
137
+ active.pos,
138
+ active.pos + node.nodeSize,
139
+ );
140
+ (view as unknown as { dragging: { slice: Slice; move: boolean } | null }).dragging = {
141
+ slice,
142
+ move: true,
143
+ };
144
+ if (e.dataTransfer) {
145
+ e.dataTransfer.effectAllowed = "move";
146
+ // Provide a non-empty text payload so Chromium starts the drag at
147
+ // all; PM's drop handler ignores it and consumes view.dragging.
148
+ e.dataTransfer.setData("text/plain", node.textContent || " ");
149
+ }
150
+ });
151
+
152
+ handle.addEventListener("dragend", () => {
153
+ (view as unknown as { dragging: unknown }).dragging = null;
154
+ });
155
+
156
+ return {
157
+ destroy() {
158
+ view.dom.removeEventListener("mousemove", onMove);
159
+ view.dom.removeEventListener("mouseleave", onLeave);
160
+ handle.remove();
161
+ releaseStyle();
162
+ },
163
+ };
164
+ },
165
+ });
166
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { EditorState, TextSelection } from "prosemirror-state";
3
+ import { docxSchema } from "./schema";
4
+ import { applyFormat, captureFormat } from "./format_painter";
5
+ import {
6
+ EMPTY_PARAGRAPH_PROPERTIES,
7
+ type ParagraphProperties,
8
+ } from "../model/properties";
9
+
10
+ function makeState(paragraphs: { text: string; props?: Partial<ParagraphProperties> }[]) {
11
+ const nodes = paragraphs.map(({ text, props }) =>
12
+ docxSchema.nodes.paragraph.create(
13
+ {
14
+ properties: { ...EMPTY_PARAGRAPH_PROPERTIES, ...props },
15
+ unknownProperties: [],
16
+ resolvedProperties: null,
17
+ resolvedBaseRun: null,
18
+ numberingLabel: null,
19
+ },
20
+ text.length === 0 ? undefined : [docxSchema.text(text)],
21
+ ),
22
+ );
23
+ const doc = docxSchema.nodes.doc.create({}, nodes);
24
+ return EditorState.create({ doc, schema: docxSchema });
25
+ }
26
+
27
+ describe("applyFormat", () => {
28
+ it("paints distinct property objects to each paragraph (no shared references)", () => {
29
+ const source = makeState([
30
+ { text: "source", props: { alignment: "center", indent: { left: 720, right: null, firstLine: null, hanging: null } } },
31
+ ]);
32
+ const snapshot = captureFormat(
33
+ source.apply(
34
+ source.tr.setSelection(TextSelection.create(source.doc, 1, 7)),
35
+ ),
36
+ );
37
+
38
+ // Two-paragraph target.
39
+ const target = makeState([
40
+ { text: "first" },
41
+ { text: "second" },
42
+ ]);
43
+ const selectAll = target.tr.setSelection(
44
+ TextSelection.create(target.doc, 1, target.doc.content.size - 1),
45
+ );
46
+ const painted = target.apply(selectAll);
47
+ const tr = applyFormat(painted, snapshot);
48
+ expect(tr).not.toBeNull();
49
+ const next = painted.apply(tr!);
50
+
51
+ const props: ParagraphProperties[] = [];
52
+ next.doc.forEach((p) => {
53
+ props.push(p.attrs.properties as ParagraphProperties);
54
+ });
55
+ expect(props.length).toBe(2);
56
+ expect(props[0].alignment).toBe("center");
57
+ expect(props[1].alignment).toBe("center");
58
+
59
+ // Each paragraph must own its own indent object — mutating one should
60
+ // not affect the other (snapshot's properties also stay isolated).
61
+ expect(props[0].indent).not.toBe(props[1].indent);
62
+ expect(props[0].indent).not.toBe(snapshot.paragraph?.indent);
63
+ expect(props[0]).not.toBe(props[1]);
64
+ });
65
+
66
+ it("does not mutate the source paragraph properties", () => {
67
+ const sourceProps = {
68
+ alignment: "right" as const,
69
+ indent: { left: 360, right: null, firstLine: null, hanging: null },
70
+ };
71
+ const source = makeState([{ text: "src", props: sourceProps }]);
72
+ const snapshot = captureFormat(
73
+ source.apply(
74
+ source.tr.setSelection(TextSelection.create(source.doc, 1, 4)),
75
+ ),
76
+ );
77
+
78
+ // Mutate the snapshot's clone — source must remain unchanged.
79
+ if (snapshot.paragraph?.indent) {
80
+ snapshot.paragraph.indent.left = 9999;
81
+ }
82
+ const live = source.doc.firstChild?.attrs.properties as ParagraphProperties;
83
+ expect(live.indent?.left).toBe(360);
84
+ });
85
+
86
+ it("returns null when the selection is collapsed", () => {
87
+ const state = makeState([{ text: "hello" }]);
88
+ const tr = applyFormat(state, { marks: [], paragraph: null });
89
+ expect(tr).toBeNull();
90
+ });
91
+ });
@@ -0,0 +1,109 @@
1
+ import type { EditorState, Transaction } from "prosemirror-state";
2
+ import type { Mark } from "prosemirror-model";
3
+ import type { ParagraphProperties } from "../model/properties";
4
+ import {
5
+ EMPTY_RUN_PROPERTIES,
6
+ } from "../model/properties";
7
+ import {
8
+ resolveParagraphProperties,
9
+ resolveRunProperties,
10
+ } from "../model/style_resolution";
11
+ import type { StyleTable } from "../model/style_table";
12
+ import { getActiveParagraphProperties } from "./commands";
13
+
14
+ export type FormatSnapshot = {
15
+ marks: readonly Mark[];
16
+ paragraph: ParagraphProperties | null;
17
+ };
18
+
19
+ export function captureFormat(state: EditorState): FormatSnapshot {
20
+ const { from, to, empty } = state.selection;
21
+ const marks = empty
22
+ ? state.storedMarks ?? state.selection.$from.marks()
23
+ : (() => {
24
+ const collected: Mark[] = [];
25
+ state.doc.nodesBetween(from, to, (node) => {
26
+ if (node.isInline) {
27
+ for (const m of node.marks) {
28
+ if (!collected.some((cm) => cm.eq(m))) collected.push(m);
29
+ }
30
+ }
31
+ return true;
32
+ });
33
+ return collected;
34
+ })();
35
+ return {
36
+ marks,
37
+ paragraph: cloneParagraphProperties(getActiveParagraphProperties(state)),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Apply a captured snapshot over the current selection. Each paragraph in
43
+ * range receives a *fresh* deep-cloned property object — never the snapshot
44
+ * reference — so subsequent edits to one paragraph don't ripple through any
45
+ * other paragraph that received the paint. resolvedProperties /
46
+ * resolvedBaseRun are recomputed via the style table when one is provided
47
+ * (read from `ctx.styleTable`).
48
+ */
49
+ export function applyFormat(
50
+ state: EditorState,
51
+ snapshot: FormatSnapshot,
52
+ ctx: { styleTable?: StyleTable | null } = {},
53
+ ): Transaction | null {
54
+ const { from, to } = state.selection;
55
+ if (from === to) return null;
56
+ const tr = state.tr;
57
+
58
+ const markTypes = Object.values(state.schema.marks);
59
+ for (const type of markTypes) {
60
+ tr.removeMark(from, to, type);
61
+ }
62
+ for (const mark of snapshot.marks) {
63
+ tr.addMark(from, to, mark);
64
+ }
65
+
66
+ if (snapshot.paragraph) {
67
+ const styleTable = ctx.styleTable ?? null;
68
+ state.doc.nodesBetween(from, to, (node, pos) => {
69
+ if (node.type.name !== "paragraph") return true;
70
+ const next = cloneParagraphProperties(snapshot.paragraph);
71
+ if (!next) return false;
72
+ const resolvedProperties = styleTable
73
+ ? resolveParagraphProperties(styleTable, next)
74
+ : null;
75
+ const resolvedBaseRun = styleTable
76
+ ? resolveRunProperties(styleTable, next.styleId, {
77
+ ...EMPTY_RUN_PROPERTIES,
78
+ })
79
+ : null;
80
+ tr.setNodeMarkup(pos, undefined, {
81
+ ...node.attrs,
82
+ properties: next,
83
+ resolvedProperties,
84
+ resolvedBaseRun,
85
+ });
86
+ return false;
87
+ });
88
+ }
89
+ return tr;
90
+ }
91
+
92
+ function cloneParagraphProperties(
93
+ src: ParagraphProperties | null,
94
+ ): ParagraphProperties | null {
95
+ if (!src) return null;
96
+ return {
97
+ styleId: src.styleId,
98
+ alignment: src.alignment,
99
+ indent: src.indent ? { ...src.indent } : null,
100
+ spacing: src.spacing ? { ...src.spacing } : null,
101
+ numbering: src.numbering ? { ...src.numbering } : null,
102
+ pageBreakBefore: src.pageBreakBefore,
103
+ keepLines: src.keepLines,
104
+ keepNext: src.keepNext,
105
+ widowControl: src.widowControl,
106
+ outlineLevel: src.outlineLevel,
107
+ };
108
+ }
109
+
@@ -0,0 +1,24 @@
1
+ import type { Node as PMNode } from "prosemirror-model";
2
+ import type { HeaderFooterFragment } from "../parse/header_footer";
3
+ import type { DocxDocument } from "../model/types";
4
+ import type { StyleTable } from "../model/style_table";
5
+ import type { NumberingTable } from "../model/numbering_table";
6
+ import { docxToPm } from "./docx_to_pm";
7
+
8
+ /**
9
+ * Convert a parsed header/footer fragment into a PM doc using the same
10
+ * pipeline as the body. Tables, opaque blocks, and rich runs all flow
11
+ * through docxToPm — no fidelity loss.
12
+ */
13
+ export function headerFooterFragmentToPm(
14
+ fragment: HeaderFooterFragment,
15
+ context: { styleTable?: StyleTable | null; numberingTable?: NumberingTable | null } = {},
16
+ ): PMNode {
17
+ // The fragment.blocks are the same Block[] shape that DocxDocument.body uses.
18
+ const synthetic: DocxDocument = {
19
+ parts: new Map(),
20
+ documentAttrs: {},
21
+ body: { children: fragment.blocks as DocxDocument["body"]["children"] },
22
+ };
23
+ return docxToPm(synthetic, context);
24
+ }
@@ -0,0 +1,234 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { parseXml } from "@lotics/ooxml/xml";
4
+ import { docxToPm } from "./docx_to_pm";
5
+ import { pmToDocx } from "./pm_to_docx";
6
+ import {
7
+ parseDocumentRelationships,
8
+ resolveHyperlinkUrl,
9
+ } from "../parse/relationships";
10
+ import { createReadOnlyView } from "../render/view";
11
+ import { buildFontRegistry } from "../fonts/registry";
12
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
13
+ import type { DocxDocument } from "../model/types";
14
+ import type { Section } from "../model/sections";
15
+
16
+ const fontRegistry = buildFontRegistry({
17
+ embeddedFonts: [],
18
+ workspaceFonts: [],
19
+ });
20
+
21
+ const defaultSections: Section[] = [
22
+ {
23
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
24
+ blockStartIndex: 0,
25
+ blockEndIndex: 0,
26
+ },
27
+ ];
28
+
29
+ const NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`;
30
+
31
+ function relsXml(): string {
32
+ return `<?xml version="1.0"?>
33
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
34
+ <Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com" TargetMode="External"/>
35
+ <Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>
36
+ </Relationships>`;
37
+ }
38
+
39
+ function relsParts() {
40
+ return new Map([
41
+ ["word/_rels/document.xml.rels", new TextEncoder().encode(relsXml())],
42
+ ]);
43
+ }
44
+
45
+ describe("relationships parser", () => {
46
+ it("parses hyperlink and image relationships", () => {
47
+ const map = parseDocumentRelationships(relsParts());
48
+ expect(map.get("rId4")?.target).toBe("https://example.com");
49
+ expect(map.get("rId5")?.target).toBe("media/image1.png");
50
+ });
51
+
52
+ it("resolveHyperlinkUrl returns target only for hyperlink rel type", () => {
53
+ const map = parseDocumentRelationships(relsParts());
54
+ expect(resolveHyperlinkUrl(map, "rId4")).toBe("https://example.com");
55
+ expect(resolveHyperlinkUrl(map, "rId5")).toBeNull();
56
+ expect(resolveHyperlinkUrl(map, "rId-missing")).toBeNull();
57
+ });
58
+ });
59
+
60
+ describe("hyperlink conversion", () => {
61
+ it("attaches link mark to text inside <w:hyperlink>", () => {
62
+ const hyperlinkXml = parseXml(
63
+ `<w:hyperlink ${NS} r:id="rId4"><w:r><w:t>Click me</w:t></w:r></w:hyperlink>`,
64
+ )[0];
65
+ const docx: DocxDocument = {
66
+ parts: new Map(),
67
+ documentAttrs: {},
68
+ body: {
69
+ children: [
70
+ {
71
+ kind: "paragraph",
72
+ properties: null,
73
+ content: [{ kind: "opaque_inline", xml: hyperlinkXml }],
74
+ },
75
+ ],
76
+ },
77
+ };
78
+ const pm = docxToPm(docx);
79
+ const text = pm.firstChild!.firstChild!;
80
+ expect(text.text).toBe("Click me");
81
+ const link = text.marks.find((m) => m.type.name === "link");
82
+ expect(link).toBeDefined();
83
+ expect(link!.attrs.relationshipId).toBe("rId4");
84
+ });
85
+
86
+ it("supports internal links via w:anchor", () => {
87
+ const hyperlinkXml = parseXml(
88
+ `<w:hyperlink ${NS} w:anchor="bookmark1"><w:r><w:t>Section</w:t></w:r></w:hyperlink>`,
89
+ )[0];
90
+ const docx: DocxDocument = {
91
+ parts: new Map(),
92
+ documentAttrs: {},
93
+ body: {
94
+ children: [
95
+ {
96
+ kind: "paragraph",
97
+ properties: null,
98
+ content: [{ kind: "opaque_inline", xml: hyperlinkXml }],
99
+ },
100
+ ],
101
+ },
102
+ };
103
+ const pm = docxToPm(docx);
104
+ const text = pm.firstChild!.firstChild!;
105
+ const link = text.marks.find((m) => m.type.name === "link");
106
+ expect(link?.attrs.anchor).toBe("bookmark1");
107
+ expect(link?.attrs.relationshipId).toBeNull();
108
+ });
109
+
110
+ it("preserves run formatting inside the hyperlink", () => {
111
+ const hyperlinkXml = parseXml(
112
+ `<w:hyperlink ${NS} r:id="rId4"><w:r><w:rPr><w:b/></w:rPr><w:t>bold link</w:t></w:r></w:hyperlink>`,
113
+ )[0];
114
+ const docx: DocxDocument = {
115
+ parts: new Map(),
116
+ documentAttrs: {},
117
+ body: {
118
+ children: [
119
+ {
120
+ kind: "paragraph",
121
+ properties: null,
122
+ content: [{ kind: "opaque_inline", xml: hyperlinkXml }],
123
+ },
124
+ ],
125
+ },
126
+ };
127
+ const pm = docxToPm(docx);
128
+ const text = pm.firstChild!.firstChild!;
129
+ const names = text.marks.map((m) => m.type.name).sort();
130
+ expect(names).toContain("bold");
131
+ expect(names).toContain("link");
132
+ });
133
+
134
+ it("roundtrips link mark back into <w:hyperlink>", () => {
135
+ const hyperlinkXml = parseXml(
136
+ `<w:hyperlink ${NS} r:id="rId4"><w:r><w:t>Click me</w:t></w:r></w:hyperlink>`,
137
+ )[0];
138
+ const docx: DocxDocument = {
139
+ parts: new Map(),
140
+ documentAttrs: {},
141
+ body: {
142
+ children: [
143
+ {
144
+ kind: "paragraph",
145
+ properties: null,
146
+ content: [{ kind: "opaque_inline", xml: hyperlinkXml }],
147
+ },
148
+ ],
149
+ },
150
+ };
151
+ const pm = docxToPm(docx);
152
+ const reconstructed = pmToDocx(pm, {
153
+ parts: docx.parts,
154
+ documentAttrs: docx.documentAttrs,
155
+ });
156
+ const para = reconstructed.body.children[0];
157
+ if (para.kind !== "paragraph") throw new Error();
158
+ const inline = para.content[0];
159
+ expect(inline.kind).toBe("opaque_inline");
160
+ if (inline.kind === "opaque_inline") {
161
+ expect(Object.keys(inline.xml)).toContain("w:hyperlink");
162
+ const attrs = inline.xml[":@"] as Record<string, string>;
163
+ expect(attrs["@_r:id"]).toBe("rId4");
164
+ }
165
+ });
166
+ });
167
+
168
+ describe("link mark rendering", () => {
169
+ let host: HTMLElement;
170
+ beforeEach(() => {
171
+ document.body.innerHTML = "";
172
+ host = document.createElement("div");
173
+ document.body.appendChild(host);
174
+ });
175
+
176
+ it("renders <a href> with resolved URL when relationships are provided", () => {
177
+ const hyperlinkXml = parseXml(
178
+ `<w:hyperlink ${NS} r:id="rId4"><w:r><w:t>Click me</w:t></w:r></w:hyperlink>`,
179
+ )[0];
180
+ const docx: DocxDocument = {
181
+ parts: new Map(),
182
+ documentAttrs: {},
183
+ body: {
184
+ children: [
185
+ {
186
+ kind: "paragraph",
187
+ properties: null,
188
+ content: [{ kind: "opaque_inline", xml: hyperlinkXml }],
189
+ },
190
+ ],
191
+ },
192
+ };
193
+ const pm = docxToPm(docx);
194
+ const view = createReadOnlyView(host, pm, {
195
+ fontRegistry,
196
+ sections: defaultSections,
197
+ relationships: parseDocumentRelationships(relsParts()),
198
+ });
199
+
200
+ const a = host.querySelector("a") as HTMLAnchorElement;
201
+ expect(a).not.toBeNull();
202
+ expect(a.getAttribute("href")).toBe("https://example.com");
203
+ expect(a.textContent).toBe("Click me");
204
+ view.destroy();
205
+ });
206
+
207
+ it("renders #anchor href for internal links", () => {
208
+ const hyperlinkXml = parseXml(
209
+ `<w:hyperlink ${NS} w:anchor="bookmark1"><w:r><w:t>Section</w:t></w:r></w:hyperlink>`,
210
+ )[0];
211
+ const docx: DocxDocument = {
212
+ parts: new Map(),
213
+ documentAttrs: {},
214
+ body: {
215
+ children: [
216
+ {
217
+ kind: "paragraph",
218
+ properties: null,
219
+ content: [{ kind: "opaque_inline", xml: hyperlinkXml }],
220
+ },
221
+ ],
222
+ },
223
+ };
224
+ const pm = docxToPm(docx);
225
+ const view = createReadOnlyView(host, pm, {
226
+ fontRegistry,
227
+ sections: defaultSections,
228
+ });
229
+
230
+ const a = host.querySelector("a") as HTMLAnchorElement;
231
+ expect(a.getAttribute("href")).toBe("#bookmark1");
232
+ view.destroy();
233
+ });
234
+ });