@v1hz/md2docx 1.5.0 → 1.6.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/src/web/app.js CHANGED
@@ -1,178 +1,877 @@
1
- const form = document.querySelector("#config-form");
2
- const index = document.querySelector("#config-index");
1
+ const configForm = document.querySelector("#config-form");
2
+ const configIndex = document.querySelector("#config-index");
3
+ const styleIndex = document.querySelector("#style-index");
4
+ const styleEditor = document.querySelector("#style-editor");
5
+ const styleSearch = document.querySelector("#style-search");
3
6
  const status = document.querySelector("#status");
7
+ const statusIndicator = document.querySelector("#status-indicator");
4
8
  const saveButton = document.querySelector("#save-button");
5
9
  const resetButton = document.querySelector("#reset-button");
10
+ const previewButton = document.querySelector("#preview-button");
6
11
 
12
+ let activeView = "config";
7
13
  let schema;
8
14
  let originalConfig;
9
15
  let currentConfig;
16
+ let originalStyles;
17
+ let currentStyles;
18
+ let styleCatalog;
19
+ let styleRevision;
20
+ let selectedStyle = { kind: "default" };
21
+
22
+ const styleFields = [
23
+ { section: "文字", label: "中文字体", path: ["run", "font", "eastAsia"], type: "text" },
24
+ {
25
+ section: "文字",
26
+ label: "西文字体",
27
+ path: ["run", "font", "ascii"],
28
+ type: "text",
29
+ mirror: ["run", "font", "hAnsi"],
30
+ },
31
+ {
32
+ section: "文字",
33
+ label: "字号",
34
+ path: ["run", "size"],
35
+ type: "number",
36
+ unit: "pt",
37
+ scale: 2,
38
+ min: 1,
39
+ step: 0.5,
40
+ mirror: ["run", "sizeComplexScript"],
41
+ },
42
+ { section: "文字", label: "文字颜色", path: ["run", "color"], type: "color" },
43
+ { section: "文字", label: "加粗", path: ["run", "bold"], type: "boolean" },
44
+ { section: "文字", label: "斜体", path: ["run", "italics"], type: "boolean" },
45
+ {
46
+ section: "段落",
47
+ label: "对齐方式",
48
+ path: ["paragraph", "alignment"],
49
+ type: "select",
50
+ options: [
51
+ ["left", "左对齐"],
52
+ ["center", "居中"],
53
+ ["right", "右对齐"],
54
+ ["both", "两端对齐"],
55
+ ],
56
+ },
57
+ {
58
+ section: "段落",
59
+ label: "段前间距",
60
+ path: ["paragraph", "spacing", "before"],
61
+ type: "number",
62
+ unit: "pt",
63
+ scale: 20,
64
+ min: 0,
65
+ step: 1,
66
+ },
67
+ {
68
+ section: "段落",
69
+ label: "段后间距",
70
+ path: ["paragraph", "spacing", "after"],
71
+ type: "number",
72
+ unit: "pt",
73
+ scale: 20,
74
+ min: 0,
75
+ step: 1,
76
+ },
77
+ {
78
+ section: "段落",
79
+ label: "行距",
80
+ path: ["paragraph", "spacing", "line"],
81
+ type: "select-number",
82
+ options: [
83
+ [240, "单倍"],
84
+ [300, "1.25 倍"],
85
+ [360, "1.5 倍"],
86
+ [480, "2 倍"],
87
+ ],
88
+ },
89
+ {
90
+ section: "段落",
91
+ label: "首行缩进",
92
+ path: ["paragraph", "indent", "firstLineChars"],
93
+ type: "number",
94
+ unit: "字符",
95
+ scale: 100,
96
+ min: 0,
97
+ step: 0.5,
98
+ },
99
+ { section: "分页", label: "与下段同页", path: ["paragraph", "keepNext"], type: "boolean" },
100
+ { section: "分页", label: "段中不分页", path: ["paragraph", "keepLines"], type: "boolean" },
101
+ { section: "分页", label: "段前分页", path: ["paragraph", "pageBreakBefore"], type: "boolean" },
102
+ ];
103
+
104
+ const propertySectionDescriptions = {
105
+ 文字: "字体、字号与字形",
106
+ 段落: "对齐、间距与缩进",
107
+ 分页: "段落分页行为",
108
+ 表格: "字体、对齐与单元格留白",
109
+ };
10
110
 
11
111
  initialize();
112
+ document.body.dataset.view = activeView;
113
+
114
+ styleSearch.addEventListener("input", filterStyleIndex);
115
+
116
+ async function apiFetch(url, body) {
117
+ const response = await fetch(
118
+ url,
119
+ body
120
+ ? {
121
+ method: "PUT",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify(body),
124
+ }
125
+ : void 0,
126
+ );
127
+ const result = body ? await response.json().catch(() => ({})) : void 0;
128
+ if (!response.ok) throw new Error(result?.error ?? `${response.status} ${response.statusText}`);
129
+ return result;
130
+ }
12
131
 
13
132
  async function initialize() {
14
133
  try {
15
- const response = await fetch("/api/config");
16
- if (!response.ok) throw new Error("无法读取配置");
17
- ({ schema, config: originalConfig } = await response.json());
134
+ const [configResponse, stylesResponse] = await Promise.all([
135
+ fetch("/api/config"),
136
+ fetch("/api/styles"),
137
+ ]);
138
+ if (!configResponse.ok) throw new Error("无法读取转换配置");
139
+ if (!stylesResponse.ok) throw new Error("无法读取文档样式");
140
+ ({ schema, config: originalConfig } = await configResponse.json());
141
+ const styleData = await stylesResponse.json();
18
142
  delete originalConfig.$schema;
19
143
  currentConfig = structuredClone(originalConfig);
144
+ originalStyles = styleData.styles;
145
+ currentStyles = structuredClone(originalStyles);
146
+ styleCatalog = styleData.catalog;
147
+ styleRevision = styleData.revision;
20
148
  enforceHeadingDependency(originalConfig);
21
149
  enforceHeadingDependency(currentConfig);
22
- renderForm();
150
+ renderConfigForm();
151
+ renderStyleIndex();
152
+ selectFirstStyle();
153
+ updateProof();
23
154
  updateView();
24
155
  } catch (error) {
25
- form.innerHTML = `<div class="loading">${escapeHtml(error.message)}</div>`;
156
+ configForm.innerHTML = `<div class="loading error-copy">${escapeHtml(error.message)}</div>`;
157
+ styleEditor.innerHTML = `<div class="loading error-copy">${escapeHtml(error.message)}</div>`;
26
158
  setStatus(error.message, true);
27
159
  }
28
160
  }
29
161
 
30
- function renderForm() {
31
- form.innerHTML = "";
32
- index.innerHTML = "";
162
+ document.querySelectorAll(".mode-tab").forEach((tab) => {
163
+ tab.addEventListener("click", () => switchView(tab.dataset.view));
164
+ });
165
+
166
+ function switchView(view) {
167
+ activeView = view;
168
+ document.body.dataset.view = view;
169
+ document
170
+ .querySelectorAll(".mode-tab")
171
+ .forEach((tab) => tab.classList.toggle("active", tab.dataset.view === view));
172
+ document.querySelectorAll(".view").forEach((panel) => {
173
+ const active = panel.id === `${view}-view`;
174
+ panel.classList.toggle("active", active);
175
+ panel.hidden = !active;
176
+ });
177
+ previewButton.hidden = view !== "styles";
178
+ saveButton.textContent = view === "styles" ? "保存文档样式" : "保存转换配置";
179
+ saveButton.removeAttribute("form");
180
+ if (view === "config") saveButton.setAttribute("form", "config-form");
181
+ updateView();
182
+ }
183
+
184
+ function renderConfigForm() {
185
+ configForm.innerHTML = "";
186
+ configIndex.innerHTML = "";
33
187
  for (const [groupName, groupSchema] of Object.entries(schema.properties)) {
34
188
  const title = groupSchema.description ?? groupName;
35
189
  const sectionId = `section-${groupName}`;
36
190
  const link = document.createElement("a");
37
191
  link.href = `#${sectionId}`;
38
192
  link.textContent = title;
39
- index.append(link);
193
+ configIndex.append(link);
40
194
 
41
195
  const group = document.createElement("section");
42
- group.className = "group";
196
+ group.className = "config-group";
43
197
  group.id = sectionId;
44
- group.innerHTML = `<header class="group-title"><h2>${escapeHtml(title)}</h2></header><div class="fields"></div>`;
45
- const fields = group.querySelector(".fields");
198
+ group.innerHTML = `<header><p>转换规则</p><h2>${escapeHtml(title)}</h2></header><div class="config-fields"></div>`;
199
+ const fields = group.querySelector(".config-fields");
46
200
  for (const [name, fieldSchema] of Object.entries(groupSchema.properties)) {
47
- fields.append(createField(groupName, name, fieldSchema));
201
+ fields.append(createConfigField(groupName, name, fieldSchema));
202
+ }
203
+ configForm.append(group);
204
+ }
205
+ }
206
+
207
+ function createConfigField(group, name, field) {
208
+ const path = `${group}.${name}`;
209
+ const div = document.createElement("div");
210
+ div.className = "config-field";
211
+ const description = field.description ?? name;
212
+ div.innerHTML = `<label for="${path}">${escapeHtml(description)}</label>`;
213
+ let input;
214
+ if (field.type === "boolean") {
215
+ const checked = currentConfig[group][name];
216
+ div.innerHTML += `<label class="switch"><input type="checkbox" id="${path}" ${checked ? "checked" : ""}><span class="switch-track"></span></label>`;
217
+ input = div.querySelector("input");
218
+ input.addEventListener("change", () => {
219
+ currentConfig[group][name] = input.checked;
220
+ enforceHeadingDependency(currentConfig);
221
+ syncHeadingDependencyControls();
222
+ updateView();
223
+ });
224
+ } else {
225
+ const wrapper = document.createElement("div");
226
+ wrapper.className = "property-control";
227
+ if (field.type === "string" || field.type === "text") {
228
+ input = document.createElement("input");
229
+ input.type = "text";
230
+ input.id = path;
231
+ input.value = currentConfig[group][name] ?? "";
232
+ input.addEventListener("input", () => {
233
+ currentConfig[group][name] = input.value;
234
+ updateView();
235
+ });
236
+ } else {
237
+ input = document.createElement("select");
238
+ input.id = path;
239
+ if (field.enum) {
240
+ for (const option of field.enum) {
241
+ const opt = document.createElement("option");
242
+ opt.value = option;
243
+ opt.textContent = option;
244
+ if (currentConfig[group][name] === option) opt.selected = true;
245
+ input.append(opt);
246
+ }
247
+ input.addEventListener("change", () => {
248
+ currentConfig[group][name] = input.value;
249
+ updateView();
250
+ });
251
+ } else if (field.type === "integer") {
252
+ input = document.createElement("input");
253
+ input.type = "number";
254
+ input.id = path;
255
+ input.min = field.minimum ?? 0;
256
+ input.value = currentConfig[group][name] ?? "";
257
+ input.addEventListener("input", () => {
258
+ currentConfig[group][name] = input.value === "" ? "" : Number(input.value);
259
+ updateView();
260
+ });
261
+ }
262
+ }
263
+ wrapper.append(input);
264
+ div.append(wrapper);
265
+ }
266
+ return div;
267
+ }
268
+
269
+ function renderStyleIndex() {
270
+ styleIndex.innerHTML = "";
271
+ const bySection = {};
272
+ for (const entry of styleCatalog ?? []) {
273
+ const section = entry.section ?? "其他";
274
+ if (!bySection[section]) bySection[section] = [];
275
+ bySection[section].push(entry);
276
+ }
277
+ for (const [section, entries] of Object.entries(bySection)) {
278
+ const group = document.createElement("div");
279
+ group.className = section === "其他" ? "style-nav-group advanced-nav" : "style-nav-group";
280
+ group.innerHTML = `<h2>${escapeHtml(section)}</h2><div></div>`;
281
+ const list = group.querySelector("div");
282
+ for (const entry of entries) {
283
+ const button = document.createElement("button");
284
+ button.type = "button";
285
+ button.dataset.kind = entry.kind;
286
+ button.dataset.key = entry.key;
287
+ button.dataset.collection = entry.collection ?? "";
288
+ button.dataset.id = entry.id ?? "";
289
+ button.textContent = entry.label;
290
+ button.addEventListener("click", () => selectStyle(button));
291
+ list.append(button);
292
+ }
293
+ styleIndex.append(group);
294
+ }
295
+ document.querySelectorAll(`.style-nav-group button`).forEach((b) => b.classList.remove("active"));
296
+ }
297
+
298
+ function filterStyleIndex() {
299
+ const query = styleSearch.value.toLowerCase();
300
+ document.querySelectorAll(".style-nav-group").forEach((group) => {
301
+ const match =
302
+ query === "" ||
303
+ Array.from(group.querySelectorAll("button")).some((btn) =>
304
+ btn.textContent.toLowerCase().includes(query),
305
+ );
306
+ group.hidden = !match;
307
+ });
308
+ }
309
+
310
+ function selectFirstStyle() {
311
+ const first = styleIndex.querySelector("button");
312
+ if (first) selectStyle(first);
313
+ }
314
+
315
+ function selectStyle(button) {
316
+ selectedStyle = {
317
+ kind: button.dataset.kind,
318
+ collection: button.dataset.collection,
319
+ id: button.dataset.id,
320
+ key: button.dataset.key,
321
+ };
322
+ document.querySelectorAll(".style-nav-group button").forEach((b) => b.classList.remove("active"));
323
+ button.classList.add("active");
324
+ renderStyleEditor();
325
+ updateProof();
326
+ }
327
+
328
+ function renderStyleEditor() {
329
+ styleEditor.innerHTML = "";
330
+ if (!selectedStyle) return;
331
+ const entry = selectedEntry();
332
+ const name = selectedStyle.key ?? "";
333
+ const label = styleCatalog?.find((c) => c.key === name)?.label ?? name;
334
+ const inheritedFrom = entry?.basedOn ? styleNameById(entry.basedOn) : null;
335
+
336
+ const container = document.createElement("div");
337
+ container.innerHTML = `<div class="editor-heading"><div><p class="eyebrow">正在编辑</p><h1>${escapeHtml(label)}</h1>${inheritedFrom ? `<p>基于 <strong>${escapeHtml(displayStyleName(inheritedFrom))}</strong></p>` : `<p>文档默认样式</p>`}</div>${inheritedFrom ? '<span class="inherit-chip">继承样式</span>' : ""}</div>`;
338
+
339
+ if (selectedStyle.collection === "tableStyles") {
340
+ container.append(createTableFields());
341
+ } else {
342
+ const groups = {};
343
+ for (const field of styleFields) {
344
+ if (!groups[field.section]) groups[field.section] = [];
345
+ groups[field.section].push(field);
346
+ }
347
+ for (const [section, fields] of Object.entries(groups)) {
348
+ const fieldset = createPropertyGroup(section, fields, entry);
349
+ if (fieldset) container.append(fieldset);
48
350
  }
49
- form.append(group);
50
351
  }
352
+ styleEditor.append(container);
51
353
  }
52
354
 
53
- function createField(groupName, name, fieldSchema) {
54
- const path = `${groupName}.${name}`;
55
- const field = document.createElement("div");
56
- field.className = "field";
57
- field.innerHTML = `<label for="${path}">${escapeHtml(fieldSchema.description ?? name)}</label>`;
355
+ function createStyleField(field, entry) {
356
+ const explicit = deepGet(entry, field.path);
357
+ const effective = effectiveValue(entry, field.path);
358
+ const row = document.createElement("div");
359
+ row.className = "property-row";
360
+ const inherited = effective.source !== (entry.name ? displayStyleName(entry.name) : "文档默认");
58
361
 
362
+ row.innerHTML = `<div class="property-label"><label for="${field.path.join(".")}">${escapeHtml(field.label)}</label><small>${inherited ? `继承自 ${escapeHtml(effective.source)}` : " "}</small></div>`;
363
+ const control = document.createElement("div");
364
+ control.className = "property-control";
365
+ const input = makeStyleInput(field, entry);
366
+ control.append(input);
367
+ row.append(control);
368
+ const reset = document.createElement("button");
369
+ reset.className = "reset-property";
370
+ reset.type = "button";
371
+ reset.disabled = explicit === undefined;
372
+ reset.textContent = "↺";
373
+ reset.title = "恢复继承值";
374
+ reset.addEventListener("click", () => {
375
+ deepDelete(entry, field.path);
376
+ renderStyleEditor();
377
+ updateProof();
378
+ updateView();
379
+ });
380
+ row.append(reset);
381
+ return row;
382
+ }
383
+
384
+ function makeStyleInput(field, entry) {
59
385
  let input;
60
- if (fieldSchema.type === "boolean") {
386
+ const value = deepGet(entry, field.path);
387
+ if (field.type === "boolean") {
61
388
  const wrapper = document.createElement("label");
62
- wrapper.className = "switch";
63
- wrapper.innerHTML = `<input id="${path}" type="checkbox" ${getPath(currentConfig, path) ? "checked" : ""}><span class="switch-track"></span>`;
64
- input = wrapper.querySelector("input");
65
- if (path === "normalizeHeadings.enabled" && currentConfig.numberHeadings.enabled) {
66
- input.disabled = true;
67
- input.title = "标题编号开启时无法关闭";
389
+ wrapper.className = "check-control";
390
+ input = document.createElement("input");
391
+ input.type = "checkbox";
392
+ input.checked = value ?? false;
393
+ input.addEventListener("change", () => {
394
+ deepSet(entry, field.path, input.checked);
395
+ renderStyleEditor();
396
+ updateProof();
397
+ updateView();
398
+ });
399
+ wrapper.append(
400
+ input,
401
+ field.mirror
402
+ ? document.createTextNode("同步到等宽字号")
403
+ : document.createTextNode(field.label),
404
+ );
405
+ return wrapper;
406
+ }
407
+ if (field.type === "color") {
408
+ const wrapper = document.createElement("div");
409
+ wrapper.className = "color-control";
410
+ input = document.createElement("input");
411
+ input.type = "color";
412
+ input.value = normalizeColor(value);
413
+ const text = document.createElement("input");
414
+ text.type = "text";
415
+ text.value = value ?? "";
416
+ text.placeholder = "无";
417
+ const apply = () => {
418
+ deepSet(entry, field.path, text.value.toUpperCase());
419
+ renderStyleEditor();
420
+ updateProof();
421
+ updateView();
422
+ };
423
+ input.addEventListener("input", () => {
424
+ text.value = input.value.slice(1);
425
+ apply();
426
+ });
427
+ text.addEventListener("change", apply);
428
+ wrapper.append(input, text);
429
+ return wrapper;
430
+ }
431
+ if (field.type === "select") {
432
+ input = document.createElement("select");
433
+ for (const [val, label] of field.options) {
434
+ const opt = document.createElement("option");
435
+ opt.value = val;
436
+ opt.textContent = label;
437
+ if (value === val || (!value && val === field.default)) opt.selected = true;
438
+ input.append(opt);
68
439
  }
69
- field.append(wrapper);
70
- } else if (fieldSchema.enum) {
440
+ input.addEventListener("change", () => {
441
+ deepSet(entry, field.path, input.value || undefined);
442
+ renderStyleEditor();
443
+ updateProof();
444
+ updateView();
445
+ });
446
+ return input;
447
+ }
448
+ if (field.type === "select-number") {
71
449
  input = document.createElement("select");
72
- input.id = path;
73
- for (const value of fieldSchema.enum) {
74
- const option = document.createElement("option");
75
- option.value = value;
76
- option.textContent = fieldSchema.enumDescriptions?.[value] ?? value;
77
- option.selected = value === getPath(currentConfig, path);
78
- input.append(option);
450
+ for (const [val, label] of field.options) {
451
+ const opt = document.createElement("option");
452
+ opt.value = val;
453
+ opt.textContent = label;
454
+ if (value == val) opt.selected = true;
455
+ input.append(opt);
79
456
  }
80
- field.append(input);
81
- } else {
457
+ const customOpt = document.createElement("option");
458
+ customOpt.value = "custom";
459
+ customOpt.textContent = "自定义";
460
+ input.append(customOpt);
461
+ input.addEventListener("change", () => {
462
+ if (input.value !== "custom") deepSet(entry, field.path, Number(input.value));
463
+ renderStyleEditor();
464
+ updateProof();
465
+ updateView();
466
+ });
467
+ // Show custom input if current value not in options
468
+ const inOptions = field.options.some(([v]) => v == value);
469
+ if (value !== undefined && !inOptions) {
470
+ const wrapper = document.createElement("div");
471
+ wrapper.className = "unit-control";
472
+ wrapper.append(input);
473
+ const customInput = document.createElement("input");
474
+ customInput.type = "number";
475
+ customInput.value = value;
476
+ customInput.step = field.step ?? 1;
477
+ customInput.addEventListener("change", () => {
478
+ deepSet(entry, field.path, Number(customInput.value));
479
+ renderStyleEditor();
480
+ updateProof();
481
+ updateView();
482
+ });
483
+ wrapper.append(customInput);
484
+ if (field.unit) {
485
+ const unit = document.createElement("span");
486
+ unit.textContent = field.unit;
487
+ wrapper.append(unit);
488
+ }
489
+ return wrapper;
490
+ }
491
+ return input;
492
+ }
493
+ if (field.unit) {
494
+ const wrapper = document.createElement("div");
495
+ wrapper.className = "unit-control";
82
496
  input = document.createElement("input");
83
- input.id = path;
84
- input.type =
85
- fieldSchema.type === "integer" || fieldSchema.type === "number" ? "number" : "text";
86
- input.value = getPath(currentConfig, path) ?? "";
87
- if (fieldSchema.minimum !== undefined) input.min = fieldSchema.minimum;
88
- if (fieldSchema.type === "integer") input.step = "1";
89
- field.append(input);
90
- }
91
-
92
- input.addEventListener("input", () => {
93
- const value =
94
- fieldSchema.type === "boolean"
95
- ? input.checked
96
- : fieldSchema.type === "integer"
97
- ? Number.parseInt(input.value, 10)
98
- : fieldSchema.type === "number"
99
- ? Number(input.value)
100
- : input.value;
101
- setPath(currentConfig, path, value);
102
- if (path === "numberHeadings.enabled") {
103
- enforceHeadingDependency(currentConfig);
104
- syncHeadingDependencyControls();
497
+ input.type = "number";
498
+ input.min = field.min;
499
+ input.step = field.step ?? 1;
500
+ input.placeholder = "";
501
+ const raw = value !== undefined ? value / field.scale : "";
502
+ // Preserve decimals for small values
503
+ input.value = field.scale === 100 && raw === 0 ? "0" : raw === "" ? "" : String(raw);
504
+ const apply = () => {
505
+ const v = input.value === "" ? undefined : Math.round(Number(input.value) * field.scale);
506
+ deepSet(entry, field.path, v);
507
+ updateProof();
508
+ updateView();
509
+ };
510
+ input.addEventListener("change", apply);
511
+ wrapper.append(input);
512
+ if (field.unit) {
513
+ const unit = document.createElement("span");
514
+ unit.textContent = field.unit;
515
+ wrapper.append(unit);
105
516
  }
517
+ return wrapper;
518
+ }
519
+ input = document.createElement("input");
520
+ input.type = "text";
521
+ input.value = value ?? "";
522
+ input.placeholder = "继承";
523
+ input.addEventListener("change", () => {
524
+ deepSet(entry, field.path, input.value || undefined);
525
+ renderStyleEditor();
526
+ updateProof();
106
527
  updateView();
107
528
  });
108
- return field;
529
+ return input;
530
+ }
531
+
532
+ function createTableFields() {
533
+ const fieldset = document.createElement("div");
534
+ const entry = selectedEntry();
535
+ if (!entry) return fieldset;
536
+
537
+ const definitions = [
538
+ { label: "中文字体", path: ["run", "font", "eastAsia"], type: "text" },
539
+ {
540
+ label: "西文字体",
541
+ path: ["run", "font", "ascii"],
542
+ type: "text",
543
+ mirror: ["run", "font", "hAnsi"],
544
+ },
545
+ {
546
+ label: "字号",
547
+ path: ["run", "size"],
548
+ type: "number",
549
+ unit: "pt",
550
+ scale: 2,
551
+ min: 1,
552
+ step: 0.5,
553
+ mirror: ["run", "sizeComplexScript"],
554
+ },
555
+ {
556
+ label: "表格对齐",
557
+ path: ["table", "alignment"],
558
+ type: "select",
559
+ options: [
560
+ ["left", "左对齐"],
561
+ ["center", "居中"],
562
+ ["right", "右对齐"],
563
+ ],
564
+ },
565
+ {
566
+ label: "表格字号",
567
+ path: ["run", "size"],
568
+ type: "number",
569
+ unit: "pt",
570
+ scale: 2,
571
+ min: 1,
572
+ step: 0.5,
573
+ mirror: ["run", "sizeComplexScript"],
574
+ },
575
+ {
576
+ label: "上边距",
577
+ path: ["table", "cellMargin", "top"],
578
+ type: "number",
579
+ unit: "pt",
580
+ scale: 20,
581
+ min: 0,
582
+ step: 1,
583
+ default: 0,
584
+ },
585
+ {
586
+ label: "下边距",
587
+ path: ["table", "cellMargin", "bottom"],
588
+ type: "number",
589
+ unit: "pt",
590
+ scale: 20,
591
+ min: 0,
592
+ step: 1,
593
+ default: 0,
594
+ },
595
+ {
596
+ label: "左边距",
597
+ path: ["table", "cellMargin", "left"],
598
+ type: "number",
599
+ unit: "pt",
600
+ scale: 20,
601
+ min: 0,
602
+ step: 1,
603
+ default: 0,
604
+ },
605
+ {
606
+ label: "右边距",
607
+ path: ["table", "cellMargin", "right"],
608
+ type: "number",
609
+ unit: "pt",
610
+ scale: 20,
611
+ min: 0,
612
+ step: 1,
613
+ default: 0,
614
+ },
615
+ ];
616
+ for (const def of definitions) {
617
+ const field = createStyleField(def, entry);
618
+ if (field) fieldset.append(field);
619
+ }
620
+ return fieldset;
109
621
  }
110
622
 
111
- form.addEventListener("submit", async (event) => {
623
+ function createPropertyGroup(section, fields, entry) {
624
+ if (!fields.length) return null;
625
+ const fieldset = document.createElement("fieldset");
626
+ fieldset.className = "property-group";
627
+ fieldset.innerHTML = `<legend>${escapeHtml(section)}</legend><p class="group-note">${escapeHtml(propertySectionDescriptions[section] ?? "")}</p>`;
628
+ for (const field of fields) fieldset.append(createStyleField(field, entry));
629
+ return fieldset;
630
+ }
631
+
632
+ // Event listeners
633
+ resetButton.addEventListener("click", () => {
634
+ if (activeView === "config") {
635
+ currentConfig = structuredClone(originalConfig);
636
+ renderConfigForm();
637
+ } else {
638
+ currentStyles = structuredClone(originalStyles);
639
+ renderStyleIndex();
640
+ selectFirstStyle();
641
+ updateProof();
642
+ }
643
+ updateView();
644
+ });
645
+
646
+ saveButton.addEventListener("click", async (event) => {
647
+ if (activeView === "styles") {
648
+ event.preventDefault();
649
+ setBusy(true, "正在验证样式并重建 Word 模板…");
650
+ try {
651
+ const result = await apiFetch("/api/styles", {
652
+ styles: currentStyles,
653
+ revision: styleRevision,
654
+ });
655
+ originalStyles = structuredClone(currentStyles);
656
+ styleRevision = result.revision;
657
+ setStatus("文档样式已保存,Word 模板已重建");
658
+ } catch (error) {
659
+ setStatus(error.message, true);
660
+ } finally {
661
+ setBusy(false);
662
+ updateView();
663
+ }
664
+ }
665
+ });
666
+
667
+ document.querySelector("#config-form")?.addEventListener("submit", async (event) => {
112
668
  event.preventDefault();
113
- saveButton.disabled = true;
114
- setStatus("正在保存配置…");
669
+ setBusy(true, "正在保存转换配置…");
115
670
  try {
116
- const response = await fetch("/api/config", {
117
- method: "PUT",
118
- headers: { "Content-Type": "application/json" },
119
- body: JSON.stringify(currentConfig),
120
- });
121
- const result = await response.json();
122
- if (!response.ok) throw new Error(result.error ?? "保存失败");
671
+ await apiFetch("/api/config", currentConfig);
123
672
  originalConfig = structuredClone(currentConfig);
124
- updateView();
125
- setStatus("默认配置已保存");
673
+ setStatus("转换配置已保存");
126
674
  } catch (error) {
127
675
  setStatus(error.message, true);
128
- saveButton.disabled = false;
676
+ } finally {
677
+ setBusy(false);
678
+ updateView();
129
679
  }
130
680
  });
131
681
 
132
- resetButton.addEventListener("click", () => {
133
- currentConfig = structuredClone(originalConfig);
134
- renderForm();
135
- updateView();
682
+ previewButton.addEventListener("click", async () => {
683
+ setBusy(true, "正在生成真实 DOCX 校样…");
684
+ try {
685
+ const response = await fetch("/api/styles/preview", {
686
+ method: "POST",
687
+ headers: { "Content-Type": "application/json" },
688
+ body: JSON.stringify({ styles: currentStyles }),
689
+ });
690
+ if (!response.ok) {
691
+ const result = await response.json().catch(() => ({}));
692
+ throw new Error(result.error ?? "预览生成失败");
693
+ }
694
+ const url = URL.createObjectURL(await response.blob());
695
+ const link = document.createElement("a");
696
+ link.href = url;
697
+ link.download = "md2docx-style-preview.docx";
698
+ link.click();
699
+ URL.revokeObjectURL(url);
700
+ setStatus("真实 DOCX 校样已生成");
701
+ } catch (error) {
702
+ setStatus(error.message, true);
703
+ } finally {
704
+ setBusy(false);
705
+ updateView();
706
+ }
136
707
  });
137
708
 
138
709
  function updateView() {
139
- const changes = countChanges(originalConfig, currentConfig);
140
- saveButton.disabled = changes === 0;
141
- resetButton.disabled = changes === 0;
142
- if (changes) setStatus("有尚未保存的修改");
143
- else if (status.textContent !== "默认配置已保存") setStatus("配置尚未修改");
144
- }
145
-
146
- function countChanges(before, after) {
147
- return Object.keys(schema.properties).reduce(
148
- (total, group) =>
149
- total +
150
- Object.keys(schema.properties[group].properties).filter(
151
- (name) => before[group][name] !== after[group][name],
152
- ).length,
153
- 0,
710
+ if (!originalConfig || !originalStyles) return;
711
+ const changed =
712
+ activeView === "config"
713
+ ? JSON.stringify(originalConfig) !== JSON.stringify(currentConfig)
714
+ : JSON.stringify(originalStyles) !== JSON.stringify(currentStyles);
715
+ saveButton.disabled = !changed;
716
+ resetButton.disabled = !changed;
717
+ if (changed)
718
+ setStatus(activeView === "config" ? "转换配置有尚未保存的修改" : "文档样式有尚未保存的修改");
719
+ else if (!status.textContent.includes("已保存") && !status.textContent.includes("已生成"))
720
+ setStatus(activeView === "config" ? "转换配置尚未修改" : "文档样式尚未修改");
721
+ }
722
+
723
+ function updateProof() {
724
+ if (!currentStyles) return;
725
+ document
726
+ .querySelectorAll("[data-proof]")
727
+ .forEach((node) => applyProofStyle(node, node.dataset.proof));
728
+ }
729
+
730
+ function applyProofStyle(node, name) {
731
+ const style = findStyleAny(name);
732
+ if (!style) return;
733
+ const font =
734
+ effectiveValue(style, ["run", "font", "eastAsia"]).value ??
735
+ effectiveValue(style, ["run", "font", "ascii"]).value;
736
+ const size = effectiveValue(style, ["run", "size"]).value;
737
+ const color = effectiveValue(style, ["run", "color"]).value;
738
+ const bold = effectiveValue(style, ["run", "bold"]).value;
739
+ const italics = effectiveValue(style, ["run", "italics"]).value;
740
+ const align = effectiveValue(style, ["paragraph", "alignment"]).value;
741
+ const line = effectiveValue(style, ["paragraph", "spacing", "line"]).value;
742
+ const before = effectiveValue(style, ["paragraph", "spacing", "before"]).value;
743
+ const after = effectiveValue(style, ["paragraph", "spacing", "after"]).value;
744
+ const indent = effectiveValue(style, ["paragraph", "indent", "firstLineChars"]).value;
745
+ node.style.fontFamily = font ? `"${font}", serif` : "";
746
+ node.style.fontSize = size ? `${size / 2}pt` : "";
747
+ node.style.color = color && color !== "auto" ? `#${color}` : "";
748
+ node.style.fontWeight = bold ? "700" : "400";
749
+ node.style.fontStyle = italics ? "italic" : "normal";
750
+ node.style.textAlign = align === "both" ? "justify" : (align ?? "");
751
+ node.style.lineHeight = line ? String(line / 240) : "";
752
+ node.style.marginTop = before !== undefined ? `${before / 20}pt` : "";
753
+ node.style.marginBottom = after !== undefined ? `${after / 20}pt` : "";
754
+ node.style.textIndent = indent ? `${indent / 100}em` : "";
755
+ }
756
+
757
+ function selectedEntry() {
758
+ if (selectedStyle.kind === "default") return currentStyles.default?.document;
759
+ if (selectedStyle.kind === "style")
760
+ return collectionEntries(selectedStyle.collection).find(
761
+ (entry) => entry.id === selectedStyle.id,
762
+ );
763
+ return null;
764
+ }
765
+
766
+ function findStyleAny(name) {
767
+ return ["paragraphStyles", "characterStyles", "tableStyles"]
768
+ .flatMap(collectionEntries)
769
+ .find((entry) => entry.name === name);
770
+ }
771
+ function collectionEntries(collection) {
772
+ return Array.isArray(currentStyles?.[collection]) ? currentStyles[collection] : [];
773
+ }
774
+ function styleNameById(id) {
775
+ return (
776
+ ["paragraphStyles", "characterStyles", "tableStyles"]
777
+ .flatMap(collectionEntries)
778
+ .find((entry) => entry.id === id)?.name ?? id
154
779
  );
155
780
  }
156
781
 
157
- function getPath(source, path) {
158
- return path.split(".").reduce((value, key) => value?.[key], source);
782
+ function effectiveValue(entry, path, visited = new Set()) {
783
+ const explicit = deepGet(entry, path);
784
+ if (explicit !== undefined)
785
+ return { value: explicit, source: displayStyleName(entry.name ?? "文档默认") };
786
+ if (entry.basedOn && !visited.has(entry.basedOn)) {
787
+ visited.add(entry.basedOn);
788
+ const parent = ["paragraphStyles", "characterStyles", "tableStyles"]
789
+ .flatMap(collectionEntries)
790
+ .find((candidate) => candidate.id === entry.basedOn);
791
+ if (parent) {
792
+ const inherited = effectiveValue(parent, path, visited);
793
+ if (inherited.value !== undefined) return inherited;
794
+ }
795
+ }
796
+ const fallback = deepGet(currentStyles.default?.document, path);
797
+ return { value: fallback, source: "文档默认" };
798
+ }
799
+
800
+ function displayStyleName(name) {
801
+ const labels = {
802
+ "heading 1": "一级标题",
803
+ "heading 2": "二级标题",
804
+ "heading 3": "三级标题",
805
+ "heading 4": "四级标题",
806
+ "heading 5": "五级标题",
807
+ "heading 6": "六级标题",
808
+ "TOC Heading": "目录标题",
809
+ "Body Text": "正文",
810
+ "First Paragraph": "首段正文",
811
+ "Block Text": "块引用",
812
+ "Source Code": "代码块",
813
+ "Inline Code": "行内代码",
814
+ "Table Caption": "表格题注",
815
+ "Image Caption": "图片题注",
816
+ "Captioned Figure": "带题注图片",
817
+ "footnote text": "脚注正文",
818
+ "footnote reference": "脚注引用",
819
+ header: "页眉",
820
+ footer: "页脚",
821
+ caption: "通用题注",
822
+ };
823
+ return labels[name] ?? name;
824
+ }
825
+
826
+ function deepGet(source, path) {
827
+ return path.reduce((value, key) => value?.[key], source);
159
828
  }
160
- function setPath(source, path, value) {
161
- const [group, name] = path.split(".");
162
- source[group][name] = value;
829
+ function deepSet(source, path, value) {
830
+ let current = source;
831
+ for (const key of path.slice(0, -1)) {
832
+ if (!current[key] || typeof current[key] !== "object") current[key] = {};
833
+ current = current[key];
834
+ }
835
+ current[path.at(-1)] = value;
836
+ }
837
+ function deepDelete(source, path) {
838
+ const parents = [source];
839
+ let current = source;
840
+ for (const key of path.slice(0, -1)) {
841
+ current = current?.[key];
842
+ if (!current) return;
843
+ parents.push(current);
844
+ }
845
+ delete current[path.at(-1)];
846
+ for (let index = path.length - 2; index >= 0; index--) {
847
+ const parent = parents[index];
848
+ const key = path[index];
849
+ if (parent[key] && Object.keys(parent[key]).length === 0) delete parent[key];
850
+ else break;
851
+ }
852
+ }
853
+ function normalizeColor(value) {
854
+ return typeof value === "string" && /^[0-9a-f]{6}$/i.test(value) ? `#${value}` : "#202B3C";
163
855
  }
164
856
  function enforceHeadingDependency(config) {
165
857
  if (config.numberHeadings.enabled) config.normalizeHeadings.enabled = true;
166
858
  }
167
859
  function syncHeadingDependencyControls() {
168
- const normalizeInput = document.getElementById("normalizeHeadings.enabled");
169
- normalizeInput.checked = currentConfig.normalizeHeadings.enabled;
170
- normalizeInput.disabled = currentConfig.numberHeadings.enabled;
171
- normalizeInput.title = currentConfig.numberHeadings.enabled ? "标题编号开启时无法关闭" : "";
860
+ const input = document.getElementById("normalizeHeadings.enabled");
861
+ input.checked = currentConfig.normalizeHeadings.enabled;
862
+ input.disabled = currentConfig.numberHeadings.enabled;
863
+ input.title = currentConfig.numberHeadings.enabled ? "标题编号开启时无法关闭" : "";
864
+ }
865
+ function setBusy(busy, message) {
866
+ saveButton.disabled = busy;
867
+ resetButton.disabled = busy;
868
+ previewButton.disabled = busy;
869
+ if (message) setStatus(message);
172
870
  }
173
871
  function setStatus(message, error = false) {
174
872
  status.textContent = message;
175
- status.classList.toggle("error", error);
873
+ status.classList.toggle("error-copy", error);
874
+ statusIndicator.classList.toggle("error", error);
176
875
  }
177
876
  function escapeHtml(value) {
178
877
  const node = document.createElement("span");