@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,81 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createImageRegistry, findImagesNeedingRelationship } from "./image_registry";
|
|
3
|
+
import { docxSchema } from "./schema";
|
|
4
|
+
|
|
5
|
+
describe("createImageRegistry", () => {
|
|
6
|
+
it("allocates fresh rIds avoiding existing ones", () => {
|
|
7
|
+
const reg = createImageRegistry({
|
|
8
|
+
existingRelationshipIds: new Set(["rIdImage1"]),
|
|
9
|
+
});
|
|
10
|
+
const a = reg.registerInsertedImage({
|
|
11
|
+
bytes: new Uint8Array([1, 2, 3]),
|
|
12
|
+
mimeType: "image/png",
|
|
13
|
+
extension: "png",
|
|
14
|
+
});
|
|
15
|
+
expect(a.relationshipId).not.toBe("rIdImage1");
|
|
16
|
+
expect(a.partPath).toContain("word/media/");
|
|
17
|
+
expect(a.partPath.endsWith(".png")).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("takePending() returns and clears", () => {
|
|
21
|
+
const reg = createImageRegistry();
|
|
22
|
+
reg.registerInsertedImage({
|
|
23
|
+
bytes: new Uint8Array([1]),
|
|
24
|
+
mimeType: "image/png",
|
|
25
|
+
extension: "png",
|
|
26
|
+
});
|
|
27
|
+
reg.registerInsertedImage({
|
|
28
|
+
bytes: new Uint8Array([2]),
|
|
29
|
+
mimeType: "image/jpeg",
|
|
30
|
+
extension: "jpg",
|
|
31
|
+
});
|
|
32
|
+
const taken = reg.takePending();
|
|
33
|
+
expect(taken).toHaveLength(2);
|
|
34
|
+
expect(reg.takePending()).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("hasRelationship reflects existing + newly registered ids", () => {
|
|
38
|
+
const reg = createImageRegistry({
|
|
39
|
+
existingRelationshipIds: new Set(["rId7"]),
|
|
40
|
+
});
|
|
41
|
+
expect(reg.hasRelationship("rId7")).toBe(true);
|
|
42
|
+
const a = reg.registerInsertedImage({
|
|
43
|
+
bytes: new Uint8Array([1]),
|
|
44
|
+
mimeType: "image/png",
|
|
45
|
+
extension: "png",
|
|
46
|
+
});
|
|
47
|
+
expect(reg.hasRelationship(a.relationshipId)).toBe(true);
|
|
48
|
+
expect(reg.hasRelationship("rId-missing")).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("custom rIdAllocator is honored", () => {
|
|
52
|
+
let counter = 100;
|
|
53
|
+
const reg = createImageRegistry({
|
|
54
|
+
rIdAllocator: () => `custom${counter++}`,
|
|
55
|
+
});
|
|
56
|
+
expect(
|
|
57
|
+
reg.registerInsertedImage({
|
|
58
|
+
bytes: new Uint8Array([1]),
|
|
59
|
+
mimeType: "image/png",
|
|
60
|
+
extension: "png",
|
|
61
|
+
}).relationshipId,
|
|
62
|
+
).toBe("custom100");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("findImagesNeedingRelationship", () => {
|
|
67
|
+
it("collects image_inline nodes that lack a relationshipId", () => {
|
|
68
|
+
const { doc, paragraph, image_inline } = docxSchema.nodes;
|
|
69
|
+
const node = doc.create({}, [
|
|
70
|
+
paragraph.create({}, [
|
|
71
|
+
image_inline.create({ relationshipId: null, alt: "fresh" }),
|
|
72
|
+
]),
|
|
73
|
+
paragraph.create({}, [
|
|
74
|
+
image_inline.create({ relationshipId: "rId7", alt: "existing" }),
|
|
75
|
+
]),
|
|
76
|
+
]);
|
|
77
|
+
const found = findImagesNeedingRelationship(node);
|
|
78
|
+
expect(found).toHaveLength(1);
|
|
79
|
+
expect(found[0].attrs.alt).toBe("fresh");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
2
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
3
|
+
|
|
4
|
+
export type PendingImage = {
|
|
5
|
+
relationshipId: string;
|
|
6
|
+
partPath: string;
|
|
7
|
+
bytes: Uint8Array;
|
|
8
|
+
mimeType: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ImageRegistry = {
|
|
12
|
+
registerInsertedImage(input: {
|
|
13
|
+
bytes: Uint8Array;
|
|
14
|
+
mimeType: string;
|
|
15
|
+
extension: string;
|
|
16
|
+
}): { relationshipId: string; partPath: string };
|
|
17
|
+
takePending(): PendingImage[];
|
|
18
|
+
hasRelationship(relationshipId: string): boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ImageRegistryConfig = {
|
|
22
|
+
existingRelationshipIds?: ReadonlySet<string>;
|
|
23
|
+
rIdAllocator?: () => string;
|
|
24
|
+
partPathAllocator?: (extension: string) => string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function createImageRegistry(config: ImageRegistryConfig = {}): ImageRegistry {
|
|
28
|
+
const existing = new Set(config.existingRelationshipIds ?? []);
|
|
29
|
+
const pending: PendingImage[] = [];
|
|
30
|
+
const used = new Set<string>(existing);
|
|
31
|
+
|
|
32
|
+
let nextSeq = 0;
|
|
33
|
+
const allocateRelId = (): string => {
|
|
34
|
+
if (config.rIdAllocator) {
|
|
35
|
+
const candidate = config.rIdAllocator();
|
|
36
|
+
if (used.has(candidate)) {
|
|
37
|
+
throw new Error(`rIdAllocator returned an in-use id: ${candidate}`);
|
|
38
|
+
}
|
|
39
|
+
used.add(candidate);
|
|
40
|
+
return candidate;
|
|
41
|
+
}
|
|
42
|
+
let candidate: string;
|
|
43
|
+
do {
|
|
44
|
+
nextSeq += 1;
|
|
45
|
+
candidate = `rIdImage${nextSeq}`;
|
|
46
|
+
} while (used.has(candidate));
|
|
47
|
+
used.add(candidate);
|
|
48
|
+
return candidate;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const allocatePartPath = (extension: string): string =>
|
|
52
|
+
config.partPathAllocator
|
|
53
|
+
? config.partPathAllocator(extension)
|
|
54
|
+
: `word/media/inserted_${used.size}.${extension}`;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
registerInsertedImage({ bytes, mimeType, extension }) {
|
|
58
|
+
const relationshipId = allocateRelId();
|
|
59
|
+
const partPath = allocatePartPath(extension);
|
|
60
|
+
pending.push({ relationshipId, partPath, bytes, mimeType });
|
|
61
|
+
return { relationshipId, partPath };
|
|
62
|
+
},
|
|
63
|
+
takePending() {
|
|
64
|
+
const out = pending.slice();
|
|
65
|
+
pending.length = 0;
|
|
66
|
+
return out;
|
|
67
|
+
},
|
|
68
|
+
hasRelationship(relationshipId) {
|
|
69
|
+
return used.has(relationshipId);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const imageRegistryPluginKey = new PluginKey<ImageRegistry>(
|
|
75
|
+
"docx-image-registry",
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export function imageRegistryPlugin(registry: ImageRegistry): Plugin<ImageRegistry> {
|
|
79
|
+
return new Plugin<ImageRegistry>({
|
|
80
|
+
key: imageRegistryPluginKey,
|
|
81
|
+
state: {
|
|
82
|
+
init: () => registry,
|
|
83
|
+
apply: (_tr, value) => value,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getImageRegistry(state: import("prosemirror-state").EditorState): ImageRegistry | null {
|
|
89
|
+
return imageRegistryPluginKey.getState(state) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function findImagesNeedingRelationship(doc: PMNode): PMNode[] {
|
|
93
|
+
const out: PMNode[] = [];
|
|
94
|
+
doc.descendants((node) => {
|
|
95
|
+
if (node.type.name !== "image_inline") return true;
|
|
96
|
+
if (node.attrs.relationshipId === null) out.push(node);
|
|
97
|
+
return false;
|
|
98
|
+
});
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
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 { parseDrawing } from "../parse/drawing";
|
|
7
|
+
import { parseDocumentRelationships } from "../parse/relationships";
|
|
8
|
+
import { buildMediaResolver } from "../render/media_resolver";
|
|
9
|
+
import { createReadOnlyView } from "../render/view";
|
|
10
|
+
import { buildFontRegistry } from "../fonts/registry";
|
|
11
|
+
import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
|
|
12
|
+
import type { DocxDocument } from "../model/types";
|
|
13
|
+
import type { Section } from "../model/sections";
|
|
14
|
+
|
|
15
|
+
const NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"`;
|
|
16
|
+
|
|
17
|
+
function inlineDrawingXml(opts: { rId: string; cx: number; cy: number; alt?: string }) {
|
|
18
|
+
return parseXml(
|
|
19
|
+
`<w:drawing ${NS}>
|
|
20
|
+
<wp:inline distT="0" distB="0" distL="0" distR="0">
|
|
21
|
+
<wp:extent cx="${opts.cx}" cy="${opts.cy}"/>
|
|
22
|
+
<wp:docPr id="1" name="Picture 1" descr="${opts.alt ?? ""}"/>
|
|
23
|
+
<a:graphic>
|
|
24
|
+
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
|
|
25
|
+
<pic:pic>
|
|
26
|
+
<pic:nvPicPr>
|
|
27
|
+
<pic:cNvPr id="1" name="${opts.alt ?? "image"}"/>
|
|
28
|
+
<pic:cNvPicPr/>
|
|
29
|
+
</pic:nvPicPr>
|
|
30
|
+
<pic:blipFill>
|
|
31
|
+
<a:blip r:embed="${opts.rId}"/>
|
|
32
|
+
<a:stretch><a:fillRect/></a:stretch>
|
|
33
|
+
</pic:blipFill>
|
|
34
|
+
<pic:spPr>
|
|
35
|
+
<a:xfrm><a:off x="0" y="0"/><a:ext cx="${opts.cx}" cy="${opts.cy}"/></a:xfrm>
|
|
36
|
+
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
|
37
|
+
</pic:spPr>
|
|
38
|
+
</pic:pic>
|
|
39
|
+
</a:graphicData>
|
|
40
|
+
</a:graphic>
|
|
41
|
+
</wp:inline>
|
|
42
|
+
</w:drawing>`,
|
|
43
|
+
)[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("parseDrawing", () => {
|
|
47
|
+
it("extracts r:embed, extents (EMU), and alt text", () => {
|
|
48
|
+
const drawing = inlineDrawingXml({
|
|
49
|
+
rId: "rId7",
|
|
50
|
+
cx: 1828800,
|
|
51
|
+
cy: 1371600,
|
|
52
|
+
alt: "Sample picture",
|
|
53
|
+
});
|
|
54
|
+
const info = parseDrawing(drawing);
|
|
55
|
+
expect(info).toEqual({
|
|
56
|
+
relationshipId: "rId7",
|
|
57
|
+
widthEmu: 1828800,
|
|
58
|
+
heightEmu: 1371600,
|
|
59
|
+
alt: "Sample picture",
|
|
60
|
+
inline: true,
|
|
61
|
+
wrap: null,
|
|
62
|
+
floatSide: null,
|
|
63
|
+
behindDoc: false,
|
|
64
|
+
offsetXEmu: null,
|
|
65
|
+
offsetYEmu: null,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null for non-drawing elements", () => {
|
|
70
|
+
const notADrawing = parseXml(`<w:r ${NS}><w:t>x</w:t></w:r>`)[0];
|
|
71
|
+
expect(parseDrawing(notADrawing)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("docxToPm — image_inline", () => {
|
|
76
|
+
it("converts <w:drawing> inside a run into an image_inline node with typed attrs and originalXml", () => {
|
|
77
|
+
const drawing = inlineDrawingXml({ rId: "rId7", cx: 914400, cy: 914400, alt: "logo" });
|
|
78
|
+
const docx: DocxDocument = {
|
|
79
|
+
parts: new Map(),
|
|
80
|
+
documentAttrs: {},
|
|
81
|
+
body: {
|
|
82
|
+
children: [
|
|
83
|
+
{
|
|
84
|
+
kind: "paragraph",
|
|
85
|
+
properties: null,
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
kind: "run",
|
|
89
|
+
properties: null,
|
|
90
|
+
content: [{ kind: "opaque_run_child", xml: drawing }],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const pm = docxToPm(docx);
|
|
98
|
+
const node = pm.firstChild!.firstChild!;
|
|
99
|
+
expect(node.type.name).toBe("image_inline");
|
|
100
|
+
expect(node.attrs.relationshipId).toBe("rId7");
|
|
101
|
+
expect(node.attrs.widthEmu).toBe(914400);
|
|
102
|
+
expect(node.attrs.heightEmu).toBe(914400);
|
|
103
|
+
expect(node.attrs.alt).toBe("logo");
|
|
104
|
+
expect(node.attrs.originalXml).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("roundtrips the original drawing XML verbatim through pmToDocx", () => {
|
|
108
|
+
const drawing = inlineDrawingXml({ rId: "rId7", cx: 914400, cy: 914400 });
|
|
109
|
+
const docx: DocxDocument = {
|
|
110
|
+
parts: new Map(),
|
|
111
|
+
documentAttrs: {},
|
|
112
|
+
body: {
|
|
113
|
+
children: [
|
|
114
|
+
{
|
|
115
|
+
kind: "paragraph",
|
|
116
|
+
properties: null,
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
kind: "run",
|
|
120
|
+
properties: null,
|
|
121
|
+
content: [{ kind: "opaque_run_child", xml: drawing }],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const pm = docxToPm(docx);
|
|
129
|
+
const reconstructed = pmToDocx(pm, {
|
|
130
|
+
parts: docx.parts,
|
|
131
|
+
documentAttrs: docx.documentAttrs,
|
|
132
|
+
});
|
|
133
|
+
const para = reconstructed.body.children[0];
|
|
134
|
+
if (para.kind !== "paragraph") throw new Error();
|
|
135
|
+
const run = para.content[0];
|
|
136
|
+
if (run?.kind !== "run") throw new Error();
|
|
137
|
+
const child = run.content[0];
|
|
138
|
+
expect(child.kind).toBe("opaque_run_child");
|
|
139
|
+
if (child.kind === "opaque_run_child") {
|
|
140
|
+
expect(child.xml).toEqual(drawing);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("media resolver + image rendering", () => {
|
|
146
|
+
let host: HTMLElement;
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
document.body.innerHTML = "";
|
|
149
|
+
host = document.createElement("div");
|
|
150
|
+
document.body.appendChild(host);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("emits an <img> tag with a blob URL when the media part exists", () => {
|
|
154
|
+
const drawing = inlineDrawingXml({ rId: "rId5", cx: 914400, cy: 914400, alt: "logo" });
|
|
155
|
+
|
|
156
|
+
const relsXml = `<?xml version="1.0"?>
|
|
157
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
158
|
+
<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>
|
|
159
|
+
</Relationships>`;
|
|
160
|
+
const fakePngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
161
|
+
|
|
162
|
+
const parts = new Map<string, Uint8Array>([
|
|
163
|
+
["word/_rels/document.xml.rels", new TextEncoder().encode(relsXml)],
|
|
164
|
+
["word/media/image1.png", fakePngBytes],
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const docx: DocxDocument = {
|
|
168
|
+
parts,
|
|
169
|
+
documentAttrs: {},
|
|
170
|
+
body: {
|
|
171
|
+
children: [
|
|
172
|
+
{
|
|
173
|
+
kind: "paragraph",
|
|
174
|
+
properties: null,
|
|
175
|
+
content: [
|
|
176
|
+
{
|
|
177
|
+
kind: "run",
|
|
178
|
+
properties: null,
|
|
179
|
+
content: [{ kind: "opaque_run_child", xml: drawing }],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const pm = docxToPm(docx);
|
|
188
|
+
const relationships = parseDocumentRelationships(parts);
|
|
189
|
+
const mediaResolver = buildMediaResolver(parts, relationships);
|
|
190
|
+
|
|
191
|
+
const view = createReadOnlyView(host, pm, {
|
|
192
|
+
fontRegistry: buildFontRegistry({
|
|
193
|
+
embeddedFonts: [],
|
|
194
|
+
workspaceFonts: [],
|
|
195
|
+
}),
|
|
196
|
+
sections: [
|
|
197
|
+
{
|
|
198
|
+
properties: { ...DEFAULT_SECTION_PROPERTIES },
|
|
199
|
+
blockStartIndex: 0,
|
|
200
|
+
blockEndIndex: 0,
|
|
201
|
+
} satisfies Section,
|
|
202
|
+
],
|
|
203
|
+
relationships,
|
|
204
|
+
mediaResolver,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const img = host.querySelector("img");
|
|
208
|
+
expect(img).not.toBeNull();
|
|
209
|
+
expect(img!.getAttribute("src")).toMatch(/^blob:/);
|
|
210
|
+
expect(img!.getAttribute("alt")).toBe("logo");
|
|
211
|
+
expect(img!.style.width).toBe("96px");
|
|
212
|
+
expect(img!.style.height).toBe("96px");
|
|
213
|
+
|
|
214
|
+
view.destroy();
|
|
215
|
+
mediaResolver.destroy();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("renders [image] placeholder when no resolver is provided", () => {
|
|
219
|
+
const drawing = inlineDrawingXml({ rId: "rId5", cx: 914400, cy: 914400, alt: "" });
|
|
220
|
+
const docx: DocxDocument = {
|
|
221
|
+
parts: new Map(),
|
|
222
|
+
documentAttrs: {},
|
|
223
|
+
body: {
|
|
224
|
+
children: [
|
|
225
|
+
{
|
|
226
|
+
kind: "paragraph",
|
|
227
|
+
properties: null,
|
|
228
|
+
content: [
|
|
229
|
+
{
|
|
230
|
+
kind: "run",
|
|
231
|
+
properties: null,
|
|
232
|
+
content: [{ kind: "opaque_run_child", xml: drawing }],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
const pm = docxToPm(docx);
|
|
240
|
+
const view = createReadOnlyView(host, pm, {
|
|
241
|
+
fontRegistry: buildFontRegistry({
|
|
242
|
+
embeddedFonts: [],
|
|
243
|
+
workspaceFonts: [],
|
|
244
|
+
}),
|
|
245
|
+
sections: [
|
|
246
|
+
{
|
|
247
|
+
properties: { ...DEFAULT_SECTION_PROPERTIES },
|
|
248
|
+
blockStartIndex: 0,
|
|
249
|
+
blockEndIndex: 0,
|
|
250
|
+
} satisfies Section,
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
expect(host.querySelector("img")).toBeNull();
|
|
254
|
+
expect(host.querySelector(".docx-image-inline__placeholder")?.textContent).toBe("[image]");
|
|
255
|
+
view.destroy();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
|
|
2
|
+
import type { EditorView } from "prosemirror-view";
|
|
3
|
+
import type { Mark } from "prosemirror-model";
|
|
4
|
+
import { docxSchema } from "./schema";
|
|
5
|
+
import { registerGlobalStyle } from "../render/style_registry";
|
|
6
|
+
|
|
7
|
+
const linkPopoverKey = new PluginKey("docx-link-popover");
|
|
8
|
+
|
|
9
|
+
const POPOVER_CSS = `
|
|
10
|
+
.docx-link-popover {
|
|
11
|
+
position: absolute;
|
|
12
|
+
display: none;
|
|
13
|
+
background: #ffffff;
|
|
14
|
+
border: 1px solid #dadce0;
|
|
15
|
+
box-shadow: 0 2px 8px rgba(60,64,67,0.18);
|
|
16
|
+
border-radius: 6px;
|
|
17
|
+
padding: 6px;
|
|
18
|
+
font: 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
19
|
+
z-index: 1000;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: 4px;
|
|
22
|
+
}
|
|
23
|
+
.docx-link-popover[data-visible="true"] { display: inline-flex; }
|
|
24
|
+
.docx-link-popover a {
|
|
25
|
+
color: #1a73e8;
|
|
26
|
+
text-decoration: underline;
|
|
27
|
+
max-width: 280px;
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
text-overflow: ellipsis;
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
}
|
|
32
|
+
.docx-link-popover button {
|
|
33
|
+
border: none;
|
|
34
|
+
background: transparent;
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
font: inherit;
|
|
37
|
+
color: #3c4043;
|
|
38
|
+
padding: 4px 8px;
|
|
39
|
+
border-radius: 4px;
|
|
40
|
+
}
|
|
41
|
+
.docx-link-popover button:hover { background: #f1f3f4; }
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
function installStyle(): () => void {
|
|
45
|
+
return registerGlobalStyle("docx-link-popover-style", POPOVER_CSS);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findLinkAtSelection(view: EditorView): {
|
|
49
|
+
mark: Mark;
|
|
50
|
+
from: number;
|
|
51
|
+
to: number;
|
|
52
|
+
} | null {
|
|
53
|
+
const linkType = docxSchema.marks.link;
|
|
54
|
+
if (!linkType) return null;
|
|
55
|
+
const { selection, doc } = view.state;
|
|
56
|
+
const $pos = doc.resolve(selection.from);
|
|
57
|
+
const mark = $pos.marks().find((m) => m.type === linkType);
|
|
58
|
+
if (!mark) return null;
|
|
59
|
+
// Expand to cover the contiguous link range.
|
|
60
|
+
let from = selection.from;
|
|
61
|
+
let to = selection.from;
|
|
62
|
+
while (from > 0) {
|
|
63
|
+
const $from = doc.resolve(from - 1);
|
|
64
|
+
if (!$from.marks().some((m) => m.eq(mark))) break;
|
|
65
|
+
from -= 1;
|
|
66
|
+
}
|
|
67
|
+
const docSize = doc.content.size;
|
|
68
|
+
while (to < docSize) {
|
|
69
|
+
const $to = doc.resolve(to);
|
|
70
|
+
if (!$to.marks().some((m) => m.eq(mark))) break;
|
|
71
|
+
to += 1;
|
|
72
|
+
}
|
|
73
|
+
return { mark, from, to };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function linkHref(mark: Mark): string {
|
|
77
|
+
const anchor = mark.attrs.anchor as string | null;
|
|
78
|
+
if (anchor) return `#${anchor}`;
|
|
79
|
+
const relId = mark.attrs.relationshipId as string | null;
|
|
80
|
+
return relId ? `[${relId}]` : "#";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function linkPopoverPlugin(): Plugin {
|
|
84
|
+
return new Plugin({
|
|
85
|
+
key: linkPopoverKey,
|
|
86
|
+
view(view) {
|
|
87
|
+
const releaseStyle = installStyle();
|
|
88
|
+
|
|
89
|
+
const popover = document.createElement("div");
|
|
90
|
+
popover.className = "docx-link-popover";
|
|
91
|
+
popover.setAttribute("data-docx-link-popover", "true");
|
|
92
|
+
popover.setAttribute("data-visible", "false");
|
|
93
|
+
|
|
94
|
+
const linkEl = document.createElement("a");
|
|
95
|
+
linkEl.target = "_blank";
|
|
96
|
+
linkEl.rel = "noopener noreferrer";
|
|
97
|
+
const editBtn = document.createElement("button");
|
|
98
|
+
editBtn.type = "button";
|
|
99
|
+
editBtn.textContent = "Edit";
|
|
100
|
+
const unlinkBtn = document.createElement("button");
|
|
101
|
+
unlinkBtn.type = "button";
|
|
102
|
+
unlinkBtn.textContent = "Unlink";
|
|
103
|
+
|
|
104
|
+
popover.append(linkEl, editBtn, unlinkBtn);
|
|
105
|
+
document.body.appendChild(popover);
|
|
106
|
+
|
|
107
|
+
const update = () => {
|
|
108
|
+
const found = findLinkAtSelection(view);
|
|
109
|
+
if (!found || !view.hasFocus()) {
|
|
110
|
+
popover.setAttribute("data-visible", "false");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const coords = view.coordsAtPos(found.from);
|
|
114
|
+
const href = linkHref(found.mark);
|
|
115
|
+
linkEl.href = href;
|
|
116
|
+
linkEl.textContent = href;
|
|
117
|
+
popover.style.left = `${coords.left + window.scrollX}px`;
|
|
118
|
+
popover.style.top = `${coords.bottom + window.scrollY + 4}px`;
|
|
119
|
+
popover.setAttribute("data-visible", "true");
|
|
120
|
+
|
|
121
|
+
editBtn.onclick = () => {
|
|
122
|
+
const initial = href.startsWith("#") ? href.slice(1) : href;
|
|
123
|
+
const next = prompt("Edit anchor name (#bookmark)", initial);
|
|
124
|
+
if (next === null) return;
|
|
125
|
+
const trimmed = next.trim().replace(/^#/, "");
|
|
126
|
+
if (trimmed.length === 0) return;
|
|
127
|
+
const linkType = docxSchema.marks.link;
|
|
128
|
+
const tr = view.state.tr
|
|
129
|
+
.removeMark(found.from, found.to, linkType)
|
|
130
|
+
.addMark(
|
|
131
|
+
found.from,
|
|
132
|
+
found.to,
|
|
133
|
+
linkType.create({ relationshipId: null, anchor: trimmed }),
|
|
134
|
+
);
|
|
135
|
+
view.dispatch(tr);
|
|
136
|
+
view.focus();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
unlinkBtn.onclick = () => {
|
|
140
|
+
const linkType = docxSchema.marks.link;
|
|
141
|
+
const tr = view.state.tr.removeMark(found.from, found.to, linkType);
|
|
142
|
+
// Restore selection to the same range so popover hides.
|
|
143
|
+
tr.setSelection(TextSelection.create(tr.doc, found.from, found.to));
|
|
144
|
+
view.dispatch(tr);
|
|
145
|
+
view.focus();
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
update();
|
|
150
|
+
return {
|
|
151
|
+
update,
|
|
152
|
+
destroy() {
|
|
153
|
+
popover.remove();
|
|
154
|
+
releaseStyle();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Command } from "prosemirror-state";
|
|
2
|
+
import { docxSchema } from "./schema";
|
|
3
|
+
|
|
4
|
+
function applyMark(
|
|
5
|
+
type: "color" | "highlight" | "font" | "size",
|
|
6
|
+
attrs: Record<string, unknown>,
|
|
7
|
+
): Command {
|
|
8
|
+
return (state, dispatch) => {
|
|
9
|
+
const { from, to, empty } = state.selection;
|
|
10
|
+
const markType = docxSchema.marks[type];
|
|
11
|
+
if (!markType) return false;
|
|
12
|
+
const tr = state.tr;
|
|
13
|
+
if (empty) {
|
|
14
|
+
const stored = state.storedMarks ?? state.selection.$from.marks();
|
|
15
|
+
const next = markType.create(attrs).addToSet(stored.filter((m) => m.type !== markType));
|
|
16
|
+
tr.setStoredMarks(next);
|
|
17
|
+
} else {
|
|
18
|
+
tr.removeMark(from, to, markType);
|
|
19
|
+
tr.addMark(from, to, markType.create(attrs));
|
|
20
|
+
}
|
|
21
|
+
if (dispatch) dispatch(tr);
|
|
22
|
+
return true;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clearMark(type: "color" | "highlight" | "font" | "size"): Command {
|
|
27
|
+
return (state, dispatch) => {
|
|
28
|
+
const { from, to } = state.selection;
|
|
29
|
+
const markType = docxSchema.marks[type];
|
|
30
|
+
if (!markType) return false;
|
|
31
|
+
const tr = state.tr.removeMark(from, to, markType);
|
|
32
|
+
if (dispatch) dispatch(tr);
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setTextColor(value: string | null): Command {
|
|
38
|
+
if (!value) return clearMark("color");
|
|
39
|
+
return applyMark("color", { value });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setHighlight(value: string | null): Command {
|
|
43
|
+
if (!value) return clearMark("highlight");
|
|
44
|
+
return applyMark("highlight", { value });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function setFontFamily(family: string | null): Command {
|
|
48
|
+
if (!family) return clearMark("font");
|
|
49
|
+
return applyMark("font", {
|
|
50
|
+
ascii: family,
|
|
51
|
+
hAnsi: family,
|
|
52
|
+
eastAsia: null,
|
|
53
|
+
complexScript: null,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function setFontSize(pt: number | null): Command {
|
|
58
|
+
if (pt === null) return clearMark("size");
|
|
59
|
+
return applyMark("size", { halfPoints: Math.round(pt * 2) });
|
|
60
|
+
}
|