@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.
- package/package.json +40 -0
- package/src/fixtures/.gitkeep +0 -0
- package/src/fixtures/lotics_generated_contract.docx +0 -0
- package/src/fonts/bundled.ts +123 -0
- package/src/fonts/registry.test.ts +233 -0
- package/src/fonts/registry.ts +219 -0
- package/src/fonts/types.ts +83 -0
- package/src/index.ts +16 -0
- package/src/layout/engine.test.ts +430 -0
- package/src/layout/engine.ts +566 -0
- package/src/layout/page_geometry.ts +43 -0
- package/src/layout/types.ts +159 -0
- package/src/load.test.ts +144 -0
- package/src/load.ts +142 -0
- package/src/model/default_numbering.ts +101 -0
- package/src/model/default_styles.ts +201 -0
- package/src/model/numbering_table.ts +52 -0
- package/src/model/properties.ts +328 -0
- package/src/model/sections.ts +94 -0
- package/src/model/style_resolution.test.ts +219 -0
- package/src/model/style_resolution.ts +113 -0
- package/src/model/style_table.ts +22 -0
- package/src/model/theme.ts +156 -0
- package/src/model/types.ts +55 -0
- package/src/parse/drawing.ts +157 -0
- package/src/parse/font_table.ts +132 -0
- package/src/parse/footnotes.ts +60 -0
- package/src/parse/header_footer.test.ts +264 -0
- package/src/parse/header_footer.ts +66 -0
- package/src/parse/numbering.ts +187 -0
- package/src/parse/parser.ts +184 -0
- package/src/parse/relationships.ts +83 -0
- package/src/parse/sections.test.ts +192 -0
- package/src/parse/sections.ts +182 -0
- package/src/parse/styles.ts +149 -0
- package/src/parse/theme.test.ts +86 -0
- package/src/parse/theme.ts +112 -0
- package/src/pm/bubble_menu.ts +117 -0
- package/src/pm/commands.test.ts +185 -0
- package/src/pm/commands.ts +697 -0
- package/src/pm/commands_insert.test.ts +183 -0
- package/src/pm/docx_to_pm.test.ts +330 -0
- package/src/pm/docx_to_pm.ts +643 -0
- package/src/pm/drag_handle.ts +166 -0
- package/src/pm/format_painter.test.ts +91 -0
- package/src/pm/format_painter.ts +109 -0
- package/src/pm/header_footer_doc.ts +24 -0
- package/src/pm/hyperlinks.test.ts +234 -0
- package/src/pm/image_registry.test.ts +81 -0
- package/src/pm/image_registry.ts +100 -0
- package/src/pm/images.test.ts +257 -0
- package/src/pm/link_popover.ts +159 -0
- package/src/pm/mark_commands.ts +60 -0
- package/src/pm/marks.ts +169 -0
- package/src/pm/nodes.ts +258 -0
- package/src/pm/numbering.test.ts +210 -0
- package/src/pm/numbering_plugin.test.ts +71 -0
- package/src/pm/numbering_plugin.ts +96 -0
- package/src/pm/outline.ts +41 -0
- package/src/pm/page_break.test.ts +80 -0
- package/src/pm/page_layout.test.ts +87 -0
- package/src/pm/pagination_plugin.test.ts +155 -0
- package/src/pm/pagination_plugin.ts +590 -0
- package/src/pm/phase5.test.ts +271 -0
- package/src/pm/phase6.test.ts +215 -0
- package/src/pm/placeholder_plugin.ts +24 -0
- package/src/pm/plugins.ts +91 -0
- package/src/pm/pm_to_docx.ts +0 -0
- package/src/pm/roundtrip.test.ts +332 -0
- package/src/pm/schema.test.ts +188 -0
- package/src/pm/schema.ts +79 -0
- package/src/pm/search.ts +46 -0
- package/src/pm/table_attrs.ts +48 -0
- package/src/pm/table_borders.test.ts +117 -0
- package/src/pm/table_borders.ts +130 -0
- package/src/pm/table_convert.test.ts +221 -0
- package/src/pm/table_convert.ts +541 -0
- package/src/pm/table_decorations.ts +132 -0
- package/src/pm/table_handles.ts +163 -0
- package/src/pm/template_marker.ts +47 -0
- package/src/pm/template_plugin.ts +65 -0
- package/src/pm/templates.test.ts +162 -0
- package/src/render/clipboard.test.ts +115 -0
- package/src/render/clipboard.ts +200 -0
- package/src/render/editable_view.test.ts +173 -0
- package/src/render/footnotes_view.ts +94 -0
- package/src/render/header_footer_view.ts +95 -0
- package/src/render/link_mark_view.ts +26 -0
- package/src/render/media_resolver.ts +61 -0
- package/src/render/node_views.ts +296 -0
- package/src/render/numbering_counter.ts +149 -0
- package/src/render/page_chrome.test.ts +262 -0
- package/src/render/page_chrome.ts +343 -0
- package/src/render/page_styles.ts +234 -0
- package/src/render/paragraph_view.test.ts +162 -0
- package/src/render/paragraph_view.ts +141 -0
- package/src/render/ruler.ts +110 -0
- package/src/render/style_registry.ts +33 -0
- package/src/render/table_dom.test.ts +171 -0
- package/src/render/table_dom.ts +288 -0
- package/src/render/units.ts +18 -0
- package/src/render/view.test.ts +165 -0
- package/src/render/view.ts +607 -0
- package/src/roundtrip.test.ts +179 -0
- package/src/serialize/default_parts.ts +128 -0
- package/src/serialize/header_footer_pm.ts +82 -0
- package/src/serialize/serializer.ts +114 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { EditorView } from "prosemirror-view";
|
|
2
|
+
import { TextSelection } from "prosemirror-state";
|
|
3
|
+
import type { ImageRegistry } from "../pm/image_registry";
|
|
4
|
+
import { docxSchema } from "../pm/schema";
|
|
5
|
+
import { emuToPx } from "./units";
|
|
6
|
+
import type { MediaResolver } from "./media_resolver";
|
|
7
|
+
|
|
8
|
+
const MSO_CONDITIONAL_RE = /<!--\s*\[if[^\]]*\][\s\S]*?\[endif\]\s*-->/gi;
|
|
9
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
10
|
+
const O_P_RE = /<o:p\b[^>]*>[\s\S]*?<\/o:p>|<o:p\b[^>]*\/>/gi;
|
|
11
|
+
const W_NS_TAG_RE = /<\/?(?:w|m|v|o|x):[a-z][\w-]*\b[^>]*>/gi;
|
|
12
|
+
const MSO_STYLE_DECL_RE = /(?:^|;)\s*mso-[^:;]+:[^;"]*(?=;|$)/gi;
|
|
13
|
+
const CLASS_MSO_RE = /\sclass="[^"]*Mso[^"]*"/gi;
|
|
14
|
+
const CLASS_MSO_SQ_RE = /\sclass='[^']*Mso[^']*'/gi;
|
|
15
|
+
const EMPTY_STYLE_RE = /\sstyle=(?:""|'')/g;
|
|
16
|
+
const STYLE_BLOCK_RE = /<style\b[^>]*>[\s\S]*?<\/style>/gi;
|
|
17
|
+
const META_TAG_RE = /<meta\b[^>]*\/?>/gi;
|
|
18
|
+
const XMLNS_ATTR_RE = /\sxmlns(?::\w+)?="[^"]*"/g;
|
|
19
|
+
const LANG_ATTR_RE = /\slang="[^"]*"/g;
|
|
20
|
+
const PROOFREAD_ATTR_RE = /\s(?:onerror|onload|onclick)="[^"]*"/gi;
|
|
21
|
+
const HTML_BODY_WRAPPER_RE = /<\/?(?:html|body|head)\b[^>]*>/gi;
|
|
22
|
+
const FONT_TAG_NO_FACE_RE = /<\/?font\b[^>]*>/gi;
|
|
23
|
+
|
|
24
|
+
// Heuristic: only run aggressive Word-specific strips when the clipboard
|
|
25
|
+
// HTML looks like Word output. Detecting `mso-`, `<o:p>`, or the Word
|
|
26
|
+
// "ProgId" comment covers the cases we care about. For non-Word HTML
|
|
27
|
+
// (e.g. a styled email, another rich editor), only the security-related
|
|
28
|
+
// strips run — `<style>`, `<meta>`, conditional comments, and on-event
|
|
29
|
+
// handlers — preserving everything else verbatim.
|
|
30
|
+
const MSO_MARKER_RE = /\bmso-|<o:p|class="?[^"\s>]*Mso|ProgId\s*=\s*"Word\.Document"/i;
|
|
31
|
+
|
|
32
|
+
export function cleanWordHtml(html: string): string {
|
|
33
|
+
let out = html;
|
|
34
|
+
|
|
35
|
+
// Always-safe strips: security and parser hygiene.
|
|
36
|
+
out = out.replace(MSO_CONDITIONAL_RE, "");
|
|
37
|
+
out = out.replace(STYLE_BLOCK_RE, "");
|
|
38
|
+
out = out.replace(META_TAG_RE, "");
|
|
39
|
+
out = out.replace(HTML_COMMENT_RE, "");
|
|
40
|
+
out = out.replace(PROOFREAD_ATTR_RE, "");
|
|
41
|
+
|
|
42
|
+
if (!MSO_MARKER_RE.test(html)) {
|
|
43
|
+
// Not Word HTML — preserve as much structure as possible.
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Word-specific strips.
|
|
48
|
+
out = out.replace(O_P_RE, "");
|
|
49
|
+
out = out.replace(W_NS_TAG_RE, "");
|
|
50
|
+
out = out.replace(HTML_BODY_WRAPPER_RE, "");
|
|
51
|
+
out = out.replace(FONT_TAG_NO_FACE_RE, "");
|
|
52
|
+
out = out.replace(XMLNS_ATTR_RE, "");
|
|
53
|
+
out = out.replace(LANG_ATTR_RE, "");
|
|
54
|
+
out = out.replace(/\sstyle="([^"]*)"/g, (_match, body: string) => {
|
|
55
|
+
const cleaned = body
|
|
56
|
+
.replace(MSO_STYLE_DECL_RE, "")
|
|
57
|
+
.replace(/^\s*;\s*/, "")
|
|
58
|
+
.replace(/;\s*$/, "")
|
|
59
|
+
.trim();
|
|
60
|
+
return cleaned.length === 0 ? "" : ` style="${cleaned}"`;
|
|
61
|
+
});
|
|
62
|
+
out = out.replace(CLASS_MSO_RE, "");
|
|
63
|
+
out = out.replace(CLASS_MSO_SQ_RE, "");
|
|
64
|
+
out = out.replace(EMPTY_STYLE_RE, "");
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const IMAGE_MIME = /^image\//;
|
|
69
|
+
|
|
70
|
+
export function fileExtensionForMime(mime: string): string {
|
|
71
|
+
switch (mime) {
|
|
72
|
+
case "image/png":
|
|
73
|
+
return "png";
|
|
74
|
+
case "image/jpeg":
|
|
75
|
+
return "jpg";
|
|
76
|
+
case "image/gif":
|
|
77
|
+
return "gif";
|
|
78
|
+
case "image/webp":
|
|
79
|
+
return "webp";
|
|
80
|
+
case "image/bmp":
|
|
81
|
+
return "bmp";
|
|
82
|
+
case "image/tiff":
|
|
83
|
+
return "tiff";
|
|
84
|
+
case "image/svg+xml":
|
|
85
|
+
return "svg";
|
|
86
|
+
default:
|
|
87
|
+
return "bin";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function fileToUint8(file: File): Promise<Uint8Array> {
|
|
92
|
+
const buf = await file.arrayBuffer();
|
|
93
|
+
return new Uint8Array(buf);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function insertImageNode(
|
|
97
|
+
view: EditorView,
|
|
98
|
+
relationshipId: string,
|
|
99
|
+
alt: string,
|
|
100
|
+
): void {
|
|
101
|
+
const node = docxSchema.nodes.image_inline.create({
|
|
102
|
+
relationshipId,
|
|
103
|
+
widthEmu: null,
|
|
104
|
+
heightEmu: null,
|
|
105
|
+
alt,
|
|
106
|
+
originalXml: null,
|
|
107
|
+
});
|
|
108
|
+
const tr = view.state.tr.replaceSelectionWith(node, false);
|
|
109
|
+
view.dispatch(tr);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleImageFiles(
|
|
113
|
+
view: EditorView,
|
|
114
|
+
files: readonly File[],
|
|
115
|
+
registry: ImageRegistry,
|
|
116
|
+
mediaResolver: MediaResolver | null,
|
|
117
|
+
): Promise<boolean> {
|
|
118
|
+
let anyHandled = false;
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
if (!IMAGE_MIME.test(file.type)) continue;
|
|
121
|
+
const bytes = await fileToUint8(file);
|
|
122
|
+
const { relationshipId } = registry.registerInsertedImage({
|
|
123
|
+
bytes,
|
|
124
|
+
mimeType: file.type,
|
|
125
|
+
extension: fileExtensionForMime(file.type),
|
|
126
|
+
});
|
|
127
|
+
insertImageNode(view, relationshipId, file.name || "");
|
|
128
|
+
void mediaResolver;
|
|
129
|
+
anyHandled = true;
|
|
130
|
+
}
|
|
131
|
+
return anyHandled;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type ClipboardHooksOptions = {
|
|
135
|
+
imageRegistry?: ImageRegistry;
|
|
136
|
+
mediaResolver?: MediaResolver | null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function buildClipboardHooks(options: ClipboardHooksOptions = {}): {
|
|
140
|
+
transformPastedHTML: (html: string) => string;
|
|
141
|
+
handlePaste:
|
|
142
|
+
| ((view: EditorView, event: ClipboardEvent) => boolean)
|
|
143
|
+
| undefined;
|
|
144
|
+
handleDrop:
|
|
145
|
+
| ((view: EditorView, event: DragEvent) => boolean)
|
|
146
|
+
| undefined;
|
|
147
|
+
} {
|
|
148
|
+
const imageRegistry = options.imageRegistry;
|
|
149
|
+
const mediaResolver = options.mediaResolver ?? null;
|
|
150
|
+
|
|
151
|
+
const handlePaste = imageRegistry
|
|
152
|
+
? (view: EditorView, event: ClipboardEvent) => {
|
|
153
|
+
const data = event.clipboardData;
|
|
154
|
+
if (!data) return false;
|
|
155
|
+
const files: File[] = [];
|
|
156
|
+
for (let i = 0; i < data.files.length; i++) {
|
|
157
|
+
const f = data.files[i];
|
|
158
|
+
if (f) files.push(f);
|
|
159
|
+
}
|
|
160
|
+
if (files.length === 0) return false;
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
void handleImageFiles(view, files, imageRegistry, mediaResolver);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
: undefined;
|
|
166
|
+
|
|
167
|
+
const handleDrop = imageRegistry
|
|
168
|
+
? (view: EditorView, event: DragEvent) => {
|
|
169
|
+
const dt = event.dataTransfer;
|
|
170
|
+
if (!dt) return false;
|
|
171
|
+
const files: File[] = [];
|
|
172
|
+
for (let i = 0; i < dt.files.length; i++) {
|
|
173
|
+
const f = dt.files[i];
|
|
174
|
+
if (f && IMAGE_MIME.test(f.type)) files.push(f);
|
|
175
|
+
}
|
|
176
|
+
if (files.length === 0) return false;
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
const coords = view.posAtCoords({
|
|
179
|
+
left: event.clientX,
|
|
180
|
+
top: event.clientY,
|
|
181
|
+
});
|
|
182
|
+
if (coords) {
|
|
183
|
+
const tr = view.state.tr.setSelection(
|
|
184
|
+
TextSelection.near(view.state.doc.resolve(coords.pos)),
|
|
185
|
+
);
|
|
186
|
+
view.dispatch(tr);
|
|
187
|
+
}
|
|
188
|
+
void handleImageFiles(view, files, imageRegistry, mediaResolver);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
: undefined;
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
transformPastedHTML: cleanWordHtml,
|
|
195
|
+
handlePaste,
|
|
196
|
+
handleDrop,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const _emuToPxForTests = emuToPx;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
3
|
+
import { TextSelection } from "prosemirror-state";
|
|
4
|
+
import { toggleMark } from "prosemirror-commands";
|
|
5
|
+
import { undo, redo } from "prosemirror-history";
|
|
6
|
+
import { docxSchema } from "../pm/schema";
|
|
7
|
+
import { createEditableView } from "./view";
|
|
8
|
+
import { buildFontRegistry } from "../fonts/registry";
|
|
9
|
+
import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
|
|
10
|
+
import type { Section } from "../model/sections";
|
|
11
|
+
|
|
12
|
+
const fontRegistry = buildFontRegistry({
|
|
13
|
+
embeddedFonts: [],
|
|
14
|
+
workspaceFonts: [],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const defaultSections: Section[] = [
|
|
18
|
+
{
|
|
19
|
+
properties: { ...DEFAULT_SECTION_PROPERTIES },
|
|
20
|
+
blockStartIndex: 0,
|
|
21
|
+
blockEndIndex: 0,
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
let host: HTMLElement;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
host = document.createElement("div");
|
|
29
|
+
document.body.appendChild(host);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function paragraph(text: string) {
|
|
33
|
+
const { paragraph } = docxSchema.nodes;
|
|
34
|
+
return paragraph.create({}, [docxSchema.text(text)]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function emptyDoc() {
|
|
38
|
+
const { doc, paragraph } = docxSchema.nodes;
|
|
39
|
+
return doc.create({}, [paragraph.create({}, [])]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function selectAll(view: ReturnType<typeof createEditableView>): void {
|
|
43
|
+
const { editorView } = view;
|
|
44
|
+
const { state } = editorView;
|
|
45
|
+
const tr = state.tr.setSelection(
|
|
46
|
+
TextSelection.create(state.doc, 1, state.doc.content.size - 1),
|
|
47
|
+
);
|
|
48
|
+
editorView.dispatch(tr);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("createEditableView — basic editing", () => {
|
|
52
|
+
it("mounts an editable view with contenteditable=true", () => {
|
|
53
|
+
const doc = docxSchema.nodes.doc.create({}, [paragraph("hello")]);
|
|
54
|
+
const view = createEditableView(host, doc, {
|
|
55
|
+
fontRegistry,
|
|
56
|
+
sections: defaultSections,
|
|
57
|
+
});
|
|
58
|
+
expect(view.editorView.editable).toBe(true);
|
|
59
|
+
const editor = host.querySelector(".ProseMirror") as HTMLElement;
|
|
60
|
+
expect(editor.getAttribute("contenteditable")).toBe("true");
|
|
61
|
+
expect(host.querySelector("[data-docx-editor]")?.getAttribute("data-docx-editor")).toBe(
|
|
62
|
+
"editable",
|
|
63
|
+
);
|
|
64
|
+
view.destroy();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("inserting text via transaction updates the document", () => {
|
|
68
|
+
const view = createEditableView(host, emptyDoc(), {
|
|
69
|
+
fontRegistry,
|
|
70
|
+
sections: defaultSections,
|
|
71
|
+
});
|
|
72
|
+
const { editorView } = view;
|
|
73
|
+
const tr = editorView.state.tr.insertText("typed", 1);
|
|
74
|
+
editorView.dispatch(tr);
|
|
75
|
+
|
|
76
|
+
expect(view.getDocument().textContent).toBe("typed");
|
|
77
|
+
view.destroy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("invokes onChange whenever the document changes", () => {
|
|
81
|
+
let calls = 0;
|
|
82
|
+
const view = createEditableView(host, emptyDoc(), {
|
|
83
|
+
fontRegistry,
|
|
84
|
+
sections: defaultSections,
|
|
85
|
+
onChange: () => {
|
|
86
|
+
calls += 1;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const { editorView } = view;
|
|
90
|
+
editorView.dispatch(editorView.state.tr.insertText("a", 1));
|
|
91
|
+
editorView.dispatch(editorView.state.tr.insertText("b", 2));
|
|
92
|
+
expect(calls).toBe(2);
|
|
93
|
+
view.destroy();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("createEditableView — undo/redo via prosemirror-history", () => {
|
|
98
|
+
it("undo reverts the last insertion, redo re-applies it", () => {
|
|
99
|
+
const view = createEditableView(host, emptyDoc(), {
|
|
100
|
+
fontRegistry,
|
|
101
|
+
sections: defaultSections,
|
|
102
|
+
});
|
|
103
|
+
const { editorView } = view;
|
|
104
|
+
|
|
105
|
+
editorView.dispatch(editorView.state.tr.insertText("step1", 1));
|
|
106
|
+
expect(view.getDocument().textContent).toBe("step1");
|
|
107
|
+
|
|
108
|
+
undo(editorView.state, editorView.dispatch);
|
|
109
|
+
expect(view.getDocument().textContent).toBe("");
|
|
110
|
+
|
|
111
|
+
redo(editorView.state, editorView.dispatch);
|
|
112
|
+
expect(view.getDocument().textContent).toBe("step1");
|
|
113
|
+
|
|
114
|
+
view.destroy();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("createEditableView — toggleMark for bold/italic/underline", () => {
|
|
119
|
+
it("toggleMark(bold) wraps the selection with bold mark", () => {
|
|
120
|
+
const doc = docxSchema.nodes.doc.create({}, [paragraph("hello")]);
|
|
121
|
+
const view = createEditableView(host, doc, {
|
|
122
|
+
fontRegistry,
|
|
123
|
+
sections: defaultSections,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
selectAll(view);
|
|
127
|
+
toggleMark(docxSchema.marks.bold)(view.editorView.state, view.editorView.dispatch);
|
|
128
|
+
|
|
129
|
+
const text = view.getDocument().firstChild!.firstChild!;
|
|
130
|
+
expect(text.marks.map((m) => m.type.name)).toContain("bold");
|
|
131
|
+
view.destroy();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("toggleMark(italic) and toggleMark(underline) compose", () => {
|
|
135
|
+
const doc = docxSchema.nodes.doc.create({}, [paragraph("hello")]);
|
|
136
|
+
const view = createEditableView(host, doc, {
|
|
137
|
+
fontRegistry,
|
|
138
|
+
sections: defaultSections,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
selectAll(view);
|
|
142
|
+
toggleMark(docxSchema.marks.italic)(view.editorView.state, view.editorView.dispatch);
|
|
143
|
+
toggleMark(docxSchema.marks.underline)(view.editorView.state, view.editorView.dispatch);
|
|
144
|
+
|
|
145
|
+
const names = view.getDocument().firstChild!.firstChild!.marks.map(
|
|
146
|
+
(m) => m.type.name,
|
|
147
|
+
);
|
|
148
|
+
expect(names).toContain("italic");
|
|
149
|
+
expect(names).toContain("underline");
|
|
150
|
+
view.destroy();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("createEditableView — read-only mode", () => {
|
|
155
|
+
it("read-only mode does not register history plugin (undo is a no-op)", () => {
|
|
156
|
+
const doc = docxSchema.nodes.doc.create({}, [paragraph("locked")]);
|
|
157
|
+
const view = createEditableView(host, doc, {
|
|
158
|
+
fontRegistry,
|
|
159
|
+
sections: defaultSections,
|
|
160
|
+
});
|
|
161
|
+
view.destroy();
|
|
162
|
+
|
|
163
|
+
const readOnlyView = createEditableView(host, doc, {
|
|
164
|
+
fontRegistry,
|
|
165
|
+
sections: defaultSections,
|
|
166
|
+
plugins: [],
|
|
167
|
+
});
|
|
168
|
+
expect(undo(readOnlyView.editorView.state, readOnlyView.editorView.dispatch)).toBe(
|
|
169
|
+
false,
|
|
170
|
+
);
|
|
171
|
+
readOnlyView.destroy();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
2
|
+
import type {
|
|
3
|
+
FootnoteEntry,
|
|
4
|
+
FootnoteRegistry,
|
|
5
|
+
} from "../parse/footnotes";
|
|
6
|
+
import { isTableXml, renderParagraph, renderTable } from "./table_dom";
|
|
7
|
+
import type { Block } from "../model/types";
|
|
8
|
+
import type { XmlElement } from "@lotics/ooxml/xml";
|
|
9
|
+
|
|
10
|
+
function paragraphBlockToXml(
|
|
11
|
+
block: Extract<Block, { kind: "paragraph" }>,
|
|
12
|
+
): XmlElement {
|
|
13
|
+
const children: XmlElement[] = [];
|
|
14
|
+
if (block.properties !== null) {
|
|
15
|
+
children.push({ "w:pPr": [...block.properties] });
|
|
16
|
+
}
|
|
17
|
+
for (const inline of block.content) {
|
|
18
|
+
if (inline.kind === "run") {
|
|
19
|
+
const runChildren: XmlElement[] = [];
|
|
20
|
+
if (inline.properties !== null) {
|
|
21
|
+
runChildren.push({ "w:rPr": [...inline.properties] });
|
|
22
|
+
}
|
|
23
|
+
for (const c of inline.content) {
|
|
24
|
+
if (c.kind === "text") {
|
|
25
|
+
const t: XmlElement = {
|
|
26
|
+
"w:t": c.value === "" ? [] : [{ "#text": c.value }],
|
|
27
|
+
};
|
|
28
|
+
if (c.preserveSpace) t[":@"] = { "@_xml:space": "preserve" };
|
|
29
|
+
runChildren.push(t);
|
|
30
|
+
} else {
|
|
31
|
+
runChildren.push(c.xml);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
children.push({ "w:r": runChildren });
|
|
35
|
+
} else {
|
|
36
|
+
children.push(inline.xml);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { "w:p": children };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderFootnoteEntry(entry: FootnoteEntry, number: number): HTMLElement {
|
|
43
|
+
const li = document.createElement("li");
|
|
44
|
+
li.className = "docx-footnote";
|
|
45
|
+
li.setAttribute("data-footnote-id", entry.id);
|
|
46
|
+
li.setAttribute("value", String(number));
|
|
47
|
+
for (const block of entry.blocks) {
|
|
48
|
+
if (block.kind === "paragraph") {
|
|
49
|
+
li.appendChild(renderParagraph(paragraphBlockToXml(block)));
|
|
50
|
+
} else if (block.kind === "opaque_block" && isTableXml(block.xml)) {
|
|
51
|
+
li.appendChild(renderTable(block.xml));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return li;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function collectReferencedFootnotes(
|
|
58
|
+
doc: PMNode,
|
|
59
|
+
): Array<{ id: string; number: number }> {
|
|
60
|
+
const out: Array<{ id: string; number: number }> = [];
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
doc.descendants((node) => {
|
|
63
|
+
if (node.type.name !== "footnote_ref") return true;
|
|
64
|
+
const id = node.attrs.footnoteId as string;
|
|
65
|
+
if (!id || seen.has(id)) return false;
|
|
66
|
+
seen.add(id);
|
|
67
|
+
out.push({ id, number: node.attrs.number as number });
|
|
68
|
+
return false;
|
|
69
|
+
});
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function renderFootnotesSection(
|
|
74
|
+
registry: FootnoteRegistry,
|
|
75
|
+
doc: PMNode,
|
|
76
|
+
): HTMLElement | null {
|
|
77
|
+
const referenced = collectReferencedFootnotes(doc);
|
|
78
|
+
if (referenced.length === 0) return null;
|
|
79
|
+
const wrapper = document.createElement("aside");
|
|
80
|
+
wrapper.className = "docx-footnotes";
|
|
81
|
+
wrapper.setAttribute("data-docx-footnotes", "true");
|
|
82
|
+
const ol = document.createElement("ol");
|
|
83
|
+
ol.className = "docx-footnotes__list";
|
|
84
|
+
let appended = 0;
|
|
85
|
+
for (const ref of referenced) {
|
|
86
|
+
const entry = registry.byId.get(ref.id);
|
|
87
|
+
if (!entry) continue;
|
|
88
|
+
ol.appendChild(renderFootnoteEntry(entry, ref.number));
|
|
89
|
+
appended += 1;
|
|
90
|
+
}
|
|
91
|
+
if (appended === 0) return null;
|
|
92
|
+
wrapper.appendChild(ol);
|
|
93
|
+
return wrapper;
|
|
94
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { getTagName } from "@lotics/ooxml/xml";
|
|
2
|
+
import type {
|
|
3
|
+
HeaderFooterFragment,
|
|
4
|
+
HeaderFooterRegistry,
|
|
5
|
+
} from "../parse/header_footer";
|
|
6
|
+
import type { Block } from "../model/types";
|
|
7
|
+
import type { SectionProperties } from "../model/sections";
|
|
8
|
+
import { isTableXml, renderParagraph, renderTable } from "./table_dom";
|
|
9
|
+
|
|
10
|
+
function renderBlock(block: Block): HTMLElement | null {
|
|
11
|
+
if (block.kind === "paragraph") {
|
|
12
|
+
return renderParagraph(blockToXml(block));
|
|
13
|
+
}
|
|
14
|
+
if (block.kind === "opaque_block") {
|
|
15
|
+
if (isTableXml(block.xml)) return renderTable(block.xml);
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function blockToXml(
|
|
21
|
+
block: Extract<Block, { kind: "paragraph" }>,
|
|
22
|
+
): import("@lotics/ooxml/xml").XmlElement {
|
|
23
|
+
const children: import("@lotics/ooxml/xml").XmlElement[] = [];
|
|
24
|
+
if (block.properties !== null) {
|
|
25
|
+
children.push({ "w:pPr": [...block.properties] });
|
|
26
|
+
}
|
|
27
|
+
for (const inline of block.content) {
|
|
28
|
+
if (inline.kind === "run") {
|
|
29
|
+
const runChildren: import("@lotics/ooxml/xml").XmlElement[] = [];
|
|
30
|
+
if (inline.properties !== null) {
|
|
31
|
+
runChildren.push({ "w:rPr": [...inline.properties] });
|
|
32
|
+
}
|
|
33
|
+
for (const c of inline.content) {
|
|
34
|
+
if (c.kind === "text") {
|
|
35
|
+
const t: import("@lotics/ooxml/xml").XmlElement = {
|
|
36
|
+
"w:t": c.value === "" ? [] : [{ "#text": c.value }],
|
|
37
|
+
};
|
|
38
|
+
if (c.preserveSpace) t[":@"] = { "@_xml:space": "preserve" };
|
|
39
|
+
runChildren.push(t);
|
|
40
|
+
} else {
|
|
41
|
+
runChildren.push(c.xml);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
children.push({ "w:r": runChildren });
|
|
45
|
+
} else {
|
|
46
|
+
children.push(inline.xml);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { "w:p": children };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render a header/footer fragment as read-only DOM. Editable header/footer
|
|
54
|
+
* editing is handled by `view.ts` via a separate ProseMirror EditorView
|
|
55
|
+
* mounted per fragment — not by toggling contenteditable on this DOM.
|
|
56
|
+
*/
|
|
57
|
+
export function renderHeaderFooterFragment(
|
|
58
|
+
fragment: HeaderFooterFragment,
|
|
59
|
+
): HTMLElement {
|
|
60
|
+
const wrapper = document.createElement("div");
|
|
61
|
+
wrapper.className = "docx-header-footer";
|
|
62
|
+
for (const block of fragment.blocks) {
|
|
63
|
+
const el = renderBlock(block);
|
|
64
|
+
if (el) wrapper.appendChild(el);
|
|
65
|
+
}
|
|
66
|
+
return wrapper;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function pickHeaderFragment(
|
|
70
|
+
section: SectionProperties,
|
|
71
|
+
registry: HeaderFooterRegistry,
|
|
72
|
+
): HeaderFooterFragment | null {
|
|
73
|
+
return pickByType(section.headerRefs, registry);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function pickFooterFragment(
|
|
77
|
+
section: SectionProperties,
|
|
78
|
+
registry: HeaderFooterRegistry,
|
|
79
|
+
): HeaderFooterFragment | null {
|
|
80
|
+
return pickByType(section.footerRefs, registry);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pickByType(
|
|
84
|
+
refs: SectionProperties["headerRefs"],
|
|
85
|
+
registry: HeaderFooterRegistry,
|
|
86
|
+
): HeaderFooterFragment | null {
|
|
87
|
+
const defaultRef = refs.find((r) => r.type === "default") ?? refs[0];
|
|
88
|
+
if (!defaultRef) return null;
|
|
89
|
+
return registry.byRelationshipId.get(defaultRef.relationshipId) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isOpaqueTable(block: Block): boolean {
|
|
93
|
+
if (block.kind !== "opaque_block") return false;
|
|
94
|
+
return getTagName(block.xml) === "w:tbl";
|
|
95
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Mark } from "prosemirror-model";
|
|
2
|
+
import type { RelationshipMap } from "../parse/relationships";
|
|
3
|
+
import { resolveHyperlinkUrl } from "../parse/relationships";
|
|
4
|
+
|
|
5
|
+
export function buildLinkMarkView(relationships: RelationshipMap) {
|
|
6
|
+
return (mark: Mark): { dom: HTMLElement; contentDOM: HTMLElement } => {
|
|
7
|
+
const dom = document.createElement("a");
|
|
8
|
+
const relationshipId = mark.attrs.relationshipId as string | null;
|
|
9
|
+
const anchor = mark.attrs.anchor as string | null;
|
|
10
|
+
let href = "#";
|
|
11
|
+
if (relationshipId) {
|
|
12
|
+
const url = resolveHyperlinkUrl(relationships, relationshipId);
|
|
13
|
+
if (url) href = url;
|
|
14
|
+
} else if (anchor) {
|
|
15
|
+
href = `#${anchor}`;
|
|
16
|
+
}
|
|
17
|
+
dom.setAttribute("href", href);
|
|
18
|
+
if (relationshipId) {
|
|
19
|
+
dom.setAttribute("data-rel", relationshipId);
|
|
20
|
+
dom.setAttribute("target", "_blank");
|
|
21
|
+
dom.setAttribute("rel", "noreferrer noopener");
|
|
22
|
+
}
|
|
23
|
+
dom.className = "docx-link";
|
|
24
|
+
return { dom, contentDOM: dom };
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveImagePartPath,
|
|
3
|
+
type RelationshipMap,
|
|
4
|
+
} from "../parse/relationships";
|
|
5
|
+
|
|
6
|
+
export type MediaResolver = {
|
|
7
|
+
resolveByRelationshipId(relId: string): string | null;
|
|
8
|
+
destroy(): void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
12
|
+
png: "image/png",
|
|
13
|
+
jpg: "image/jpeg",
|
|
14
|
+
jpeg: "image/jpeg",
|
|
15
|
+
gif: "image/gif",
|
|
16
|
+
bmp: "image/bmp",
|
|
17
|
+
tiff: "image/tiff",
|
|
18
|
+
tif: "image/tiff",
|
|
19
|
+
svg: "image/svg+xml",
|
|
20
|
+
webp: "image/webp",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function mimeForPath(path: string): string {
|
|
24
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
25
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildMediaResolver(
|
|
29
|
+
parts: ReadonlyMap<string, Uint8Array>,
|
|
30
|
+
relationships: RelationshipMap,
|
|
31
|
+
): MediaResolver {
|
|
32
|
+
const cache = new Map<string, string>();
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
resolveByRelationshipId(relId: string): string | null {
|
|
36
|
+
const cached = cache.get(relId);
|
|
37
|
+
if (cached) return cached;
|
|
38
|
+
|
|
39
|
+
const partPath = resolveImagePartPath(relationships, relId);
|
|
40
|
+
if (!partPath) return null;
|
|
41
|
+
const bytes = parts.get(partPath);
|
|
42
|
+
if (!bytes) return null;
|
|
43
|
+
|
|
44
|
+
// Need a fresh ArrayBuffer for Blob (defensive copy keeps the parts map intact).
|
|
45
|
+
const copy = new Uint8Array(bytes);
|
|
46
|
+
const blob = new Blob([copy.buffer], { type: mimeForPath(partPath) });
|
|
47
|
+
const url = URL.createObjectURL(blob);
|
|
48
|
+
cache.set(relId, url);
|
|
49
|
+
return url;
|
|
50
|
+
},
|
|
51
|
+
destroy() {
|
|
52
|
+
for (const url of cache.values()) URL.revokeObjectURL(url);
|
|
53
|
+
cache.clear();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const NULL_MEDIA_RESOLVER: MediaResolver = {
|
|
59
|
+
resolveByRelationshipId: () => null,
|
|
60
|
+
destroy: () => {},
|
|
61
|
+
};
|