@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,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAttr,
|
|
3
|
+
getChildren,
|
|
4
|
+
findChild,
|
|
5
|
+
getTagName,
|
|
6
|
+
type XmlElement,
|
|
7
|
+
} from "@lotics/ooxml/xml";
|
|
8
|
+
import type { Body, Block } from "../model/types";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_COLUMNS,
|
|
11
|
+
DEFAULT_PAGE_MARGINS,
|
|
12
|
+
DEFAULT_PAGE_SIZE,
|
|
13
|
+
DEFAULT_SECTION_PROPERTIES,
|
|
14
|
+
type Column,
|
|
15
|
+
type Columns,
|
|
16
|
+
type HeaderFooterRef,
|
|
17
|
+
type HeaderFooterType,
|
|
18
|
+
type Orientation,
|
|
19
|
+
type PageMargins,
|
|
20
|
+
type PageSize,
|
|
21
|
+
type Section,
|
|
22
|
+
type SectionProperties,
|
|
23
|
+
type SectionType,
|
|
24
|
+
} from "../model/sections";
|
|
25
|
+
|
|
26
|
+
function parseIntOrDefault(value: string | undefined, fallback: number): number {
|
|
27
|
+
if (value === undefined) return fallback;
|
|
28
|
+
const n = Number.parseInt(value, 10);
|
|
29
|
+
return Number.isFinite(n) ? n : fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseIntOrNull(value: string | undefined): number | null {
|
|
33
|
+
if (value === undefined) return null;
|
|
34
|
+
const n = Number.parseInt(value, 10);
|
|
35
|
+
return Number.isFinite(n) ? n : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isOrientation(value: string): value is Orientation {
|
|
39
|
+
return value === "portrait" || value === "landscape";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isSectionType(value: string): value is SectionType {
|
|
43
|
+
return (
|
|
44
|
+
value === "continuous" ||
|
|
45
|
+
value === "evenPage" ||
|
|
46
|
+
value === "nextColumn" ||
|
|
47
|
+
value === "nextPage" ||
|
|
48
|
+
value === "oddPage"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isHeaderFooterType(value: string): value is HeaderFooterType {
|
|
53
|
+
return value === "default" || value === "first" || value === "even";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parsePageSize(el: XmlElement | undefined): PageSize {
|
|
57
|
+
if (!el) return DEFAULT_PAGE_SIZE;
|
|
58
|
+
const orientStr = getAttr(el, "w:orient");
|
|
59
|
+
const orientation: Orientation =
|
|
60
|
+
orientStr && isOrientation(orientStr) ? orientStr : "portrait";
|
|
61
|
+
const width = parseIntOrDefault(getAttr(el, "w:w"), DEFAULT_PAGE_SIZE.width);
|
|
62
|
+
const height = parseIntOrDefault(getAttr(el, "w:h"), DEFAULT_PAGE_SIZE.height);
|
|
63
|
+
return { width, height, orientation };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseMargins(el: XmlElement | undefined): PageMargins {
|
|
67
|
+
if (!el) return DEFAULT_PAGE_MARGINS;
|
|
68
|
+
return {
|
|
69
|
+
top: parseIntOrDefault(getAttr(el, "w:top"), DEFAULT_PAGE_MARGINS.top),
|
|
70
|
+
right: parseIntOrDefault(getAttr(el, "w:right"), DEFAULT_PAGE_MARGINS.right),
|
|
71
|
+
bottom: parseIntOrDefault(getAttr(el, "w:bottom"), DEFAULT_PAGE_MARGINS.bottom),
|
|
72
|
+
left: parseIntOrDefault(getAttr(el, "w:left"), DEFAULT_PAGE_MARGINS.left),
|
|
73
|
+
header: parseIntOrDefault(getAttr(el, "w:header"), DEFAULT_PAGE_MARGINS.header),
|
|
74
|
+
footer: parseIntOrDefault(getAttr(el, "w:footer"), DEFAULT_PAGE_MARGINS.footer),
|
|
75
|
+
gutter: parseIntOrDefault(getAttr(el, "w:gutter"), DEFAULT_PAGE_MARGINS.gutter),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseColumns(el: XmlElement | undefined): Columns {
|
|
80
|
+
if (!el) return DEFAULT_COLUMNS;
|
|
81
|
+
const count = parseIntOrDefault(getAttr(el, "w:num"), 1);
|
|
82
|
+
const space = parseIntOrNull(getAttr(el, "w:space"));
|
|
83
|
+
const equalWidth = getAttr(el, "w:equalWidth") !== "0";
|
|
84
|
+
const separator = getAttr(el, "w:sep") === "1";
|
|
85
|
+
const columns: Column[] = [];
|
|
86
|
+
for (const child of getChildren(el)) {
|
|
87
|
+
if (getTagName(child) !== "w:col") continue;
|
|
88
|
+
columns.push({
|
|
89
|
+
width: parseIntOrNull(getAttr(child, "w:w")),
|
|
90
|
+
space: parseIntOrNull(getAttr(child, "w:space")),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return { count, space, equalWidth, separator, columns };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseHeaderFooterRefs(
|
|
97
|
+
sectPr: XmlElement,
|
|
98
|
+
refTag: "w:headerReference" | "w:footerReference",
|
|
99
|
+
): HeaderFooterRef[] {
|
|
100
|
+
const refs: HeaderFooterRef[] = [];
|
|
101
|
+
for (const child of getChildren(sectPr)) {
|
|
102
|
+
if (getTagName(child) !== refTag) continue;
|
|
103
|
+
const typeStr = getAttr(child, "w:type") ?? "default";
|
|
104
|
+
if (!isHeaderFooterType(typeStr)) continue;
|
|
105
|
+
const rId = getAttr(child, "r:id");
|
|
106
|
+
if (!rId) continue;
|
|
107
|
+
refs.push({ type: typeStr, relationshipId: rId });
|
|
108
|
+
}
|
|
109
|
+
return refs;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseSectionProperties(sectPr: XmlElement): SectionProperties {
|
|
113
|
+
const typeChild = findChild(sectPr, "w:type");
|
|
114
|
+
const typeStr = typeChild ? getAttr(typeChild, "w:val") : undefined;
|
|
115
|
+
const type: SectionType =
|
|
116
|
+
typeStr && isSectionType(typeStr) ? typeStr : "nextPage";
|
|
117
|
+
|
|
118
|
+
const pageSize = parsePageSize(findChild(sectPr, "w:pgSz"));
|
|
119
|
+
const margins = parseMargins(findChild(sectPr, "w:pgMar"));
|
|
120
|
+
const columns = parseColumns(findChild(sectPr, "w:cols"));
|
|
121
|
+
const titlePage = findChild(sectPr, "w:titlePg") !== undefined;
|
|
122
|
+
const headerRefs = parseHeaderFooterRefs(sectPr, "w:headerReference");
|
|
123
|
+
const footerRefs = parseHeaderFooterRefs(sectPr, "w:footerReference");
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
type,
|
|
127
|
+
pageSize,
|
|
128
|
+
margins,
|
|
129
|
+
columns,
|
|
130
|
+
titlePage,
|
|
131
|
+
headerRefs,
|
|
132
|
+
footerRefs,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function findParagraphSectPr(block: Block): XmlElement | null {
|
|
137
|
+
if (block.kind !== "paragraph") return null;
|
|
138
|
+
if (block.properties === null) return null;
|
|
139
|
+
for (const propEl of block.properties) {
|
|
140
|
+
if (getTagName(propEl) === "w:sectPr") return propEl;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parseBodySections(body: Body): Section[] {
|
|
146
|
+
const sections: Section[] = [];
|
|
147
|
+
let sectionStart = 0;
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < body.children.length; i++) {
|
|
150
|
+
const block = body.children[i];
|
|
151
|
+
|
|
152
|
+
if (block.kind === "body_sect_pr") {
|
|
153
|
+
sections.push({
|
|
154
|
+
properties: parseSectionProperties(block.xml),
|
|
155
|
+
blockStartIndex: sectionStart,
|
|
156
|
+
blockEndIndex: i - 1,
|
|
157
|
+
});
|
|
158
|
+
sectionStart = i + 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const inlineSectPr = findParagraphSectPr(block);
|
|
163
|
+
if (inlineSectPr !== null) {
|
|
164
|
+
sections.push({
|
|
165
|
+
properties: parseSectionProperties(inlineSectPr),
|
|
166
|
+
blockStartIndex: sectionStart,
|
|
167
|
+
blockEndIndex: i,
|
|
168
|
+
});
|
|
169
|
+
sectionStart = i + 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (sections.length === 0) {
|
|
174
|
+
sections.push({
|
|
175
|
+
properties: { ...DEFAULT_SECTION_PROPERTIES },
|
|
176
|
+
blockStartIndex: 0,
|
|
177
|
+
blockEndIndex: body.children.length - 1,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return sections;
|
|
182
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseXml,
|
|
3
|
+
getTagName,
|
|
4
|
+
getChildren,
|
|
5
|
+
findChild,
|
|
6
|
+
getAttr,
|
|
7
|
+
type XmlElement,
|
|
8
|
+
} from "@lotics/ooxml/xml";
|
|
9
|
+
import {
|
|
10
|
+
EMPTY_PARAGRAPH_PROPERTIES,
|
|
11
|
+
EMPTY_RUN_PROPERTIES,
|
|
12
|
+
extractParagraphProperties,
|
|
13
|
+
extractRunProperties,
|
|
14
|
+
type ParagraphProperties,
|
|
15
|
+
type RunProperties,
|
|
16
|
+
} from "../model/properties";
|
|
17
|
+
import type { Style, StyleTable, StyleType } from "../model/style_table";
|
|
18
|
+
|
|
19
|
+
export function parseStyleTable(parts: ReadonlyMap<string, Uint8Array>): StyleTable {
|
|
20
|
+
const stylesBytes = parts.get("word/styles.xml");
|
|
21
|
+
if (!stylesBytes) {
|
|
22
|
+
return {
|
|
23
|
+
paragraphDefaults: { ...EMPTY_PARAGRAPH_PROPERTIES },
|
|
24
|
+
runDefaults: { ...EMPTY_RUN_PROPERTIES },
|
|
25
|
+
styles: new Map(),
|
|
26
|
+
defaultParagraphStyleId: null,
|
|
27
|
+
defaultCharacterStyleId: null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const xml = new TextDecoder("utf-8").decode(stylesBytes);
|
|
32
|
+
const parsed = parseXml(xml);
|
|
33
|
+
const stylesEl = parsed.find((el) => getTagName(el) === "w:styles");
|
|
34
|
+
|
|
35
|
+
if (!stylesEl) {
|
|
36
|
+
return {
|
|
37
|
+
paragraphDefaults: { ...EMPTY_PARAGRAPH_PROPERTIES },
|
|
38
|
+
runDefaults: { ...EMPTY_RUN_PROPERTIES },
|
|
39
|
+
styles: new Map(),
|
|
40
|
+
defaultParagraphStyleId: null,
|
|
41
|
+
defaultCharacterStyleId: null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let paragraphDefaults = { ...EMPTY_PARAGRAPH_PROPERTIES };
|
|
46
|
+
let runDefaults = { ...EMPTY_RUN_PROPERTIES };
|
|
47
|
+
const styles = new Map<string, Style>();
|
|
48
|
+
let defaultParagraphStyleId: string | null = null;
|
|
49
|
+
let defaultCharacterStyleId: string | null = null;
|
|
50
|
+
|
|
51
|
+
for (const child of getChildren(stylesEl)) {
|
|
52
|
+
const tag = getTagName(child);
|
|
53
|
+
if (tag === "w:docDefaults") {
|
|
54
|
+
const result = parseDocDefaults(child);
|
|
55
|
+
paragraphDefaults = result.paragraph;
|
|
56
|
+
runDefaults = result.run;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (tag === "w:style") {
|
|
60
|
+
const style = parseStyle(child);
|
|
61
|
+
if (!style) continue;
|
|
62
|
+
styles.set(style.id, style);
|
|
63
|
+
if (style.isDefault) {
|
|
64
|
+
if (style.type === "paragraph" && defaultParagraphStyleId === null) {
|
|
65
|
+
defaultParagraphStyleId = style.id;
|
|
66
|
+
} else if (style.type === "character" && defaultCharacterStyleId === null) {
|
|
67
|
+
defaultCharacterStyleId = style.id;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
paragraphDefaults,
|
|
75
|
+
runDefaults,
|
|
76
|
+
styles,
|
|
77
|
+
defaultParagraphStyleId,
|
|
78
|
+
defaultCharacterStyleId,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseDocDefaults(
|
|
83
|
+
el: XmlElement,
|
|
84
|
+
): { paragraph: ParagraphProperties; run: RunProperties } {
|
|
85
|
+
let paragraph = { ...EMPTY_PARAGRAPH_PROPERTIES };
|
|
86
|
+
let run = { ...EMPTY_RUN_PROPERTIES };
|
|
87
|
+
|
|
88
|
+
const rPrDefault = findChild(el, "w:rPrDefault");
|
|
89
|
+
if (rPrDefault) {
|
|
90
|
+
const rPr = findChild(rPrDefault, "w:rPr");
|
|
91
|
+
if (rPr) run = extractRunProperties(getChildren(rPr));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pPrDefault = findChild(el, "w:pPrDefault");
|
|
95
|
+
if (pPrDefault) {
|
|
96
|
+
const pPr = findChild(pPrDefault, "w:pPr");
|
|
97
|
+
if (pPr) paragraph = extractParagraphProperties(getChildren(pPr));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { paragraph, run };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseStyle(el: XmlElement): Style | null {
|
|
104
|
+
const typeStr = getAttr(el, "w:type");
|
|
105
|
+
const id = getAttr(el, "w:styleId");
|
|
106
|
+
if (!id || !typeStr) return null;
|
|
107
|
+
if (
|
|
108
|
+
typeStr !== "paragraph" &&
|
|
109
|
+
typeStr !== "character" &&
|
|
110
|
+
typeStr !== "table" &&
|
|
111
|
+
typeStr !== "numbering"
|
|
112
|
+
) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const type: StyleType = typeStr;
|
|
116
|
+
const isDefault = getAttr(el, "w:default") === "1";
|
|
117
|
+
|
|
118
|
+
let name: string | null = null;
|
|
119
|
+
let basedOn: string | null = null;
|
|
120
|
+
let linkedStyleId: string | null = null;
|
|
121
|
+
let paragraphProperties: ParagraphProperties | null = null;
|
|
122
|
+
let runProperties: RunProperties | null = null;
|
|
123
|
+
|
|
124
|
+
for (const child of getChildren(el)) {
|
|
125
|
+
const tag = getTagName(child);
|
|
126
|
+
if (tag === "w:name") {
|
|
127
|
+
name = getAttr(child, "w:val") ?? null;
|
|
128
|
+
} else if (tag === "w:basedOn") {
|
|
129
|
+
basedOn = getAttr(child, "w:val") ?? null;
|
|
130
|
+
} else if (tag === "w:link") {
|
|
131
|
+
linkedStyleId = getAttr(child, "w:val") ?? null;
|
|
132
|
+
} else if (tag === "w:pPr") {
|
|
133
|
+
paragraphProperties = extractParagraphProperties(getChildren(child));
|
|
134
|
+
} else if (tag === "w:rPr") {
|
|
135
|
+
runProperties = extractRunProperties(getChildren(child));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
id,
|
|
141
|
+
name,
|
|
142
|
+
type,
|
|
143
|
+
basedOn,
|
|
144
|
+
linkedStyleId,
|
|
145
|
+
paragraphProperties,
|
|
146
|
+
runProperties,
|
|
147
|
+
isDefault,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseThemeTable } from "./theme";
|
|
3
|
+
import { resolveThemeColor } from "../model/theme";
|
|
4
|
+
|
|
5
|
+
const THEME_XML = `<?xml version="1.0"?>
|
|
6
|
+
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office">
|
|
7
|
+
<a:themeElements>
|
|
8
|
+
<a:clrScheme name="Office">
|
|
9
|
+
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
|
|
10
|
+
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
|
|
11
|
+
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
|
|
12
|
+
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
|
|
13
|
+
<a:accent1><a:srgbClr val="5B9BD5"/></a:accent1>
|
|
14
|
+
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
|
|
15
|
+
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
|
|
16
|
+
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
|
|
17
|
+
<a:accent5><a:srgbClr val="4472C4"/></a:accent5>
|
|
18
|
+
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
|
|
19
|
+
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
|
|
20
|
+
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
|
|
21
|
+
</a:clrScheme>
|
|
22
|
+
<a:fontScheme name="Office">
|
|
23
|
+
<a:majorFont><a:latin typeface="Calibri Light"/></a:majorFont>
|
|
24
|
+
<a:minorFont><a:latin typeface="Calibri"/></a:minorFont>
|
|
25
|
+
</a:fontScheme>
|
|
26
|
+
</a:themeElements>
|
|
27
|
+
</a:theme>`;
|
|
28
|
+
|
|
29
|
+
function themeParts() {
|
|
30
|
+
return new Map([["word/theme/theme1.xml", new TextEncoder().encode(THEME_XML)]]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("parseThemeTable", () => {
|
|
34
|
+
it("extracts all 12 theme colors", () => {
|
|
35
|
+
const theme = parseThemeTable(themeParts());
|
|
36
|
+
expect(theme.colors.get("dk1")).toBe("000000");
|
|
37
|
+
expect(theme.colors.get("lt1")).toBe("FFFFFF");
|
|
38
|
+
expect(theme.colors.get("accent1")).toBe("5B9BD5");
|
|
39
|
+
expect(theme.colors.get("hlink")).toBe("0563C1");
|
|
40
|
+
expect(theme.colors.get("folHlink")).toBe("954F72");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("extracts major and minor latin fonts", () => {
|
|
44
|
+
const theme = parseThemeTable(themeParts());
|
|
45
|
+
expect(theme.majorFonts.latin).toBe("Calibri Light");
|
|
46
|
+
expect(theme.minorFonts.latin).toBe("Calibri");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns empty table when theme part is missing", () => {
|
|
50
|
+
const theme = parseThemeTable(new Map());
|
|
51
|
+
expect(theme.colors.size).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("resolveThemeColor", () => {
|
|
56
|
+
it("resolves named theme color (text1 → dk1) without modifiers", () => {
|
|
57
|
+
const theme = parseThemeTable(themeParts());
|
|
58
|
+
expect(resolveThemeColor(theme, "text1", null, null)).toBe("000000");
|
|
59
|
+
expect(resolveThemeColor(theme, "background1", null, null)).toBe("FFFFFF");
|
|
60
|
+
expect(resolveThemeColor(theme, "accent1", null, null)).toBe("5B9BD5");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("applies tint to lighten the base color", () => {
|
|
64
|
+
const theme = parseThemeTable(themeParts());
|
|
65
|
+
const lightened = resolveThemeColor(theme, "accent1", "BF", null);
|
|
66
|
+
expect(lightened).not.toBe("5B9BD5");
|
|
67
|
+
expect(lightened).not.toBeNull();
|
|
68
|
+
if (!lightened) return;
|
|
69
|
+
const r = Number.parseInt(lightened.slice(0, 2), 16);
|
|
70
|
+
expect(r).toBeGreaterThan(0x5b);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("applies shade to darken the base color", () => {
|
|
74
|
+
const theme = parseThemeTable(themeParts());
|
|
75
|
+
const darkened = resolveThemeColor(theme, "accent1", null, "80");
|
|
76
|
+
expect(darkened).not.toBeNull();
|
|
77
|
+
if (!darkened) return;
|
|
78
|
+
const r = Number.parseInt(darkened.slice(0, 2), 16);
|
|
79
|
+
expect(r).toBeLessThan(0x5b);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns null for unknown theme color name", () => {
|
|
83
|
+
const theme = parseThemeTable(themeParts());
|
|
84
|
+
expect(resolveThemeColor(theme, "ghost", null, null)).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseXml,
|
|
3
|
+
getAttr,
|
|
4
|
+
getChildren,
|
|
5
|
+
getTagName,
|
|
6
|
+
type XmlElement,
|
|
7
|
+
} from "@lotics/ooxml/xml";
|
|
8
|
+
import {
|
|
9
|
+
EMPTY_THEME_TABLE,
|
|
10
|
+
type ThemeColorName,
|
|
11
|
+
type ThemeFontVariants,
|
|
12
|
+
type ThemeTable,
|
|
13
|
+
} from "../model/theme";
|
|
14
|
+
|
|
15
|
+
const COLOR_NAMES: ThemeColorName[] = [
|
|
16
|
+
"dk1",
|
|
17
|
+
"lt1",
|
|
18
|
+
"dk2",
|
|
19
|
+
"lt2",
|
|
20
|
+
"accent1",
|
|
21
|
+
"accent2",
|
|
22
|
+
"accent3",
|
|
23
|
+
"accent4",
|
|
24
|
+
"accent5",
|
|
25
|
+
"accent6",
|
|
26
|
+
"hlink",
|
|
27
|
+
"folHlink",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function findChildByLocal(
|
|
31
|
+
el: XmlElement,
|
|
32
|
+
local: string,
|
|
33
|
+
): XmlElement | null {
|
|
34
|
+
for (const child of getChildren(el)) {
|
|
35
|
+
const tag = getTagName(child);
|
|
36
|
+
if (tag && tag.endsWith(`:${local}`)) return child;
|
|
37
|
+
if (tag === local) return child;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readColorValue(el: XmlElement | null): string | null {
|
|
43
|
+
if (!el) return null;
|
|
44
|
+
for (const child of getChildren(el)) {
|
|
45
|
+
const tag = getTagName(child) ?? "";
|
|
46
|
+
if (tag.endsWith(":sysClr")) {
|
|
47
|
+
const last = getAttr(child, "lastClr");
|
|
48
|
+
if (last) return last.toUpperCase();
|
|
49
|
+
} else if (tag.endsWith(":srgbClr")) {
|
|
50
|
+
const v = getAttr(child, "val");
|
|
51
|
+
if (v) return v.toUpperCase();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseFontVariants(el: XmlElement | null): ThemeFontVariants {
|
|
58
|
+
if (!el) return { latin: null, eastAsia: null, complexScript: null };
|
|
59
|
+
const out: ThemeFontVariants = { latin: null, eastAsia: null, complexScript: null };
|
|
60
|
+
for (const child of getChildren(el)) {
|
|
61
|
+
const tag = getTagName(child) ?? "";
|
|
62
|
+
if (tag.endsWith(":latin")) out.latin = getAttr(child, "typeface") ?? null;
|
|
63
|
+
else if (tag.endsWith(":ea")) out.eastAsia = getAttr(child, "typeface") ?? null;
|
|
64
|
+
else if (tag.endsWith(":cs"))
|
|
65
|
+
out.complexScript = getAttr(child, "typeface") ?? null;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseThemeTable(
|
|
71
|
+
parts: ReadonlyMap<string, Uint8Array>,
|
|
72
|
+
): ThemeTable {
|
|
73
|
+
let bytes: Uint8Array | undefined;
|
|
74
|
+
for (const [path, value] of parts) {
|
|
75
|
+
if (/^word\/theme\/theme\d+\.xml$/.test(path)) {
|
|
76
|
+
bytes = value;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!bytes) return EMPTY_THEME_TABLE;
|
|
81
|
+
|
|
82
|
+
const xml = new TextDecoder("utf-8").decode(bytes);
|
|
83
|
+
const parsed = parseXml(xml);
|
|
84
|
+
const themeEl = parsed.find((el) => {
|
|
85
|
+
const tag = getTagName(el) ?? "";
|
|
86
|
+
return tag.endsWith(":theme") || tag === "theme";
|
|
87
|
+
});
|
|
88
|
+
if (!themeEl) return EMPTY_THEME_TABLE;
|
|
89
|
+
|
|
90
|
+
const elements = findChildByLocal(themeEl, "themeElements");
|
|
91
|
+
if (!elements) return EMPTY_THEME_TABLE;
|
|
92
|
+
|
|
93
|
+
const colors = new Map<ThemeColorName, string>();
|
|
94
|
+
const clrScheme = findChildByLocal(elements, "clrScheme");
|
|
95
|
+
if (clrScheme) {
|
|
96
|
+
for (const name of COLOR_NAMES) {
|
|
97
|
+
const colorWrapper = findChildByLocal(clrScheme, name);
|
|
98
|
+
const value = readColorValue(colorWrapper);
|
|
99
|
+
if (value) colors.set(name, value);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fontScheme = findChildByLocal(elements, "fontScheme");
|
|
104
|
+
const majorFonts = parseFontVariants(
|
|
105
|
+
fontScheme ? findChildByLocal(fontScheme, "majorFont") : null,
|
|
106
|
+
);
|
|
107
|
+
const minorFonts = parseFontVariants(
|
|
108
|
+
fontScheme ? findChildByLocal(fontScheme, "minorFont") : null,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return { colors, majorFonts, minorFonts };
|
|
112
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
2
|
+
import { toggleMark } from "prosemirror-commands";
|
|
3
|
+
import type { EditorView } from "prosemirror-view";
|
|
4
|
+
import { docxSchema } from "./schema";
|
|
5
|
+
import { registerGlobalStyle } from "../render/style_registry";
|
|
6
|
+
|
|
7
|
+
const bubbleKey = new PluginKey("docx-bubble-menu");
|
|
8
|
+
|
|
9
|
+
const BUBBLE_CSS = `
|
|
10
|
+
.docx-bubble-menu {
|
|
11
|
+
position: absolute;
|
|
12
|
+
display: none;
|
|
13
|
+
background: #202124;
|
|
14
|
+
color: #ffffff;
|
|
15
|
+
border-radius: 6px;
|
|
16
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
17
|
+
padding: 4px;
|
|
18
|
+
gap: 2px;
|
|
19
|
+
z-index: 1000;
|
|
20
|
+
font: 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
21
|
+
}
|
|
22
|
+
.docx-bubble-menu[data-visible="true"] { display: flex; }
|
|
23
|
+
.docx-bubble-menu button {
|
|
24
|
+
background: transparent;
|
|
25
|
+
color: #ffffff;
|
|
26
|
+
border: none;
|
|
27
|
+
border-radius: 4px;
|
|
28
|
+
padding: 4px 8px;
|
|
29
|
+
cursor: pointer;
|
|
30
|
+
font: inherit;
|
|
31
|
+
}
|
|
32
|
+
.docx-bubble-menu button:hover { background: rgba(255,255,255,0.15); }
|
|
33
|
+
.docx-bubble-menu button[data-active="true"] { background: rgba(26,115,232,0.6); }
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
function installStyle(): () => void {
|
|
37
|
+
return registerGlobalStyle("docx-bubble-style", BUBBLE_CSS);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
|
|
41
|
+
const btn = document.createElement("button");
|
|
42
|
+
btn.type = "button";
|
|
43
|
+
btn.textContent = label;
|
|
44
|
+
btn.title = title;
|
|
45
|
+
btn.addEventListener("mousedown", (e) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
e.stopPropagation();
|
|
48
|
+
onClick();
|
|
49
|
+
});
|
|
50
|
+
return btn;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function bubbleMenuPlugin(): Plugin {
|
|
54
|
+
return new Plugin({
|
|
55
|
+
key: bubbleKey,
|
|
56
|
+
view(view) {
|
|
57
|
+
const releaseStyle = installStyle();
|
|
58
|
+
const menu = document.createElement("div");
|
|
59
|
+
menu.className = "docx-bubble-menu";
|
|
60
|
+
menu.setAttribute("data-docx-bubble-menu", "true");
|
|
61
|
+
menu.setAttribute("data-visible", "false");
|
|
62
|
+
|
|
63
|
+
const boldBtn = buildButton("B", "Bold (⌘B)", () => {
|
|
64
|
+
toggleMark(docxSchema.marks.bold)(view.state, view.dispatch);
|
|
65
|
+
view.focus();
|
|
66
|
+
});
|
|
67
|
+
boldBtn.style.fontWeight = "700";
|
|
68
|
+
const italicBtn = buildButton("I", "Italic (⌘I)", () => {
|
|
69
|
+
toggleMark(docxSchema.marks.italic)(view.state, view.dispatch);
|
|
70
|
+
view.focus();
|
|
71
|
+
});
|
|
72
|
+
italicBtn.style.fontStyle = "italic";
|
|
73
|
+
const underlineBtn = buildButton("U", "Underline (⌘U)", () => {
|
|
74
|
+
toggleMark(docxSchema.marks.underline)(view.state, view.dispatch);
|
|
75
|
+
view.focus();
|
|
76
|
+
});
|
|
77
|
+
underlineBtn.style.textDecoration = "underline";
|
|
78
|
+
|
|
79
|
+
menu.append(boldBtn, italicBtn, underlineBtn);
|
|
80
|
+
document.body.appendChild(menu);
|
|
81
|
+
|
|
82
|
+
const update = (v: EditorView) => {
|
|
83
|
+
const { from, to, empty } = v.state.selection;
|
|
84
|
+
if (empty || !v.hasFocus()) {
|
|
85
|
+
menu.setAttribute("data-visible", "false");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const start = v.coordsAtPos(from);
|
|
89
|
+
const end = v.coordsAtPos(to);
|
|
90
|
+
const left = (start.left + end.right) / 2;
|
|
91
|
+
const top = start.top - 8;
|
|
92
|
+
menu.style.left = `${left}px`;
|
|
93
|
+
menu.style.top = `${top + window.scrollY}px`;
|
|
94
|
+
menu.style.transform = "translate(-50%, -100%)";
|
|
95
|
+
menu.setAttribute("data-visible", "true");
|
|
96
|
+
|
|
97
|
+
for (const [btn, type] of [
|
|
98
|
+
[boldBtn, docxSchema.marks.bold],
|
|
99
|
+
[italicBtn, docxSchema.marks.italic],
|
|
100
|
+
[underlineBtn, docxSchema.marks.underline],
|
|
101
|
+
] as const) {
|
|
102
|
+
const active = v.state.doc.rangeHasMark(from, to, type);
|
|
103
|
+
btn.setAttribute("data-active", String(active));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
update(view);
|
|
108
|
+
return {
|
|
109
|
+
update,
|
|
110
|
+
destroy() {
|
|
111
|
+
menu.remove();
|
|
112
|
+
releaseStyle();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|