@v1hz/md2docx 1.6.0 → 2.0.1
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/README.md +46 -26
- package/config/config.json +1 -5
- package/config/config.schema.json +2 -21
- package/config/lua/add-inline-code.lua +6 -0
- package/dist/index.js +1272 -0
- package/package.json +10 -5
- package/src/cli.ts +0 -210
- package/src/config.ts +0 -52
- package/src/index.ts +0 -99
- package/src/paths.ts +0 -34
- package/src/preprocess/caption.ts +0 -107
- package/src/preprocess/index.ts +0 -49
- package/src/preprocess/mermaid.ts +0 -146
- package/src/preprocess/title.ts +0 -144
- package/src/style/extract.ts +0 -653
- package/src/style/generate.ts +0 -65
- package/src/web/app.css +0 -921
- package/src/web/app.js +0 -880
- package/src/web/index.html +0 -114
- package/src/web.ts +0 -512
package/src/web/index.html
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="zh-CN">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<meta name="description" content="md2docx 转换配置与 Word 文档样式编辑器" />
|
|
7
|
-
<title>md2docx 文档设计台</title>
|
|
8
|
-
<link rel="stylesheet" href="/app.css" />
|
|
9
|
-
</head>
|
|
10
|
-
<body>
|
|
11
|
-
<header class="masthead">
|
|
12
|
-
<a class="brand" href="#" aria-label="md2docx 文档设计台首页">
|
|
13
|
-
<span class="brand-mark">md</span>
|
|
14
|
-
<span><strong>md2docx</strong><small>文档设计台</small></span>
|
|
15
|
-
</a>
|
|
16
|
-
<nav class="mode-tabs" aria-label="工作区">
|
|
17
|
-
<button class="mode-tab active" type="button" data-view="config">转换配置</button>
|
|
18
|
-
<button class="mode-tab" type="button" data-view="styles">文档样式</button>
|
|
19
|
-
</nav>
|
|
20
|
-
</header>
|
|
21
|
-
|
|
22
|
-
<main>
|
|
23
|
-
<section id="config-view" class="view active" aria-label="转换配置">
|
|
24
|
-
<div class="config-workspace">
|
|
25
|
-
<nav id="config-index" class="config-index" aria-label="配置索引"></nav>
|
|
26
|
-
<form id="config-form" class="config-sheet" aria-label="默认配置">
|
|
27
|
-
<div class="loading">正在读取转换配置…</div>
|
|
28
|
-
</form>
|
|
29
|
-
</div>
|
|
30
|
-
</section>
|
|
31
|
-
|
|
32
|
-
<section id="styles-view" class="view" aria-label="文档样式" hidden>
|
|
33
|
-
<div class="style-workspace">
|
|
34
|
-
<aside class="style-rail">
|
|
35
|
-
<header>
|
|
36
|
-
<p class="eyebrow">样式目录</p>
|
|
37
|
-
<h1>设计一份文档</h1>
|
|
38
|
-
<p>按内容角色调整样式,未修改的属性继续沿用 Word 样式继承。</p>
|
|
39
|
-
<label class="style-search">
|
|
40
|
-
<span aria-hidden="true">⌕</span>
|
|
41
|
-
<input id="style-search" type="search" placeholder="搜索标题、正文、题注…" />
|
|
42
|
-
</label>
|
|
43
|
-
</header>
|
|
44
|
-
<nav id="style-index" aria-label="样式分类"></nav>
|
|
45
|
-
</aside>
|
|
46
|
-
|
|
47
|
-
<section id="style-editor" class="style-editor" aria-live="polite">
|
|
48
|
-
<div class="loading">正在读取文档样式…</div>
|
|
49
|
-
</section>
|
|
50
|
-
|
|
51
|
-
<aside class="proof-panel" aria-label="样式即时预览">
|
|
52
|
-
<header class="proof-header">
|
|
53
|
-
<div><span class="proof-dot"></span>即时校样</div>
|
|
54
|
-
<small>近似预览</small>
|
|
55
|
-
</header>
|
|
56
|
-
<article id="proof-page" class="proof-page">
|
|
57
|
-
<header class="proof-cover">
|
|
58
|
-
<h1 data-proof="Title">文档样式校样</h1>
|
|
59
|
-
<p data-proof="Subtitle">md2docx preview</p>
|
|
60
|
-
</header>
|
|
61
|
-
<section>
|
|
62
|
-
<h2 data-proof="heading 1">第一章 文档排版</h2>
|
|
63
|
-
<p data-proof="First Paragraph">
|
|
64
|
-
这是一段用于检查中文字体、西文字体、字号、行距和首行缩进的正文。好的样式让结构清晰,也让长篇阅读保持舒适。
|
|
65
|
-
</p>
|
|
66
|
-
<h3 data-proof="heading 2">1.1 次级标题</h3>
|
|
67
|
-
<p data-proof="Body Text">
|
|
68
|
-
正文中可以包含<a data-proof="Hyperlink">超链接</a>和
|
|
69
|
-
<code data-proof="Inline Code">inline code</code>。
|
|
70
|
-
</p>
|
|
71
|
-
<blockquote data-proof="Block Text">
|
|
72
|
-
排版不是装饰,而是内容结构的可见表达。
|
|
73
|
-
</blockquote>
|
|
74
|
-
<p class="proof-caption" data-proof="Table Caption">表 1:样式示例</p>
|
|
75
|
-
<table data-proof="Table">
|
|
76
|
-
<thead>
|
|
77
|
-
<tr>
|
|
78
|
-
<th>项目</th>
|
|
79
|
-
<th>当前状态</th>
|
|
80
|
-
</tr>
|
|
81
|
-
</thead>
|
|
82
|
-
<tbody>
|
|
83
|
-
<tr>
|
|
84
|
-
<td>标题</td>
|
|
85
|
-
<td>清晰</td>
|
|
86
|
-
</tr>
|
|
87
|
-
<tr>
|
|
88
|
-
<td>正文</td>
|
|
89
|
-
<td>易读</td>
|
|
90
|
-
</tr>
|
|
91
|
-
</tbody>
|
|
92
|
-
</table>
|
|
93
|
-
<pre data-proof="Source Code"><code>format(document).toDocx()</code></pre>
|
|
94
|
-
</section>
|
|
95
|
-
</article>
|
|
96
|
-
</aside>
|
|
97
|
-
</div>
|
|
98
|
-
</section>
|
|
99
|
-
</main>
|
|
100
|
-
|
|
101
|
-
<footer class="action-bar">
|
|
102
|
-
<div class="status-copy">
|
|
103
|
-
<span id="status-indicator"></span>
|
|
104
|
-
<p id="status" role="status">正在读取配置…</p>
|
|
105
|
-
</div>
|
|
106
|
-
<div class="actions">
|
|
107
|
-
<button id="reset-button" class="secondary" type="button" disabled>撤销修改</button>
|
|
108
|
-
<button id="preview-button" class="secondary" type="button" hidden>生成预览 DOCX</button>
|
|
109
|
-
<button id="save-button" type="submit" form="config-form" disabled>保存转换配置</button>
|
|
110
|
-
</div>
|
|
111
|
-
</footer>
|
|
112
|
-
<script type="module" src="/app.js"></script>
|
|
113
|
-
</body>
|
|
114
|
-
</html>
|
package/src/web.ts
DELETED
|
@@ -1,512 +0,0 @@
|
|
|
1
|
-
import { $ } from "bun";
|
|
2
|
-
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
|
-
import { dirname, join, resolve } from "path";
|
|
5
|
-
|
|
6
|
-
import { getConfigOptions } from "./cli";
|
|
7
|
-
import {
|
|
8
|
-
CONFIG_PATH,
|
|
9
|
-
CONFIG_SCHEMA_PATH,
|
|
10
|
-
PKG_DIR,
|
|
11
|
-
STYLE_CONFIG,
|
|
12
|
-
STYLE_TEMPLATE_DOCX,
|
|
13
|
-
} from "./paths";
|
|
14
|
-
import { generateTemplateDocx } from "./style/generate";
|
|
15
|
-
|
|
16
|
-
const HOSTNAME = "127.0.0.1";
|
|
17
|
-
const PORT = 3210;
|
|
18
|
-
const WEB_DIR = resolve(import.meta.dir, "web");
|
|
19
|
-
|
|
20
|
-
const STYLE_CATALOG = [
|
|
21
|
-
{
|
|
22
|
-
id: "document",
|
|
23
|
-
label: "全局默认",
|
|
24
|
-
description: "未被具体样式覆盖时使用的文档基础字体和段落设置。",
|
|
25
|
-
collection: "default",
|
|
26
|
-
name: "文档默认",
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
id: "title",
|
|
30
|
-
label: "文档信息",
|
|
31
|
-
description: "文档标题、副标题、作者与日期。",
|
|
32
|
-
collection: "paragraphStyles",
|
|
33
|
-
names: ["Title", "Subtitle", "Author", "Date"],
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
id: "heading",
|
|
37
|
-
label: "章节标题",
|
|
38
|
-
description: "一级至六级标题和目录标题。",
|
|
39
|
-
collection: "paragraphStyles",
|
|
40
|
-
names: [
|
|
41
|
-
"heading 1",
|
|
42
|
-
"heading 2",
|
|
43
|
-
"heading 3",
|
|
44
|
-
"heading 4",
|
|
45
|
-
"heading 5",
|
|
46
|
-
"heading 6",
|
|
47
|
-
"TOC Heading",
|
|
48
|
-
],
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
id: "body",
|
|
52
|
-
label: "正文与摘要",
|
|
53
|
-
description: "普通正文、首段、紧凑段落、引用和摘要。",
|
|
54
|
-
collection: "paragraphStyles",
|
|
55
|
-
names: ["Normal", "First Paragraph", "Body Text", "Compact", "Block Text", "Abstract"],
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
id: "caption",
|
|
59
|
-
label: "图片与题注",
|
|
60
|
-
description: "图片段落以及图片、表格的题注样式。",
|
|
61
|
-
collection: "paragraphStyles",
|
|
62
|
-
names: ["Figure", "Captioned Figure", "caption", "Table Caption", "Image Caption", "图片"],
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: "code",
|
|
66
|
-
label: "代码与链接",
|
|
67
|
-
description: "代码块、行内代码、超链接和脚注。",
|
|
68
|
-
groups: [
|
|
69
|
-
{ collection: "paragraphStyles", names: ["Source Code", "footnote text"] },
|
|
70
|
-
{ collection: "characterStyles", names: ["Inline Code", "Hyperlink", "footnote reference"] },
|
|
71
|
-
],
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
id: "table",
|
|
75
|
-
label: "表格",
|
|
76
|
-
description: "表格字体、对齐与单元格间距。",
|
|
77
|
-
collection: "tableStyles",
|
|
78
|
-
names: ["Table"],
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
id: "header-footer",
|
|
82
|
-
label: "页眉页脚",
|
|
83
|
-
description: "页眉和页脚文字的基础外观。",
|
|
84
|
-
collection: "paragraphStyles",
|
|
85
|
-
names: ["header", "footer"],
|
|
86
|
-
},
|
|
87
|
-
] as const;
|
|
88
|
-
|
|
89
|
-
const PREVIEW_MARKDOWN = `---
|
|
90
|
-
title: 文档样式校样
|
|
91
|
-
subtitle: md2docx preview
|
|
92
|
-
author: 示例作者
|
|
93
|
-
date: 2026 年 7 月
|
|
94
|
-
---
|
|
95
|
-
|
|
96
|
-
# 第一章 文档排版
|
|
97
|
-
|
|
98
|
-
这是一段用于检查中文字体、西文字体 Times New Roman、字号、行距和首行缩进的正文。良好的样式应当让结构清晰,也让长篇阅读保持舒适。
|
|
99
|
-
|
|
100
|
-
## 1.1 次级标题
|
|
101
|
-
|
|
102
|
-
正文中可以包含[超链接](https://example.com)和\`inline code\`,也可以使用脚注。[^1]
|
|
103
|
-
|
|
104
|
-
> 这是一段引用文字,用于检查块文本的缩进、字号和段落间距。
|
|
105
|
-
|
|
106
|
-
Table: 表 1:样式示例
|
|
107
|
-
|
|
108
|
-
| 项目 | 当前状态 |
|
|
109
|
-
| --- | --- |
|
|
110
|
-
| 标题 | 清晰 |
|
|
111
|
-
| 正文 | 易读 |
|
|
112
|
-
|
|
113
|
-

|
|
114
|
-
|
|
115
|
-
\`\`\`ts
|
|
116
|
-
export function format(document: string): string {
|
|
117
|
-
return document.trim();
|
|
118
|
-
}
|
|
119
|
-
\`\`\`
|
|
120
|
-
|
|
121
|
-
[^1]: 这是一条脚注,用于检查脚注正文和引用标记。
|
|
122
|
-
`;
|
|
123
|
-
|
|
124
|
-
interface WebHandlerOptions {
|
|
125
|
-
configPath?: string;
|
|
126
|
-
schemaPath?: string;
|
|
127
|
-
stylePath?: string;
|
|
128
|
-
templatePath?: string;
|
|
129
|
-
webDir?: string;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function createWebHandler(
|
|
133
|
-
options: WebHandlerOptions = {},
|
|
134
|
-
): (request: Request) => Promise<Response> {
|
|
135
|
-
const configPath = options.configPath ?? CONFIG_PATH;
|
|
136
|
-
const schemaPath = options.schemaPath ?? CONFIG_SCHEMA_PATH;
|
|
137
|
-
const stylePath = options.stylePath ?? STYLE_CONFIG;
|
|
138
|
-
const templatePath = options.templatePath ?? STYLE_TEMPLATE_DOCX;
|
|
139
|
-
const webDir = options.webDir ?? WEB_DIR;
|
|
140
|
-
|
|
141
|
-
return async (request) => {
|
|
142
|
-
const url = new URL(request.url);
|
|
143
|
-
|
|
144
|
-
if (url.pathname === "/api/config" && request.method === "GET") {
|
|
145
|
-
const [schema, config] = await Promise.all([
|
|
146
|
-
Bun.file(schemaPath).json(),
|
|
147
|
-
Bun.file(configPath).json(),
|
|
148
|
-
]);
|
|
149
|
-
return Response.json({ schema, config });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (url.pathname === "/api/config" && request.method === "PUT") {
|
|
153
|
-
try {
|
|
154
|
-
const schema = await Bun.file(schemaPath).json();
|
|
155
|
-
const config = await request.json();
|
|
156
|
-
enforceConfigDependencies(config);
|
|
157
|
-
validateWebConfig(config, schema);
|
|
158
|
-
const savedConfig = { $schema: "./config.schema.json", ...config };
|
|
159
|
-
await writeFile(configPath, `${JSON.stringify(savedConfig, null, 2)}\n`, "utf-8");
|
|
160
|
-
return Response.json({ ok: true });
|
|
161
|
-
} catch (error) {
|
|
162
|
-
return errorResponse(error);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (url.pathname === "/api/styles" && request.method === "GET") {
|
|
167
|
-
try {
|
|
168
|
-
const source = await Bun.file(stylePath).text();
|
|
169
|
-
return Response.json({
|
|
170
|
-
styles: JSON.parse(source),
|
|
171
|
-
catalog: STYLE_CATALOG,
|
|
172
|
-
revision: styleRevision(source),
|
|
173
|
-
});
|
|
174
|
-
} catch (error) {
|
|
175
|
-
return errorResponse(error);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (url.pathname === "/api/styles" && request.method === "PUT") {
|
|
180
|
-
try {
|
|
181
|
-
const body = await request.json();
|
|
182
|
-
if (!isRecord(body)) throw new Error("请求内容必须是 JSON 对象");
|
|
183
|
-
const styles = body.styles;
|
|
184
|
-
const revision = body.revision;
|
|
185
|
-
validateStyles(styles);
|
|
186
|
-
synchronizeTableStylesXml(styles);
|
|
187
|
-
if (typeof revision === "string") {
|
|
188
|
-
const currentSource = await Bun.file(stylePath).text();
|
|
189
|
-
if (revision !== styleRevision(currentSource)) {
|
|
190
|
-
throw new Error("样式文件已被其他操作修改,请刷新页面后重试");
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
const source = `${JSON.stringify(styles, null, 2)}\n`;
|
|
194
|
-
await saveStylesAndTemplate(stylePath, templatePath, source);
|
|
195
|
-
return Response.json({ ok: true, revision: styleRevision(source) });
|
|
196
|
-
} catch (error) {
|
|
197
|
-
return errorResponse(error);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (url.pathname === "/api/styles/preview" && request.method === "POST") {
|
|
202
|
-
try {
|
|
203
|
-
const body = await request.json();
|
|
204
|
-
const styles = isRecord(body) && "styles" in body ? body.styles : body;
|
|
205
|
-
validateStyles(styles);
|
|
206
|
-
synchronizeTableStylesXml(styles);
|
|
207
|
-
const preview = await createPreviewDocx(styles);
|
|
208
|
-
return new Response(preview, {
|
|
209
|
-
headers: {
|
|
210
|
-
"Content-Type":
|
|
211
|
-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
212
|
-
"Content-Disposition": 'attachment; filename="md2docx-style-preview.docx"',
|
|
213
|
-
},
|
|
214
|
-
});
|
|
215
|
-
} catch (error) {
|
|
216
|
-
return errorResponse(error);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const files: Record<string, { path: string; type: string }> = {
|
|
221
|
-
"/": { path: "index.html", type: "text/html; charset=utf-8" },
|
|
222
|
-
"/app.css": { path: "app.css", type: "text/css; charset=utf-8" },
|
|
223
|
-
"/app.js": { path: "app.js", type: "text/javascript; charset=utf-8" },
|
|
224
|
-
};
|
|
225
|
-
const asset = files[url.pathname];
|
|
226
|
-
if (!asset || request.method !== "GET") return new Response("Not found", { status: 404 });
|
|
227
|
-
return new Response(Bun.file(resolve(webDir, asset.path)), {
|
|
228
|
-
headers: { "Content-Type": asset.type },
|
|
229
|
-
});
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export async function startWebEditor(): Promise<void> {
|
|
234
|
-
const server = Bun.serve({ hostname: HOSTNAME, port: PORT, fetch: createWebHandler() });
|
|
235
|
-
const url = `http://${server.hostname}:${server.port}`;
|
|
236
|
-
console.log(`文档设计台已启动:${url}`);
|
|
237
|
-
console.log("按 Ctrl+C 停止服务");
|
|
238
|
-
openBrowser(url);
|
|
239
|
-
await new Promise(() => undefined);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
async function saveStylesAndTemplate(
|
|
243
|
-
stylePath: string,
|
|
244
|
-
templatePath: string,
|
|
245
|
-
source: string,
|
|
246
|
-
): Promise<void> {
|
|
247
|
-
const suffix = `${process.pid}-${Date.now()}`;
|
|
248
|
-
const temporaryStyle = `${stylePath}.${suffix}.tmp`;
|
|
249
|
-
const temporaryTemplate = `${templatePath}.${suffix}.tmp`;
|
|
250
|
-
const previousStyle = await readFile(stylePath);
|
|
251
|
-
const previousTemplate = await readFile(templatePath).catch(() => null);
|
|
252
|
-
|
|
253
|
-
try {
|
|
254
|
-
await mkdir(dirname(stylePath), { recursive: true });
|
|
255
|
-
await writeFile(temporaryStyle, source, "utf-8");
|
|
256
|
-
await generateTemplateDocx(temporaryStyle, temporaryTemplate);
|
|
257
|
-
await rename(temporaryStyle, stylePath);
|
|
258
|
-
await mkdir(dirname(templatePath), { recursive: true });
|
|
259
|
-
await rm(templatePath, { force: true });
|
|
260
|
-
await rename(temporaryTemplate, templatePath);
|
|
261
|
-
} catch (error) {
|
|
262
|
-
await writeFile(stylePath, previousStyle).catch(() => undefined);
|
|
263
|
-
if (previousTemplate) await writeFile(templatePath, previousTemplate).catch(() => undefined);
|
|
264
|
-
throw error;
|
|
265
|
-
} finally {
|
|
266
|
-
await Promise.all([
|
|
267
|
-
rm(temporaryStyle, { force: true }).catch(() => undefined),
|
|
268
|
-
rm(temporaryTemplate, { force: true }).catch(() => undefined),
|
|
269
|
-
]);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async function createPreviewDocx(styles: unknown): Promise<ArrayBuffer> {
|
|
274
|
-
const directory = join(tmpdir(), `md2docx-preview-${process.pid}-${Date.now()}`);
|
|
275
|
-
const stylePath = join(directory, "style.json");
|
|
276
|
-
const referencePath = join(directory, "reference.docx");
|
|
277
|
-
const markdownPath = join(directory, "preview.md");
|
|
278
|
-
const outputPath = join(directory, "preview.docx");
|
|
279
|
-
try {
|
|
280
|
-
await mkdir(directory, { recursive: true });
|
|
281
|
-
await Promise.all([
|
|
282
|
-
writeFile(stylePath, `${JSON.stringify(styles, null, 2)}\n`, "utf-8"),
|
|
283
|
-
writeFile(markdownPath, PREVIEW_MARKDOWN, "utf-8"),
|
|
284
|
-
]);
|
|
285
|
-
await generateTemplateDocx(stylePath, referencePath);
|
|
286
|
-
const luaFilter = resolve(PKG_DIR, "config/lua/add-inline-code.lua");
|
|
287
|
-
const result =
|
|
288
|
-
await $`pandoc ${markdownPath} --from=gfm+yaml_metadata_block -o ${outputPath} --reference-doc=${referencePath} --lua-filter=${luaFilter}`.nothrow();
|
|
289
|
-
if (result.exitCode !== 0) {
|
|
290
|
-
const detail = result.stderr.toString().trim();
|
|
291
|
-
throw new Error(`预览 DOCX 生成失败${detail ? `:${detail}` : ",请确认已安装 pandoc"}`);
|
|
292
|
-
}
|
|
293
|
-
return await Bun.file(outputPath).arrayBuffer();
|
|
294
|
-
} finally {
|
|
295
|
-
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function validateStyles(styles: unknown): asserts styles is Record<string, unknown> {
|
|
300
|
-
if (!isRecord(styles)) throw new Error("样式配置必须是 JSON 对象");
|
|
301
|
-
for (const collection of ["paragraphStyles", "characterStyles", "tableStyles"] as const) {
|
|
302
|
-
const entries = styles[collection];
|
|
303
|
-
if (entries !== undefined && !Array.isArray(entries)) {
|
|
304
|
-
throw new Error(`${collection} 必须是数组`);
|
|
305
|
-
}
|
|
306
|
-
if (!Array.isArray(entries)) continue;
|
|
307
|
-
const ids = new Set<string>();
|
|
308
|
-
for (const [index, entry] of entries.entries()) {
|
|
309
|
-
if (!isRecord(entry)) throw new Error(`${collection}[${index}] 必须是对象`);
|
|
310
|
-
if (typeof entry.id !== "string" || !entry.id) {
|
|
311
|
-
throw new Error(`${collection}[${index}].id 必须是非空字符串`);
|
|
312
|
-
}
|
|
313
|
-
if (typeof entry.name !== "string" || !entry.name) {
|
|
314
|
-
throw new Error(`${collection}[${index}].name 必须是非空字符串`);
|
|
315
|
-
}
|
|
316
|
-
if (ids.has(entry.id)) throw new Error(`${collection} 中存在重复样式 ID:${entry.id}`);
|
|
317
|
-
ids.add(entry.id);
|
|
318
|
-
validateStyleValues(entry, `${collection}.${entry.name}`);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (styles.default !== undefined && !isRecord(styles.default)) {
|
|
322
|
-
throw new Error("default 必须是对象");
|
|
323
|
-
}
|
|
324
|
-
if (styles.tableStylesXml !== undefined && typeof styles.tableStylesXml !== "string") {
|
|
325
|
-
throw new Error("tableStylesXml 必须是字符串");
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function validateStyleValues(value: unknown, path: string): void {
|
|
330
|
-
if (Array.isArray(value)) {
|
|
331
|
-
value.forEach((child, index) => validateStyleValues(child, `${path}[${index}]`));
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
if (!isRecord(value)) return;
|
|
335
|
-
for (const [key, child] of Object.entries(value)) {
|
|
336
|
-
const childPath = `${path}.${key}`;
|
|
337
|
-
if ((key === "size" || key === "sizeComplexScript") && child !== undefined) {
|
|
338
|
-
if (typeof child !== "number" || !Number.isFinite(child) || child <= 0) {
|
|
339
|
-
throw new Error(`${childPath} 必须是大于 0 的数字`);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
if ((key === "color" || key === "fill") && typeof child === "string") {
|
|
343
|
-
if (child !== "auto" && !/^[0-9a-fA-F]{6}$/.test(child)) {
|
|
344
|
-
throw new Error(`${childPath} 必须是六位十六进制颜色或 auto`);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
validateStyleValues(child, childPath);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function synchronizeTableStylesXml(styles: Record<string, unknown>): void {
|
|
352
|
-
const xml = styles.tableStylesXml;
|
|
353
|
-
const tableStyles = styles.tableStyles;
|
|
354
|
-
if (typeof xml !== "string" || !Array.isArray(tableStyles)) return;
|
|
355
|
-
const tableStyle = tableStyles.find(
|
|
356
|
-
(entry): entry is Record<string, unknown> => isRecord(entry) && entry.name === "Table",
|
|
357
|
-
);
|
|
358
|
-
if (!tableStyle) return;
|
|
359
|
-
const match = xml.match(/<w:style\b[^>]*w:styleId="Table"[\s\S]*?<\/w:style>/);
|
|
360
|
-
if (!match) return;
|
|
361
|
-
|
|
362
|
-
let fragment = match[0];
|
|
363
|
-
const run = isRecord(tableStyle.run) ? tableStyle.run : {};
|
|
364
|
-
const font = isRecord(run.font) ? run.font : {};
|
|
365
|
-
fragment = replaceFirstXmlTag(fragment, "w:rFonts", {
|
|
366
|
-
"w:ascii": font.ascii,
|
|
367
|
-
"w:hAnsi": font.hAnsi,
|
|
368
|
-
"w:eastAsia": font.eastAsia,
|
|
369
|
-
});
|
|
370
|
-
fragment = replaceFirstXmlTag(fragment, "w:sz", { "w:val": run.size });
|
|
371
|
-
fragment = replaceFirstXmlTag(fragment, "w:szCs", {
|
|
372
|
-
"w:val": run.sizeComplexScript ?? run.size,
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
const paragraph = isRecord(tableStyle.paragraph) ? tableStyle.paragraph : {};
|
|
376
|
-
const table = isRecord(tableStyle.table) ? tableStyle.table : {};
|
|
377
|
-
fragment = replaceXmlTagInSection(fragment, "w:pPr", "w:jc", {
|
|
378
|
-
"w:val": paragraph.alignment,
|
|
379
|
-
});
|
|
380
|
-
fragment = replaceXmlTagInSection(fragment, "w:tblPr", "w:jc", {
|
|
381
|
-
"w:val": table.alignment,
|
|
382
|
-
});
|
|
383
|
-
const cellMargin = isRecord(table.cellMargin) ? table.cellMargin : {};
|
|
384
|
-
for (const side of ["top", "left", "bottom", "right"] as const) {
|
|
385
|
-
fragment = replaceXmlTagInSection(fragment, "w:tblCellMar", `w:${side}`, {
|
|
386
|
-
"w:w": cellMargin[side],
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
styles.tableStylesXml = xml.replace(match[0], fragment);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function replaceXmlTagInSection(
|
|
393
|
-
xml: string,
|
|
394
|
-
sectionName: string,
|
|
395
|
-
tagName: string,
|
|
396
|
-
attributes: Record<string, unknown>,
|
|
397
|
-
): string {
|
|
398
|
-
const sectionPattern = new RegExp(`(<${sectionName}\\b[^>]*>)([\\s\\S]*?)(<\\/${sectionName}>)`);
|
|
399
|
-
return xml.replace(sectionPattern, (_, open: string, body: string, close: string) => {
|
|
400
|
-
return `${open}${replaceFirstXmlTag(body, tagName, attributes)}${close}`;
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function replaceFirstXmlTag(
|
|
405
|
-
xml: string,
|
|
406
|
-
tagName: string,
|
|
407
|
-
attributes: Record<string, unknown>,
|
|
408
|
-
): string {
|
|
409
|
-
const tagPattern = new RegExp(`<${tagName}\\b[^>]*/>`);
|
|
410
|
-
return xml.replace(tagPattern, (tag) => {
|
|
411
|
-
let updated = tag;
|
|
412
|
-
for (const [name, value] of Object.entries(attributes)) {
|
|
413
|
-
if (value === undefined) continue;
|
|
414
|
-
const encoded = String(value).replaceAll("&", "&").replaceAll('"', """);
|
|
415
|
-
const attributePattern = new RegExp(`${name}="[^"]*"`);
|
|
416
|
-
updated = attributePattern.test(updated)
|
|
417
|
-
? updated.replace(attributePattern, `${name}="${encoded}"`)
|
|
418
|
-
: updated.replace("/>", ` ${name}="${encoded}"/>`);
|
|
419
|
-
}
|
|
420
|
-
return updated;
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function styleRevision(source: string): string {
|
|
425
|
-
return Bun.hash(source).toString(16);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function errorResponse(error: unknown): Response {
|
|
429
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
430
|
-
return Response.json({ error: message }, { status: 400 });
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function enforceConfigDependencies(config: unknown): void {
|
|
434
|
-
if (!isRecord(config)) return;
|
|
435
|
-
const numberHeadings = config.numberHeadings;
|
|
436
|
-
const normalizeHeadings = config.normalizeHeadings;
|
|
437
|
-
if (!isRecord(numberHeadings) || !isRecord(normalizeHeadings)) return;
|
|
438
|
-
if (numberHeadings.enabled === true) normalizeHeadings.enabled = true;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function validateWebConfig(config: unknown, schema: unknown): void {
|
|
442
|
-
if (!isRecord(config) || !isRecord(schema)) throw new Error("配置必须是 JSON 对象");
|
|
443
|
-
const properties = schema.properties;
|
|
444
|
-
if (!isRecord(properties)) throw new Error("配置 schema 无效");
|
|
445
|
-
assertKnownKeys(config, properties, "");
|
|
446
|
-
const configOptions = getConfigOptions(schema);
|
|
447
|
-
for (const option of configOptions) {
|
|
448
|
-
const value = getPath(config, option.path);
|
|
449
|
-
if (value === undefined) {
|
|
450
|
-
if (option.required) throw new Error(`缺少配置项:${option.path}`);
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
if (option.type === "integer" && !Number.isInteger(value)) {
|
|
454
|
-
throw new Error(`${option.path} 必须是整数`);
|
|
455
|
-
}
|
|
456
|
-
if (option.type === "number" && (typeof value !== "number" || !Number.isFinite(value))) {
|
|
457
|
-
throw new Error(`${option.path} 必须是数字`);
|
|
458
|
-
}
|
|
459
|
-
if (option.type !== "integer" && option.type !== "number" && typeof value !== option.type) {
|
|
460
|
-
throw new Error(`${option.path} 必须是 ${option.type}`);
|
|
461
|
-
}
|
|
462
|
-
if (option.enum && !option.enum.includes(value)) throw new Error(`${option.path} 的值无效`);
|
|
463
|
-
if (typeof value === "number" && option.minimum !== undefined && value < option.minimum) {
|
|
464
|
-
throw new Error(`${option.path} 不能小于 ${option.minimum}`);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function assertKnownKeys(
|
|
470
|
-
config: Record<string, unknown>,
|
|
471
|
-
properties: Record<string, unknown>,
|
|
472
|
-
prefix: string,
|
|
473
|
-
): void {
|
|
474
|
-
for (const key of Object.keys(config)) {
|
|
475
|
-
if (!(key in properties)) throw new Error(`未知配置项:${prefix}${key}`);
|
|
476
|
-
}
|
|
477
|
-
for (const [key, childSchema] of Object.entries(properties)) {
|
|
478
|
-
if (!isRecord(childSchema) || childSchema.type !== "object") continue;
|
|
479
|
-
const childConfig = config[key];
|
|
480
|
-
if (!isRecord(childConfig)) throw new Error(`${prefix}${key} 必须是对象`);
|
|
481
|
-
const childProperties = childSchema.properties;
|
|
482
|
-
if (!isRecord(childProperties)) throw new Error("配置 schema 无效");
|
|
483
|
-
assertKnownKeys(childConfig, childProperties, `${prefix}${key}.`);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function getPath(source: Record<string, unknown>, path: string): unknown {
|
|
488
|
-
let current: unknown = source;
|
|
489
|
-
for (const segment of path.split(".")) {
|
|
490
|
-
if (!isRecord(current)) return undefined;
|
|
491
|
-
current = current[segment];
|
|
492
|
-
}
|
|
493
|
-
return current;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
497
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function openBrowser(url: string): void {
|
|
501
|
-
const command =
|
|
502
|
-
process.platform === "win32"
|
|
503
|
-
? ["cmd", "/c", "start", "", url]
|
|
504
|
-
: process.platform === "darwin"
|
|
505
|
-
? ["open", url]
|
|
506
|
-
: ["xdg-open", url];
|
|
507
|
-
Bun.spawn(command, { stdout: "ignore", stderr: "ignore" });
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
if (import.meta.main) {
|
|
511
|
-
await startWebEditor();
|
|
512
|
-
}
|