@v1hz/md2docx 1.0.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.
@@ -0,0 +1,381 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { DOMParser } from "@xmldom/xmldom";
3
+ import { useNamespaces } from "xpath";
4
+ import PizZip from "pizzip";
5
+
6
+ const W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
7
+
8
+ // 注册命名空间,创建带 w: 前缀的 xpath 选择器
9
+ const x = useNamespaces({ w: W });
10
+
11
+ type XmlNode = Node;
12
+
13
+ /** 选择单个元素 */
14
+ function one(node: XmlNode, expr: string): Element | null {
15
+ const result = x(expr, node) as Node | Node[] | null;
16
+ if (!result) return null;
17
+ if (Array.isArray(result)) return (result[0] as Element) ?? null;
18
+ return result as Element;
19
+ }
20
+
21
+ /** 选择元素列表 */
22
+ function all(node: XmlNode, expr: string): Element[] {
23
+ const result = x(expr, node);
24
+ if (!result) return [];
25
+ if (Array.isArray(result)) return result as Element[];
26
+ return [result as Element];
27
+ }
28
+
29
+ /** 取 w: 命名空间下的属性值 */
30
+ function wAttr(el: Element | null, local: string): string | null {
31
+ if (!el) return null;
32
+ return el.getAttributeNS(W, local) ?? null;
33
+ }
34
+
35
+ function wNumber(el: Element | null, local = "val"): number | null {
36
+ const value = wAttr(el, local);
37
+ if (value === null || value.trim() === "") return null;
38
+ const number = Number(value);
39
+ return Number.isFinite(number) ? number : null;
40
+ }
41
+
42
+ /** 读取 OOXML ON/OFF 属性;省略 val 表示 true。 */
43
+ function wBool(el: Element | null, local: string): boolean | null {
44
+ if (!el) return null;
45
+ const found = one(el, `w:${local}`);
46
+ if (!found) return null;
47
+ const v = wAttr(found, "val");
48
+ return v === null || !["0", "false", "off", "no"].includes(v.toLowerCase());
49
+ }
50
+
51
+ // ── 映射函数 ─────────────────────────────────────────────
52
+
53
+ function mapRunProps(rPr: Element | null): Record<string, unknown> {
54
+ if (!rPr) return {};
55
+ const out: Record<string, unknown> = {};
56
+
57
+ // 字体
58
+ const rFonts = one(rPr, "w:rFonts");
59
+ if (rFonts) {
60
+ const font: Record<string, string> = {};
61
+ for (const a of ["ascii", "hAnsi", "cs", "eastAsia"]) {
62
+ const v = wAttr(rFonts, a);
63
+ if (v) font[a] = v;
64
+ }
65
+ if (Object.keys(font).length) out.font = font;
66
+ }
67
+
68
+ // 字号
69
+ const sz = one(rPr, "w:sz");
70
+ if (sz) {
71
+ const v = wNumber(sz);
72
+ if (v !== null) out.size = v;
73
+ }
74
+ const szCs = one(rPr, "w:szCs");
75
+ if (szCs) {
76
+ const v = wNumber(szCs);
77
+ if (v !== null) out.sizeComplexScript = v;
78
+ }
79
+
80
+ // 颜色
81
+ const color = one(rPr, "w:color");
82
+ if (color) {
83
+ const v = wAttr(color, "val");
84
+ if (v) out.color = v.toUpperCase();
85
+ }
86
+
87
+ // 开关属性
88
+ const boolMap: [string, string][] = [
89
+ ["b", "bold"],
90
+ ["bCs", "boldComplexScript"],
91
+ ["i", "italics"],
92
+ ["iCs", "italicsComplexScript"],
93
+ ["strike", "strike"],
94
+ ["dstrike", "doubleStrike"],
95
+ ["smallCaps", "smallCaps"],
96
+ ["caps", "allCaps"],
97
+ ["vanish", "vanish"],
98
+ ["emboss", "emboss"],
99
+ ["imprint", "imprint"],
100
+ ];
101
+ for (const [tag, key] of boolMap) {
102
+ const v = wBool(rPr, tag);
103
+ if (v !== null) out[key] = v;
104
+ }
105
+
106
+ // 上标/下标
107
+ const va = one(rPr, "w:vertAlign");
108
+ if (va) {
109
+ const v = wAttr(va, "val");
110
+ if (v === "superscript") out.superScript = true;
111
+ else if (v === "subscript") out.subScript = true;
112
+ }
113
+
114
+ // 下划线
115
+ const u = one(rPr, "w:u");
116
+ if (u) {
117
+ const ul: Record<string, string> = {};
118
+ const ut = wAttr(u, "val");
119
+ if (ut) ul.type = ut;
120
+ const uc = wAttr(u, "color");
121
+ if (uc) ul.color = uc;
122
+ out.underline = ul;
123
+ }
124
+
125
+ // 字符间距
126
+ const sp = one(rPr, "w:spacing");
127
+ if (sp) {
128
+ const v = wNumber(sp);
129
+ if (v !== null) out.characterSpacing = v;
130
+ }
131
+
132
+ // 语言
133
+ const lang = one(rPr, "w:lang");
134
+ if (lang) {
135
+ const lo: Record<string, string> = {};
136
+ for (const a of ["val", "eastAsia", "bidi"]) {
137
+ const v = wAttr(lang, a);
138
+ if (v) lo[a] = v;
139
+ }
140
+ if (Object.keys(lo).length) out.language = lo;
141
+ }
142
+
143
+ return out;
144
+ }
145
+
146
+ function mapParagraphProps(pPr: Element | null): Record<string, unknown> {
147
+ if (!pPr) return {};
148
+ const out: Record<string, unknown> = {};
149
+
150
+ const ALIGN_MAP: Record<string, string> = {
151
+ left: "left",
152
+ center: "center",
153
+ right: "right",
154
+ both: "both",
155
+ start: "start",
156
+ end: "end",
157
+ };
158
+
159
+ // 对齐
160
+ const jc = one(pPr, "w:jc");
161
+ if (jc) {
162
+ const v = wAttr(jc, "val");
163
+ if (v && v in ALIGN_MAP) out.alignment = ALIGN_MAP[v];
164
+ }
165
+
166
+ // 间距
167
+ const spacing = one(pPr, "w:spacing");
168
+ if (spacing) {
169
+ const sp: Record<string, number | string> = {};
170
+ for (const [tag, key] of [
171
+ ["before", "before"],
172
+ ["after", "after"],
173
+ ["line", "line"],
174
+ ] as [string, string][]) {
175
+ const v = wNumber(spacing, tag);
176
+ if (v !== null) sp[key] = v;
177
+ }
178
+ const lr = wAttr(spacing, "lineRule");
179
+ if (lr !== null) sp.lineRule = lr;
180
+ if (Object.keys(sp).length) out.spacing = sp;
181
+ }
182
+
183
+ // 缩进
184
+ const ind = one(pPr, "w:ind");
185
+ if (ind) {
186
+ const id: Record<string, number> = {};
187
+ for (const a of ["left", "right", "firstLine", "hanging", "start", "end"]) {
188
+ const v = wNumber(ind, a);
189
+ if (v !== null) id[a] = v;
190
+ }
191
+ if (Object.keys(id).length) out.indent = id;
192
+ }
193
+
194
+ // 开关
195
+ for (const tag of [
196
+ "keepNext",
197
+ "keepLines",
198
+ "pageBreakBefore",
199
+ "widowControl",
200
+ "contextualSpacing",
201
+ ]) {
202
+ const v = wBool(pPr, tag);
203
+ if (v !== null) out[tag] = v;
204
+ }
205
+
206
+ // 大纲级别
207
+ const ol = one(pPr, "w:outlineLvl");
208
+ if (ol) {
209
+ const v = wNumber(ol);
210
+ if (v !== null) out.outlineLevel = v;
211
+ }
212
+
213
+ return out;
214
+ }
215
+
216
+ // ── 导出类型 ─────────────────────────────────────────────
217
+
218
+ export interface StyleEntry {
219
+ id: string;
220
+ name: string;
221
+ basedOn?: string;
222
+ next?: string;
223
+ link?: string;
224
+ quickFormat?: boolean;
225
+ semiHidden?: boolean;
226
+ unhideWhenUsed?: boolean;
227
+ uiPriority?: number;
228
+ run?: Record<string, unknown>;
229
+ paragraph?: Record<string, unknown>;
230
+ }
231
+
232
+ export interface ExtractedStyles {
233
+ default?: Record<string, unknown>;
234
+ paragraphStyles?: StyleEntry[];
235
+ characterStyles?: StyleEntry[];
236
+ }
237
+
238
+ const BUILTIN_PARA: Record<string, string> = {
239
+ Title: "title",
240
+ Heading1: "heading1",
241
+ Heading2: "heading2",
242
+ Heading3: "heading3",
243
+ Heading4: "heading4",
244
+ Heading5: "heading5",
245
+ Heading6: "heading6",
246
+ Strong: "strong",
247
+ ListParagraph: "listParagraph",
248
+ FootnoteText: "footnoteText",
249
+ EndnoteText: "endnoteText",
250
+ };
251
+
252
+ const BUILTIN_CHAR: Record<string, string> = {
253
+ Hyperlink: "hyperlink",
254
+ FootnoteReference: "footnoteReference",
255
+ FootnoteTextChar: "footnoteTextChar",
256
+ EndnoteReference: "endnoteReference",
257
+ EndnoteTextChar: "endnoteTextChar",
258
+ };
259
+
260
+ /** 从 docx 提取 styles 配置,返回可直接给 `new Document({ styles })` 的对象 */
261
+ export function extractStylesFromDocx(docxPath: string): ExtractedStyles {
262
+ const zip = new PizZip(readFileSync(docxPath));
263
+ const stylesFile = zip.file("word/styles.xml");
264
+ if (!stylesFile) throw new Error("word/styles.xml not found in docx");
265
+
266
+ const xml = stylesFile.asText();
267
+ const doc = new DOMParser().parseFromString(xml, "text/xml");
268
+ const parseError = doc.getElementsByTagName("parsererror").item(0);
269
+ if (parseError) throw new Error(`Invalid word/styles.xml: ${parseError.textContent}`);
270
+
271
+ const defaultDoc: Record<string, unknown> = {};
272
+ const defaultOverrides: Record<string, unknown> = {};
273
+ const paragraphStyles: StyleEntry[] = [];
274
+ const characterStyles: StyleEntry[] = [];
275
+
276
+ // 1. docDefaults
277
+ const xmlDocument = doc as unknown as XmlNode;
278
+ const dd = one(xmlDocument, "//w:docDefaults");
279
+ if (dd) {
280
+ const rPrDef = one(dd, "w:rPrDefault/w:rPr");
281
+ if (rPrDef) {
282
+ const run = mapRunProps(rPrDef);
283
+ if (Object.keys(run).length) defaultDoc.run = run;
284
+ }
285
+ const pPrDef = one(dd, "w:pPrDefault/w:pPr");
286
+ if (pPrDef) {
287
+ const para = mapParagraphProps(pPrDef);
288
+ if (Object.keys(para).length) defaultDoc.paragraph = para;
289
+ }
290
+ }
291
+
292
+ // 2. 样式
293
+ for (const style of all(xmlDocument, "//w:style")) {
294
+ const styleId = wAttr(style, "styleId") ?? "";
295
+ const styleType = wAttr(style, "type") ?? "";
296
+ if (!styleId || !["paragraph", "character"].includes(styleType)) continue;
297
+ const name = wAttr(one(style, "w:name"), "val") ?? styleId;
298
+
299
+ // 跳过隐式样式和 token
300
+ if (["Normal", "DefaultParagraphFont", "TableNormal"].includes(styleId)) continue;
301
+ if (styleId.endsWith("Tok") && styleType === "character") continue;
302
+
303
+ const entry: StyleEntry = { id: styleId, name };
304
+
305
+ // basedOn
306
+ const bo = one(style, "w:basedOn");
307
+ if (bo) {
308
+ const v = wAttr(bo, "val");
309
+ if (v && !["Normal", "DefaultParagraphFont"].includes(v)) entry.basedOn = v;
310
+ }
311
+
312
+ // next
313
+ const next = one(style, "w:next");
314
+ if (next) {
315
+ const v = wAttr(next, "val");
316
+ if (v && v !== styleId) entry.next = v;
317
+ }
318
+
319
+ // link
320
+ const link = one(style, "w:link");
321
+ if (link) {
322
+ const v = wAttr(link, "val");
323
+ if (v) entry.link = v;
324
+ }
325
+
326
+ // 标志位
327
+ if (one(style, "w:qFormat")) entry.quickFormat = true;
328
+ if (one(style, "w:semiHidden")) entry.semiHidden = true;
329
+ if (one(style, "w:unhideWhenUsed")) entry.unhideWhenUsed = true;
330
+
331
+ const ui = one(style, "w:uiPriority");
332
+ if (ui) {
333
+ const v = wNumber(ui);
334
+ if (v !== null) entry.uiPriority = v;
335
+ }
336
+
337
+ // run / paragraph
338
+ const run = mapRunProps(one(style, "w:rPr"));
339
+ if (Object.keys(run).length) entry.run = run;
340
+ const para = mapParagraphProps(one(style, "w:pPr"));
341
+ if (Object.keys(para).length) entry.paragraph = para;
342
+
343
+ // 分发
344
+ if (styleType === "paragraph") {
345
+ const key = BUILTIN_PARA[styleId];
346
+ if (key) {
347
+ const { id: _, ...rest } = entry;
348
+ defaultOverrides[key] = rest;
349
+ } else {
350
+ paragraphStyles.push(entry);
351
+ }
352
+ } else if (styleType === "character") {
353
+ const key = BUILTIN_CHAR[styleId];
354
+ if (key) {
355
+ const { id: _, ...rest } = entry;
356
+ defaultOverrides[key] = rest;
357
+ } else {
358
+ characterStyles.push(entry);
359
+ }
360
+ }
361
+ }
362
+
363
+ const result: ExtractedStyles = {};
364
+ if (Object.keys(defaultDoc).length > 0 || Object.keys(defaultOverrides).length > 0) {
365
+ result.default = {
366
+ ...(Object.keys(defaultDoc).length > 0 ? { document: defaultDoc } : {}),
367
+ ...defaultOverrides,
368
+ };
369
+ }
370
+ if (paragraphStyles.length) result.paragraphStyles = paragraphStyles;
371
+ if (characterStyles.length) result.characterStyles = characterStyles;
372
+
373
+ return result;
374
+ }
375
+
376
+ if (import.meta.main) {
377
+ const { STYLE_REF_DOCX, STYLE_JSON } = await import("../paths");
378
+ const styles = extractStylesFromDocx(STYLE_REF_DOCX);
379
+ writeFileSync(STYLE_JSON, JSON.stringify(styles, null, 2), "utf-8");
380
+ console.log(`Styles extracted from ${STYLE_REF_DOCX} to ${STYLE_JSON}`);
381
+ }
@@ -0,0 +1,298 @@
1
+ :root {
2
+ --paper: #f7f8fa;
3
+ --sheet: #ffffff;
4
+ --ink: #15233b;
5
+ --muted: #637087;
6
+ --rule: #dce2eb;
7
+ --blue: #2457d6;
8
+ --blue-soft: #eaf0ff;
9
+ --red: #b73535;
10
+ --shadow: 0 24px 60px rgb(21 35 59 / 9%);
11
+ font-family: "Segoe UI Variable", "Microsoft YaHei UI", sans-serif;
12
+ color: var(--ink);
13
+ background: var(--paper);
14
+ }
15
+
16
+ * {
17
+ box-sizing: border-box;
18
+ }
19
+ html {
20
+ scroll-behavior: smooth;
21
+ }
22
+ body {
23
+ margin: 0;
24
+ min-height: 100vh;
25
+ }
26
+ button,
27
+ input,
28
+ select {
29
+ font: inherit;
30
+ }
31
+
32
+ .workspace {
33
+ display: grid;
34
+ grid-template-columns: minmax(0, 820px);
35
+ justify-content: center;
36
+ gap: 28px;
37
+ max-width: 1320px;
38
+ margin: 0 auto;
39
+ padding: 36px;
40
+ align-items: start;
41
+ }
42
+ .config-index {
43
+ position: fixed;
44
+ top: 50%;
45
+ left: max(36px, calc((100vw - 1292px) / 2));
46
+ transform: translateY(-50%);
47
+ display: grid;
48
+ gap: 4px;
49
+ width: 180px;
50
+ }
51
+
52
+ .config-index a {
53
+ padding: 9px 12px;
54
+ color: var(--muted);
55
+ border-left: 2px solid transparent;
56
+ text-decoration: none;
57
+ font-size: 14px;
58
+ transition: 0.15s ease;
59
+ }
60
+ .config-index a:hover,
61
+ .config-index a:focus-visible {
62
+ color: var(--blue);
63
+ background: var(--blue-soft);
64
+ border-left-color: var(--blue);
65
+ outline: none;
66
+ }
67
+ .config-sheet {
68
+ min-height: 400px;
69
+ overflow: hidden;
70
+ background: var(--sheet);
71
+ border: 1px solid var(--rule);
72
+ box-shadow: var(--shadow);
73
+ }
74
+ .loading {
75
+ padding: 60px;
76
+ color: var(--muted);
77
+ }
78
+ .group {
79
+ display: grid;
80
+ grid-template-columns: 210px 1fr;
81
+ border-bottom: 1px solid var(--rule);
82
+ scroll-margin-top: 24px;
83
+ }
84
+ .group:last-child {
85
+ border-bottom: 0;
86
+ }
87
+ .group-title {
88
+ padding: 30px;
89
+ background: #fbfcfe;
90
+ border-right: 1px solid var(--rule);
91
+ }
92
+ .group-title h2 {
93
+ margin: 0;
94
+ font-size: 17px;
95
+ line-height: 1.45;
96
+ }
97
+ .fields {
98
+ padding: 14px 30px;
99
+ }
100
+ .field {
101
+ display: grid;
102
+ grid-template-columns: minmax(0, 1fr) minmax(180px, 260px);
103
+ gap: 24px;
104
+ padding: 18px 0;
105
+ align-items: center;
106
+ border-bottom: 1px solid #edf0f5;
107
+ }
108
+ .field:last-child {
109
+ border-bottom: 0;
110
+ }
111
+ .field > label {
112
+ display: block;
113
+ font-weight: 650;
114
+ line-height: 1.5;
115
+ }
116
+
117
+ input[type="text"],
118
+ input[type="number"],
119
+ select {
120
+ width: 100%;
121
+ min-height: 42px;
122
+ padding: 9px 11px;
123
+ color: var(--ink);
124
+ background: white;
125
+ border: 1px solid #bfc8d6;
126
+ border-radius: 4px;
127
+ outline: none;
128
+ }
129
+ input:focus,
130
+ select:focus,
131
+ button:focus-visible {
132
+ border-color: var(--blue);
133
+ box-shadow: 0 0 0 3px rgb(36 87 214 / 16%);
134
+ }
135
+ .switch {
136
+ position: relative;
137
+ width: 52px;
138
+ height: 30px;
139
+ justify-self: end;
140
+ }
141
+ .switch input {
142
+ position: absolute;
143
+ opacity: 0;
144
+ }
145
+ .switch-track {
146
+ display: block;
147
+ width: 100%;
148
+ height: 100%;
149
+ border-radius: 30px;
150
+ background: #b8c0cc;
151
+ transition: 0.18s ease;
152
+ cursor: pointer;
153
+ }
154
+ .switch-track::after {
155
+ content: "";
156
+ position: absolute;
157
+ top: 4px;
158
+ left: 4px;
159
+ width: 22px;
160
+ height: 22px;
161
+ border-radius: 50%;
162
+ background: white;
163
+ box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
164
+ transition: 0.18s ease;
165
+ }
166
+ .switch input:checked + .switch-track {
167
+ background: var(--blue);
168
+ }
169
+ .switch input:checked + .switch-track::after {
170
+ transform: translateX(22px);
171
+ }
172
+ .switch input:disabled + .switch-track {
173
+ cursor: not-allowed;
174
+ opacity: 0.55;
175
+ }
176
+
177
+ .action-bar {
178
+ position: fixed;
179
+ z-index: 5;
180
+ top: 50%;
181
+ right: max(36px, calc((100vw - 1292px) / 2));
182
+ display: grid;
183
+ gap: 16px;
184
+ width: 180px;
185
+ padding: 18px;
186
+ background: var(--sheet);
187
+ border: 1px solid var(--rule);
188
+ border-radius: 6px;
189
+ box-shadow: var(--shadow);
190
+ transform: translateY(-50%);
191
+ }
192
+ .action-bar p {
193
+ margin: 0;
194
+ color: var(--muted);
195
+ font-size: 13px;
196
+ line-height: 1.5;
197
+ }
198
+ .action-bar p.error {
199
+ color: var(--red);
200
+ }
201
+ .actions {
202
+ display: grid;
203
+ gap: 8px;
204
+ }
205
+ button {
206
+ min-height: 42px;
207
+ padding: 0 18px;
208
+ color: white;
209
+ background: var(--blue);
210
+ border: 1px solid var(--blue);
211
+ border-radius: 4px;
212
+ font-weight: 700;
213
+ cursor: pointer;
214
+ }
215
+ button.secondary {
216
+ color: var(--ink);
217
+ background: white;
218
+ border-color: #bfc8d6;
219
+ }
220
+ button:disabled {
221
+ opacity: 0.45;
222
+ cursor: not-allowed;
223
+ }
224
+
225
+ @media (max-width: 1120px) {
226
+ .workspace {
227
+ grid-template-columns: 1fr;
228
+ }
229
+ .config-index {
230
+ position: sticky;
231
+ z-index: 4;
232
+ top: 0;
233
+ left: 0;
234
+ transform: none;
235
+ display: flex;
236
+ gap: 2px;
237
+ overflow-x: auto;
238
+ padding: 8px;
239
+ background: rgb(247 248 250 / 96%);
240
+ border: 1px solid var(--rule);
241
+ backdrop-filter: blur(12px);
242
+ width: auto;
243
+ }
244
+ .config-index a {
245
+ flex: 0 0 auto;
246
+ border-bottom: 2px solid transparent;
247
+ border-left: 0;
248
+ }
249
+ .config-index a:hover,
250
+ .config-index a:focus-visible {
251
+ border-bottom-color: var(--blue);
252
+ }
253
+ .group {
254
+ grid-template-columns: 1fr;
255
+ }
256
+ .group-title {
257
+ border-right: 0;
258
+ border-bottom: 1px solid var(--rule);
259
+ }
260
+ .action-bar {
261
+ position: static;
262
+ width: auto;
263
+ max-width: 820px;
264
+ margin: -8px auto 36px;
265
+ transform: none;
266
+ }
267
+ .actions {
268
+ grid-template-columns: 1fr 1fr;
269
+ }
270
+ }
271
+ @media (max-width: 560px) {
272
+ .workspace {
273
+ padding-right: 18px;
274
+ padding-left: 18px;
275
+ }
276
+ .field {
277
+ grid-template-columns: 1fr;
278
+ }
279
+ .switch {
280
+ justify-self: start;
281
+ }
282
+ .action-bar {
283
+ margin-right: 18px;
284
+ margin-left: 18px;
285
+ padding: 16px;
286
+ }
287
+ button {
288
+ padding: 0 12px;
289
+ }
290
+ }
291
+ @media (prefers-reduced-motion: reduce) {
292
+ html {
293
+ scroll-behavior: auto;
294
+ }
295
+ * {
296
+ transition: none !important;
297
+ }
298
+ }