@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,96 @@
1
+ import { Plugin, PluginKey } from "prosemirror-state";
2
+ import type { EditorState } from "prosemirror-state";
3
+ import { Decoration, DecorationSet } from "prosemirror-view";
4
+ import type { ParagraphProperties } from "../model/properties";
5
+ import type { NumberingTable } from "../model/numbering_table";
6
+ import {
7
+ computeLabel,
8
+ createNumberingState,
9
+ } from "../render/numbering_counter";
10
+
11
+ const numberingPluginKey = new PluginKey<DecorationSet>("docx-numbering-decorations");
12
+
13
+ function widgetFor(label: string): () => HTMLElement {
14
+ return () => {
15
+ const span = document.createElement("span");
16
+ span.className = "docx-numbering-marker";
17
+ span.setAttribute("aria-hidden", "true");
18
+ span.textContent = label;
19
+ return span;
20
+ };
21
+ }
22
+
23
+ function buildDecorations(
24
+ state: EditorState,
25
+ numberingTable: NumberingTable | null,
26
+ ): DecorationSet {
27
+ const decorations: Decoration[] = [];
28
+ const liveState = numberingTable ? createNumberingState() : null;
29
+
30
+ state.doc.descendants((node, pos) => {
31
+ if (node.type.name !== "paragraph") return true;
32
+
33
+ let label: string | null = null;
34
+ let ilvl: number | null = null;
35
+ if (numberingTable && liveState) {
36
+ const props = node.attrs.properties as ParagraphProperties | null;
37
+ const numbering = props?.numbering ?? null;
38
+ if (numbering) {
39
+ ilvl = numbering.ilvl;
40
+ label = computeLabel(
41
+ liveState,
42
+ numberingTable,
43
+ numbering.numId,
44
+ numbering.ilvl,
45
+ );
46
+ }
47
+ } else {
48
+ label = node.attrs.numberingLabel as string | null;
49
+ // No numbering table available; we can't derive ilvl from the label.
50
+ // The Decoration.node indent below will be skipped.
51
+ }
52
+
53
+ if (label) {
54
+ decorations.push(
55
+ Decoration.widget(pos + 1, widgetFor(label), {
56
+ side: -1,
57
+ ignoreSelection: true,
58
+ key: `numbering-${pos}-${label}`,
59
+ }),
60
+ );
61
+ // Outline indent per nesting level. Word renders each indent level
62
+ // shifted right by ~0.25" so deeper levels are visually nested.
63
+ // Without this, every level stacks at the same x and the outline
64
+ // structure is invisible.
65
+ if (ilvl !== null) {
66
+ decorations.push(
67
+ Decoration.node(pos, pos + node.nodeSize, {
68
+ "data-list-ilvl": String(ilvl),
69
+ }),
70
+ );
71
+ }
72
+ }
73
+ return false;
74
+ });
75
+ return DecorationSet.create(state.doc, decorations);
76
+ }
77
+
78
+ export function numberingDecorationPlugin(
79
+ numberingTable: NumberingTable | null = null,
80
+ ): Plugin<DecorationSet> {
81
+ return new Plugin<DecorationSet>({
82
+ key: numberingPluginKey,
83
+ state: {
84
+ init: (_config, state) => buildDecorations(state, numberingTable),
85
+ apply: (tr, value, _oldState, newState) => {
86
+ if (!tr.docChanged) return value.map(tr.mapping, tr.doc);
87
+ return buildDecorations(newState, numberingTable);
88
+ },
89
+ },
90
+ props: {
91
+ decorations(state) {
92
+ return numberingPluginKey.getState(state) ?? null;
93
+ },
94
+ },
95
+ });
96
+ }
@@ -0,0 +1,41 @@
1
+ import type { Node as PMNode } from "prosemirror-model";
2
+ import type { ParagraphProperties } from "../model/properties";
3
+
4
+ export type OutlineEntry = {
5
+ pos: number;
6
+ level: number;
7
+ text: string;
8
+ };
9
+
10
+ const STYLE_LEVELS: Record<string, number> = {
11
+ Title: 0,
12
+ Heading1: 1,
13
+ Heading2: 2,
14
+ Heading3: 3,
15
+ Heading4: 4,
16
+ Heading5: 5,
17
+ Heading6: 6,
18
+ };
19
+
20
+ export function buildOutline(doc: PMNode): OutlineEntry[] {
21
+ const out: OutlineEntry[] = [];
22
+ doc.descendants((node, pos) => {
23
+ if (node.type.name !== "paragraph") return true;
24
+ const resolved = node.attrs.resolvedProperties as ParagraphProperties | null;
25
+ const direct = node.attrs.properties as ParagraphProperties | null;
26
+ const levelFromOutline =
27
+ resolved?.outlineLevel ?? direct?.outlineLevel ?? null;
28
+ const styleId = resolved?.styleId ?? direct?.styleId ?? null;
29
+ const levelFromStyle =
30
+ styleId && Object.prototype.hasOwnProperty.call(STYLE_LEVELS, styleId)
31
+ ? STYLE_LEVELS[styleId]
32
+ : null;
33
+ const level = levelFromStyle ?? levelFromOutline;
34
+ if (level === null) return false;
35
+ const text = node.textContent.trim();
36
+ if (text.length === 0) return false;
37
+ out.push({ pos, level, text });
38
+ return false;
39
+ });
40
+ return out;
41
+ }
@@ -0,0 +1,80 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect } from "vitest";
3
+ import { type XmlElement } from "@lotics/ooxml/xml";
4
+ import { docxToPm } from "./docx_to_pm";
5
+ import { pmToDocx } from "./pm_to_docx";
6
+ import type { DocxDocument } from "../model/types";
7
+
8
+ function brOf(type: string | null): XmlElement {
9
+ return type === null
10
+ ? { "w:br": [] }
11
+ : { "w:br": [], ":@": { "@_w:type": type } };
12
+ }
13
+
14
+ function buildDocxWithBreak(brXml: XmlElement): DocxDocument {
15
+ return {
16
+ parts: new Map(),
17
+ documentAttrs: {},
18
+ body: {
19
+ children: [
20
+ {
21
+ kind: "paragraph",
22
+ properties: null,
23
+ content: [
24
+ {
25
+ kind: "run",
26
+ properties: null,
27
+ content: [
28
+ { kind: "text", value: "before", preserveSpace: false },
29
+ { kind: "opaque_run_child", xml: brXml },
30
+ { kind: "text", value: "after", preserveSpace: false },
31
+ ],
32
+ },
33
+ ],
34
+ },
35
+ ],
36
+ },
37
+ };
38
+ }
39
+
40
+ describe("page/column break detection", () => {
41
+ it("plain <w:br/> becomes hard_break with breakType=line", () => {
42
+ const pm = docxToPm(buildDocxWithBreak(brOf(null)));
43
+ const para = pm.firstChild!;
44
+ expect(para.child(1).type.name).toBe("hard_break");
45
+ expect(para.child(1).attrs.breakType).toBe("line");
46
+ });
47
+
48
+ it("<w:br w:type=\"page\"/> becomes hard_break with breakType=page", () => {
49
+ const pm = docxToPm(buildDocxWithBreak(brOf("page")));
50
+ const br = pm.firstChild!.child(1);
51
+ expect(br.type.name).toBe("hard_break");
52
+ expect(br.attrs.breakType).toBe("page");
53
+ });
54
+
55
+ it("<w:br w:type=\"column\"/> becomes hard_break with breakType=column", () => {
56
+ const pm = docxToPm(buildDocxWithBreak(brOf("column")));
57
+ expect(pm.firstChild!.child(1).attrs.breakType).toBe("column");
58
+ });
59
+
60
+ it("roundtrip preserves page break type", () => {
61
+ const original = buildDocxWithBreak(brOf("page"));
62
+ const pm = docxToPm(original);
63
+ const reconstructed = pmToDocx(pm, {
64
+ parts: original.parts,
65
+ documentAttrs: original.documentAttrs,
66
+ });
67
+ const para = reconstructed.body.children[0];
68
+ if (para.kind !== "paragraph") throw new Error();
69
+ const run = para.content[0];
70
+ if (run?.kind !== "run") throw new Error();
71
+ const br = run.content[1];
72
+ expect(br.kind).toBe("opaque_run_child");
73
+ if (br.kind === "opaque_run_child") {
74
+ expect(br.xml).toEqual({
75
+ "w:br": [],
76
+ ":@": { "@_w:type": "page" },
77
+ });
78
+ }
79
+ });
80
+ });
@@ -0,0 +1,87 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import {
4
+ setPageMargins,
5
+ setPageOrientation,
6
+ setPageSize,
7
+ } from "./commands";
8
+ import { docxSchema } from "./schema";
9
+ import { createEditableView } from "../render/view";
10
+ import { buildFontRegistry } from "../fonts/registry";
11
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
12
+ import type { Section } from "../model/sections";
13
+
14
+ const fontRegistry = buildFontRegistry({
15
+ embeddedFonts: [],
16
+ workspaceFonts: [],
17
+ });
18
+
19
+ const defaultSections: Section[] = [
20
+ {
21
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
22
+ blockStartIndex: 0,
23
+ blockEndIndex: 0,
24
+ },
25
+ ];
26
+
27
+ let host: HTMLElement;
28
+ beforeEach(() => {
29
+ document.body.innerHTML = "";
30
+ host = document.createElement("div");
31
+ document.body.appendChild(host);
32
+ });
33
+
34
+ function buildView() {
35
+ const { doc, paragraph, section_break } = docxSchema.nodes;
36
+ const node = doc.create({}, [
37
+ paragraph.create({}, [docxSchema.text("body")]),
38
+ section_break.create({
39
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
40
+ isFinal: true,
41
+ }),
42
+ ]);
43
+ return createEditableView(host, node, {
44
+ fontRegistry,
45
+ sections: defaultSections,
46
+ });
47
+ }
48
+
49
+ describe("page layout commands", () => {
50
+ it("setPageSize changes the section_break properties", () => {
51
+ const view = buildView();
52
+ const cmd = setPageSize({ width: 11906, height: 16838, orientation: "portrait" });
53
+ cmd(view.editorView.state, view.editorView.dispatch);
54
+
55
+ const section = view.getDocument().lastChild!;
56
+ expect(section.attrs.properties.pageSize.width).toBe(11906);
57
+ expect(section.attrs.properties.pageSize.height).toBe(16838);
58
+ view.destroy();
59
+ });
60
+
61
+ it("setPageMargins updates margins", () => {
62
+ const view = buildView();
63
+ const cmd = setPageMargins({
64
+ top: 720,
65
+ right: 720,
66
+ bottom: 720,
67
+ left: 720,
68
+ header: 360,
69
+ footer: 360,
70
+ gutter: 0,
71
+ });
72
+ cmd(view.editorView.state, view.editorView.dispatch);
73
+ const section = view.getDocument().lastChild!;
74
+ expect(section.attrs.properties.margins.top).toBe(720);
75
+ view.destroy();
76
+ });
77
+
78
+ it("setPageOrientation('landscape') swaps width and height", () => {
79
+ const view = buildView();
80
+ setPageOrientation("landscape")(view.editorView.state, view.editorView.dispatch);
81
+ const section = view.getDocument().lastChild!;
82
+ expect(section.attrs.properties.pageSize.orientation).toBe("landscape");
83
+ expect(section.attrs.properties.pageSize.width).toBe(15840);
84
+ expect(section.attrs.properties.pageSize.height).toBe(12240);
85
+ view.destroy();
86
+ });
87
+ });
@@ -0,0 +1,155 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { EditorState } from "prosemirror-state";
4
+ import { EditorView } from "prosemirror-view";
5
+ import { docxSchema } from "./schema";
6
+ import { DEFAULT_SECTION_PROPERTIES } from "../model/sections";
7
+ import type { Section } from "../model/sections";
8
+ import type { LayoutResult, SectionGeometry } from "../layout/types";
9
+ import {
10
+ getPaginationState,
11
+ paginationPlugin,
12
+ setPaginationLayout,
13
+ } from "./pagination_plugin";
14
+
15
+ const sections: Section[] = [
16
+ {
17
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
18
+ blockStartIndex: 0,
19
+ blockEndIndex: 0,
20
+ },
21
+ ];
22
+
23
+ const geometries: SectionGeometry[] = [
24
+ {
25
+ sectionIndex: 0,
26
+ blockStartIndex: 0,
27
+ blockEndIndex: 0,
28
+ pageHeight: 1056,
29
+ pageWidth: 816,
30
+ pageContentHeight: 900,
31
+ headerBand: 78,
32
+ footerBand: 78,
33
+ titlePage: false,
34
+ hasEvenHeader: false,
35
+ },
36
+ ];
37
+
38
+ function buildDoc(paragraphCount: number) {
39
+ const { doc, paragraph } = docxSchema.nodes;
40
+ return doc.create(
41
+ {},
42
+ Array.from({ length: paragraphCount }, (_, i) =>
43
+ paragraph.create({}, [docxSchema.text(`paragraph ${i + 1}`)]),
44
+ ),
45
+ );
46
+ }
47
+
48
+ function buildLayout(pages: number, blocksPerPage: number): LayoutResult {
49
+ return {
50
+ totalPages: pages,
51
+ pages: Array.from({ length: pages }, (_, i) => ({
52
+ pageNumber: i + 1,
53
+ sectionIndex: 0,
54
+ firstBlockIndex: i * blocksPerPage,
55
+ lastBlockIndex: i * blocksPerPage + blocksPerPage - 1,
56
+ headerKind: "default" as const,
57
+ isFirstInSection: i === 0,
58
+ startsWithContinuation: null,
59
+ endsWithContinuation: null,
60
+ gapHeightBefore: i === 0 ? 0 : 200,
61
+ })),
62
+ };
63
+ }
64
+
65
+ let host: HTMLDivElement;
66
+ let view: EditorView;
67
+
68
+ function mountView(paragraphCount: number) {
69
+ host = document.createElement("div");
70
+ document.body.appendChild(host);
71
+ const state = EditorState.create({
72
+ schema: docxSchema,
73
+ doc: buildDoc(paragraphCount),
74
+ plugins: [
75
+ paginationPlugin({
76
+ sections,
77
+ sectionGeometries: geometries,
78
+ pageGapPx: 24,
79
+ }),
80
+ ],
81
+ });
82
+ view = new EditorView(host, { state });
83
+ }
84
+
85
+ beforeEach(() => {
86
+ document.body.innerHTML = "";
87
+ });
88
+
89
+ describe("paginationPlugin", () => {
90
+ it("initializes with empty layout", () => {
91
+ mountView(5);
92
+ const ps = getPaginationState(view.state);
93
+ expect(ps?.layout).toBeNull();
94
+ expect(ps?.decorations).toBeTruthy();
95
+ });
96
+
97
+ it("accepts a layout via setPaginationLayout and emits gap decorations", () => {
98
+ mountView(6);
99
+ const layout = buildLayout(3, 2);
100
+ view.dispatch(setPaginationLayout(view, layout));
101
+
102
+ const ps = getPaginationState(view.state);
103
+ expect(ps?.layout).toEqual(layout);
104
+
105
+ // Two gap widgets (between pages 1-2 and 2-3).
106
+ const gaps = host.querySelectorAll(".docx-page-gap");
107
+ expect(gaps).toHaveLength(2);
108
+ gaps.forEach((g) => {
109
+ // Each gap takes the per-page gapHeightBefore (200 in buildLayout).
110
+ expect((g as HTMLElement).style.height).toBe("200px");
111
+ });
112
+ });
113
+
114
+ it("emits no decorations for a single-page layout", () => {
115
+ mountView(2);
116
+ const layout = buildLayout(1, 2);
117
+ view.dispatch(setPaginationLayout(view, layout));
118
+ expect(host.querySelectorAll(".docx-page-gap")).toHaveLength(0);
119
+ });
120
+
121
+ it("positions gap widgets immediately before page-starting paragraphs", () => {
122
+ mountView(6);
123
+ const layout = buildLayout(3, 2);
124
+ view.dispatch(setPaginationLayout(view, layout));
125
+ // Gaps should appear before "paragraph 3" (start of page 2) and
126
+ // "paragraph 5" (start of page 3) in DOM order.
127
+ const children = Array.from(host.querySelector(".ProseMirror")?.children ?? []);
128
+ const texts = children.map((el) => {
129
+ if (el.classList.contains("docx-page-gap")) return "[GAP]";
130
+ return el.textContent;
131
+ });
132
+ expect(texts).toEqual([
133
+ "paragraph 1",
134
+ "paragraph 2",
135
+ "[GAP]",
136
+ "paragraph 3",
137
+ "paragraph 4",
138
+ "[GAP]",
139
+ "paragraph 5",
140
+ "paragraph 6",
141
+ ]);
142
+ });
143
+
144
+ it("retains the same plugin state when re-applying an equal layout", () => {
145
+ mountView(4);
146
+ const layout = buildLayout(2, 2);
147
+ view.dispatch(setPaginationLayout(view, layout));
148
+ const stateBefore = getPaginationState(view.state);
149
+ // Re-dispatch identical layout via setPaginationLayout — this will
150
+ // create a fresh DecorationSet but the layout shape is the same.
151
+ view.dispatch(setPaginationLayout(view, layout));
152
+ const stateAfter = getPaginationState(view.state);
153
+ expect(stateAfter?.layout).toEqual(stateBefore?.layout);
154
+ });
155
+ });