sdpy-kit 0.1.0__py3-none-any.whl

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.
kit/doc_kit.py ADDED
@@ -0,0 +1,1502 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ # date: 2026-08-30
21
+ """文档转换工具类,基于 anydoc + PiMD 实现文档与 Markdown 双向转换
22
+
23
+ 本模块封装 firecrawl-anydoc(pip 安装名 firecrawl-anydoc,import 名 anydoc),
24
+ 将 Word、PowerPoint、Excel、CSV、PDF 格式统一转为 GitHub-Flavored Markdown;
25
+ 并基于 PiMD(pimd >= 2.2.5)将 Markdown 反向转换为 Word(.docx)与独立
26
+ HTML(PDF 由本模块内嵌的 fpdf2 渲染器直接生成,保证中文与表格完整)。
27
+
28
+ 设计要点:
29
+ - 全部使用静态方法提供服务,无内部状态;PiMD 转换引擎为模块级单例,线程安全。
30
+ - 低层 convert_to_md 直接抛出异常(与 FileKit 风格一致,便于精确定位)。
31
+ - 高层 convert 返回 ConvertResult 结果对象,封装成功/失败状态,便于批量处理。
32
+ - 路径参数统一支持 str 与 pathlib.Path,内部 resolve 为绝对路径。
33
+ - md → Word/PDF/HTML 均为 GFM 语法,转换前统一改写 CJK 加粗定界符;
34
+ Word 走 PiMD 引擎 + 表格撑满/全局字体后处理,PDF 与 HTML 同源解析
35
+ (markdown-it),由本模块 fpdf2 渲染器生成,观感与 HTML 对齐。
36
+ """
37
+
38
+ import os
39
+ import re
40
+ import zipfile
41
+ from dataclasses import dataclass
42
+ from pathlib import Path
43
+
44
+ import anydoc
45
+ from fpdf import FPDF
46
+ from markdown_it import MarkdownIt
47
+ from markdown_it.token import Token
48
+ from pimd import PiMD
49
+ from pimd.exceptions import ConversionError
50
+
51
+ from kit.config.log_config import get_default_logger
52
+
53
+ logger = get_default_logger(__name__)
54
+
55
+ # PiMD 转换引擎单例:内部自带 MemoryCache,按进程复用(线程安全)
56
+ _PIMD = PiMD()
57
+
58
+ # PDF 渲染解析器:与 convert_md_to_html(PiMD 内部)同为 markdown-it 解析,
59
+ # 保证 PDF 与 HTML 对同一 Markdown 的结构理解一致(GFM 表格需显式启用)
60
+ _MD_PARSER = MarkdownIt("commonmark").enable("table")
61
+
62
+ # anydoc 支持的文件扩展名集合(用于本地快速校验与错误提示)
63
+ _SUPPORTED_EXTENSIONS: frozenset[str] = frozenset(
64
+ {
65
+ # Word
66
+ ".doc",
67
+ ".docx",
68
+ # PowerPoint
69
+ ".ppt",
70
+ ".pptx",
71
+ # Excel
72
+ ".xls",
73
+ ".xlsx",
74
+ # 其他
75
+ ".csv",
76
+ ".pdf",
77
+ }
78
+ )
79
+
80
+
81
+ # CJK 加粗泄漏模式:闭合 ** 前为标点串、后紧跟文字(如 `**加粗:**后接`)。
82
+ # PiMD(markdown-it,CommonMark 系)同样执行定界符规则,闭合 ** 须满足
83
+ # right-flanking(不被标点前置,或被空白/标点后置),上述写法不满足,
84
+ # 星号会以字面文本进入产物。改写策略:把尾部标点串移出粗体定界符。
85
+ _CJK_BOLD_LEAK_RE = re.compile(
86
+ r"\*\*(?P<content>[^*\n]+?)(?P<punct>[^\w\s]+?)\*\*(?=\w)"
87
+ )
88
+
89
+
90
+ def _normalize_cjk_bold(markdown: str) -> str:
91
+ """规避 Markdown 引擎对中文加粗的 CommonMark 定界限制
92
+
93
+ 两类处理:
94
+ - 普通文本行:将 `**加粗:**后接` 改写为 `**加粗**:后接`(尾部标点
95
+ 移出粗体定界符),闭合 ** 后紧跟文字的泄漏场景;文本内容零增删。
96
+ - 表格行:PiMD 的表格单元格以纯文本写入(不解析行内 Markdown),
97
+ `**` 定界符必然字面泄漏,直接剥除定界符保留文本(表头自带加粗
98
+ 样式,不依赖 `**`)。
99
+ 围栏代码块与行内代码段不做处理。
100
+ """
101
+ out_lines: list[str] = []
102
+ in_fence = False
103
+ fence_marker = ""
104
+ for line in markdown.splitlines(keepends=True):
105
+ stripped = line.lstrip()
106
+ if not in_fence and (stripped.startswith("```") or stripped.startswith("~~~")):
107
+ in_fence = True
108
+ fence_marker = stripped[:3]
109
+ out_lines.append(line)
110
+ continue
111
+ if in_fence:
112
+ if stripped.startswith(fence_marker):
113
+ in_fence = False
114
+ out_lines.append(line)
115
+ continue
116
+ # 行内代码段(反引号包裹)保护:不做处理
117
+ segments = re.split(r"(`[^`]*`)", line)
118
+ is_table = _is_table_row(line)
119
+ fixed = "".join(
120
+ (
121
+ seg
122
+ if seg.startswith("`")
123
+ else (
124
+ _strip_bold_delims(seg)
125
+ if is_table
126
+ else _CJK_BOLD_LEAK_RE.sub(r"**\g<content>**\g<punct>", seg)
127
+ )
128
+ )
129
+ for seg in segments
130
+ )
131
+ out_lines.append(fixed)
132
+ return "".join(out_lines)
133
+
134
+
135
+ def _strip_bold_delims(text: str) -> str:
136
+ """剥除加粗/斜体定界符,保留文本内容(用于不解析行内格式的表格单元格)"""
137
+ text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
138
+ text = re.sub(r"\*([^*]+)\*", r"\1", text)
139
+ return text
140
+
141
+
142
+ # HTML 默认排版:覆盖 PiMD 自带 HTML 样式(body `max-width: 900px` 等默认值),
143
+ # 注入方式为转换后插入 `</head>` 前(覆盖同名选择器声明);CSS 侧重中文文档宽屏阅读。
144
+ _MD_HTML_CSS = """<style>
145
+ /* 默认不注入页面标题(PiMD 默认不输出 title 元素,显式传 title 时才会出现) */
146
+ head title {
147
+ display: none;
148
+ }
149
+ body {
150
+ max-width: 60em;
151
+ margin: 0 auto;
152
+ padding: 1em 1.5em 3em;
153
+ font-size: 16px;
154
+ line-height: 1.7;
155
+ font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
156
+ color: #1a1a1a;
157
+ background-color: #fdfdfd;
158
+ }
159
+ h1, h2, h3, h4, h5, h6 {
160
+ line-height: 1.3;
161
+ margin-top: 1.4em;
162
+ margin-bottom: 0.5em;
163
+ font-weight: 600;
164
+ overflow-wrap: anywhere;
165
+ }
166
+ h1 { font-size: 2em; border-bottom: 1px solid #e6e6e6; padding-bottom: 0.3em; }
167
+ h2 { font-size: 1.6em; border-bottom: 1px solid #e6e6e6; padding-bottom: 0.3em; }
168
+ h3 { font-size: 1.3em; }
169
+ h4, h5, h6 { font-size: 1.1em; }
170
+ table {
171
+ width: 100%;
172
+ max-width: 100%;
173
+ border-collapse: collapse;
174
+ margin: 1em 0;
175
+ font-size: 0.95em;
176
+ }
177
+ th, td {
178
+ border: 1px solid #ccc;
179
+ padding: 6px 10px;
180
+ text-align: left;
181
+ vertical-align: top;
182
+ }
183
+ th {
184
+ background-color: #f0f0f0;
185
+ font-weight: 600;
186
+ }
187
+ pre {
188
+ background-color: #f6f8fa;
189
+ border: 1px solid #e6e6e6;
190
+ border-radius: 4px;
191
+ padding: 0.8em 1em;
192
+ overflow-x: auto;
193
+ line-height: 1.5;
194
+ }
195
+ code {
196
+ font-family: "Cascadia Code", Menlo, Consolas, monospace;
197
+ font-size: 0.9em;
198
+ background-color: #f6f8fa;
199
+ border-radius: 4px;
200
+ padding: 0.1em 0.35em;
201
+ }
202
+ pre code {
203
+ background-color: transparent;
204
+ padding: 0;
205
+ }
206
+ img {
207
+ max-width: 100%;
208
+ height: auto;
209
+ }
210
+ @media (max-width: 720px) {
211
+ body {
212
+ padding: 0.5em 0.8em 2em;
213
+ font-size: 15px;
214
+ }
215
+ table {
216
+ display: block;
217
+ overflow-x: auto;
218
+ }
219
+ }
220
+ </style>"""
221
+
222
+
223
+ def _polish_table_layout(docx_path: Path) -> None:
224
+ """表格版式后处理:宽度撑满页宽并整体居中
225
+
226
+ 转换引擎(PiMD 底层 python-docx)写出的 `tblW type=auto`
227
+ 会使表格按内容收缩、不满页宽;将其替换为 pct 5000(100% 页宽)并
228
+ 追加居中标记(jc 紧随 tblW,符合 OOXML 元素顺序),其余包内成员
229
+ 原样重打包。无表格时为空操作。
230
+ """
231
+ marker = r'<w:tblW w:type="auto" w:w="0"\s*/>'
232
+ with zipfile.ZipFile(docx_path) as zin:
233
+ doc_xml = zin.read("word/document.xml").decode("utf-8")
234
+ if not re.search(marker, doc_xml):
235
+ return
236
+ polished = re.sub(
237
+ marker,
238
+ '<w:tblW w:type="pct" w:w="5000" />\n<w:jc w:val="center" />',
239
+ doc_xml,
240
+ )
241
+ tmp = docx_path.with_name(f".{docx_path.stem}.polish-tmp")
242
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
243
+ for item in zin.infolist():
244
+ if item.filename == "word/document.xml":
245
+ zout.writestr(item, polished)
246
+ else:
247
+ zout.writestr(item, zin.read(item.filename))
248
+ tmp.replace(docx_path)
249
+
250
+
251
+ # 等宽样式隔离:这些 styleId 的 rFonts 属于代码块,字体后处理跳过,
252
+ # 保证代码块在全文宋体化后仍为等宽字体
253
+ _MONO_STYLE_IDS = frozenset({"CodeBlock", "MacroText", "MacroTextChar"})
254
+ # 显式字体名含此模式的样式视为等宽,同样跳过
255
+ _MONO_FONT_RE = re.compile(
256
+ r"Courier|Consolas|Menlo|Monaco|Source Code|Lucida Console", re.I
257
+ )
258
+
259
+
260
+ def _polish_docx_font(docx_path: Path, font_name: str) -> None:
261
+ """全局字体后处理:docDefaults / 主题 / 样式级 rFonts 统一为指定字体
262
+
263
+ PiMD 产物正文西文走主题 minorFont(Calibri)、标题走 majorFont,
264
+ **且每个样式自带显式 rFonts**(样式级优先级高于 docDefaults 与主题)。
265
+ 仅改写默认通道会在 Word 中仍显示 Calibri。因此此处三通道一起改写,
266
+ 使全文统一为 font_name:
267
+ - styles.xml docDefaults:主题引用 rFonts 替换为显式四项字体(兜底);
268
+ - theme1.xml:majorFont/minorFont 顶层 latin/ea/cs typeface 改写
269
+ (Heading 1~6 / Title 等走主题字体的样式兜底);
270
+ - styles.xml 各 `<w:style>` 内 rFonts:显式 Calibri 替换为 font_name,
271
+ 主题引用(majorHAnsi/majorEastAsia……)替换为显式四项 font_name。
272
+ 等宽样式(CodeBlock/MacroText 等、Courier 系字体)跳过,保持代码块
273
+ 等宽观感;run 级显式字体优先级最高,本处理不触及。
274
+ """
275
+ esc = font_name.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
276
+ with zipfile.ZipFile(docx_path) as zin:
277
+ names = zin.namelist()
278
+ patched: dict[str, str] = {}
279
+ if "word/styles.xml" in names:
280
+ xml = zin.read("word/styles.xml").decode("utf-8")
281
+
282
+ def _rewrite_rfonts(m: re.Match[str]) -> str:
283
+ """单个 <w:rFonts .../> 重写:等宽样式跳过,其余统一字体
284
+
285
+ - 含主题引用(asciiTheme 等)→ 替换为显式四项字体;
286
+ - 显式字体(ascii/hAnsi/eastAsia/cs)→ 仅替换字体名,
287
+ 保留 style/val 等其他属性。
288
+ """
289
+ attrs = m.group(0)
290
+ style_head = xml.rfind("<w:style ", 0, m.start())
291
+ sid = None
292
+ if style_head != -1:
293
+ sm = re.search(
294
+ r'<w:style[^>]*w:styleId="([^"]+)"',
295
+ xml[style_head : m.start()],
296
+ )
297
+ sid = sm.group(1) if sm else None
298
+ if sid in _MONO_STYLE_IDS or re.search(_MONO_FONT_RE, attrs):
299
+ return attrs
300
+ if "w:asciiTheme" in attrs:
301
+ return (
302
+ f'<w:rFonts w:ascii="{esc}" w:eastAsia="{esc}" '
303
+ f'w:hAnsi="{esc}" w:cs="{esc}" />'
304
+ )
305
+ for prop in ("w:ascii", "w:hAnsi", "w:eastAsia", "w:cs"):
306
+ attr_re = re.compile(rf"{re.escape(prop)}=\"[^\"]*\"")
307
+ attrs = attr_re.sub(f'{prop}="{esc}"', attrs)
308
+ return attrs
309
+
310
+ new = re.sub(r"<w:rFonts[^>]*/>", _rewrite_rfonts, xml)
311
+ if new != xml:
312
+ patched["word/styles.xml"] = new
313
+ if "word/theme/theme1.xml" in names:
314
+ xml = zin.read("word/theme/theme1.xml").decode("utf-8")
315
+ scheme = re.search(r"<a:fontScheme.*?</a:fontScheme>", xml, re.S)
316
+ if scheme:
317
+ block = scheme.group(0)
318
+ new_block = re.sub(
319
+ r'<a:latin typeface="[^"]*"', f'<a:latin typeface="{esc}"', block
320
+ )
321
+ new_block = re.sub(
322
+ r'<a:ea typeface="[^"]*"', f'<a:ea typeface="{esc}"', new_block
323
+ )
324
+ new_block = re.sub(
325
+ r'<a:cs typeface="[^"]*"', f'<a:cs typeface="{esc}"', new_block
326
+ )
327
+ if new_block != block:
328
+ patched["word/theme/theme1.xml"] = xml.replace(block, new_block)
329
+ if not patched:
330
+ return
331
+ tmp = docx_path.with_name(f".{docx_path.stem}.font-tmp")
332
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
333
+ for item in zin.infolist():
334
+ data = patched.get(item.filename)
335
+ if data is not None:
336
+ zout.writestr(item, data)
337
+ else:
338
+ zout.writestr(item, zin.read(item.filename))
339
+ tmp.replace(docx_path)
340
+
341
+
342
+ def _font_search_paths() -> list[str]:
343
+ """收集系统可用的中文字体文件路径(存在才返回)
344
+
345
+ 仅查询 Windows 字体目录(WINDIR/Fonts)下的常规中文字体(含 .ttc),
346
+ 供 `_fpdf_font_key` 回退匹配使用。
347
+ """
348
+ windir = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
349
+ candidates: dict[tuple[str, ...], Path] = {
350
+ ("SimSun", "宋体", "SimSun-ExtB"): windir / "simsun.ttc",
351
+ ("SimHei", "黑体"): windir / "simhei.ttf",
352
+ ("Microsoft YaHei", "微软雅黑"): windir / "msyh.ttc",
353
+ ("KaiTi", "楷体"): windir / "simkai.ttf",
354
+ ("FangSong", "仿宋"): windir / "simfang.ttf",
355
+ }
356
+ paths: list[Path] = []
357
+ for _, fp in candidates.items():
358
+ if fp.is_file() and fp not in paths:
359
+ paths.append(fp)
360
+ for p in paths:
361
+ if p.is_file():
362
+ return [str(p)]
363
+ return []
364
+
365
+
366
+ _FONT_ALIAS_MAP: dict[str, str] = {
367
+ "simsun": "SimSun",
368
+ "宋体": "SimSun",
369
+ "simhei": "SimHei",
370
+ "黑体": "SimHei",
371
+ "msyh": "MicrosoftYaHei",
372
+ "微软雅黑": "MicrosoftYaHei",
373
+ "kaiti": "KaiTi",
374
+ "楷体": "KaiTi",
375
+ "fangsong": "FangSong",
376
+ "仿宋": "FangSong",
377
+ }
378
+
379
+ # 族名 → Windows 字体文件名(ttc/ttf);fpdf2 以 add_font 注册名引用
380
+ _FONT_FILE_MAP: dict[str, tuple[str, ...]] = {
381
+ "SimSun": ("simsun.ttc",),
382
+ "SimHei": ("simhei.ttf",),
383
+ "MicrosoftYaHei": ("msyh.ttc",),
384
+ "KaiTi": ("simkai.ttf",),
385
+ "FangSong": ("simfang.ttf",),
386
+ }
387
+
388
+
389
+ def _fpdf_font_key(mainfont: str) -> tuple[str | None, str | None]:
390
+ """按 mainfont 名称匹配 fpdf2 可用的中文字体(fontfile + 注册名)
391
+
392
+ 返回 (字体文件绝对路径, fpdf2 add_font 注册名);名称先精确匹配别名表,
393
+ 再模糊匹配(如 SimSun-ExtB),均未命中回退 SimSun(存在则优先)。
394
+ 全部不可用时返回 (None, None)(PDF 将降级为 Helvetica,仅 ASCII 可读)。
395
+ """
396
+ lowered = mainfont.lower()
397
+ family = _FONT_ALIAS_MAP.get(lowered)
398
+ if family is None:
399
+ for alias, fam in _FONT_ALIAS_MAP.items():
400
+ if alias in lowered or lowered in alias:
401
+ family = fam
402
+ break
403
+ if family is None:
404
+ family = "SimSun"
405
+
406
+ windir = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
407
+ for fname in _FONT_FILE_MAP.get(family, ()):
408
+ fp = windir / fname
409
+ if fp.is_file():
410
+ return (str(fp), family)
411
+ # 回退到任意已知存在的中文字体
412
+ pool = _font_search_paths()
413
+ if pool:
414
+ return (pool[0], Path(pool[0]).stem)
415
+ return (None, None)
416
+
417
+
418
+ class _PdfMarkdownRenderer:
419
+ """markdown-it token 流 → fpdf2 渲染器(与 convert_md_to_html 同源解析)
420
+
421
+ 与 HTML 版观感对齐:标题分级加粗、正文 11pt、表格撑满页宽表头灰底、
422
+ 引用块左侧竖线 + 灰字、行内代码灰底等宽、链接蓝色;嵌套列表逐层
423
+ 缩进(fpdf2 的 write 自动换行回到 left_margin,天然实现悬挂缩进)。
424
+ 行内样式经 pdf.write 流式切换字体渲染;表格单元格因需列内折行,
425
+ 改用 multi_cell(markdown=False)(纯文本,表头灰底深色维持加粗观感,
426
+ 规避 CJK 粗体字体未注册导致的崩溃)。
427
+ 行推进模型:fpdf2 的 write 结束后光标停在最后一行顶部(当前行高度
428
+ 未消费),凡经 write 渲染的块收尾必须 ln(整行高 + 块间隙),否则
429
+ 相邻文本行纵向重叠;multi_cell 系块(表格/代码块)收尾 y 已在块
430
+ 底部,其后 ln 仅为纯块间距。
431
+ """
432
+
433
+ _BODY_SIZE = 11
434
+ _TABLE_SIZE = 10
435
+ _HEADING_SIZES = {1: 18, 2: 15, 3: 13, 4: 12, 5: 12, 6: 11}
436
+ _BULLET_MARKS = ("•", "○") # GB2312 字库安全的项目符号
437
+ _QUOTE_TEXT_COLOR = (95, 95, 95)
438
+ _QUOTE_LINE_COLOR = (180, 180, 180)
439
+ _LINK_COLOR = (5, 99, 195)
440
+ _CODE_TEXT_COLOR = (51, 51, 51)
441
+ _CODE_BG_COLOR = (243, 244, 246)
442
+ _BORDER_COLOR = (204, 204, 204)
443
+ _HEAD_BG_COLOR = (240, 240, 240)
444
+ # 分页底限:与 set_auto_page_break(margin=20) 的 trigger 一致;
445
+ # 手动排版(表格/代码块)以同一阈值判断分页,避免越界
446
+ _BOTTOM_LIMIT = 20
447
+
448
+ def __init__(self, pdf: FPDF, font_key: str | None, has_cjk_font: bool) -> None:
449
+ self.pdf = pdf
450
+ self.font_key = font_key
451
+ self.has_cjk_font = has_cjk_font
452
+ self._base_left = pdf.l_margin
453
+ self._bold = False
454
+ self._italic = False
455
+ self._href: str | None = None
456
+ self._text_color: tuple[int, int, int] = (0, 0, 0)
457
+
458
+ # ---- 基础工具 ----
459
+
460
+ @property
461
+ def _family(self) -> str:
462
+ return self.font_key if self.has_cjk_font and self.font_key else "Helvetica"
463
+
464
+ def _style(self) -> str:
465
+ return ("B" if self._bold else "") + ("I" if self._italic else "")
466
+
467
+ def _line_h(self, size: int) -> float:
468
+ return size * 0.5
469
+
470
+ def _ensure_space(self, need: float) -> None:
471
+ """手动绘制前分页检查:剩余空间不足则换页"""
472
+ if self.pdf.get_y() + need > self.pdf.h - self._BOTTOM_LIMIT:
473
+ self.pdf.add_page()
474
+
475
+ def _avail_width(self) -> float:
476
+ """当前行可用宽度(从当前 x 到右边距)"""
477
+ return self.pdf.w - self.pdf.x - self.pdf.r_margin
478
+
479
+ # ---- 行内渲染(pdf.write 流式,样式即切即用)----
480
+
481
+ def _flow_run(self, text: str, size: int) -> None:
482
+ """流式写入文本,先在当前行剩余宽度内测宽排布;
483
+ 放不下则手动画行到左边缘(悬挂缩进),再交给 write 补齐文本。
484
+ write 对超长片段自带按词折行与翻页,二者结合避免内容越右边界。
485
+ """
486
+ if not text:
487
+ return
488
+ pdf = self.pdf
489
+ h = self._line_h(size)
490
+ while text:
491
+ pdf.set_font(self._family, self._style(), size)
492
+ # 整段宽度放得下直接整段写入(保持词间不折行)
493
+ if pdf.get_string_width(text) <= self._avail_width():
494
+ self._write_run(text, size)
495
+ return
496
+ # 找当前行能容纳的最长前缀(按字符累计宽度)
497
+ prefix = ""
498
+ width = 0.0
499
+ for ch in text:
500
+ width += pdf.get_string_width(ch)
501
+ if width > self._avail_width():
502
+ break
503
+ prefix += ch
504
+ if not prefix:
505
+ # 单个字符都放不下(极端窄列),直接交给 write 处理
506
+ self._write_run(text, size)
507
+ return
508
+ page_before = pdf.page_no()
509
+ self._write_run(prefix, size)
510
+ text = text[len(prefix) :]
511
+ if pdf.page_no() != page_before:
512
+ # writer 已触发翻页,剩余文本在新页顶部继续排
513
+ continue
514
+ pdf.ln(h)
515
+ pdf.set_x(pdf.l_margin)
516
+
517
+ def _write_run(self, text: str, size: int) -> None:
518
+ if not text:
519
+ return
520
+ pdf = self.pdf
521
+ pdf.set_font(self._family, self._style(), size)
522
+ color = self._LINK_COLOR if self._href else self._text_color
523
+ pdf.set_text_color(*color)
524
+ pdf.write(self._line_h(size), text, link=self._href or "")
525
+
526
+ def _render_code_span(self, content: str, size: int) -> None:
527
+ """行内代码:等宽小号 + 灰底(先画底色矩形再覆写文本)"""
528
+ pdf = self.pdf
529
+ code_size = max(size - 2, 8)
530
+ label = f" {content} "
531
+ pdf.set_font(self._family, "", code_size)
532
+ w = pdf.get_string_width(label)
533
+ h = self._line_h(size)
534
+ # 行内剩余宽度不足时先软换行到左边缘(保持悬挂缩进)
535
+ if w > self._avail_width():
536
+ pdf.ln(h)
537
+ pdf.set_x(pdf.l_margin)
538
+ x, y = pdf.get_x(), pdf.get_y()
539
+ pdf.set_fill_color(*self._CODE_BG_COLOR)
540
+ pdf.rect(x, y + 0.4, w + 2, h - 0.6, "F")
541
+ pdf.set_text_color(*self._CODE_TEXT_COLOR)
542
+ pdf.set_xy(x, y)
543
+ pdf.write(h, label, link=self._href or "")
544
+ pdf.set_text_color(*self._text_color)
545
+
546
+ def _render_inline(self, children: list[Token], size: int) -> None:
547
+ for tok in children:
548
+ t = tok.type
549
+ if t == "text":
550
+ self._flow_run(tok.content, size)
551
+ elif t == "softbreak":
552
+ self._write_run(" ", size)
553
+ elif t == "hardbreak":
554
+ self.pdf.ln(self._line_h(size))
555
+ self.pdf.set_x(self.pdf.l_margin)
556
+ elif t == "strong_open":
557
+ self._bold = True
558
+ elif t == "strong_close":
559
+ self._bold = False
560
+ elif t == "em_open":
561
+ self._italic = True
562
+ elif t == "em_close":
563
+ self._italic = False
564
+ elif t == "link_open":
565
+ self._href = tok.attrGet("href") or ""
566
+ elif t == "link_close":
567
+ self._href = None
568
+ elif t == "code_inline":
569
+ self._render_code_span(tok.content, size)
570
+ elif t == "image":
571
+ self._flow_run(tok.content or "", size)
572
+
573
+ # ---- 块级渲染 ----
574
+
575
+ def render(self, tokens: list[Token]) -> None:
576
+ self._render_blocks(tokens, 0)
577
+
578
+ def _render_blocks(
579
+ self, tokens: list[Token], i: int, stop: str | None = None
580
+ ) -> int:
581
+ """顺序渲染块级 token 流;遇 stop 类型(如 blockquote_close)即返回"""
582
+ n = len(tokens)
583
+ while i < n:
584
+ tok = tokens[i]
585
+ t = tok.type
586
+ if stop is not None and t == stop:
587
+ return i
588
+ if t == "paragraph_open":
589
+ self._ensure_space(self._line_h(self._BODY_SIZE) * 2)
590
+ self._render_inline(tokens[i + 1].children or [], self._BODY_SIZE)
591
+ # fpdf2 的 write 结束后光标停在最后一行顶部(未消费行高),
592
+ # 须推进整行高结束该行,再加段间隙;只推进小间隙会与后文重叠
593
+ self.pdf.ln(self._line_h(self._BODY_SIZE) + 1.2)
594
+ i += 3
595
+ elif t.startswith("heading_"):
596
+ level = int(tok.tag[1])
597
+ self._render_heading(level, tokens[i + 1].children or [])
598
+ i += 3
599
+ elif t in ("bullet_list_open", "ordered_list_open"):
600
+ i = self._render_list(tokens, i, 0)
601
+ elif t == "blockquote_open":
602
+ i = self._render_blockquote(tokens, i)
603
+ elif t == "fence":
604
+ self._render_fence(tok)
605
+ i += 1
606
+ elif t == "table_open":
607
+ i = self._render_table(tokens, i)
608
+ elif t == "hr":
609
+ self._render_hr()
610
+ i += 1
611
+ else:
612
+ i += 1
613
+ return i
614
+
615
+ def _render_heading(self, level: int, children: list[Token]) -> None:
616
+ size = self._HEADING_SIZES.get(level, self._BODY_SIZE)
617
+ self._ensure_space(size * 1.8)
618
+ self.pdf.ln(2)
619
+ saved_bold = self._bold
620
+ self._bold = True # 对齐 HTML:h1~h6 均 font-weight 600
621
+ self._render_inline(children, size)
622
+ self._bold = saved_bold
623
+ self.pdf.ln(self._line_h(size) + 1.5)
624
+
625
+ def _render_hr(self) -> None:
626
+ self._ensure_space(6)
627
+ pdf = self.pdf
628
+ pdf.set_draw_color(230, 230, 230)
629
+ pdf.set_line_width(0.4)
630
+ y = pdf.get_y() + 1.5
631
+ pdf.line(pdf.l_margin, y, pdf.w - pdf.r_margin, y)
632
+ pdf.ln(4)
633
+
634
+ def _render_fence(self, tok: Token) -> None:
635
+ """代码块:浅灰背景 + 等宽 9pt,长行先折再逐行输出
636
+
637
+ 对齐 HTML 的 pre 样式:浅灰底、等宽字。每行先按可用宽度折行成
638
+ 片段段,再逐段 multi_cell;页底不足时 add_page 续排(同一逻辑行
639
+ 的续段在新页顶部继续,代码不错位)。行首固定缩进 2mm。
640
+ """
641
+ pdf = self.pdf
642
+ code_size = 9
643
+ line_h = 5
644
+ self._ensure_space(line_h * 2)
645
+ pdf.ln(1)
646
+ code_bottom = pdf.h - 20
647
+ for cl in tok.content.rstrip("\n").splitlines():
648
+ if not cl:
649
+ pdf.ln(line_h)
650
+ continue
651
+ # 长行按可用宽度折成多段(一次测宽,避免 multi_cell 内部换行打乱对齐)
652
+ chunks: list[str] = []
653
+ rest = cl
654
+ while rest and pdf.get_string_width(rest) > self._avail_width():
655
+ width = 0
656
+ split_at = len(rest)
657
+ for idx, ch in enumerate(rest):
658
+ width += pdf.get_string_width(ch)
659
+ if width > self._avail_width():
660
+ split_at = idx
661
+ break
662
+ chunks.append(rest[:split_at])
663
+ rest = rest[split_at:]
664
+ if rest:
665
+ chunks.append(rest)
666
+ for c in chunks:
667
+ if pdf.get_y() + line_h > code_bottom:
668
+ pdf.add_page()
669
+ pdf.set_x(pdf.l_margin + 2)
670
+ pdf.set_font(self._family, "", code_size)
671
+ pdf.multi_cell(0, line_h, c)
672
+ pdf.ln(3)
673
+
674
+ def _render_blockquote(self, tokens: list[Token], i: int) -> int:
675
+ pdf = self.pdf
676
+ prev_left = pdf.l_margin
677
+ left = prev_left + 8
678
+ start_page = pdf.page_no()
679
+ self._ensure_space(self._line_h(self._BODY_SIZE) * 2)
680
+ y0 = pdf.get_y()
681
+ pdf.set_left_margin(left)
682
+ pdf.set_x(left)
683
+ saved_color = self._text_color
684
+ self._text_color = self._QUOTE_TEXT_COLOR
685
+ i = self._render_blocks(tokens, i + 1, stop="blockquote_close")
686
+ pdf.set_left_margin(prev_left)
687
+ self._text_color = saved_color
688
+ y1 = pdf.get_y()
689
+ # 左侧竖线(跨页引用仅绘制续页段,避免在旧页坐标误画)
690
+ pdf.set_draw_color(*self._QUOTE_LINE_COLOR)
691
+ pdf.set_line_width(0.6)
692
+ if pdf.page_no() == start_page:
693
+ pdf.line(left - 2.5, y0 + 0.5, left - 2.5, max(y1 - 1, y0 + 1.5))
694
+ else:
695
+ pdf.line(
696
+ left - 2.5, pdf.t_margin, left - 2.5, max(y1 - 1, pdf.t_margin + 1.5)
697
+ )
698
+ pdf.ln(2)
699
+ return min(i + 1, len(tokens))
700
+
701
+ def _render_list(self, tokens: list[Token], i: int, depth: int) -> int:
702
+ open_tok = tokens[i]
703
+ ordered = open_tok.type == "ordered_list_open"
704
+ try:
705
+ idx = int(open_tok.attrGet("start") or 1)
706
+ except (TypeError, ValueError):
707
+ idx = 1
708
+ n = len(tokens)
709
+ i += 1
710
+ while i < n:
711
+ tok = tokens[i]
712
+ t = tok.type
713
+ if t in ("bullet_list_close", "ordered_list_close"):
714
+ return i + 1
715
+ if t == "list_item_open":
716
+ if ordered:
717
+ marker = f"{idx}."
718
+ idx += 1
719
+ else:
720
+ marker = self._BULLET_MARKS[depth % len(self._BULLET_MARKS)]
721
+ i = self._render_list_item(tokens, i + 1, depth, marker)
722
+ continue
723
+ i += 1
724
+ return i
725
+
726
+ def _render_list_item(
727
+ self, tokens: list[Token], i: int, depth: int, marker: str
728
+ ) -> int:
729
+ pdf = self.pdf
730
+ n = len(tokens)
731
+ prev_left = pdf.l_margin
732
+ left = prev_left + 6 * (depth + 1)
733
+ pdf.set_left_margin(left)
734
+ # 项目符号悬挂在缩进左侧
735
+ pdf.set_x(left - 6)
736
+ pdf.set_font(self._family, "", self._BODY_SIZE)
737
+ pdf.set_text_color(*self._text_color)
738
+ pdf.cell(
739
+ pdf.get_string_width(marker) + 1.5, self._line_h(self._BODY_SIZE), marker
740
+ )
741
+ first_para = True
742
+ while i < n and tokens[i].type != "list_item_close":
743
+ tok = tokens[i]
744
+ t = tok.type
745
+ if t == "paragraph_open":
746
+ if not first_para:
747
+ self._ensure_space(self._line_h(self._BODY_SIZE) * 2)
748
+ pdf.set_x(left)
749
+ self._render_inline(tokens[i + 1].children or [], self._BODY_SIZE)
750
+ # write 后光标停在最后一行顶部:推进整行高 + 项内段间隙,
751
+ # 保证下一列表项/段落不与本项末行重叠
752
+ pdf.ln(self._line_h(self._BODY_SIZE) + 0.8)
753
+ first_para = False
754
+ i += 3
755
+ continue
756
+ if t in ("bullet_list_open", "ordered_list_open"):
757
+ pdf.ln(self._line_h(self._BODY_SIZE) * 0.3)
758
+ i = self._render_list(tokens, i, depth + 1)
759
+ continue
760
+ if t == "fence":
761
+ pdf.ln(1)
762
+ self._render_fence(tok)
763
+ i += 1
764
+ continue
765
+ if t == "blockquote_open":
766
+ i = self._render_blockquote(tokens, i)
767
+ continue
768
+ i += 1
769
+ pdf.set_left_margin(prev_left)
770
+ # 行推进已由项内段落/嵌套块按"整行高+间隙"收尾,此处不再额外
771
+ # 推进(原 ln(2) 在 write 行模型下是列表项纵向重叠的根因)
772
+ return min(i + 1, n)
773
+
774
+ def _render_table(self, tokens: list[Token], i: int) -> int:
775
+ """收集 thead/tbody 行列后交由 _draw_table 绘制"""
776
+ header: list[list[Token]] = []
777
+ body: list[list[list[Token]]] = []
778
+ n = len(tokens)
779
+ i += 1 # 跳过 table_open
780
+ while i < n and tokens[i].type != "table_close":
781
+ if tokens[i].type != "tr_open":
782
+ i += 1
783
+ continue
784
+ cells: list[list[Token]] = []
785
+ has_th = False
786
+ i += 1
787
+ while i < n and tokens[i].type != "tr_close":
788
+ if tokens[i].type in ("th_open", "td_open"):
789
+ has_th = has_th or tokens[i].type == "th_open"
790
+ cells.append(tokens[i + 1].children or [])
791
+ i += 3
792
+ continue
793
+ i += 1
794
+ i += 1 # tr_close
795
+ if not cells:
796
+ continue
797
+ if has_th and not header:
798
+ header = cells
799
+ else:
800
+ body.append(cells)
801
+ i += 1 # table_close
802
+ self._draw_table(header, body)
803
+ return i
804
+
805
+ @staticmethod
806
+ def _inline_to_pdf_text(children: list[Token]) -> str:
807
+ """行内 token → 纯文本(表格单元格用)
808
+
809
+ 仅保留文本/软换行/硬换行/行内代码/图片 alt;链接取文字、粗斜体
810
+ 标记剥除(表头灰底深色维持加粗观感,不依赖字形切换)。返回文本
811
+ 保证不触发 fpdf2 markdown 解析(`**` 定界符在 CJK 场景会请求
812
+ 未注册的粗体字体名导致崩溃)。
813
+ """
814
+ parts: list[str] = []
815
+ for tok in children:
816
+ t = tok.type
817
+ if t == "text":
818
+ parts.append(tok.content)
819
+ elif t == "softbreak":
820
+ parts.append(" ")
821
+ elif t == "hardbreak":
822
+ parts.append("\n")
823
+ elif t in ("code_inline", "image"):
824
+ parts.append(tok.content or "")
825
+ return "".join(parts).strip()
826
+
827
+ def _draw_table(
828
+ self, header: list[list[Token]], body: list[list[list[Token]]]
829
+ ) -> None:
830
+ """自适应列宽表格:表头灰底加粗、单元格边框换行、撑满页宽
831
+
832
+ 列宽按内容自适应:优先给每列最长单元文本宽度,总宽不足时按比例
833
+ 缩放,最小列宽保护;行高由 dry_run 预演(markdown 标记参与)得出,
834
+ 保证边框与文字不脱节;跨页时新页重绘表头。
835
+ """
836
+ pdf = self.pdf
837
+ if not header:
838
+ return
839
+ ncols = len(header)
840
+ rows: list[tuple[bool, list[list[Token]]]] = [(True, header)]
841
+ rows.extend((False, r) for r in body)
842
+ # --- 列宽分配:按内容自适应(表头 + 全部数据行)---
843
+ avail = pdf.w - pdf.l_margin - pdf.r_margin
844
+ pdf.set_font(self._family, "", self._TABLE_SIZE)
845
+ # 最小列宽保护:至少容纳 4 个全角字符(防长文列被压成一字一行
846
+ # 竖排);列数过多时以页宽均分为上限
847
+ min_col_w = min(pdf.get_string_width("中" * 4) + 2.4, avail / ncols)
848
+ widths: list[float] = []
849
+ for ci in range(ncols):
850
+ max_w = min_col_w
851
+ for is_head, row in rows:
852
+ if ci < len(row) and row[ci]:
853
+ marked = self._inline_to_pdf_text(row[ci])
854
+ max_w = max(max_w, pdf.get_string_width(marked))
855
+ widths.append(max_w)
856
+ total_w = sum(widths)
857
+ if total_w > avail:
858
+ scale = avail / total_w
859
+ widths = [w * scale for w in widths]
860
+ else:
861
+ # 富余量按列均摊(保持撑满页宽,列宽更均匀)
862
+ bonus = (avail - total_w) / ncols
863
+ widths = [w + bonus for w in widths]
864
+ # --- 逐行绘制(跨页时新页重绘表头)---
865
+ for is_head, row in rows:
866
+ row = row + [[]] * (ncols - len(row))
867
+ # 行高预演:dry_run 折行行数 → 行高
868
+ row_lines: list[int] = []
869
+ for ci, cell in enumerate(row):
870
+ marked = self._inline_to_pdf_text(cell)
871
+ pdf.set_font(self._family, "", self._TABLE_SIZE)
872
+ out = pdf.multi_cell(
873
+ widths[ci] - 2.4,
874
+ 5,
875
+ marked,
876
+ markdown=False,
877
+ dry_run=True,
878
+ output="LINES",
879
+ )
880
+ row_lines.append(len(out) if isinstance(out, list) else 1)
881
+ row_h = max(row_lines) * 5 + 2.4 if row_lines else 8
882
+ # 本页放不下(且当前不在页首)→ 翻页;非表头行翻页后重绘表头
883
+ if (
884
+ pdf.get_y() + row_h > pdf.h - self._BOTTOM_LIMIT
885
+ and pdf.get_y() > pdf.t_margin
886
+ ):
887
+ pdf.add_page()
888
+ if not is_head:
889
+ self._draw_table_header(header, widths, avail)
890
+ y0 = pdf.get_y()
891
+ # 表头灰底整行填充
892
+ if is_head:
893
+ pdf.set_fill_color(*self._HEAD_BG_COLOR)
894
+ pdf.rect(pdf.l_margin, y0, avail, row_h, "F")
895
+ # 边框 + 单元格内容
896
+ pdf.set_draw_color(*self._BORDER_COLOR)
897
+ pdf.set_line_width(0.2)
898
+ x0 = pdf.l_margin
899
+ for ci, cell in enumerate(row):
900
+ pdf.rect(x0, y0, widths[ci], row_h)
901
+ marked = self._inline_to_pdf_text(cell)
902
+ pdf.set_xy(x0 + 1.2, y0 + 0.8)
903
+ pdf.set_font(self._family, "", self._TABLE_SIZE)
904
+ pdf.multi_cell(widths[ci] - 2.4, 5, marked, markdown=False)
905
+ x0 += widths[ci]
906
+ pdf.set_y(y0 + row_h)
907
+ pdf.ln(3)
908
+
909
+ def _draw_table_header(
910
+ self,
911
+ header: list[list[Token]],
912
+ widths: list[float],
913
+ avail: float,
914
+ ) -> None:
915
+ """跨页后在新页顶部重绘表头(灰底加粗)"""
916
+ pdf = self.pdf
917
+ y0 = pdf.get_y()
918
+ row_lines: list[int] = []
919
+ for ci, cell in enumerate(header):
920
+ marked = self._inline_to_pdf_text(cell)
921
+ pdf.set_font(self._family, "", self._TABLE_SIZE)
922
+ out = pdf.multi_cell(
923
+ widths[ci] - 2.4,
924
+ 5,
925
+ marked,
926
+ markdown=False,
927
+ dry_run=True,
928
+ output="LINES",
929
+ )
930
+ row_lines.append(len(out) if isinstance(out, list) else 1)
931
+ row_h = max(row_lines) * 5 + 2.4 if row_lines else 8
932
+ pdf.set_fill_color(*self._HEAD_BG_COLOR)
933
+ pdf.rect(pdf.l_margin, y0, avail, row_h, "F")
934
+ pdf.set_draw_color(*self._BORDER_COLOR)
935
+ pdf.set_line_width(0.2)
936
+ x0 = pdf.l_margin
937
+ for ci, cell in enumerate(header):
938
+ pdf.rect(x0, y0, widths[ci], row_h)
939
+ marked = self._inline_to_pdf_text(cell)
940
+ pdf.set_xy(x0 + 1.2, y0 + 0.8)
941
+ pdf.set_font(self._family, "", self._TABLE_SIZE)
942
+ pdf.multi_cell(widths[ci] - 2.4, 5, marked, markdown=False)
943
+ x0 += widths[ci]
944
+ pdf.set_y(y0 + row_h)
945
+
946
+
947
+ def _pdf_render_markdown(md_text: str, dest: Path, mainfont: str) -> None:
948
+ """markdown-it token 流驱动 fpdf2 渲染 Markdown 为 PDF(与 HTML 同源解析)
949
+
950
+ - 解析:MarkdownIt(commonmark)+table,与 convert_md_to_html(PiMD 内部
951
+ 同为 markdown-it)结构理解一致;支持标题、嵌套列表(有序/无序)、
952
+ 引用块、围栏代码块、GFM 表格、分隔线,及行内粗体/斜体/行内代码/
953
+ 链接/图片 alt 文本。
954
+ - PDF/A 不可用于中文:PiMD 内置 PDF 引擎为 fpdf2 文本提取(Helvetica),
955
+ 不支持 CJK;本渲染器按 mainfont 注册系统 CJK 字体(B/I/BI 同文件
956
+ 注册,未注册样式在 set_font 时直接抛错),保证中文完整落盘。
957
+ - 布局对齐 convert_md_to_html 的宽版式观感:A4 纵向、正文 11pt、
958
+ h1~h6 分级加粗、表格撑满页宽表头灰底、引用块左竖线灰字、链接蓝色。
959
+ """
960
+ font_path, font_key = _fpdf_font_key(mainfont)
961
+ if font_path is None:
962
+ logger.warning(
963
+ f"未找到中文字体(mainfont={mainfont}),"
964
+ "PDF 将使用 Helvetica(仅 ASCII 可读)"
965
+ )
966
+
967
+ pdf = FPDF(format="A4")
968
+ pdf.set_auto_page_break(auto=True, margin=20)
969
+ pdf.add_page()
970
+ if font_path is not None:
971
+ # Unicode 中文字体无真实粗/斜变体:B/I/BI 均注册同一字形
972
+ for style in ("", "B", "I", "BI"):
973
+ pdf.add_font(font_key, style, font_path)
974
+
975
+ tokens = _MD_PARSER.parse(md_text)
976
+ _PdfMarkdownRenderer(pdf, font_key, font_path is not None).render(tokens)
977
+ pdf.output(str(dest))
978
+
979
+
980
+ def _is_table_row(line: str) -> bool:
981
+ """GFM 表格数据行:以竖线开头/结尾,或含 ` | ` 分隔的多列(单列表格
982
+ 仅两端各一条竖线,不能按中间竖线判定)"""
983
+ s = line.strip()
984
+ if not s:
985
+ return False
986
+ return s.startswith("|") or s.endswith("|") or " | " in s
987
+
988
+
989
+ @dataclass(frozen=True, slots=True)
990
+ class ConvertResult:
991
+ """文档转换结果对象
992
+
993
+ 由 DocKit.convert 返回,将成功与失败信息统一封装,调用方无需 try/except。
994
+
995
+ Attributes:
996
+ success: 转换是否成功
997
+ markdown: 转换后的 Markdown 文本,失败时为空字符串
998
+ file_path: 源文件的绝对路径
999
+ source_format: anydoc 检测到的规范格式名(如 'docx'、'pdf'),无法检测为 None
1000
+ error: 失败原因描述(异常类型与信息),成功时为 None
1001
+ """
1002
+
1003
+ success: bool
1004
+ markdown: str
1005
+ file_path: str
1006
+ source_format: str | None = None
1007
+ error: str | None = None
1008
+
1009
+
1010
+ class DocKit:
1011
+ """文档转换工具类,基于 anydoc 将各类文档转为 Markdown
1012
+
1013
+ 支持 Word(.doc/.docx)、PowerPoint(.ppt/.pptx)、Excel(.xls/.xlsx)、
1014
+ CSV、PDF 共 5 类 8 种扩展名,统一输出 GitHub-Flavored Markdown。
1015
+
1016
+ 使用方式:
1017
+ # 1. 直接转换(异常向上抛出)
1018
+ markdown = DocKit.convert_to_md("report.docx")
1019
+
1020
+ # 2. 安全转换(返回状态结果,不抛异常)
1021
+ result = DocKit.convert("report.pdf")
1022
+ if result.success:
1023
+ print(result.markdown)
1024
+
1025
+ # 3. 转换并写入 .md 文件
1026
+ DocKit.convert_to_file("report.docx", "out/report.md")
1027
+
1028
+ # 4. 字节内容转换(无需落盘)
1029
+ md = DocKit.convert_bytes_to_md(file_bytes, "csv")
1030
+
1031
+ # 5. Markdown 转 PDF(基于本模块内嵌 fpdf2 渲染器,支持中文)
1032
+ DocKit.convert_md_to_pdf("report.md") # 同目录 report.pdf
1033
+ DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
1034
+
1035
+ # 6. Markdown 转 Word(基于 PiMD 默认主题 + 表格撑满后处理)
1036
+ DocKit.convert_md_to_docx("report.md") # 同目录 report.docx
1037
+ DocKit.convert_md_to_docx("report.md", "out.docx", generate_toc=True)
1038
+
1039
+ # 7. Markdown 转 HTML(基于 PiMD + 宽版式样式注入)
1040
+ DocKit.convert_md_to_html("report.md") # 同目录 report.html
1041
+ DocKit.convert_md_to_html("report.md", title="项目报告")
1042
+
1043
+ 注意:convert_md_to_pdf 为**实验性/内部使用,不提供对外服务**(见
1044
+ convert_md_to_pdf 方法说明);需要 PDF 时优先用 convert_md_to_html
1045
+ 后在浏览器中打印/另存 PDF,或 convert_md_to_docx 后在 Word 中另存 PDF。
1046
+
1047
+ 可与 FileKit 组合使用以访问 FILE_STORAGE_ROOT 下的相对路径文件:
1048
+ full_path = FileKit.get_full_path("docs/report.docx")
1049
+ markdown = DocKit.convert_to_md(full_path)
1050
+ """
1051
+
1052
+ SUPPORTED_EXTENSIONS: frozenset[str] = _SUPPORTED_EXTENSIONS
1053
+
1054
+ @staticmethod
1055
+ def is_supported(file_path: str | Path) -> bool:
1056
+ """检查文件格式是否受支持
1057
+
1058
+ Args:
1059
+ file_path: 文件路径(仅依据扩展名判断)
1060
+
1061
+ Returns:
1062
+ bool: 扩展名在支持列表中返回 True
1063
+
1064
+ Example:
1065
+ >>> DocKit.is_supported("report.docx")
1066
+ True
1067
+ >>> DocKit.is_supported("photo.png")
1068
+ False
1069
+ """
1070
+ ext = Path(file_path).suffix.lower()
1071
+ return ext in DocKit.SUPPORTED_EXTENSIONS
1072
+
1073
+ @staticmethod
1074
+ def detect_format(file_path: str | Path) -> str | None:
1075
+ """检测文档的 anydoc 规范格式名称
1076
+
1077
+ 通过文件扩展名推断规范格式(如 .pptm → 'pptx',.docx → 'docx')。
1078
+ 无法识别的扩展名返回 None。
1079
+
1080
+ Args:
1081
+ file_path: 文件路径
1082
+
1083
+ Returns:
1084
+ str | None: 规范格式名称,无法识别返回 None
1085
+
1086
+ Example:
1087
+ >>> DocKit.detect_format("slides.pptm")
1088
+ 'pptx'
1089
+ """
1090
+ return anydoc.format_from_path(str(Path(file_path)))
1091
+
1092
+ @staticmethod
1093
+ def convert_to_md(file_path: str | Path) -> str:
1094
+ """将文档文件转换为 Markdown 文本(核心转换方法)
1095
+
1096
+ 基于 anydoc,读取本地文件并转换为 GitHub-Flavored Markdown。
1097
+ 文本型 PDF 可直接转换;加密、图片型 PDF 等无法转换的情况会抛出异常。
1098
+
1099
+ Args:
1100
+ file_path: 文档文件路径(支持 str 或 Path)
1101
+
1102
+ Returns:
1103
+ str: 转换后的 Markdown 文本
1104
+
1105
+ Raises:
1106
+ FileNotFoundError: 文件不存在
1107
+ ValueError: 文件不是常规文件或格式不受支持
1108
+ anydoc.ConvertError: 文档转换失败(加密、损坏、格式异常等子类)
1109
+ OSError: 文件读取失败
1110
+
1111
+ Example:
1112
+ >>> markdown = DocKit.convert_to_md("report.docx")
1113
+ >>> print(markdown[:100])
1114
+ """
1115
+ path = Path(file_path).resolve()
1116
+
1117
+ if not path.exists():
1118
+ raise FileNotFoundError(f"文件不存在: {path}")
1119
+ if not path.is_file():
1120
+ raise ValueError(f"路径不是有效文件: {path}")
1121
+ if not DocKit.is_supported(path):
1122
+ raise ValueError(
1123
+ f"不支持的文件格式: {path.suffix or '<无扩展名>'},"
1124
+ f"支持的格式: {', '.join(sorted(DocKit.SUPPORTED_EXTENSIONS))}"
1125
+ )
1126
+
1127
+ logger.info(f"开始转换文档: {path.name}")
1128
+ try:
1129
+ markdown = anydoc.to_markdown(str(path))
1130
+ except anydoc.ConvertError as e:
1131
+ logger.error(f"文档转换失败: {path.name}({type(e).__name__}): {e}")
1132
+ raise
1133
+ except OSError as e:
1134
+ logger.error(f"文件读取失败: {path.name}: {e}")
1135
+ raise
1136
+
1137
+ logger.info(f"✅ 文档转换成功: {path.name}({len(markdown)} 字符)")
1138
+ return markdown
1139
+
1140
+ @staticmethod
1141
+ def convert_bytes_to_md(data: bytes, fmt: str | None = None) -> str:
1142
+ """将文件字节内容转换为 Markdown 文本
1143
+
1144
+ 适用于从网络、数据库等渠道获取的字节数据,无需落盘即可转换。
1145
+ 格式默认从内容自动检测;CSV 等无特征格式需通过 fmt 显式指定。
1146
+
1147
+ Args:
1148
+ data: 文档文件的字节内容
1149
+ fmt: 显式指定的规范格式名称(如 'csv'),None 时自动检测
1150
+
1151
+ Returns:
1152
+ str: 转换后的 Markdown 文本
1153
+
1154
+ Raises:
1155
+ TypeError: data 不是字节类型
1156
+ ValueError: fmt 指定了不支持的格式名称
1157
+ anydoc.ConvertError: 文档转换失败
1158
+
1159
+ Example:
1160
+ >>> with open("data.csv", "rb") as f:
1161
+ ... md = DocKit.convert_bytes_to_md(f.read(), "csv")
1162
+ >>> md = DocKit.convert_bytes_to_md(file_bytes) # 自动检测
1163
+ """
1164
+ if not isinstance(data, (bytes, bytearray)):
1165
+ raise TypeError(f"data 必须为字节类型,当前类型: {type(data).__name__}")
1166
+
1167
+ try:
1168
+ if fmt is not None:
1169
+ # fmt 为任意合法格式名,anydoc 内部会校验;stub 将其限定为
1170
+ # Literal 联合,此处运行时由 anydoc 兜底校验。
1171
+ return anydoc.to_markdown_bytes(bytes(data), fmt) # type: ignore[arg-type]
1172
+ return anydoc.to_markdown_bytes(bytes(data))
1173
+ except anydoc.ConvertError as e:
1174
+ logger.error(f"字节内容转换失败({type(e).__name__}): {e}")
1175
+ raise
1176
+
1177
+ @staticmethod
1178
+ def convert(file_path: str | Path) -> ConvertResult:
1179
+ """安全转换文档为 Markdown,返回包含状态的结果对象
1180
+
1181
+ 与 convert_to_md 不同,本方法不抛出异常,而是将成功/失败信息
1182
+ 封装到 ConvertResult 中,便于批量处理或状态检查。
1183
+
1184
+ Args:
1185
+ file_path: 文档文件路径
1186
+
1187
+ Returns:
1188
+ ConvertResult: 转换结果,包含 success/markdown/file_path/source_format/error
1189
+
1190
+ Example:
1191
+ >>> result = DocKit.convert("report.pdf")
1192
+ >>> if result.success:
1193
+ ... print(result.markdown[:100])
1194
+ ... else:
1195
+ ... print(f"转换失败: {result.error}")
1196
+ """
1197
+ path = Path(file_path).resolve()
1198
+ source_format = DocKit.detect_format(path)
1199
+
1200
+ try:
1201
+ markdown = DocKit.convert_to_md(path)
1202
+ except (FileNotFoundError, ValueError, anydoc.ConvertError, OSError) as e:
1203
+ logger.warning(f"文档转换失败: {path.name}({type(e).__name__}): {e}")
1204
+ return ConvertResult(
1205
+ success=False,
1206
+ markdown="",
1207
+ file_path=str(path),
1208
+ source_format=source_format,
1209
+ error=f"{type(e).__name__}: {e}",
1210
+ )
1211
+
1212
+ return ConvertResult(
1213
+ success=True,
1214
+ markdown=markdown,
1215
+ file_path=str(path),
1216
+ source_format=source_format,
1217
+ )
1218
+
1219
+ @staticmethod
1220
+ def convert_to_file(
1221
+ src_path: str | Path, dest_path: str | Path | None = None
1222
+ ) -> str:
1223
+ """转换文档并将 Markdown 输出到 .md 文件
1224
+
1225
+ Args:
1226
+ src_path: 源文档路径
1227
+ dest_path: 目标 .md 文件路径;None 时使用源文件名同目录加 .md 后缀
1228
+
1229
+ Returns:
1230
+ str: 实际写入的目标文件绝对路径
1231
+
1232
+ Raises:
1233
+ FileNotFoundError: 源文件不存在
1234
+ ValueError: 源文件格式不受支持
1235
+ anydoc.ConvertError: 文档转换失败
1236
+
1237
+ Example:
1238
+ >>> DocKit.convert_to_file("report.docx") # 生成同目录 report.md
1239
+ >>> DocKit.convert_to_file("report.docx", "out/report.md")
1240
+ """
1241
+ src = Path(src_path).resolve()
1242
+ markdown = DocKit.convert_to_md(src)
1243
+
1244
+ if dest_path is None:
1245
+ dest = src.with_suffix(".md")
1246
+ else:
1247
+ dest = Path(dest_path)
1248
+
1249
+ dest.parent.mkdir(parents=True, exist_ok=True)
1250
+ dest.write_text(markdown, encoding="utf-8")
1251
+ logger.info(f"✅ Markdown 已写入: {dest}")
1252
+ return str(dest)
1253
+
1254
+ @staticmethod
1255
+ def convert_md_to_pdf(
1256
+ src_path: str | Path,
1257
+ dest_path: str | Path | None = None,
1258
+ *,
1259
+ mainfont: str = "SimSun",
1260
+ ) -> str:
1261
+ """将 Markdown 文件转换为 PDF 文件(基于本模块内嵌 fpdf2 渲染器)
1262
+
1263
+ ⚠️ **实验性/内部使用,不提供对外服务**:本方法效果受 fpdf2 能力
1264
+ 局限(CJK 无真实粗/斜体变体、复杂长表格视觉打折),不保证排版
1265
+ 质量,不承诺对外服务。**外部需要 PDF 时请改用以下路径**:
1266
+ - 推荐:`convert_md_to_html(...)` 后在浏览器中打印/另存 PDF;
1267
+ - 或 `convert_md_to_docx(...)` 后在 Word 中另存 PDF。
1268
+ 本方法仅供内部试验,效果波动属预期;签名与行为保持稳定(不改不删)。
1269
+
1270
+ PiMD 内置 PDF 引擎(Helvetica 文本提取)不支持中文,故 PDF 由本
1271
+ 模块 fpdf2 渲染器直接生成:与 convert_md_to_html 同源解析
1272
+ (markdown-it token 流),渲染效果向 HTML 版对齐——支持标题、
1273
+ 嵌套列表(有序/无序)、引用块、表格(表头灰底加粗)、围栏代码块、
1274
+ 分隔线及行内粗体/斜体/行内代码/链接;按 mainfont 匹配 Windows
1275
+ 系统中文字体并注册为 unicode 子集字体,中文文档默认宋体。
1276
+ 排版默认值:A4 纵向、正文 11pt、h1~h6 分级加粗、表格撑满页宽、
1277
+ 单元格换行、多页自适应分页。
1278
+
1279
+ 转换前自动做 CJK 加粗预处理(详见 convert_md_to_docx)。
1280
+
1281
+ Args:
1282
+ src_path: Markdown 源文件路径(支持 str 或 Path)
1283
+ dest_path: 目标 PDF 路径;None 时使用源文件名同目录加 .pdf 后缀
1284
+ mainfont: 正文字体族名,默认 SimSun(需系统已安装该字体)
1285
+
1286
+ Returns:
1287
+ str: 实际写入的 PDF 文件绝对路径
1288
+
1289
+ Raises:
1290
+ FileNotFoundError: 源文件不存在
1291
+ ValueError: 源文件不是常规文件或扩展名不是 .md
1292
+ OSError: 文件读写失败
1293
+ RuntimeError: PDF 渲染失败
1294
+
1295
+ Example:
1296
+ >>> DocKit.convert_md_to_pdf("report.md") # 生成同目录 report.pdf
1297
+ >>> DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
1298
+ """
1299
+ src = Path(src_path).resolve()
1300
+ if not src.exists():
1301
+ raise FileNotFoundError(f"文件不存在: {src}")
1302
+ if not src.is_file():
1303
+ raise ValueError(f"路径不是有效文件: {src}")
1304
+ if src.suffix.lower() != ".md":
1305
+ raise ValueError(
1306
+ "源文件必须是 .md 后缀的 Markdown 文件,"
1307
+ f"当前: {src.suffix or '<无扩展名>'}"
1308
+ )
1309
+
1310
+ if dest_path is None:
1311
+ dest = src.with_suffix(".pdf")
1312
+ else:
1313
+ dest = Path(dest_path)
1314
+
1315
+ dest.parent.mkdir(parents=True, exist_ok=True)
1316
+ logger.info(f"开始转换 Markdown 为 PDF: {src.name}")
1317
+
1318
+ # CJK 加粗预处理;fpdf2 渲染器直接读内存文本,无需落盘临时 md
1319
+ processed = _normalize_cjk_bold(src.read_text(encoding="utf-8"))
1320
+
1321
+ try:
1322
+ _pdf_render_markdown(processed, dest, mainfont)
1323
+ except Exception as e: # noqa: BLE001 - fpdf2 渲染异常统一包装
1324
+ logger.error(f"PDF 渲染失败: {src.name}: {e}")
1325
+ raise RuntimeError(f"PDF 渲染失败: {src.name}: {e}") from e
1326
+
1327
+ logger.info(f"✅ PDF 生成成功: {dest}")
1328
+ return str(dest)
1329
+
1330
+ @staticmethod
1331
+ def convert_md_to_html(
1332
+ src_path: str | Path,
1333
+ dest_path: str | Path | None = None,
1334
+ *,
1335
+ title: str | None = None,
1336
+ ) -> str:
1337
+ """将 Markdown 文件转换为独立 HTML 文件(基于 PiMD)
1338
+
1339
+ PiMD 输出完整 HTML5 文档(自带 <meta charset> 与默认样式),支持
1340
+ GFM 全部语法;默认不注入页面标题(PiMD 默认无 <title> 标签,源
1341
+ 文件名不会出现在头部),页面标题直接使用 Markdown 自身的一级标题,
1342
+ 也可通过 title 参数显式指定。
1343
+ 默认内嵌宽版式排版样式(_MD_HTML_CSS),替代 PiMD 自带默认样式
1344
+ (body `max-width: 900px` 等):正文容器放大至 60em 并居中,中文
1345
+ 优先字体栈(宋体系/黑体系),表格撑满页宽、表头加粗,适配宽屏阅读。
1346
+
1347
+ 转换前自动做 CJK 加粗预处理(详见 convert_md_to_docx)。
1348
+
1349
+ Args:
1350
+ src_path: Markdown 源文件路径(支持 str 或 Path)
1351
+ dest_path: 目标 HTML 路径;None 时使用源文件名同目录加 .html 后缀
1352
+ title: 页面标题;None(默认)表示不注入标题,使用 Markdown 自身
1353
+ 一级标题;传入字符串则以此为页面标题
1354
+
1355
+ Returns:
1356
+ str: 实际写入的 HTML 文件绝对路径
1357
+
1358
+ Raises:
1359
+ FileNotFoundError: 源文件不存在
1360
+ ValueError: 源文件不是常规文件或扩展名不是 .md
1361
+ OSError: 文件读写失败
1362
+ RuntimeError: HTML 渲染失败
1363
+
1364
+ Example:
1365
+ >>> DocKit.convert_md_to_html("report.md") # 生成同目录 report.html
1366
+ >>> DocKit.convert_md_to_html("report.md", title="项目报告")
1367
+ """
1368
+ src = Path(src_path).resolve()
1369
+ if not src.exists():
1370
+ raise FileNotFoundError(f"文件不存在: {src}")
1371
+ if not src.is_file():
1372
+ raise ValueError(f"路径不是有效文件: {src}")
1373
+ if src.suffix.lower() != ".md":
1374
+ raise ValueError(
1375
+ "源文件必须是 .md 后缀的 Markdown 文件,"
1376
+ f"当前: {src.suffix or '<无扩展名>'}"
1377
+ )
1378
+
1379
+ if dest_path is None:
1380
+ dest = src.with_suffix(".html")
1381
+ else:
1382
+ dest = Path(dest_path)
1383
+
1384
+ dest.parent.mkdir(parents=True, exist_ok=True)
1385
+ logger.info(f"开始转换 Markdown 为 HTML: {src.name}")
1386
+
1387
+ # CJK 加粗预处理;临时 md 与源文件同目录,保证相对图片路径仍然可用
1388
+ processed = _normalize_cjk_bold(src.read_text(encoding="utf-8"))
1389
+ tmp_md = src.with_name(f".{src.stem}.pimd-tmp.md")
1390
+ tmp_md.write_text(processed, encoding="utf-8")
1391
+
1392
+ try:
1393
+ _PIMD.convert(tmp_md, "html", str(dest))
1394
+ except ConversionError as e:
1395
+ logger.error(f"HTML 渲染失败: {src.name}: {e}")
1396
+ raise RuntimeError(f"HTML 渲染失败: {src.name}: {e}") from e
1397
+ except OSError as e: # noqa: BLE001 - 文件读写异常统一包装
1398
+ logger.error(f"HTML 渲染失败: {src.name}: {e}")
1399
+ raise RuntimeError(f"HTML 渲染失败: {src.name}: {e}") from e
1400
+ finally:
1401
+ tmp_md.unlink(missing_ok=True)
1402
+
1403
+ # 转换后注入:PiMD 输出完整 HTML5 文档(自带 <style>),在 </head>
1404
+ # 前插入 _MD_HTML_CSS 覆盖其默认窄栏与配色
1405
+ html = dest.read_text(encoding="utf-8")
1406
+ if title is not None and "</title>" not in html:
1407
+ # PiMD 默认不输出 <title>;显式传 title 时才补写,页面标题
1408
+ # 默认使用 Markdown 自身的一级标题
1409
+ html = html.replace("<head>", f"<head>\n<title>{title}</title>", 1)
1410
+ html = html.replace("</head>", f"{_MD_HTML_CSS}\n</head>", 1)
1411
+ dest.write_text(html, encoding="utf-8")
1412
+
1413
+ logger.info(f"✅ HTML 生成成功: {dest}")
1414
+ return str(dest)
1415
+
1416
+ @staticmethod
1417
+ def convert_md_to_docx(
1418
+ src_path: str | Path,
1419
+ dest_path: str | Path | None = None,
1420
+ *,
1421
+ generate_toc: bool = False,
1422
+ mainfont: str = "宋体",
1423
+ reference_doc: str | Path | None = None,
1424
+ ) -> str:
1425
+ """将 Markdown 文件转换为 Word(.docx)文件(基于 PiMD)
1426
+
1427
+ 使用 PiMD(markdown-it 解析 + python-docx 渲染,CommonMark 系)
1428
+ 生成 docx,支持标题、列表、表格(表头深蓝底加粗)、代码块、链接
1429
+ 等语法;可通过 generate_toc 在文档开头插入目录。
1430
+ 全文字体统一为 mainfont(默认宋体):转换后改写 docDefaults 与
1431
+ 主题字体(正文/标题/表格全部生效),代码块保持等宽字体;
1432
+ reference_doc 参数仅保留签名兼容(PiMD 的 md_to_docx 不接收参考
1433
+ 模板,传入不生效)。
1434
+
1435
+ 转换前自动做 CJK 加粗预处理:`**加粗:**后接` 这类闭合 ** 前为
1436
+ 全角标点的写法不满足 CommonMark 定界规则(会以字面星号进入
1437
+ Word),内部将其改写为 `**加粗**:后接`,代码块内容不受影响;
1438
+ 转换后自动做表格版式后处理(表格撑满页宽并整体居中)。
1439
+
1440
+ Args:
1441
+ src_path: Markdown 源文件路径(支持 str 或 Path)
1442
+ dest_path: 目标 Word 文件路径;None 时使用源文件名同目录加 .docx 后缀
1443
+ generate_toc: 是否在文档开头插入目录
1444
+ mainfont: 文档全局字体名(Word 字体名,如 "宋体"/"黑体"),默认宋体
1445
+ reference_doc: 仅保留签名兼容;PiMD 不接收参考模板,传入不生效
1446
+
1447
+ Returns:
1448
+ str: 实际写入的 Word 文件绝对路径
1449
+
1450
+ Raises:
1451
+ FileNotFoundError: 源文件不存在
1452
+ ValueError: 源文件不是常规文件或扩展名不是 .md
1453
+ OSError: 文件读写失败
1454
+ RuntimeError: Word 渲染失败
1455
+
1456
+ Example:
1457
+ >>> DocKit.convert_md_to_docx("report.md") # 生成同目录 report.docx
1458
+ >>> DocKit.convert_md_to_docx("report.md", mainfont="仿宋")
1459
+ >>> DocKit.convert_md_to_docx("report.md", "out.docx", generate_toc=True)
1460
+ """
1461
+ src = Path(src_path).resolve()
1462
+ if not src.exists():
1463
+ raise FileNotFoundError(f"文件不存在: {src}")
1464
+ if not src.is_file():
1465
+ raise ValueError(f"路径不是有效文件: {src}")
1466
+ if src.suffix.lower() != ".md":
1467
+ raise ValueError(
1468
+ "源文件必须是 .md 后缀的 Markdown 文件,"
1469
+ f"当前: {src.suffix or '<无扩展名>'}"
1470
+ )
1471
+
1472
+ if dest_path is None:
1473
+ dest = src.with_suffix(".docx")
1474
+ else:
1475
+ dest = Path(dest_path)
1476
+
1477
+ dest.parent.mkdir(parents=True, exist_ok=True)
1478
+ logger.info(f"开始转换 Markdown 为 Word: {src.name}")
1479
+
1480
+ # CJK 加粗预处理;临时 md 与源文件同目录,保证相对图片路径仍然可用
1481
+ processed = _normalize_cjk_bold(src.read_text(encoding="utf-8"))
1482
+ tmp_md = src.with_name(f".{src.stem}.pimd-tmp.md")
1483
+ tmp_md.write_text(processed, encoding="utf-8")
1484
+
1485
+ try:
1486
+ _PIMD.md_to_docx(str(tmp_md), str(dest), generate_toc=generate_toc)
1487
+ except ConversionError as e:
1488
+ logger.error(f"Word 渲染失败: {src.name}: {e}")
1489
+ raise RuntimeError(f"Word 渲染失败: {src.name}: {e}") from e
1490
+ except OSError as e: # noqa: BLE001 - 文件读写异常统一包装
1491
+ logger.error(f"Word 渲染失败: {src.name}: {e}")
1492
+ raise RuntimeError(f"Word 渲染失败: {src.name}: {e}") from e
1493
+ finally:
1494
+ tmp_md.unlink(missing_ok=True)
1495
+
1496
+ # 表格版式后处理:撑满页宽并整体居中
1497
+ _polish_table_layout(dest)
1498
+ # 全局字体后处理:正文/标题统一为 mainfont(代码块保持等宽)
1499
+ _polish_docx_font(dest, mainfont)
1500
+
1501
+ logger.info(f"✅ Word 生成成功: {dest}")
1502
+ return str(dest)