@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,159 @@
|
|
|
1
|
+
import type { ParagraphProperties } from "../model/properties";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Block kinds the layout engine reasons about. Mirrors the OOXML structural
|
|
5
|
+
* categories that affect page-break decisions: paragraphs can split across
|
|
6
|
+
* pages (line-by-line), tables can split at row boundaries, opaque blocks
|
|
7
|
+
* are atomic, and section breaks force a new page.
|
|
8
|
+
*/
|
|
9
|
+
export type BlockKind =
|
|
10
|
+
| "paragraph"
|
|
11
|
+
| "table"
|
|
12
|
+
| "section_break"
|
|
13
|
+
| "opaque_block";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A measured block in the body. Heights are in CSS pixels at 100% zoom.
|
|
17
|
+
*
|
|
18
|
+
* `lineHeights` is non-null only for paragraphs that have measured per-line
|
|
19
|
+
* geometry from the renderer. When null, the block is treated as atomic
|
|
20
|
+
* (cannot be split across pages — must move whole).
|
|
21
|
+
*
|
|
22
|
+
* Tables expose `rowHeights` instead — splits happen at row boundaries.
|
|
23
|
+
*/
|
|
24
|
+
export type MeasuredBlock = {
|
|
25
|
+
index: number;
|
|
26
|
+
sectionIndex: number;
|
|
27
|
+
kind: BlockKind;
|
|
28
|
+
height: number;
|
|
29
|
+
paragraphProperties: ParagraphProperties | null;
|
|
30
|
+
lineHeights: readonly number[] | null;
|
|
31
|
+
rowHeights: readonly number[] | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-section geometry derived from {@link SectionProperties}. Pre-computed
|
|
36
|
+
* once so the engine can be called many times during a session without
|
|
37
|
+
* recomputing margins.
|
|
38
|
+
*/
|
|
39
|
+
export type SectionGeometry = {
|
|
40
|
+
sectionIndex: number;
|
|
41
|
+
/** Inclusive index of the first body block in this section. */
|
|
42
|
+
blockStartIndex: number;
|
|
43
|
+
/** Inclusive index of the last body block in this section. */
|
|
44
|
+
blockEndIndex: number;
|
|
45
|
+
/** Total page height in px (including margins, header band, footer band). */
|
|
46
|
+
pageHeight: number;
|
|
47
|
+
/** Total page width in px (including L/R margins). */
|
|
48
|
+
pageWidth: number;
|
|
49
|
+
/** Usable content height in px (pageHeight minus margins and header/footer bands). */
|
|
50
|
+
pageContentHeight: number;
|
|
51
|
+
/** Reserved band above the content area, in px. */
|
|
52
|
+
headerBand: number;
|
|
53
|
+
/** Reserved band below the content area, in px. */
|
|
54
|
+
footerBand: number;
|
|
55
|
+
/** Whether the section's first page uses the "first" header/footer variant. */
|
|
56
|
+
titlePage: boolean;
|
|
57
|
+
/** Whether the section defines an "even" header (otherwise even pages use default). */
|
|
58
|
+
hasEvenHeader: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The set of constants the engine uses when picking break points.
|
|
63
|
+
* Tunable per call so consumers can experiment with different widow/orphan
|
|
64
|
+
* thresholds or visual page gaps without forking the engine.
|
|
65
|
+
*/
|
|
66
|
+
export type LayoutOptions = {
|
|
67
|
+
/**
|
|
68
|
+
* Minimum number of lines from a paragraph that may appear at the
|
|
69
|
+
* bottom of a page when the paragraph continues on the next page
|
|
70
|
+
* (orphan protection). Word's default is 2.
|
|
71
|
+
*/
|
|
72
|
+
minLinesBeforeBreak: number;
|
|
73
|
+
/**
|
|
74
|
+
* Minimum number of lines from a paragraph that may appear at the
|
|
75
|
+
* top of a page when the paragraph started on the previous page
|
|
76
|
+
* (widow protection). Word's default is 2.
|
|
77
|
+
*/
|
|
78
|
+
minLinesAfterBreak: number;
|
|
79
|
+
/**
|
|
80
|
+
* Visual gap between page rectangles, in CSS px. Must match the value
|
|
81
|
+
* the renderer uses for the page-chrome stack so the engine's
|
|
82
|
+
* `gapHeightBefore` lands the flow content on the next page's body slot.
|
|
83
|
+
*/
|
|
84
|
+
visualPageGapPx: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_LAYOUT_OPTIONS: LayoutOptions = {
|
|
88
|
+
minLinesBeforeBreak: 2,
|
|
89
|
+
minLinesAfterBreak: 2,
|
|
90
|
+
visualPageGapPx: 24,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Identifies which variant of the header/footer to render on a given page.
|
|
95
|
+
* Maps directly to OOXML's `w:hdrRef`/`w:ftrRef` type attribute.
|
|
96
|
+
*/
|
|
97
|
+
export type HeaderFooterKind = "default" | "first" | "even";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* When a block must split visually across pages, this records the slice
|
|
101
|
+
* that lands on a given page. Lines are 0-based.
|
|
102
|
+
*
|
|
103
|
+
* For paragraphs: `firstLine`..`lastLine` indexes into `MeasuredBlock.lineHeights`.
|
|
104
|
+
* For tables: `firstLine`..`lastLine` indexes into `MeasuredBlock.rowHeights`.
|
|
105
|
+
*
|
|
106
|
+
* If a page contains a partialBlock at its end, the same block also appears
|
|
107
|
+
* as a partialBlock at the start of the next page (continuation).
|
|
108
|
+
*/
|
|
109
|
+
export type PartialBlockSlice = {
|
|
110
|
+
blockIndex: number;
|
|
111
|
+
firstLine: number;
|
|
112
|
+
lastLine: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type Page = {
|
|
116
|
+
pageNumber: number;
|
|
117
|
+
sectionIndex: number;
|
|
118
|
+
/**
|
|
119
|
+
* The first block whose content (in whole or in part) appears on this page.
|
|
120
|
+
* If the page begins with a continuation of a split block, this is that block.
|
|
121
|
+
*/
|
|
122
|
+
firstBlockIndex: number;
|
|
123
|
+
/**
|
|
124
|
+
* The last block whose content (in whole or in part) appears on this page.
|
|
125
|
+
* If the page ends with the start of a split block, this is that block.
|
|
126
|
+
*/
|
|
127
|
+
lastBlockIndex: number;
|
|
128
|
+
/** Which header variant this page renders. */
|
|
129
|
+
headerKind: HeaderFooterKind;
|
|
130
|
+
/** True if this is the first page of its section. */
|
|
131
|
+
isFirstInSection: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* If the page begins with a continuation of a block whose start was on
|
|
134
|
+
* the previous page, this records the slice taken by this page.
|
|
135
|
+
* Null when the page begins on a fresh block boundary.
|
|
136
|
+
*/
|
|
137
|
+
startsWithContinuation: PartialBlockSlice | null;
|
|
138
|
+
/**
|
|
139
|
+
* If the page ends with the start of a block whose remainder will appear
|
|
140
|
+
* on the next page, this records the slice taken by this page.
|
|
141
|
+
* Null when the page ends on a fresh block boundary.
|
|
142
|
+
*/
|
|
143
|
+
endsWithContinuation: PartialBlockSlice | null;
|
|
144
|
+
/**
|
|
145
|
+
* Height (in CSS px) of the spacer the renderer must insert *immediately
|
|
146
|
+
* before* this page's first block in the editor's flowing content. The
|
|
147
|
+
* spacer bridges the unused body-slot space at the bottom of the previous
|
|
148
|
+
* page, the visual page gap, and the header band of this page so that
|
|
149
|
+
* the first block on this page aligns with the chrome layer's body slot.
|
|
150
|
+
*
|
|
151
|
+
* 0 for page 1 — no spacer needed before the first block of the document.
|
|
152
|
+
*/
|
|
153
|
+
gapHeightBefore: number;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export type LayoutResult = {
|
|
157
|
+
pages: readonly Page[];
|
|
158
|
+
totalPages: number;
|
|
159
|
+
};
|
package/src/load.test.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
createBlankDocx,
|
|
8
|
+
saveDocxToBuffer,
|
|
9
|
+
appendElement,
|
|
10
|
+
} from "@lotics/ooxml/document";
|
|
11
|
+
import { buildRun } from "@lotics/ooxml/builders";
|
|
12
|
+
import type { XmlElement } from "@lotics/ooxml/xml";
|
|
13
|
+
import { loadDocxIntoElement } from "./load";
|
|
14
|
+
|
|
15
|
+
function paragraph(children: XmlElement[]): XmlElement {
|
|
16
|
+
return { "w:p": children };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function buildSampleBytes(): Promise<Uint8Array> {
|
|
20
|
+
const doc = createBlankDocx();
|
|
21
|
+
appendElement(doc, paragraph([buildRun("Headline", { bold: true })]));
|
|
22
|
+
appendElement(doc, paragraph([buildRun("Body text follows.")]));
|
|
23
|
+
appendElement(
|
|
24
|
+
doc,
|
|
25
|
+
paragraph([
|
|
26
|
+
buildRun("Plain "),
|
|
27
|
+
buildRun("italicized", { italic: true }),
|
|
28
|
+
buildRun(" tail."),
|
|
29
|
+
]),
|
|
30
|
+
);
|
|
31
|
+
const buffer = await saveDocxToBuffer(doc);
|
|
32
|
+
return new Uint8Array(buffer);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let host: HTMLElement;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
document.body.innerHTML = "";
|
|
39
|
+
host = document.createElement("div");
|
|
40
|
+
document.body.appendChild(host);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("loadDocxIntoElement", () => {
|
|
44
|
+
it("mounts a docx end-to-end into the host element", async () => {
|
|
45
|
+
const bytes = await buildSampleBytes();
|
|
46
|
+
const loaded = await loadDocxIntoElement(host, bytes);
|
|
47
|
+
|
|
48
|
+
expect(host.querySelector(".docx-page-stack")).not.toBeNull();
|
|
49
|
+
expect(host.textContent).toContain("Headline");
|
|
50
|
+
expect(host.textContent).toContain("Body text follows.");
|
|
51
|
+
expect(host.textContent).toContain("italicized");
|
|
52
|
+
|
|
53
|
+
expect(host.querySelector("strong")?.textContent).toBe("Headline");
|
|
54
|
+
expect(host.querySelector("em")?.textContent).toBe("italicized");
|
|
55
|
+
|
|
56
|
+
loaded.view.destroy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns the parsed document, style table, and sections", async () => {
|
|
60
|
+
const bytes = await buildSampleBytes();
|
|
61
|
+
const loaded = await loadDocxIntoElement(host, bytes);
|
|
62
|
+
|
|
63
|
+
expect(loaded.document.body.children.length).toBeGreaterThan(0);
|
|
64
|
+
expect(loaded.sections.length).toBeGreaterThan(0);
|
|
65
|
+
expect(loaded.sections[0].properties.pageSize.width).toBe(12240);
|
|
66
|
+
expect(loaded.styleTable).toBeDefined();
|
|
67
|
+
|
|
68
|
+
loaded.view.destroy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("emits @font-face stylesheet for the bundled fonts", async () => {
|
|
72
|
+
const bytes = await buildSampleBytes();
|
|
73
|
+
const loaded = await loadDocxIntoElement(host, bytes);
|
|
74
|
+
|
|
75
|
+
const styleEl = host.querySelector(
|
|
76
|
+
"style[data-docx-editor-styles]",
|
|
77
|
+
) as HTMLStyleElement;
|
|
78
|
+
expect(styleEl.textContent).toContain("@font-face");
|
|
79
|
+
expect(styleEl.textContent).toContain("Liberation Serif");
|
|
80
|
+
|
|
81
|
+
loaded.view.destroy();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("renders a non-editable view by default", async () => {
|
|
85
|
+
const bytes = await buildSampleBytes();
|
|
86
|
+
const loaded = await loadDocxIntoElement(host, bytes);
|
|
87
|
+
expect(loaded.view.editorView.editable).toBe(false);
|
|
88
|
+
loaded.view.destroy();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("destroy() cleans up the page and the stylesheet", async () => {
|
|
92
|
+
const bytes = await buildSampleBytes();
|
|
93
|
+
const loaded = await loadDocxIntoElement(host, bytes);
|
|
94
|
+
loaded.view.destroy();
|
|
95
|
+
expect(host.querySelector(".docx-canvas")).toBeNull();
|
|
96
|
+
expect(host.querySelector("style[data-docx-editor-styles]")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("renders the pretty-printed Vietnamese contract fixture without unsupported-content placeholders", async () => {
|
|
100
|
+
const fixturePath = resolve(
|
|
101
|
+
fileURLToPath(import.meta.url),
|
|
102
|
+
"../fixtures/lotics_generated_contract.docx",
|
|
103
|
+
);
|
|
104
|
+
const bytes = new Uint8Array(readFileSync(fixturePath));
|
|
105
|
+
const loaded = await loadDocxIntoElement(host, bytes);
|
|
106
|
+
|
|
107
|
+
const placeholders = host.querySelectorAll(
|
|
108
|
+
".docx-opaque-block__placeholder",
|
|
109
|
+
);
|
|
110
|
+
expect(
|
|
111
|
+
placeholders.length,
|
|
112
|
+
`expected zero "[unsupported content]" boxes; found ${placeholders.length}`,
|
|
113
|
+
).toBe(0);
|
|
114
|
+
|
|
115
|
+
const opaqueInlines = host.querySelectorAll("[data-opaque-inline]");
|
|
116
|
+
expect(
|
|
117
|
+
opaqueInlines.length,
|
|
118
|
+
`expected zero opaque-inline wrapper spans; found ${opaqueInlines.length}`,
|
|
119
|
+
).toBe(0);
|
|
120
|
+
|
|
121
|
+
expect(host.querySelectorAll("table").length).toBe(2);
|
|
122
|
+
expect(host.textContent).toContain("HỢP ĐỒNG MUA BÁN");
|
|
123
|
+
expect(host.textContent).toContain("ĐIỀU 1. ĐỐI TƯỢNG HỢP ĐỒNG");
|
|
124
|
+
|
|
125
|
+
loaded.view.destroy();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("accepts workspace fonts override", async () => {
|
|
129
|
+
const bytes = await buildSampleBytes();
|
|
130
|
+
const loaded = await loadDocxIntoElement(host, bytes, {
|
|
131
|
+
workspaceFonts: [
|
|
132
|
+
{
|
|
133
|
+
family: "Calibri",
|
|
134
|
+
regular: { fileId: "fil_workspace_calibri" },
|
|
135
|
+
bold: null,
|
|
136
|
+
italic: null,
|
|
137
|
+
boldItalic: null,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
expect(loaded.view.editorView).toBeDefined();
|
|
142
|
+
loaded.view.destroy();
|
|
143
|
+
});
|
|
144
|
+
});
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { BundledFont, WorkspaceFont } from "./fonts/types";
|
|
2
|
+
import { buildFontRegistry } from "./fonts/registry";
|
|
3
|
+
import { buildBundledManifest } from "./fonts/bundled";
|
|
4
|
+
import { parseDocx } from "./parse/parser";
|
|
5
|
+
import { parseStyleTable } from "./parse/styles";
|
|
6
|
+
import { withDefaultStyles } from "./model/default_styles";
|
|
7
|
+
import { parseFontTable } from "./parse/font_table";
|
|
8
|
+
import { parseBodySections } from "./parse/sections";
|
|
9
|
+
import { parseDocumentRelationships } from "./parse/relationships";
|
|
10
|
+
import { parseNumberingTable } from "./parse/numbering";
|
|
11
|
+
import { DEFAULT_NUMBERING_TABLE } from "./model/default_numbering";
|
|
12
|
+
import { parseThemeTable } from "./parse/theme";
|
|
13
|
+
import { parseHeaderFooterRegistry } from "./parse/header_footer";
|
|
14
|
+
import { parseFootnoteRegistry } from "./parse/footnotes";
|
|
15
|
+
import { docxToPm } from "./pm/docx_to_pm";
|
|
16
|
+
import {
|
|
17
|
+
createReadOnlyView,
|
|
18
|
+
type ReadOnlyView,
|
|
19
|
+
type ViewMode,
|
|
20
|
+
} from "./render/view";
|
|
21
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
22
|
+
import { buildMediaResolver } from "./render/media_resolver";
|
|
23
|
+
import {
|
|
24
|
+
createImageRegistry,
|
|
25
|
+
type ImageRegistry,
|
|
26
|
+
} from "./pm/image_registry";
|
|
27
|
+
import type { DocxDocument } from "./model/types";
|
|
28
|
+
import type { Section } from "./model/sections";
|
|
29
|
+
import type { StyleTable } from "./model/style_table";
|
|
30
|
+
import type { RelationshipMap } from "./parse/relationships";
|
|
31
|
+
|
|
32
|
+
export type LoadDocxOptions = {
|
|
33
|
+
workspaceFonts?: readonly WorkspaceFont[];
|
|
34
|
+
additionalBundledFonts?: readonly BundledFont[];
|
|
35
|
+
mode?: ViewMode;
|
|
36
|
+
onChange?: (doc: PMNode) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type LoadedDocxView = {
|
|
40
|
+
view: ReadOnlyView;
|
|
41
|
+
document: DocxDocument;
|
|
42
|
+
styleTable: StyleTable;
|
|
43
|
+
sections: readonly Section[];
|
|
44
|
+
relationships: RelationshipMap;
|
|
45
|
+
imageRegistry: ImageRegistry;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export async function loadDocxIntoElement(
|
|
49
|
+
host: HTMLElement,
|
|
50
|
+
bytes: Uint8Array,
|
|
51
|
+
options: LoadDocxOptions = {},
|
|
52
|
+
): Promise<LoadedDocxView> {
|
|
53
|
+
const document = await parseDocx(bytes);
|
|
54
|
+
const styleTable = withDefaultStyles(parseStyleTable(document.parts));
|
|
55
|
+
// Merge the bundled bullets/decimals onto the parsed numbering table.
|
|
56
|
+
// Parsed entries win; bundled entries cover numIds the file didn't
|
|
57
|
+
// declare (e.g., a paragraph references numId 1 for bullets but the
|
|
58
|
+
// file has no numbering.xml entry for it). Pure replacement would lose
|
|
59
|
+
// markers for paragraphs whose numIds aren't in either table; merging
|
|
60
|
+
// covers both partial parsed tables and missing-numbering.xml files.
|
|
61
|
+
const parsedNumbering = parseNumberingTable(document.parts);
|
|
62
|
+
const numberingTable = mergeNumberingTables(parsedNumbering, DEFAULT_NUMBERING_TABLE);
|
|
63
|
+
const themeTable = parseThemeTable(document.parts);
|
|
64
|
+
const embeddedFonts = parseFontTable(document.parts);
|
|
65
|
+
const sections = parseBodySections(document.body);
|
|
66
|
+
const relationships = parseDocumentRelationships(document.parts);
|
|
67
|
+
const headerFooterRegistry = parseHeaderFooterRegistry(
|
|
68
|
+
document.parts,
|
|
69
|
+
relationships,
|
|
70
|
+
);
|
|
71
|
+
const footnoteRegistry = parseFootnoteRegistry(document.parts);
|
|
72
|
+
|
|
73
|
+
const additionalBundled = options.additionalBundledFonts ?? [];
|
|
74
|
+
const bundled =
|
|
75
|
+
additionalBundled.length > 0
|
|
76
|
+
? buildBundledManifest(undefined, undefined, additionalBundled)
|
|
77
|
+
: undefined;
|
|
78
|
+
|
|
79
|
+
const fontRegistry = buildFontRegistry({
|
|
80
|
+
embeddedFonts,
|
|
81
|
+
workspaceFonts: options.workspaceFonts ?? [],
|
|
82
|
+
bundled,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const mediaResolver = buildMediaResolver(document.parts, relationships);
|
|
86
|
+
|
|
87
|
+
const imageRegistry = createImageRegistry({
|
|
88
|
+
existingRelationshipIds: new Set(relationships.keys()),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const pmDoc = docxToPm(document, { styleTable, numberingTable });
|
|
92
|
+
const innerView = createReadOnlyView(host, pmDoc, {
|
|
93
|
+
fontRegistry,
|
|
94
|
+
sections,
|
|
95
|
+
relationships,
|
|
96
|
+
mediaResolver,
|
|
97
|
+
imageRegistry,
|
|
98
|
+
numberingTable,
|
|
99
|
+
themeTable,
|
|
100
|
+
headerFooterRegistry,
|
|
101
|
+
footnoteRegistry,
|
|
102
|
+
mode: options.mode ?? "read-only",
|
|
103
|
+
onChange: options.onChange,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const view: ReadOnlyView = {
|
|
107
|
+
...innerView,
|
|
108
|
+
destroy: () => {
|
|
109
|
+
innerView.destroy();
|
|
110
|
+
mediaResolver.destroy();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
view,
|
|
116
|
+
document,
|
|
117
|
+
styleTable,
|
|
118
|
+
sections,
|
|
119
|
+
relationships,
|
|
120
|
+
imageRegistry,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Merge two numbering tables. Parsed entries take precedence; defaults
|
|
126
|
+
* fill in any abstractNumIds / numIds the parsed table doesn't have.
|
|
127
|
+
*
|
|
128
|
+
* Use case: a file declares its own bullet numbering as numId 1 but a
|
|
129
|
+
* paragraph references numId 5 with no matching definition. Without a
|
|
130
|
+
* merge fallback, that paragraph would render markerless. The bundled
|
|
131
|
+
* defaults cover the common cases (numId 1 = bullets, numId 2 = decimals).
|
|
132
|
+
*/
|
|
133
|
+
function mergeNumberingTables(
|
|
134
|
+
parsed: import("./model/numbering_table").NumberingTable,
|
|
135
|
+
defaults: import("./model/numbering_table").NumberingTable,
|
|
136
|
+
): import("./model/numbering_table").NumberingTable {
|
|
137
|
+
const abstractNums = new Map(defaults.abstractNums);
|
|
138
|
+
for (const [id, value] of parsed.abstractNums) abstractNums.set(id, value);
|
|
139
|
+
const nums = new Map(defaults.nums);
|
|
140
|
+
for (const [id, value] of parsed.nums) nums.set(id, value);
|
|
141
|
+
return { abstractNums, nums };
|
|
142
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AbstractNum,
|
|
3
|
+
Num,
|
|
4
|
+
NumberingLevel,
|
|
5
|
+
NumberingTable,
|
|
6
|
+
} from "./numbering_table";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default numbering definitions used when a parsed document lacks a
|
|
10
|
+
* `numbering.xml` part (or when constructing a doc programmatically — e.g.,
|
|
11
|
+
* the dev demo). Mirrors the two `numId`s the editor's list commands
|
|
12
|
+
* emit:
|
|
13
|
+
*
|
|
14
|
+
* numId 1 = bullets (• ◦ ▪ alternating per indent level)
|
|
15
|
+
* numId 2 = decimal numbers (1. 1.1. 1.1.1. etc.)
|
|
16
|
+
*
|
|
17
|
+
* Each numId references an `abstractNum` whose levels carry `lvlText`
|
|
18
|
+
* templates that the {@link computeLabel} renderer expands at decoration
|
|
19
|
+
* time. The labels visible to the reader are produced by
|
|
20
|
+
* {@link numberingDecorationPlugin}, never by mutating the doc model.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function level(args: {
|
|
24
|
+
ilvl: number;
|
|
25
|
+
numFmt: NumberingLevel["numFmt"];
|
|
26
|
+
lvlText: string;
|
|
27
|
+
start?: number;
|
|
28
|
+
}): NumberingLevel {
|
|
29
|
+
return {
|
|
30
|
+
ilvl: args.ilvl,
|
|
31
|
+
start: args.start ?? 1,
|
|
32
|
+
numFmt: args.numFmt,
|
|
33
|
+
lvlText: args.lvlText,
|
|
34
|
+
lvlJc: "left",
|
|
35
|
+
lvlRestart: null,
|
|
36
|
+
paragraphProperties: null,
|
|
37
|
+
runProperties: null,
|
|
38
|
+
suff: "tab",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function abstractNum(args: {
|
|
43
|
+
abstractNumId: number;
|
|
44
|
+
levels: readonly NumberingLevel[];
|
|
45
|
+
}): AbstractNum {
|
|
46
|
+
const map = new Map<number, NumberingLevel>();
|
|
47
|
+
for (const lvl of args.levels) map.set(lvl.ilvl, lvl);
|
|
48
|
+
return { abstractNumId: args.abstractNumId, levels: map };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function num(args: { numId: number; abstractNumId: number }): Num {
|
|
52
|
+
return {
|
|
53
|
+
numId: args.numId,
|
|
54
|
+
abstractNumId: args.abstractNumId,
|
|
55
|
+
overrides: new Map(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Word's default templates define 9 levels (ilvl 0..8). We mirror that
|
|
60
|
+
// so deeply-nested lists in customer documents still render markers,
|
|
61
|
+
// rather than silently dropping anything past level 3.
|
|
62
|
+
const bulletAbstract: AbstractNum = abstractNum({
|
|
63
|
+
abstractNumId: 0,
|
|
64
|
+
levels: [
|
|
65
|
+
level({ ilvl: 0, numFmt: "bullet", lvlText: "•" }),
|
|
66
|
+
level({ ilvl: 1, numFmt: "bullet", lvlText: "◦" }),
|
|
67
|
+
level({ ilvl: 2, numFmt: "bullet", lvlText: "▪" }),
|
|
68
|
+
level({ ilvl: 3, numFmt: "bullet", lvlText: "•" }),
|
|
69
|
+
level({ ilvl: 4, numFmt: "bullet", lvlText: "◦" }),
|
|
70
|
+
level({ ilvl: 5, numFmt: "bullet", lvlText: "▪" }),
|
|
71
|
+
level({ ilvl: 6, numFmt: "bullet", lvlText: "•" }),
|
|
72
|
+
level({ ilvl: 7, numFmt: "bullet", lvlText: "◦" }),
|
|
73
|
+
level({ ilvl: 8, numFmt: "bullet", lvlText: "▪" }),
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const decimalAbstract: AbstractNum = abstractNum({
|
|
78
|
+
abstractNumId: 1,
|
|
79
|
+
levels: [
|
|
80
|
+
level({ ilvl: 0, numFmt: "decimal", lvlText: "%1." }),
|
|
81
|
+
level({ ilvl: 1, numFmt: "decimal", lvlText: "%1.%2." }),
|
|
82
|
+
level({ ilvl: 2, numFmt: "decimal", lvlText: "%1.%2.%3." }),
|
|
83
|
+
level({ ilvl: 3, numFmt: "lowerLetter", lvlText: "%4." }),
|
|
84
|
+
level({ ilvl: 4, numFmt: "decimal", lvlText: "%5." }),
|
|
85
|
+
level({ ilvl: 5, numFmt: "lowerLetter", lvlText: "%6." }),
|
|
86
|
+
level({ ilvl: 6, numFmt: "lowerRoman", lvlText: "%7." }),
|
|
87
|
+
level({ ilvl: 7, numFmt: "decimal", lvlText: "%8." }),
|
|
88
|
+
level({ ilvl: 8, numFmt: "lowerLetter", lvlText: "%9." }),
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const DEFAULT_NUMBERING_TABLE: NumberingTable = {
|
|
93
|
+
abstractNums: new Map([
|
|
94
|
+
[bulletAbstract.abstractNumId, bulletAbstract],
|
|
95
|
+
[decimalAbstract.abstractNumId, decimalAbstract],
|
|
96
|
+
]),
|
|
97
|
+
nums: new Map([
|
|
98
|
+
[1, num({ numId: 1, abstractNumId: 0 })],
|
|
99
|
+
[2, num({ numId: 2, abstractNumId: 1 })],
|
|
100
|
+
]),
|
|
101
|
+
};
|