dsh-long-office 0.1.2

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 ADDED
@@ -0,0 +1,101 @@
1
+ # dsh-long-office
2
+
3
+ DSH Web 插件:**Office 读取 + 生成**。读取 Word/Excel/PowerPoint(含旧版格式)为文本/表格,内存解析、不落中间文件;并从 Markdown 生成 Word / Excel / PowerPoint(`md2docx` / `md2xlsx` / `md2pptx`,原 `dsh-long-plugins` 的 `md2docx` 迁入本插件)。
4
+
5
+ **工具一览**:`office_read`(读 Office)、`md2docx`(Markdown→Word)、`md2xlsx`(Markdown 表格→Excel)、`md2pptx`(Markdown 大纲→PPT,16:9/备注/图片/坐标回显)。
6
+
7
+ ---
8
+
9
+ ## 功能
10
+
11
+ ### 1. 读取 Office → 文本 / 表格(内存解析,模型按需用)
12
+ | 格式 | 依赖 | 说明 |
13
+ | --- | --- | --- |
14
+ | `.docx` | `mammoth` | Word 2007+ → 纯文本 |
15
+ | `.doc` | `word-extractor` | 旧版 Word → 纯文本 |
16
+ | `.xlsx` | `exceljs` | Excel 2007+ → 每工作表表格 |
17
+ | `.xls` | `xlsx` (SheetJS) | 旧版 Excel → 每工作表 CSV |
18
+ | `.pptx` | `jszip` | PPT 2007+ → 每页文本(zip 内 slide XML) |
19
+ | `.ppt` | — | 暂不支持,提示转换为 `.pptx` |
20
+ | `.txt / .md` | — | 直接按 UTF-8 读取 |
21
+
22
+ 读取在内存中完成,**不生成任何中间文件**,模型按需调用 `office_read` 工具查看内容。
23
+
24
+ ### 2. 生成 Office 文档(Markdown → Word / Excel / PowerPoint)
25
+
26
+ | 工具 | 产物 | 依赖 | 要点 |
27
+ | --- | --- | --- | --- |
28
+ | `md2docx` | `.docx` | `python3` + `python-docx` | 带页码;中文公文排版(宋体+Times New Roman/1.5 倍行距/首行缩进 2 字符/表格居中/无页眉与装饰线/正文不加粗) |
29
+ | `md2xlsx` | `.xlsx` | `python3` + `openpyxl` | 每个 Markdown 表格一个 sheet(名取上方标题);表头加粗、细边框、内容居中、**无底纹**、列宽自适应、冻结首行;`=` 开头写成**真公式** |
30
+ | `md2pptx` | `.pptx` | `python3` + `python-pptx` + `Pillow` | 16:9;`# `封面、`## `分页、`- `要点、`> 备注:`演讲者备注、`![说明](图 "宽x高")` 插图;生成后回显每页元素坐标 |
31
+
32
+ 三者默认都**不覆盖**已存在文件(工具的 `overwrite: true` 才覆盖)。脚本路径可分别用配置 `md2docxScript` / `md2xlsxScript` / `md2pptxScript` 覆盖(默认:插件内 `lib/md2docx.py`、`lib/md2xlsx.py`、`lib/md2pptx.py`)。
33
+
34
+ ---
35
+
36
+ ## 安装
37
+
38
+ 在 DSH profile(如 `web`)下执行:
39
+
40
+ ```bash
41
+ # 通过 npm 安装(未发布时用本地 file: 链接)
42
+ dsh plugin --profile web add dsh-long-office
43
+ # 源码调试:
44
+ dsh plugin --profile web add file:./dsh-long-office
45
+ ```
46
+
47
+ `cordis.patch.yml` 会把插件 `dsh-long-office` 插入 profile 的层栈(`inject: [webRuntime]`)。
48
+
49
+ ---
50
+
51
+ ## 提供的服务端能力
52
+
53
+ - 工具 `office_read`:读取 Office 文件 → 文本/表格。
54
+ - 工具 `md2docx`:Markdown → Word。
55
+ - 路由 `GET /api/dsh-long-office/content?path=<绝对路径>`:返回 Office 文件的内存文本/表格。
56
+
57
+ > 说明:本插件主要提供服务端工具与路由,`client/client.js` 仅为一个占位模块(无前端 UI)。
58
+
59
+ ---
60
+
61
+ ## 配置
62
+
63
+ `cordis.patch.yml` 默认复用 DSH Web 内建的 `trustedHosts`,可选覆盖 `md2docxScript`:
64
+
65
+ ```yaml
66
+ - insert:
67
+ - id: dsh-long-office
68
+ name: dsh-long-office
69
+ inject: [webRuntime]
70
+ config:
71
+ # 复用内建 Web API 的 Host/Origin 白名单
72
+ trustedHosts: !!js ctx.webRuntime.trustedHosts
73
+ # md2docx 脚本覆盖(默认 <plugin>/lib/md2docx.py)
74
+ # md2docxScript: /your/path/md2docx.py
75
+ ```
76
+
77
+ ---
78
+
79
+ ## 验证
80
+
81
+ 安装、重启 DSH 后,验证插件已加载并可用:
82
+
83
+ ```bash
84
+ # 1) 服务已启动
85
+ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3080
86
+ # 期望:200
87
+
88
+ # 2) 读取一个 docx
89
+ curl -s "http://127.0.0.1:3080/api/dsh-long-office/content?path=/absolute/path/to/example.docx"
90
+ # 期望:{"ok":true,"content":"..."}
91
+
92
+ # 3) 工具可用(通过会话问模型)
93
+ # "用 office_read 读一下 /path/to/example.xlsx"
94
+ # "用 md2docx 把 /path/to/report.md 生成成 Word"
95
+ ```
96
+
97
+ ---
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,14 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-long-office',
3
+ factory: (require) => {
4
+ const module = { exports: {} }
5
+ const exports = module.exports
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
7
+ // dsh-long-office 主要提供服务端 Office 读取/生成工具与路由,此客户端仅为占位(无前端 UI)。
8
+ function apply() {}
9
+ const inject = []
10
+ exports.apply = apply
11
+ exports.inject = inject
12
+ return module.exports
13
+ }
14
+ })
@@ -0,0 +1,10 @@
1
+ # dsh bundle patch: inserts the office plugin into a profile's layer stack.
2
+ - insert:
3
+ - id: dsh-long-office
4
+ name: dsh-long-office
5
+ inject: [webRuntime]
6
+ config:
7
+ # Reuse the same trusted Host/Origin authorities as the built-in Web API.
8
+ trustedHosts: !!js ctx.webRuntime.trustedHosts
9
+ # md2docx script override (default: <plugin>/lib/md2docx.py).
10
+ # md2docxScript: /your/path/md2docx.py
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "dsh-long-office",
3
+ "description": "DSH Office read + generate: read Word/Excel/PowerPoint into text/tables in memory (no intermediates, model on-demand); generate styled Word from Markdown (md2docx). | DSH Office 读取 + 生成:内存读取 Office 内容为文本/表格(无中间文件、模型按需用);Markdown 生成带页码 Word。",
4
+ "version": "0.1.2",
5
+ "entry": {
6
+ "module": "./lib/index.js",
7
+ "client": "./client/client.js"
8
+ },
9
+ "dependencies": [
10
+ "@deepseek-ai/dsh-tools"
11
+ ]
12
+ }
package/lib/index.js ADDED
@@ -0,0 +1,236 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname, extname, join, resolve } from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+ import { spawn } from "node:child_process";
5
+ import mammoth from "mammoth";
6
+ import ExcelJS from "exceljs";
7
+ import XLSX from "xlsx";
8
+ import WordExtractor from "word-extractor";
9
+ import { defineTool } from "@deepseek-ai/dsh-tools";
10
+
11
+ const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url));
12
+ const MD2DOCX_SCRIPT = resolve(process.env.MD2DOCX_SCRIPT || join(PACKAGE_DIR, "md2docx.py"));
13
+ const MD2XLSX_SCRIPT = resolve(process.env.MD2XLSX_SCRIPT || join(PACKAGE_DIR, "md2xlsx.py"));
14
+ const MD2PPTX_SCRIPT = resolve(process.env.MD2PPTX_SCRIPT || join(PACKAGE_DIR, "md2pptx.py"));
15
+
16
+ function sendJson(res, status, obj) {
17
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
18
+ res.end(JSON.stringify(obj));
19
+ }
20
+
21
+ /** 内存读取 docx → 纯文本(mammoth)。 */
22
+ async function docxToText(buf) {
23
+ const { value } = await mammoth.extractRawText({ buffer: buf });
24
+ return value;
25
+ }
26
+
27
+ /** 内存读取 doc → 纯文本(word-extractor)。 */
28
+ async function docToText(buf) {
29
+ const w = new WordExtractor();
30
+ const d = await w.extract(Buffer.from(buf));
31
+ return d.getBody();
32
+ }
33
+
34
+ /** 内存读取 xlsx → 表格文本(exceljs)。 */
35
+ async function xlsxToText(buf) {
36
+ const wb = new ExcelJS.Workbook();
37
+ await wb.xlsx.load(buf);
38
+ const rows = [];
39
+ wb.eachSheet((ws) => {
40
+ rows.push(`## 工作表:${ws.name}`);
41
+ ws.eachRow((row) => {
42
+ rows.push((row.values || []).slice(1).map((c) => c == null ? "" : String(c)).join("\t"));
43
+ });
44
+ rows.push("");
45
+ });
46
+ return rows.join("\n");
47
+ }
48
+
49
+ /** 内存读取 xls → 表格文本(SheetJS/xlsx,支持 .xls 与 .xlsx)。 */
50
+ function xlsToText(buf) {
51
+ const wb = XLSX.read(buf, { type: "buffer" });
52
+ const out = [];
53
+ for (const name of wb.SheetNames) {
54
+ out.push(`## 工作表:${name}`);
55
+ const csv = XLSX.utils.sheet_to_csv(wb.Sheets[name]);
56
+ out.push(csv);
57
+ out.push("");
58
+ }
59
+ return out.join("\n");
60
+ }
61
+
62
+ /** 内存读取 pptx → 每页文本(pptx 是 zip + slide XML)。 */
63
+ async function pptxToText(buf) {
64
+ const jszip = (await import("jszip")).default;
65
+ const zip = await jszip.loadAsync(buf);
66
+ const entryNames = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/i.test(n)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
67
+ const out = [];
68
+ for (const n of entryNames) {
69
+ const xml = await zip.file(n).async("string");
70
+ const texts = [...xml.matchAll(/<a:t>([^<]*)<\/a:t>/g)].map((m) => m[1]).join("");
71
+ out.push(texts);
72
+ }
73
+ return out.join("\n\n");
74
+ }
75
+
76
+ async function officeToText(path) {
77
+ const ext = extname(path).toLowerCase();
78
+ const buf = await readFile(path);
79
+ if (ext === ".docx") return docxToText(buf);
80
+ if (ext === ".doc") return docToText(buf);
81
+ if (ext === ".xls") return xlsToText(buf);
82
+ if (ext === ".xlsx") return xlsxToText(buf);
83
+ if (ext === ".pptx") return pptxToText(buf);
84
+ if (ext === ".ppt") return `暂不支持读取旧版 .ppt(请转换为 .pptx)`;
85
+ if (/\.(txt|md|markdown|log)$/i.test(ext)) return buf.toString("utf8");
86
+ return `不支持读取该类型:${ext || "未知"}`;
87
+ }
88
+
89
+ // 声明 apply 需要的 DSH 服务(否则 ctx.tools/ctx.webServer 会报 "without inject")
90
+ export const inject = ["webServer", "tools"];
91
+
92
+ async function apply(ctx, config = {}) {
93
+ // 容错:任何一步失败都只记录、不拖垮 DSH 启动
94
+ try {
95
+ await applyInner(ctx, config);
96
+ } catch (error) {
97
+ if (ctx && ctx.logger) ctx.logger.error(error instanceof Error ? error : new Error(String(error)));
98
+ else console.error("[dsh-long-office] apply failed:", error);
99
+ }
100
+ }
101
+
102
+ async function applyInner(ctx, config = {}) {
103
+ const onError = (error) => ctx.logger.error(error instanceof Error ? error : new Error(String(error)));
104
+ const runScript = (script, args) => new Promise((resolvePromise) => {
105
+ const child = spawn("python3", [script, ...args], { stdio: ["ignore", "pipe", "pipe"] });
106
+ let stdout = "", stderr = "";
107
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
108
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
109
+ child.on("error", (error) => resolvePromise({ ok: false, error: String(error), stdout, stderr }));
110
+ child.on("close", (code) => resolvePromise({ ok: code === 0, code, stdout, stderr }));
111
+ });
112
+
113
+ // 工具:md2docx(从 dsh-long-plugins 搬来)
114
+ ctx.tools.register(defineTool({
115
+ name: "md2docx",
116
+ description: "Convert a Markdown file to a styled Word (.docx) document with a page-number footer (python-docx). Requires python3 + python-docx. Override the script path via config.md2docxScript.",
117
+ parameters: {
118
+ input: { type: "string", required: true, description: "Absolute path to the input .md file." },
119
+ output: { type: "string", description: "Optional absolute path for the output .docx. Defaults to input path with .docx extension." }
120
+ },
121
+ output: {
122
+ schema: { type: "object", additionalProperties: false, properties: { ok: { type: "boolean", required: true }, docxPath: { type: "string" }, error: { type: "string" } } },
123
+ render: (_args, value) => [{ type: "text", text: value && value.ok === true ? `已生成 Word 文档:${value.docxPath}` : `md2docx 失败:${value?.error ?? "未知错误"}` }]
124
+ },
125
+ presentCall: (args) => {
126
+ const inPath = resolve(String(args.input ?? ""));
127
+ const outPath = args.output ? resolve(String(args.output)) : inPath.replace(/\.md$/i, ".docx");
128
+ return { card: "generic", title: "md2docx", kind: "edit", locations: [{ path: outPath }] };
129
+ },
130
+ async execute(args) {
131
+ const inPath = resolve(String(args.input ?? ""));
132
+ const outPath = args.output ? resolve(String(args.output)) : inPath.replace(/\.md$/i, ".docx");
133
+ const result = await runScript(config.md2docxScript ?? MD2DOCX_SCRIPT, [inPath, outPath]);
134
+ if (!result.ok) return { ok: false, error: (result.stderr || result.stdout || String(result.error)).trim() || `md2docx failed (exit ${result.code})` };
135
+ return { ok: true, docxPath: outPath };
136
+ }
137
+ }));
138
+
139
+
140
+ // 工具:md2xlsx(Markdown 表格 → Excel)
141
+ ctx.tools.register(defineTool({
142
+ name: "md2xlsx",
143
+ description: "Convert Markdown tables to an Excel (.xlsx) workbook: one sheet per table (named from the nearest heading), bold header, thin borders, centered, no shading, auto column width, frozen header row; a cell starting with '=' becomes a real formula. Requires python3 + openpyxl. Override the script path via config.md2xlsxScript.",
144
+ parameters: {
145
+ input: { type: "string", required: true, description: "Absolute path to the input .md file." },
146
+ output: { type: "string", description: "Optional absolute path for the output .xlsx. Defaults to input path with .xlsx extension." },
147
+ overwrite: { type: "boolean", description: "Overwrite an existing output file (default false)." }
148
+ },
149
+ output: {
150
+ schema: { type: "object", additionalProperties: false, properties: { ok: { type: "boolean", required: true }, xlsxPath: { type: "string" }, error: { type: "string" } } },
151
+ render: (_args, value) => [{ type: "text", text: value && value.ok === true ? `已生成 Excel:${value.xlsxPath}` : `md2xlsx 失败:${value?.error ?? "未知错误"}` }]
152
+ },
153
+ presentCall: (args) => {
154
+ const inPath = resolve(String(args.input ?? ""));
155
+ const outPath = args.output ? resolve(String(args.output)) : inPath.replace(/\.md$/i, ".xlsx");
156
+ return { card: "generic", title: "md2xlsx", kind: "edit", locations: [{ path: outPath }] };
157
+ },
158
+ async execute(args) {
159
+ const inPath = resolve(String(args.input ?? ""));
160
+ const outPath = args.output ? resolve(String(args.output)) : inPath.replace(/\.md$/i, ".xlsx");
161
+ const scriptArgs = [inPath, outPath];
162
+ if (args.overwrite === true) scriptArgs.push("--force");
163
+ const result = await runScript(config.md2xlsxScript ?? MD2XLSX_SCRIPT, scriptArgs);
164
+ if (!result.ok) return { ok: false, error: (result.stderr || result.stdout || String(result.error)).trim() || `md2xlsx failed (exit ${result.code})` };
165
+ return { ok: true, xlsxPath: outPath };
166
+ }
167
+ }));
168
+
169
+ // 工具:md2pptx(Markdown 大纲 → PowerPoint,16:9,带备注/图片/坐标回显)
170
+ ctx.tools.register(defineTool({
171
+ name: "md2pptx",
172
+ description: "Convert a Markdown outline to a 16:9 PowerPoint (.pptx): '# '=cover, '## '=new slide, '- '=bullets, '> 备注:'=speaker notes, '![alt](path \"WxH\")'=image. Requires python3 + python-pptx + Pillow. Override the script path via config.md2pptxScript.",
173
+ parameters: {
174
+ input: { type: "string", required: true, description: "Absolute path to the input .md file." },
175
+ output: { type: "string", description: "Optional absolute path for the output .pptx. Defaults to input path with .pptx extension." },
176
+ overwrite: { type: "boolean", description: "Overwrite an existing output file (default false)." }
177
+ },
178
+ output: {
179
+ schema: { type: "object", additionalProperties: false, properties: { ok: { type: "boolean", required: true }, pptxPath: { type: "string" }, error: { type: "string" } } },
180
+ render: (_args, value) => [{ type: "text", text: value && value.ok === true ? `已生成 PPT:${value.pptxPath}` : `md2pptx 失败:${value?.error ?? "未知错误"}` }]
181
+ },
182
+ presentCall: (args) => {
183
+ const inPath = resolve(String(args.input ?? ""));
184
+ const outPath = args.output ? resolve(String(args.output)) : inPath.replace(/\.md$/i, ".pptx");
185
+ return { card: "generic", title: "md2pptx", kind: "edit", locations: [{ path: outPath }] };
186
+ },
187
+ async execute(args) {
188
+ const inPath = resolve(String(args.input ?? ""));
189
+ const outPath = args.output ? resolve(String(args.output)) : inPath.replace(/\.md$/i, ".pptx");
190
+ const scriptArgs = [inPath, outPath];
191
+ if (args.overwrite === true) scriptArgs.push("--force");
192
+ const result = await runScript(config.md2pptxScript ?? MD2PPTX_SCRIPT, scriptArgs);
193
+ if (!result.ok) return { ok: false, error: (result.stderr || result.stdout || String(result.error)).trim() || `md2pptx failed (exit ${result.code})` };
194
+ return { ok: true, pptxPath: outPath };
195
+ }
196
+ }));
197
+
198
+ // 工具:读 Office → 文本/表格(内存、无中间文件、模型按需用)
199
+ ctx.tools.register(defineTool({
200
+ name: "office_read",
201
+ description: "Read an Office/Word/Excel/PowerPoint file (.docx/.doc/.xlsx/.xls/.pptx/.ppt) and return its text/table content in memory (no intermediate files). Use to let the model see/analyze office document content on demand.",
202
+ parameters: {
203
+ path: { type: "string", required: true, description: "Absolute path to the office file." }
204
+ },
205
+ output: {
206
+ schema: { type: "object", additionalProperties: false, properties: { ok: { type: "boolean", required: true }, content: { type: "string" }, error: { type: "string" } } },
207
+ render: (_args, value) => [{ type: "text", text: value && value.ok === true ? (value.content || "(空)") : `office_read 失败:${value?.error ?? "未知错误"}` }]
208
+ },
209
+ async execute(args) {
210
+ try {
211
+ const path = resolve(String(args.path ?? ""));
212
+ const content = await officeToText(path);
213
+ return { ok: true, content: String(content || "").slice(0, 1_000_000) };
214
+ } catch (error) {
215
+ return { ok: false, error: String(error && error.message || error) };
216
+ }
217
+ }
218
+ }));
219
+
220
+ // 路由:返回 office 文件的内存文本/表格(供前端/工具读取)
221
+ ctx.effect(() => ctx.webServer.register({
222
+ kind: "exact",
223
+ path: "/api/dsh-long-office/content",
224
+ handler: async (req, res) => {
225
+ try {
226
+ if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405); res.end(); return; }
227
+ const rel = (() => { try { return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || ""); } catch { return ""; } })();
228
+ const content = await officeToText(rel);
229
+ sendJson(res, 200, { ok: true, content });
230
+ } catch (error) { onError(error); sendJson(res, 500, { ok: false, error: String(error && error.message || error) }); }
231
+ },
232
+ }), "dsh-long-office: content route");
233
+ }
234
+
235
+ export { apply };
236
+ export const name = "dsh-long-office";
package/lib/md2docx.py ADDED
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Generic Markdown -> styled .docx converter with a page-number footer.
4
+
5
+ Usage:
6
+ python3 md2docx.py <input.md> [output.docx]
7
+
8
+ If output.docx is omitted, it defaults to <input-name>.docx next to the input.
9
+
10
+ Renders headings (h1-h3), bold/italic inline, tables (pipe syntax),
11
+ ordered/unordered lists, blockquotes, and horizontal rules. Adds a centered
12
+ footer with an auto-updating PAGE field (updates when opened in Word or
13
+ exported to PDF).
14
+
15
+ Requires: python3 + python-docx (`pip install python-docx`).
16
+ """
17
+ import re
18
+ import sys
19
+ import os
20
+
21
+ from docx import Document
22
+ from docx.shared import Pt, RGBColor
23
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
24
+ from docx.enum.table import WD_ALIGN_VERTICAL
25
+ from docx.enum.table import WD_TABLE_ALIGNMENT
26
+ from docx.oxml.ns import qn
27
+ from docx.oxml import OxmlElement
28
+
29
+
30
+ def die(msg):
31
+ print(f"md2docx: {msg}", file=sys.stderr)
32
+ sys.exit(1)
33
+
34
+
35
+ if len(sys.argv) < 2:
36
+ die("usage: md2docx.py <input.md> [output.docx]")
37
+
38
+ SRC = os.path.abspath(sys.argv[1])
39
+ if not os.path.isfile(SRC):
40
+ die(f"input file not found: {SRC}")
41
+ OUT = (
42
+ os.path.abspath(sys.argv[2])
43
+ if len(sys.argv) > 2
44
+ else os.path.splitext(SRC)[0] + ".docx"
45
+ )
46
+
47
+ with open(SRC, encoding="utf-8") as f:
48
+ lines = f.read().splitlines()
49
+
50
+
51
+ def add_page_number(paragraph):
52
+ """Insert an auto-updating '第 N 页' page field into a footer paragraph."""
53
+ run = paragraph.add_run("第 ")
54
+ set_east_asia(run)
55
+ fld = OxmlElement("w:fldChar")
56
+ fld.set(qn("w:fldCharType"), "begin")
57
+ instr = OxmlElement("w:instrText")
58
+ instr.set(qn("xml:space"), "preserve")
59
+ instr.text = " PAGE "
60
+ sep = OxmlElement("w:fldChar")
61
+ sep.set(qn("w:fldCharType"), "separate")
62
+ t = OxmlElement("w:t")
63
+ t.text = "1"
64
+ end = OxmlElement("w:fldChar")
65
+ end.set(qn("w:fldCharType"), "end")
66
+ r = paragraph.add_run()
67
+ set_east_asia(r)
68
+ for el in (fld, instr, sep, t, end):
69
+ r._r.append(el)
70
+ run2 = paragraph.add_run(" 页")
71
+ set_east_asia(run2)
72
+
73
+
74
+ doc = Document()
75
+
76
+ # --- base styles ---
77
+ normal = doc.styles["Normal"]
78
+ normal.font.name = "宋体"
79
+ normal.font.size = Pt(10.5)
80
+ normal._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
81
+ # --- 2026-09-14 用户反馈「生成的 docx 行距被压(预览里比我自己放上去的文件紧)」---
82
+ # 根因:python-docx 默认模板的 docDefaults 是 <w:spacing w:line="276" w:lineRule="auto"/>(≈1.15 倍),
83
+ # 而用户自己的文件基本都是 1.5 倍(w:line="360")。这里统一为 1.5 倍,Word 打开与预览观感一致。
84
+ normal.paragraph_format.line_spacing = 1.5
85
+
86
+
87
+ # --- 2026-09-12 用户要求:全篇只用黑色字体、任何底纹都不能有 ---
88
+ normal.font.color.rgb = RGBColor(0, 0, 0)
89
+ for _sn in ("Title", "Heading 1", "Heading 2", "Heading 3", "Heading 4",
90
+ "Intense Quote", "List Bullet", "List Number", "Table Grid"):
91
+ try:
92
+ _st = doc.styles[_sn]
93
+ except KeyError:
94
+ continue
95
+ try:
96
+ _st.font.color.rgb = RGBColor(0, 0, 0)
97
+ except Exception:
98
+ pass
99
+ try: # 2026-09-15 用户要求:全部报告/文件用宋体(含标题,避免继承主题 major font)
100
+ _st.font.name = "宋体"
101
+ _st.element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
102
+ except Exception:
103
+ pass
104
+ try: # 2026-09-14:标题/列表等样式同样统一 1.5 倍行距(否则仍继承 docDefaults 的 1.15 倍)
105
+ _st.paragraph_format.line_spacing = 1.5
106
+ except Exception:
107
+ pass
108
+ # 去掉该样式自带的底纹(w:shd)
109
+ for _el in list(_st.element.iter()):
110
+ if _el.tag == qn("w:shd"):
111
+ _el.getparent().remove(_el)
112
+ # 2026-09-15 用户要求:报告不要页眉、标题下不要装饰线 → 去掉样式自带的段落下边框
113
+ for _el in list(_st.element.iter()):
114
+ if _el.tag == qn("w:pBdr"):
115
+ _el.getparent().remove(_el)
116
+ # 清空页眉(只用页脚页码)
117
+ try:
118
+ for _hp in doc.sections[0].header.paragraphs:
119
+ for _r in list(_hp.runs):
120
+ _r._element.getparent().remove(_r._element)
121
+ except Exception:
122
+ pass
123
+
124
+
125
+ def set_east_asia(run):
126
+ run.font.name = "宋体"
127
+ run.font.color.rgb = RGBColor(0, 0, 0) # 2026-09-12:只用黑色
128
+ r = run._element
129
+ rPr = r.get_or_add_rPr()
130
+ rf = rPr.find(qn("w:rFonts"))
131
+ if rf is None:
132
+ rf = OxmlElement("w:rFonts")
133
+ rPr.append(rf)
134
+ rf.set(qn("w:ascii"), "Times New Roman") # 西文/数字:Times New Roman(宋体空格是全角)
135
+ rf.set(qn("w:hAnsi"), "Times New Roman")
136
+ rf.set(qn("w:eastAsia"), "宋体")
137
+
138
+
139
+ def add_runs_with_bold(par, text):
140
+ """2026-09-15 用户要求:**正文一律不加粗,只有标题加粗**。
141
+
142
+ md 里的 `**…**` / `*…*` 仅作标记清理,不再转成加粗/斜体
143
+ (标题由 Heading 样式自带加粗;表格表头由 flush_table 单独置粗)。
144
+ """
145
+ r = par.add_run(re.sub(r"\*+", "", text))
146
+ set_east_asia(r)
147
+
148
+
149
+ def add_body_paragraph(text, style=None):
150
+ p = doc.add_paragraph(style=style)
151
+ add_runs_with_bold(p, text)
152
+ return p
153
+
154
+
155
+ def flush_table():
156
+ global table_rows, in_table
157
+ if not table_rows:
158
+ in_table = False
159
+ return
160
+ ncols = max(len(r) for r in table_rows)
161
+ tbl = doc.add_table(rows=len(table_rows), cols=ncols)
162
+ tbl.style = "Table Grid" # 2026-09-12:原来 Light Grid Accent 1 带底纹/彩色框线
163
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
164
+ for ri, row in enumerate(table_rows):
165
+ for ci in range(ncols):
166
+ cell = tbl.cell(ri, ci)
167
+ cell.text = ""
168
+ cp = cell.paragraphs[0]
169
+ text = row[ci] if ci < len(row) else ""
170
+ add_runs_with_bold(cp, text)
171
+ if ri == 0:
172
+ for run in cp.runs:
173
+ run.bold = True
174
+ tbl.autofit = True
175
+ table_rows = []
176
+ in_table = False
177
+
178
+
179
+ table_rows = []
180
+ in_table = False
181
+
182
+ i = 0
183
+ while i < len(lines):
184
+ stripped = lines[i].strip()
185
+ if not stripped:
186
+ i += 1
187
+ continue
188
+ if re.fullmatch(r"-{3,}", stripped):
189
+ flush_table()
190
+ doc.add_paragraph()
191
+ i += 1
192
+ continue
193
+ if in_table and "|" in stripped and re.fullmatch(r"\|?[\s:|-]+\|?", stripped):
194
+ i += 1
195
+ continue
196
+ if stripped.startswith("|"):
197
+ in_table = True
198
+ table_rows.append([c.strip() for c in stripped.strip("|").split("|")])
199
+ i += 1
200
+ continue
201
+ if in_table:
202
+ flush_table()
203
+ if stripped.startswith("### "):
204
+ h = doc.add_heading(level=3)
205
+ add_runs_with_bold(h, stripped[4:])
206
+ i += 1
207
+ continue
208
+ if stripped.startswith("## "):
209
+ h = doc.add_heading(level=2)
210
+ add_runs_with_bold(h, stripped[3:])
211
+ i += 1
212
+ continue
213
+ if stripped.startswith("# "):
214
+ h = doc.add_heading(level=1)
215
+ h.alignment = WD_ALIGN_PARAGRAPH.CENTER # ★文档标题居中(用户要求)
216
+ add_runs_with_bold(h, stripped[2:])
217
+ i += 1
218
+ continue
219
+ if stripped.startswith("> "):
220
+ p = doc.add_paragraph(style="Intense Quote")
221
+ add_runs_with_bold(p, stripped[2:] + " ")
222
+ i += 1
223
+ continue
224
+ m = re.match(r"^(\d+)\.\s+(.*)$", stripped)
225
+ if m:
226
+ p = doc.add_paragraph(style="List Number")
227
+ add_runs_with_bold(p, m.group(2))
228
+ i += 1
229
+ continue
230
+ if stripped.startswith("- "):
231
+ p = doc.add_paragraph(style="List Bullet")
232
+ add_runs_with_bold(p, stripped[2:])
233
+ i += 1
234
+ continue
235
+ p = doc.add_paragraph()
236
+ add_runs_with_bold(p, stripped)
237
+ i += 1
238
+
239
+ if in_table:
240
+ flush_table()
241
+
242
+ # --- footer with page number field ---
243
+ footer = doc.sections[0].footer
244
+ fp = footer.paragraphs[0]
245
+ fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
246
+ add_page_number(fp)
247
+ # 2026-09-15 用户要求:页码字体与正文一致(宋体)、居中
248
+ for _r in fp.runs:
249
+ _r.font.name = "宋体"
250
+ _r.font.color.rgb = RGBColor(0, 0, 0)
251
+ _rp = _r._element.get_or_add_rPr()
252
+ _rp.get_or_add_rFonts().set(qn("w:eastAsia"), "宋体")
253
+ try:
254
+ _fs = fp.style
255
+ _fs.font.name = "宋体"
256
+ _fs.element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
257
+ except Exception:
258
+ pass
259
+
260
+ # --- 2026-09-15 用户要求:**所有生成文件**都必须符合 skill `doc-heading-format` ---
261
+ # 在源头统一:正文首行缩进 2 字符、表格内容居中(水平+垂直)、全黑字、无底纹、行距 1.5。
262
+ # 放在这里,任何技能/任何调用 md2docx 得到的 docx 都自动合规,不依赖调用方是否记得。
263
+ def apply_doc_format(doc):
264
+ for p in doc.paragraphs:
265
+ st = (p.style.name or "")
266
+ is_head = st.startswith(("Heading", "Title")) or p.alignment is not None
267
+ pPr = p._p.get_or_add_pPr()
268
+ for _shd in pPr.findall(qn("w:shd")):
269
+ pPr.remove(_shd)
270
+ for r in p.runs:
271
+ r.font.color.rgb = RGBColor(0, 0, 0)
272
+ p.paragraph_format.line_spacing = 1.5
273
+ p.paragraph_format.space_after = Pt(6)
274
+ # 只有普通正文段落才首行缩进(标题/列表项/引用不缩进)
275
+ if (not is_head) and p.text.strip() and st in ("Normal", ""):
276
+ ind = pPr.get_or_add_ind()
277
+ ind.set(qn("w:firstLineChars"), "200") # 2 字符(中文习惯)
278
+ ind.set(qn("w:firstLine"), "420")
279
+ for t in doc.tables:
280
+ for row in t.rows:
281
+ for c in row.cells:
282
+ try:
283
+ c.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
284
+ except Exception:
285
+ pass
286
+ tcPr = c._tc.get_or_add_tcPr()
287
+ for _shd in tcPr.findall(qn("w:shd")):
288
+ tcPr.remove(_shd)
289
+ for p in c.paragraphs:
290
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER # ★表格内容一律居中
291
+ p.paragraph_format.line_spacing = 1.15
292
+ p.paragraph_format.space_after = Pt(2)
293
+ for r in p.runs:
294
+ r.font.color.rgb = RGBColor(0, 0, 0)
295
+
296
+
297
+ apply_doc_format(doc)
298
+
299
+ doc.save(OUT)
300
+ print("Saved:", OUT)
package/lib/md2pptx.py ADDED
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env python3
2
+ """md2pptx.py — Markdown 大纲 → PowerPoint(.pptx),16:9
3
+
4
+ 完全自研(只依赖 python-pptx + Pillow),排版口径沿用本机硬规则:
5
+ · 白底、**无底纹/无花哨填充**、文字全黑;中文宋体、纯西文 Times New Roman
6
+ · 16:9(13.333 × 7.5 英寸);标题 28pt 粗、正文 18pt、备注 12pt
7
+ · **演讲者备注**:`> 备注:...` 行写进当前页 notes
8
+ · **图片**:`![说明](路径)` 默认按原图比例放入内容区;`![说明](路径 "4x3")` 指定英寸宽×高
9
+ · **坐标回显**:生成后打印每页每个元素的落点(英寸坐标 + 画布尺寸 + 文本线框)
10
+
11
+ Markdown 约定(自上而下):
12
+ # 标题 → 封面(副标题取 `> ...`)
13
+ ## 小节标题 → 新的一页(内容页)
14
+ - 要点 / - 子要点 → 项目符号(1/2 级)
15
+ > 备注:xxx → 当前页备注(封面页则作副标题)
16
+ ![说明](图片路径) → 当前页插图
17
+ --- → 换页(无标题)
18
+ (普通段落) → 当前页正文段落
19
+
20
+ 用法:
21
+ python3 md2pptx.py <input.md> [output.pptx] [--force] [--echo-json out.json]
22
+ 默认输出同名 .pptx;默认不覆盖已存在文件,--force 才覆盖。
23
+ """
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+
29
+ from PIL import Image
30
+ from pptx import Presentation
31
+ from pptx.dml.color import RGBColor
32
+ from pptx.enum.text import PP_ALIGN
33
+ from pptx.oxml.ns import qn
34
+ from pptx.util import Inches, Pt
35
+
36
+ EAST, LATIN = "宋体", "Times New Roman"
37
+ BLACK = RGBColor(0, 0, 0)
38
+ SLIDE_W, SLIDE_H = 13.333, 7.5
39
+ MARGIN, TITLE_H = 0.6, 1.2
40
+ TITLE_PT, BODY_PT, NOTE_PT = 28, 18, 12
41
+ BULLET_CHARS = "•", "–"
42
+ ASCII_RE = re.compile(r"^[\x00-\x7F]*$")
43
+ SEG_RE = re.compile(r"[\x00-\x7F]+|[^\x00-\x7F]+") # 按 ASCII / 非 ASCII 分段,西文走 TNR
44
+ IMG_RE = re.compile(r"^!\[(?P<alt>[^\]]*)\]\((?P<path>[^)\s]+)(?:\s+\"(?P<size>[0-9.]+x[0-9.]+)\")?\)\s*$")
45
+ NOTE_RE = re.compile(r"^>\s*(?:备注|Notes?|note)\s*[::]?\s*(.*)$", re.I)
46
+ QUOTE_RE = re.compile(r"^>\s*(.*)$")
47
+
48
+
49
+ def parse(md_text):
50
+ """→ [ {title, subtitle, bullets:[(level,text)], images:[(path,alt,w,h)], paragraphs:[str], notes:str} ]"""
51
+ slides, cur = [], None
52
+
53
+ def new(title=None):
54
+ s = {"title": title, "subtitle": None, "bullets": [], "images": [],
55
+ "paragraphs": [], "notes": ""}
56
+ slides.append(s)
57
+ return s
58
+
59
+ for raw in md_text.splitlines():
60
+ line = raw.rstrip()
61
+ s = line.strip()
62
+ if not s:
63
+ continue
64
+ m_img = IMG_RE.match(s)
65
+ if s == "---":
66
+ new()
67
+ continue
68
+ if s.startswith("# "):
69
+ cur = new(s[2:].strip()) # 封面
70
+ continue
71
+ if s.startswith("## "):
72
+ cur = new(s[3:].strip()) # 内容页
73
+ continue
74
+ if cur is None:
75
+ cur = new()
76
+ m_note = NOTE_RE.match(s)
77
+ if m_note:
78
+ if cur["title"] and not cur["bullets"] and not cur["paragraphs"] and not cur["notes"]:
79
+ cur["subtitle"] = m_note.group(1).strip() # 封面页的 > 备注: → 副标题
80
+ else:
81
+ cur["notes"] = (cur["notes"] + "\n" + m_note.group(1).strip()).strip()
82
+ continue
83
+ m_quote = QUOTE_RE.match(s)
84
+ if m_quote:
85
+ cur["notes"] = (cur["notes"] + "\n" + m_quote.group(1).strip()).strip()
86
+ continue
87
+ if m_img:
88
+ size = m_img.group("size")
89
+ w, h = (float(x) for x in size.split("x")) if size else (None, None)
90
+ cur["images"].append((m_img.group("path"), m_img.group("alt"), w, h))
91
+ continue
92
+ m_b = re.match(r"^(\s*)[-*+]\s+(.*)$", raw)
93
+ if m_b:
94
+ level = 1 if len(m_b.group(1)) < 2 else 2
95
+ cur["bullets"].append((level, m_b.group(2).strip()))
96
+ continue
97
+ cur["paragraphs"].append(s)
98
+ return [s for s in slides if any([s["title"], s["bullets"], s["images"], s["paragraphs"]])]
99
+
100
+
101
+ def style_run(run, size, bold=False):
102
+ run.font.size = Pt(size)
103
+ run.font.bold = bold
104
+ run.font.color.rgb = BLACK
105
+ txt = run.text or ""
106
+ run.font.name = LATIN if ASCII_RE.fullmatch(txt) and txt else EAST
107
+ # 东亚字体显式设置(python-pptx 的 font.name 只管 latin)
108
+ rPr = run._r.get_or_add_rPr()
109
+ for tag in ("a:ea", "a:cs"):
110
+ el = rPr.find(qn(tag))
111
+ if el is None:
112
+ el = rPr.makeelement(qn(tag), {})
113
+ rPr.append(el)
114
+ el.set("typeface", EAST)
115
+
116
+
117
+ def add_styled_runs(paragraph, text, size, bold=False):
118
+ """按'纯西文段/中日韩段'拆成多个 run:西文 Times New Roman、中文宋体(用户硬规则)"""
119
+ wrote = False
120
+ for seg in SEG_RE.findall(text):
121
+ if not seg:
122
+ continue
123
+ run = paragraph.add_run()
124
+ run.text = seg
125
+ style_run(run, size, bold)
126
+ wrote = True
127
+ if not wrote:
128
+ run = paragraph.add_run()
129
+ run.text = ""
130
+ style_run(run, size, bold)
131
+
132
+
133
+ def add_textbox(slide, left, top, width, height):
134
+ tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
135
+ tf = tb.text_frame
136
+ tf.word_wrap = True
137
+ return tf
138
+
139
+
140
+ def fill_title(slide, title, subtitle):
141
+ tf = add_textbox(slide, MARGIN, 0.35, SLIDE_W - 2 * MARGIN, TITLE_H)
142
+ add_styled_runs(tf.paragraphs[0], title or "", TITLE_PT, bold=True)
143
+ if subtitle:
144
+ add_styled_runs(tf.add_paragraph(), subtitle, 14)
145
+
146
+
147
+ def picture_size(path, w, h):
148
+ with Image.open(path) as im:
149
+ iw, ih = im.size
150
+ ratio = iw / ih if ih else 1.0
151
+ if w and h:
152
+ return w, h
153
+ box_w, box_h = 5.4, 4.6
154
+ if not w and not h:
155
+ w = box_w
156
+ h = w / ratio
157
+ if h > box_h:
158
+ h = box_h
159
+ w = h * ratio
160
+ elif w and not h:
161
+ h = w / ratio
162
+ else:
163
+ w = h * ratio
164
+ return w, h
165
+
166
+
167
+ def build(md_text, echo_json=None):
168
+ prs = Presentation()
169
+ prs.slide_width, prs.slide_height = Inches(SLIDE_W), Inches(SLIDE_H)
170
+ blank = prs.slide_layouts[6]
171
+ geometry = []
172
+
173
+ for spec in parse(md_text):
174
+ slide = prs.slides.add_slide(blank)
175
+ si = len(prs.slides._sldIdLst) # 1-based 页码
176
+ is_cover = spec["subtitle"] is not None or (spec["title"] and not spec["bullets"]
177
+ and not spec["images"] and not spec["paragraphs"]
178
+ and not spec["notes"])
179
+ if spec["title"]:
180
+ if is_cover:
181
+ tf = add_textbox(slide, MARGIN, 2.6, SLIDE_W - 2 * MARGIN, 1.6)
182
+ p = tf.paragraphs[0]
183
+ p.alignment = PP_ALIGN.CENTER
184
+ add_styled_runs(p, spec["title"], 40, bold=True)
185
+ if spec["subtitle"]:
186
+ p2 = tf.add_paragraph()
187
+ p2.alignment = PP_ALIGN.CENTER
188
+ add_styled_runs(p2, spec["subtitle"], 16)
189
+ else:
190
+ fill_title(slide, spec["title"], None)
191
+
192
+ has_img = bool(spec["images"])
193
+ text_w = 6.2 if has_img else SLIDE_W - 2 * MARGIN
194
+ top = 1.75 if spec["title"] and not is_cover else 1.2
195
+
196
+ if spec["bullets"] or spec["paragraphs"]:
197
+ tf = add_textbox(slide, MARGIN, top, text_w, SLIDE_H - top - MARGIN)
198
+ first = True
199
+ for level, text in spec["bullets"]:
200
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
201
+ first = False
202
+ p.level = min(level - 1, 4)
203
+ p.space_after = Pt(6)
204
+ bullet = BULLET_CHARS[min(level, 2) - 1]
205
+ add_styled_runs(p, f"{bullet} {text}", BODY_PT)
206
+ for text in spec["paragraphs"]:
207
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
208
+ first = False
209
+ add_styled_runs(p, text, BODY_PT)
210
+
211
+ for idx, (path, alt, w, h) in enumerate(spec["images"]):
212
+ if not os.path.isfile(path):
213
+ print(f" 警告:图片不存在,已跳过 → {path}", file=sys.stderr)
214
+ continue
215
+ w, h = picture_size(path, w, h)
216
+ left = SLIDE_W - MARGIN - w if has_img else MARGIN
217
+ pic_top = max(top, (SLIDE_H - h) / 2)
218
+ pic = slide.shapes.add_picture(path, Inches(left), Inches(pic_top), Inches(w), Inches(h))
219
+ pic.name = alt or os.path.basename(path)
220
+
221
+ if spec["notes"]:
222
+ tf = slide.notes_slide.notes_text_frame
223
+ tf.text = spec["notes"]
224
+ for p in tf.paragraphs:
225
+ for r in p.runs:
226
+ style_run(r, NOTE_PT)
227
+
228
+ for sh in slide.shapes:
229
+ kind = str(sh.shape_type).split()[0] if sh.shape_type is not None else "TEXT_BOX"
230
+ text = sh.text_frame.text.replace("\n", " / ")[:60] if sh.has_text_frame else (
231
+ sh.name if kind == "PICTURE" else "")
232
+ geometry.append({
233
+ "slide": si,
234
+ "shape": kind,
235
+ "name": sh.name,
236
+ "left_in": round(sh.left / 914400, 3), "top_in": round(sh.top / 914400, 3),
237
+ "width_in": round(sh.width / 914400, 3), "height_in": round(sh.height / 914400, 3),
238
+ "text": text,
239
+ })
240
+ if echo_json:
241
+ with open(echo_json, "w", encoding="utf-8") as fh:
242
+ json.dump({"canvas_in": [SLIDE_W, SLIDE_H], "slides": geometry}, fh, ensure_ascii=False, indent=2)
243
+ return prs, geometry
244
+
245
+
246
+ def main():
247
+ argv = [a for a in sys.argv[1:] if not a.startswith("--")]
248
+ force = "--force" in sys.argv
249
+ echo = None
250
+ if "--echo-json" in sys.argv:
251
+ echo = sys.argv[sys.argv.index("--echo-json") + 1]
252
+ if not argv:
253
+ print("用法: md2pptx.py <input.md> [output.pptx] [--force] [--echo-json out.json]", file=sys.stderr)
254
+ return 2
255
+ src = os.path.abspath(argv[0])
256
+ out = os.path.abspath(argv[1]) if len(argv) > 1 else re.sub(r"\.md$", "", src, flags=re.I) + ".pptx"
257
+ if os.path.exists(out) and not force:
258
+ print(f"已存在,未覆盖(加 --force 覆盖): {out}", file=sys.stderr)
259
+ return 3
260
+ if not os.path.isfile(src):
261
+ print(f"找不到输入: {src}", file=sys.stderr)
262
+ return 2
263
+ with open(src, encoding="utf-8") as fh:
264
+ prs, geo = build(fh.read(), echo)
265
+ prs.save(out)
266
+ print("Saved:", out)
267
+ print(f" 画布 {SLIDE_W}x{SLIDE_H} 英寸(16:9),共 {len(prs.slides._sldIdLst)} 页")
268
+ cur = None
269
+ for g in geo:
270
+ print(" [%s] %-12s left=%.2f top=%.2f w=%.2f h=%.2f in %s" % (
271
+ g["slide"], g["shape"][:12], g["left_in"], g["top_in"], g["width_in"], g["height_in"], g["text"]))
272
+ return 0
273
+
274
+
275
+ if __name__ == "__main__":
276
+ sys.exit(main())
package/lib/md2xlsx.py ADDED
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """md2xlsx.py — Markdown 表格 → Excel(.xlsx)
3
+
4
+ 排版口径与 dsh-office-reader 的 md2docx 一致(用户硬规则):
5
+ · 中文宋体 / 纯西文单元格 Times New Roman;全黑字;**无底纹(不加填充色)**
6
+ · 表格:细边框、内容水平+垂直居中、表头加粗、列宽按内容自适应、首行冻结
7
+ · 以 `=` 开头的单元格写成**真正的 Excel 公式**;数字串转数值
8
+ · 每个 Markdown 表格 → 一个 sheet,表名取该表格上方最近的标题(无则 Sheet1/2…)
9
+
10
+ 用法:
11
+ python3 md2xlsx.py <input.md> [output.xlsx] [--force]
12
+ 默认输出同名 .xlsx;默认**不覆盖**已存在文件(防误覆盖),--force 才覆盖。
13
+ """
14
+ import os
15
+ import re
16
+ import sys
17
+
18
+ from openpyxl import Workbook
19
+ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
20
+ from openpyxl.utils import get_column_letter
21
+
22
+ EAST = "宋体"
23
+ LATIN = "Times New Roman"
24
+ THIN = Side(style="thin", color="000000")
25
+ BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
26
+ CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
27
+ HEAD_FONT_EA = Font(name=EAST, bold=True, color="FF000000")
28
+ BODY_FONT_EA = Font(name=EAST, color="FF000000")
29
+ HEAD_FONT_LA = Font(name=LATIN, bold=True, color="FF000000")
30
+ BODY_FONT_LA = Font(name=LATIN, color="FF000000")
31
+
32
+ NUM_RE = re.compile(r"^-?\d+(?:\.\d+)?%?$")
33
+ ASCII_RE = re.compile(r"^[\x00-\x7F]*$")
34
+ BAD_SHEET = re.compile(r"[\\/*?:\[\]]")
35
+
36
+
37
+ def is_sep_row(cells):
38
+ """| --- | :--: | 这种分隔行"""
39
+ return all(re.fullmatch(r":?-{2,}:?", c.strip()) for c in cells if c.strip() != "") and any(
40
+ c.strip() for c in cells
41
+ )
42
+
43
+
44
+ def split_row(line):
45
+ raw = line.strip()
46
+ if raw.startswith("|"):
47
+ raw = raw[1:]
48
+ if raw.endswith("|"):
49
+ raw = raw[:-1]
50
+ return [c.strip() for c in raw.split("|")]
51
+
52
+
53
+ def parse_tables(md_text):
54
+ """→ [(sheet_name, rows)] rows = [[cell, ...], ...]"""
55
+ out, cur, heading, pending_heading = [], [], None, None
56
+ in_table = False
57
+ for line in md_text.splitlines():
58
+ s = line.strip()
59
+ if s.startswith("#"):
60
+ pending_heading = s.lstrip("#").strip() or None
61
+ if in_table:
62
+ in_table = False
63
+ continue
64
+ if s.startswith("|") and s.count("|") >= 2:
65
+ cells = split_row(s)
66
+ if not in_table:
67
+ in_table = True
68
+ cur = []
69
+ heading = pending_heading
70
+ if is_sep_row(cells):
71
+ continue
72
+ cur.append(cells)
73
+ else:
74
+ if in_table and cur:
75
+ out.append((heading, cur))
76
+ cur, in_table = [], False
77
+ if in_table and cur:
78
+ out.append((heading, cur))
79
+ return out
80
+
81
+
82
+ def clean_sheet_name(name, used, idx):
83
+ base = BAD_SHEET.sub("-", (name or "").strip()) or f"Sheet{idx}"
84
+ base = base[:31]
85
+ cand, n = base, 2
86
+ while cand in used:
87
+ suffix = f"({n})"
88
+ cand = base[: 31 - len(suffix)] + suffix
89
+ n += 1
90
+ used.add(cand)
91
+ return cand
92
+
93
+
94
+ def put_cell(ws, r, c, raw, bold):
95
+ val = raw
96
+ if val.startswith("="):
97
+ ws.cell(row=r, column=c, value=val) # 真公式
98
+ elif NUM_RE.fullmatch(val):
99
+ try:
100
+ v = float(val[:-1]) / 100 if val.endswith("%") else float(val)
101
+ ws.cell(row=r, column=c, value=v)
102
+ except ValueError:
103
+ ws.cell(row=r, column=c, value=val)
104
+ else:
105
+ ws.cell(row=r, column=c, value=val)
106
+ cell = ws.cell(row=r, column=c)
107
+ ascii_only = bool(ASCII_RE.fullmatch(val)) and val != ""
108
+ if bold:
109
+ cell.font = HEAD_FONT_LA if ascii_only else HEAD_FONT_EA
110
+ else:
111
+ cell.font = BODY_FONT_LA if ascii_only else BODY_FONT_EA
112
+ cell.alignment = CENTER
113
+ cell.border = BORDER
114
+ # 清掉任何填充(用户硬规则:不许有底纹)
115
+ cell.fill = PatternFill(fill_type=None)
116
+
117
+
118
+ def write_sheet(wb, name, rows, idx):
119
+ ws = wb.create_sheet(title=name)
120
+ for ri, row in enumerate(rows, start=1):
121
+ for ci, raw in enumerate(row, start=1):
122
+ put_cell(ws, ri, ci, raw, bold=(ri == 1))
123
+ # 列宽自适应(中文按 2 个字符宽估)
124
+ for ci in range(1, max(len(r) for r in rows) + 1):
125
+ widest = 0
126
+ for row in rows:
127
+ if ci <= len(row):
128
+ txt = row[ci - 1]
129
+ w = sum(2 if ord(ch) > 127 else 1 for ch in txt)
130
+ widest = max(widest, w)
131
+ ws.column_dimensions[get_column_letter(ci)].width = min(max(widest + 2, 6), 60)
132
+ # 行高与冻结表头
133
+ for ri in range(1, len(rows) + 1):
134
+ ws.row_dimensions[ri].height = 20
135
+ ws.freeze_panes = "A2"
136
+ return ws
137
+
138
+
139
+ def main():
140
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
141
+ force = "--force" in sys.argv
142
+ if not args:
143
+ print("用法: md2xlsx.py <input.md> [output.xlsx] [--force]", file=sys.stderr)
144
+ return 2
145
+ src = os.path.abspath(args[0])
146
+ out = os.path.abspath(args[1]) if len(args) > 1 else re.sub(r"\.md$", "", src, flags=re.I) + ".xlsx"
147
+ if os.path.exists(out) and not force:
148
+ print(f"已存在,未覆盖(加 --force 覆盖): {out}", file=sys.stderr)
149
+ return 3
150
+ if not os.path.isfile(src):
151
+ print(f"找不到输入: {src}", file=sys.stderr)
152
+ return 2
153
+
154
+ with open(src, encoding="utf-8") as fh:
155
+ tables = parse_tables(fh.read())
156
+ if not tables:
157
+ print("输入里没有 Markdown 表格(| … | 且含分隔行)", file=sys.stderr)
158
+ return 4
159
+
160
+ wb = Workbook()
161
+ wb.remove(wb.active)
162
+ used = set()
163
+ for i, (heading, rows) in enumerate(tables, start=1):
164
+ write_sheet(wb, clean_sheet_name(heading, used, i), rows, i)
165
+ wb.save(out)
166
+ print("Saved:", out)
167
+ return 0
168
+
169
+
170
+ if __name__ == "__main__":
171
+ sys.exit(main())
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "dsh-long-office",
3
+ "version": "0.1.2",
4
+ "description": "DSH Office read + generate: read Word/Excel/PowerPoint (docx/xlsx/pptx and legacy doc/xls) into text/tables in memory (no intermediate files, model on-demand); generate Word/Excel/PowerPoint from Markdown via md2docx / md2xlsx / md2pptx. | DSH 插件:Office 读取 + 生成 —— 内存读取 Word/Excel/PPT(含旧版 doc/xls)为文本/表格;Markdown 生成 Word/Excel/PPT(md2docx / md2xlsx / md2pptx)。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./client/client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib/index.js",
15
+ "lib/md2docx.py",
16
+ "lib/md2xlsx.py",
17
+ "lib/md2pptx.py",
18
+ "client",
19
+ "cordis.patch.yml",
20
+ "dsh.plugin.json"
21
+ ],
22
+ "dependencies": {
23
+ "mammoth": "^1.12.1",
24
+ "exceljs": "^4.4.0",
25
+ "xlsx": "^0.18.5",
26
+ "word-extractor": "^1.0.4",
27
+ "jszip": "^3.10.1"
28
+ },
29
+ "peerDependencies": {
30
+ "@deepseek-ai/dsh-tools": "*"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@deepseek-ai/dsh-tools": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ },
41
+ "client": {
42
+ "inject": [
43
+ "@deepseek-ai/dsh-client-connection",
44
+ "@deepseek-ai/dsh-client-runtime",
45
+ "@deepseek-ai/dsh-client-locale",
46
+ "@deepseek-ai/dsh-client-ui-slots",
47
+ "@deepseek-ai/dsh-client-ui-settings",
48
+ "@deepseek-ai/dsh-client-ui-theme",
49
+ "@deepseek-ai/dsh-client-ui-conversation",
50
+ "@deepseek-ai/dsh-client-ui-input-trigger",
51
+ "@deepseek-ai/dsh-client-ui-layout"
52
+ ],
53
+ "platform": "web"
54
+ }
55
+ }
56
+ }