sdpy-kit 0.6.3__tar.gz → 0.6.4__tar.gz
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.
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/PKG-INFO +12 -6
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/README.md +11 -5
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/__init__.py +1 -1
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/md2doc_kit.py +540 -14
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/.gitignore +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/base_service.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/chat_service.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/exceptions.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/failover.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/model_builder.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/model_client.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/pool.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/profiles.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/rerank_service.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/runner.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/types.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/common_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/article_io.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/deepseek_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/audit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/base_db_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/crud.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/engine.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/query.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/session.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/file_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/laya_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/license_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/redis_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sdpy-kit
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.4
|
|
4
4
|
Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
|
|
5
5
|
Author-email: Clark <changhongyuan@126.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -98,7 +98,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
|
|
|
98
98
|
| `kit.docs.anydoc_kit` | `AnyDocKit` | docx / pdf / xlsx 等 → Markdown | `anydoc` |
|
|
99
99
|
| `kit.docs.docx_kit` | `DocxKit` | Word 模板动态填充(章节 / 列表 / 表格) | `docx` |
|
|
100
100
|
| `kit.docs.rc_docparser_kit` | `RcDocParserKit` | 离线 docx → Markdown + Images(无模型) | `docx-parse` |
|
|
101
|
-
| `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML | `md2doc` |
|
|
101
|
+
| `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML(含图片) | `md2doc` |
|
|
102
102
|
| `kit.docs.mineru_kit` | `MinerUKit` | MinerU 文档解析(精准 + Agent 轻量) | `mineru` |
|
|
103
103
|
| `kit.docs.marker_kit` | `MarkerKit` | 本地解析(PDF / 图片 → Markdown + Images) | `marker` |
|
|
104
104
|
| `kit.laya_kit` | `LayaKit` | 类型化决策:choice / score / noul,返回判断结果 + 置信度 | `laya` |
|
|
@@ -125,7 +125,7 @@ uv add sdpy-kit
|
|
|
125
125
|
| `sqliteai` | SQLiteKit 向量方法(numpy / sqliteai-vector,SQLite 向量扩展) | `pip install "sdpy-kit[sqliteai]"` |
|
|
126
126
|
| `redis` | RedisKit | `pip install "sdpy-kit[redis]"` |
|
|
127
127
|
| `ai` | AI 服务池(httpx + python-dotenv) | `pip install "sdpy-kit[ai]"` |
|
|
128
|
-
| `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2
|
|
128
|
+
| `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2,含图片) | `pip install "sdpy-kit[md2doc]"` |
|
|
129
129
|
| `anydoc` | AnyDocKit 文档转 Markdown(firecrawl-anydoc + pikepdf) | `pip install "sdpy-kit[anydoc]"` |
|
|
130
130
|
| `docx` | DocxKit 模板动态填充(docxtpl) | `pip install "sdpy-kit[docx]"` |
|
|
131
131
|
| `docx-parse` | RcDocParserKit 离线 docx 解析(rc-docparser → Markdown + images/) | `pip install "sdpy-kit[docx-parse]"` |
|
|
@@ -1076,12 +1076,18 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
|
|
|
1076
1076
|
|
|
1077
1077
|
| 方法 | 说明 |
|
|
1078
1078
|
|------|------|
|
|
1079
|
-
| `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word
|
|
1080
|
-
| `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5
|
|
1081
|
-
| `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF
|
|
1079
|
+
| `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
|
|
1080
|
+
| `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
|
|
1081
|
+
| `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
|
|
1082
1082
|
|
|
1083
1083
|
> `dest_path=None` 时输出到源文件同目录;同一源文件不支持并发转换。
|
|
1084
1084
|
|
|
1085
|
+
**图片**:`` 以 Markdown 源文件所在目录为基准解析,宽度等比限幅
|
|
1086
|
+
(96dpi 还原自然尺寸、不放大),超高先分页/按版心收紧;缺图、越界路径、非图片
|
|
1087
|
+
格式一律回落 `alt` 文本,不抛异常。远程地址在 PDF / Word 中不下载(回落 alt),
|
|
1088
|
+
在 HTML 中直出 `<img src="https://…">` 交给浏览器。HTML 输出保留相对路径,需与
|
|
1089
|
+
`images/` 目录保持同层级才能在浏览器中显示。
|
|
1090
|
+
|
|
1085
1091
|
---
|
|
1086
1092
|
|
|
1087
1093
|
## kit.docs.mineru_kit —— MinerU 文档解析
|
|
@@ -22,7 +22,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
|
|
|
22
22
|
| `kit.docs.anydoc_kit` | `AnyDocKit` | docx / pdf / xlsx 等 → Markdown | `anydoc` |
|
|
23
23
|
| `kit.docs.docx_kit` | `DocxKit` | Word 模板动态填充(章节 / 列表 / 表格) | `docx` |
|
|
24
24
|
| `kit.docs.rc_docparser_kit` | `RcDocParserKit` | 离线 docx → Markdown + Images(无模型) | `docx-parse` |
|
|
25
|
-
| `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML | `md2doc` |
|
|
25
|
+
| `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML(含图片) | `md2doc` |
|
|
26
26
|
| `kit.docs.mineru_kit` | `MinerUKit` | MinerU 文档解析(精准 + Agent 轻量) | `mineru` |
|
|
27
27
|
| `kit.docs.marker_kit` | `MarkerKit` | 本地解析(PDF / 图片 → Markdown + Images) | `marker` |
|
|
28
28
|
| `kit.laya_kit` | `LayaKit` | 类型化决策:choice / score / noul,返回判断结果 + 置信度 | `laya` |
|
|
@@ -49,7 +49,7 @@ uv add sdpy-kit
|
|
|
49
49
|
| `sqliteai` | SQLiteKit 向量方法(numpy / sqliteai-vector,SQLite 向量扩展) | `pip install "sdpy-kit[sqliteai]"` |
|
|
50
50
|
| `redis` | RedisKit | `pip install "sdpy-kit[redis]"` |
|
|
51
51
|
| `ai` | AI 服务池(httpx + python-dotenv) | `pip install "sdpy-kit[ai]"` |
|
|
52
|
-
| `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2
|
|
52
|
+
| `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2,含图片) | `pip install "sdpy-kit[md2doc]"` |
|
|
53
53
|
| `anydoc` | AnyDocKit 文档转 Markdown(firecrawl-anydoc + pikepdf) | `pip install "sdpy-kit[anydoc]"` |
|
|
54
54
|
| `docx` | DocxKit 模板动态填充(docxtpl) | `pip install "sdpy-kit[docx]"` |
|
|
55
55
|
| `docx-parse` | RcDocParserKit 离线 docx 解析(rc-docparser → Markdown + images/) | `pip install "sdpy-kit[docx-parse]"` |
|
|
@@ -1000,12 +1000,18 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
|
|
|
1000
1000
|
|
|
1001
1001
|
| 方法 | 说明 |
|
|
1002
1002
|
|------|------|
|
|
1003
|
-
| `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word
|
|
1004
|
-
| `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5
|
|
1005
|
-
| `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF
|
|
1003
|
+
| `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
|
|
1004
|
+
| `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
|
|
1005
|
+
| `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
|
|
1006
1006
|
|
|
1007
1007
|
> `dest_path=None` 时输出到源文件同目录;同一源文件不支持并发转换。
|
|
1008
1008
|
|
|
1009
|
+
**图片**:`` 以 Markdown 源文件所在目录为基准解析,宽度等比限幅
|
|
1010
|
+
(96dpi 还原自然尺寸、不放大),超高先分页/按版心收紧;缺图、越界路径、非图片
|
|
1011
|
+
格式一律回落 `alt` 文本,不抛异常。远程地址在 PDF / Word 中不下载(回落 alt),
|
|
1012
|
+
在 HTML 中直出 `<img src="https://…">` 交给浏览器。HTML 输出保留相对路径,需与
|
|
1013
|
+
`images/` 目录保持同层级才能在浏览器中显示。
|
|
1014
|
+
|
|
1009
1015
|
---
|
|
1010
1016
|
|
|
1011
1017
|
## kit.docs.mineru_kit —— MinerU 文档解析
|
|
@@ -26,6 +26,9 @@ Author: Clark Chang
|
|
|
26
26
|
- 全部静态方法、无内部状态;PiMD 转换引擎为模块级单例(自带 MemoryCache)。
|
|
27
27
|
- Word 走 PiMD 引擎 + 表格撑满/全局字体后处理;PDF 与 HTML 同源解析
|
|
28
28
|
(markdown-it),PDF 由内置 fpdf2 渲染器生成,观感向 HTML 对齐。
|
|
29
|
+
- 图片:PDF 由 fpdf2 直接绘制;Word 用占位标记 + python-docx 回填真实图片;
|
|
30
|
+
HTML 用占位标记 + 后处理输出 `<figure><img>`。三者均以 Markdown 源文件
|
|
31
|
+
所在目录为相对路径基准,缺图/越界/不支持格式回落 alt 文本(不抛异常)。
|
|
29
32
|
- 转换前统一改写 CJK 加粗定界符:CommonMark 定界规则下 `**加粗:**后接`
|
|
30
33
|
会把星号作为字面量漏进产物。
|
|
31
34
|
|
|
@@ -33,16 +36,24 @@ Author: Clark Chang
|
|
|
33
36
|
- 输入必须是 `.md` 文件路径;转换期间使用固定名临时文件
|
|
34
37
|
(`.{源文件名}.pimd-tmp.md`),**同一源文件的并发调用不受支持**。
|
|
35
38
|
- 代码块无背景填充等与 HTML 的已知差异,见对应方法说明。
|
|
39
|
+
- 图片仅支持本地相对路径(`images/a.png` 形式);远程 URL 在 PDF/Word
|
|
40
|
+
中回落为 alt 文本(不取网),在 HTML 中直接输出 `<img src>` 交由浏览器。
|
|
36
41
|
|
|
37
42
|
依赖:
|
|
38
43
|
- `pimd` + `markdown-it-py` + `fpdf2`(extra `md2doc`);
|
|
39
44
|
未安装时导入报错并提示安装命令。
|
|
45
|
+
- 图片能力另需 Pillow(`fpdf2` 硬依赖)与 python-docx(`pimd` 硬依赖),
|
|
46
|
+
均由上游保证,不单独声明;缺失时按 ImportError 降级(回落 0.6.3 行为)。
|
|
40
47
|
"""
|
|
41
48
|
|
|
49
|
+
import importlib.util
|
|
42
50
|
import os
|
|
43
51
|
import re
|
|
44
52
|
import zipfile
|
|
53
|
+
from html import escape as _html_escape
|
|
45
54
|
from pathlib import Path
|
|
55
|
+
from typing import Any
|
|
56
|
+
from urllib.parse import unquote, urlsplit
|
|
46
57
|
|
|
47
58
|
try:
|
|
48
59
|
from fpdf import FPDF
|
|
@@ -214,6 +225,10 @@ img {
|
|
|
214
225
|
max-width: 100%;
|
|
215
226
|
height: auto;
|
|
216
227
|
}
|
|
228
|
+
figure.md2doc-img {
|
|
229
|
+
margin: 1em 0;
|
|
230
|
+
text-align: center;
|
|
231
|
+
}
|
|
217
232
|
@media (max-width: 720px) {
|
|
218
233
|
body {
|
|
219
234
|
padding: 0.5em 0.8em 2em;
|
|
@@ -363,6 +378,163 @@ def _polish_docx_font(docx_path: Path, font_name: str) -> None:
|
|
|
363
378
|
tmp.replace(docx_path)
|
|
364
379
|
|
|
365
380
|
|
|
381
|
+
# 图片占位标记(_extract_images 可能改用备用前缀):@@MD2DOC[X]-IMG-{n}@@
|
|
382
|
+
_DOCX_IMG_MARK_RE = re.compile(r"@@MD2DOCX?-IMG-(\d+)@@")
|
|
383
|
+
# EMU ↔ mm:1mm = 36000 EMU(914400 EMU/inch ÷ 25.4)
|
|
384
|
+
_EMU_PER_MM = 36000.0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _replace_marker_text(para: Any, marker: str, replacement: str) -> bool:
|
|
388
|
+
"""把段落中的占位标记文本替换为 replacement(按 `w:t` 节点逐个替换)
|
|
389
|
+
|
|
390
|
+
直接遍历段落 XML 内的 `<w:t>` 文本节点,而非 `para.runs`:后者不包含
|
|
391
|
+
`<w:hyperlink>` 内的 run,`[](链接)` 这类链接包裹图片的标记
|
|
392
|
+
会残留在超链接里(python-docx 的既有行为)。
|
|
393
|
+
|
|
394
|
+
标记被拆进多个文本节点(PiMD 极少出现)时退化为整段纯文本重建,字符级
|
|
395
|
+
样式会丢失并记 warning。
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
para: python-docx 段落对象
|
|
399
|
+
marker: 占位标记文本
|
|
400
|
+
replacement: 替换文本(空串表示删除标记)
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
bool: 是否找到该标记并替换成功
|
|
404
|
+
"""
|
|
405
|
+
for node in para._p.iter():
|
|
406
|
+
if not node.tag.endswith("}t"):
|
|
407
|
+
continue
|
|
408
|
+
if node.text and marker in node.text:
|
|
409
|
+
node.text = node.text.replace(marker, replacement)
|
|
410
|
+
return True
|
|
411
|
+
if marker not in (para.text or ""):
|
|
412
|
+
return False
|
|
413
|
+
remaining = para.text.replace(marker, replacement)
|
|
414
|
+
for node in para._p.iter():
|
|
415
|
+
if node.tag.endswith("}t"):
|
|
416
|
+
node.text = ""
|
|
417
|
+
if remaining:
|
|
418
|
+
para.add_run(remaining)
|
|
419
|
+
logger.warning("图片占位标记跨节点拆分,已按纯文本重建段落")
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _drop_paragraph(para: Any) -> None:
|
|
424
|
+
"""从文档树中移除段落(图片标记段清理后为空时使用)"""
|
|
425
|
+
element = para._p
|
|
426
|
+
element.getparent().remove(element)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _docx_available() -> bool:
|
|
430
|
+
"""python-docx 是否可导入(pimd 硬依赖;缺失时 Word 图片回填整体降级)
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
bool: 可用返回 True;不可用时 Word 输出保持 0.6.3 的 alt 超链接行为
|
|
434
|
+
"""
|
|
435
|
+
try:
|
|
436
|
+
return importlib.util.find_spec("docx") is not None
|
|
437
|
+
except (ImportError, ValueError):
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _inject_docx_images(
|
|
442
|
+
docx_path: Path, refs: list[tuple[str, str]], base_dir: Path
|
|
443
|
+
) -> None:
|
|
444
|
+
"""把正文中的图片占位标记替换为真实图片段落(python-docx 后处理)
|
|
445
|
+
|
|
446
|
+
PiMD(markdown-it)会把行内图片降级为「alt 文本 + 指向 src 的超链接」,
|
|
447
|
+
docx 内没有 `word/media/`。本函数按占位标记回填:
|
|
448
|
+
|
|
449
|
+
- 标记所在段落中的标记文本删除,全部处理完且段落为空则整段移除;
|
|
450
|
+
- 标记段之后依次插入居中的图片段落(同段多图保持源顺序),宽高按节
|
|
451
|
+
内容区等比限幅(96dpi 基准);
|
|
452
|
+
- 图片不可用(缺失 / 越界 / python-docx 不支持的格式如 webp)→ 标记替换
|
|
453
|
+
为 alt 文本并 warning,不抛异常(保持导出可用)。
|
|
454
|
+
|
|
455
|
+
必须在 `_polish_table_layout` / `_polish_docx_font` 之前调用:两次后处理
|
|
456
|
+
直接改写 docx 包内 XML,先插图片再 polish 才能保证二者互不覆盖。
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
docx_path: 待回填的 docx 路径(原地修改)
|
|
460
|
+
refs: `_extract_images` 返回的 (src, alt) 序列
|
|
461
|
+
base_dir: 相对路径基准目录(Markdown 源文件所在目录)
|
|
462
|
+
"""
|
|
463
|
+
try:
|
|
464
|
+
from docx import Document
|
|
465
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
466
|
+
from docx.image.image import Image as DocxImage
|
|
467
|
+
from docx.shared import Mm
|
|
468
|
+
except ImportError as e: # python-docx 由 pimd 硬依赖提供,理论不可达
|
|
469
|
+
logger.warning(f"python-docx 不可用,跳过 Word 图片回填: {e}")
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
doc = Document(str(docx_path))
|
|
473
|
+
if not doc.sections:
|
|
474
|
+
return
|
|
475
|
+
section = doc.sections[0]
|
|
476
|
+
# Length 是 int 子类,减法会丢 .mm 属性,故按 EMU 换算
|
|
477
|
+
content_w = (
|
|
478
|
+
int(section.page_width) - int(section.left_margin) - int(section.right_margin)
|
|
479
|
+
) / _EMU_PER_MM
|
|
480
|
+
content_h = (
|
|
481
|
+
int(section.page_height) - int(section.top_margin) - int(section.bottom_margin)
|
|
482
|
+
) / _EMU_PER_MM
|
|
483
|
+
|
|
484
|
+
changed = False
|
|
485
|
+
for para in list(doc.paragraphs):
|
|
486
|
+
if not _DOCX_IMG_MARK_RE.search(para.text or ""):
|
|
487
|
+
continue
|
|
488
|
+
changed = True
|
|
489
|
+
# 同段多张图(` `)逐个处理:锚点跟随新插入的段落,
|
|
490
|
+
# 保证图片顺序与源文档一致(若固定插在标记段之后顺序会反转)
|
|
491
|
+
anchor = para._p
|
|
492
|
+
while True:
|
|
493
|
+
match = _DOCX_IMG_MARK_RE.search(para.text or "")
|
|
494
|
+
if match is None:
|
|
495
|
+
break
|
|
496
|
+
marker = match.group(0)
|
|
497
|
+
idx = int(match.group(1))
|
|
498
|
+
src, alt = refs[idx] if idx < len(refs) else ("", "")
|
|
499
|
+
|
|
500
|
+
path = _resolve_local_image(src, base_dir)
|
|
501
|
+
fitted: tuple[float, float] | None = None
|
|
502
|
+
if path is not None:
|
|
503
|
+
try:
|
|
504
|
+
info = DocxImage.from_file(str(path))
|
|
505
|
+
fitted = _fit_size(
|
|
506
|
+
info.px_width, info.px_height, content_w, content_h
|
|
507
|
+
)
|
|
508
|
+
except Exception as e: # noqa: BLE001 - 格式不支持统一回落
|
|
509
|
+
logger.warning(f"Word 图片不可用,保留 alt 文本: {src}: {e}")
|
|
510
|
+
fitted = None
|
|
511
|
+
if fitted is None:
|
|
512
|
+
# 不可用:标记换成 alt 文本;替换失败须跳出,避免死循环
|
|
513
|
+
if not _replace_marker_text(para, marker, alt):
|
|
514
|
+
logger.warning(f"Word 图片标记无法清除,跳过: {marker}")
|
|
515
|
+
break
|
|
516
|
+
continue
|
|
517
|
+
|
|
518
|
+
width, _height = fitted
|
|
519
|
+
if not _replace_marker_text(para, marker, ""):
|
|
520
|
+
logger.warning(f"Word 图片标记无法清除,跳过: {marker}")
|
|
521
|
+
break
|
|
522
|
+
new_para = doc.add_paragraph()
|
|
523
|
+
# 先挪到锚点之后,再(必要时)删除空标记段,顺序反了会失去锚点
|
|
524
|
+
anchor.addnext(new_para._p)
|
|
525
|
+
anchor = new_para._p
|
|
526
|
+
new_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
527
|
+
new_para.paragraph_format.space_before = 0
|
|
528
|
+
new_para.paragraph_format.space_after = 0
|
|
529
|
+
new_para.add_run().add_picture(str(path), width=Mm(width))
|
|
530
|
+
# 全部标记处理完再判断:段落已空则删除(图片段落已插好,锚点安全)
|
|
531
|
+
if not (para.text or "").strip():
|
|
532
|
+
_drop_paragraph(para)
|
|
533
|
+
|
|
534
|
+
if changed:
|
|
535
|
+
doc.save(str(docx_path))
|
|
536
|
+
|
|
537
|
+
|
|
366
538
|
# 中文字体表(单一数据源):族名 / Windows 字体文件名 / 别名(小写)
|
|
367
539
|
_CN_FONT_TABLE: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|
368
540
|
("SimSun", "simsun.ttc", ("simsun", "宋体")),
|
|
@@ -428,6 +600,227 @@ def _fpdf_font_key(mainfont: str) -> tuple[str | None, str | None]:
|
|
|
428
600
|
return (None, None)
|
|
429
601
|
|
|
430
602
|
|
|
603
|
+
# ---- 图片能力公共助手(PDF / Word / HTML 三格式共用)----
|
|
604
|
+
|
|
605
|
+
# 支持的图片后缀(Pillow 可读;SVG 不在此列,避免内嵌位图与外链面)
|
|
606
|
+
_IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"})
|
|
607
|
+
# 图片自然尺寸统一按 96dpi 换算:fpdf2 仅在给定 w 时按 horz_dpi 反推高度,
|
|
608
|
+
# 与像素比、vert_dpi 均可能不一致;三格式统一基准并显式给出宽高可消除歧义。
|
|
609
|
+
_IMAGE_DPI = 96.0
|
|
610
|
+
_IMAGE_MM_PER_PX = 25.4 / _IMAGE_DPI
|
|
611
|
+
# Markdown 图片语法:alt 内不含 ],src 内不含括号与空白,可选 "title"
|
|
612
|
+
_MD_IMAGE_RE = re.compile(
|
|
613
|
+
r'!\[(?P<alt>[^\]\n]*)\]\((?P<src>[^()\s]*)(?:\s+"(?P<title>[^"\n]*)")?\)'
|
|
614
|
+
)
|
|
615
|
+
# Word/HTML 回填用的占位标记前缀(与原文撞车时改用备用前缀)
|
|
616
|
+
_MD_IMG_MARK = "@@MD2DOC-IMG-"
|
|
617
|
+
_MD_IMG_MARK_ALT = "@@MD2DOCX-IMG-"
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _resolve_local_image(src: str | None, base_dir: Path | None) -> Path | None:
|
|
621
|
+
"""把 Markdown 图片 src 解析为 base_dir 子树内的本地文件路径
|
|
622
|
+
|
|
623
|
+
仅接受本地相对路径:拒绝带 scheme 的地址(`http(s)://`、`data:`、
|
|
624
|
+
`file://`)、协议相对(`//host/x.png`)以及越出 base_dir 的路径
|
|
625
|
+
(`../`),后缀须在 `_IMAGE_SUFFIXES` 白名单内。任何不可用情况返回
|
|
626
|
+
None(调用方回落 alt 文本),不抛异常。
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
src: Markdown 图片语法中的目标地址
|
|
630
|
+
base_dir: 相对路径基准目录(通常为 Markdown 源文件所在目录)
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
Path | None: 可读取的本地图片绝对路径;不可用返回 None
|
|
634
|
+
"""
|
|
635
|
+
if not src or base_dir is None:
|
|
636
|
+
return None
|
|
637
|
+
raw = unquote(src.strip().strip("<>"))
|
|
638
|
+
# 剥离 query / fragment 后再判本地性(`images/a.png?t=1` 仍算本地)
|
|
639
|
+
raw = raw.split("#", 1)[0].split("?", 1)[0]
|
|
640
|
+
if not raw:
|
|
641
|
+
return None
|
|
642
|
+
parts = urlsplit(raw)
|
|
643
|
+
if parts.scheme or parts.netloc or raw.startswith("//"):
|
|
644
|
+
return None
|
|
645
|
+
try:
|
|
646
|
+
root = Path(base_dir).resolve()
|
|
647
|
+
path = (root / raw).resolve()
|
|
648
|
+
except OSError:
|
|
649
|
+
return None
|
|
650
|
+
if path.suffix.lower() not in _IMAGE_SUFFIXES:
|
|
651
|
+
return None
|
|
652
|
+
if not path.is_relative_to(root) or not path.is_file():
|
|
653
|
+
return None
|
|
654
|
+
return path
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _fit_size(
|
|
658
|
+
px_w: int, px_h: int, max_w: float, max_h: float
|
|
659
|
+
) -> tuple[float, float] | None:
|
|
660
|
+
"""按 96dpi 把像素尺寸换算为 mm,并用单一缩放因子同时满足宽高上限
|
|
661
|
+
|
|
662
|
+
宽高同因子保证等比;不放大(scale ≤ 1)。宽高任一非正或上限非正时
|
|
663
|
+
返回 None(调用方回落 alt 文本)。
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
px_w: 图片像素宽
|
|
667
|
+
px_h: 图片像素高
|
|
668
|
+
max_w: 可用宽度上限(mm)
|
|
669
|
+
max_h: 可用高度上限(mm)
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
tuple[float, float] | None: (宽 mm, 高 mm);参数非法返回 None
|
|
673
|
+
"""
|
|
674
|
+
if px_w <= 0 or px_h <= 0 or max_w <= 0 or max_h <= 0:
|
|
675
|
+
return None
|
|
676
|
+
nat_w = px_w * _IMAGE_MM_PER_PX
|
|
677
|
+
nat_h = px_h * _IMAGE_MM_PER_PX
|
|
678
|
+
scale = min(1.0, max_w / nat_w, max_h / nat_h)
|
|
679
|
+
return nat_w * scale, nat_h * scale
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _extract_images(markdown: str) -> tuple[str, list[tuple[str, str]]]:
|
|
683
|
+
"""把 Markdown 图片语法替换为占位标记,供 Word / HTML 后处理回填
|
|
684
|
+
|
|
685
|
+
返回 (改写后的 Markdown, [(src, alt), ...]),第 i 个标记为
|
|
686
|
+
`@@MD2DOC-IMG-{i}@@`(与原文撞车时改用 `@@MD2DOCX-IMG-{i}@@`)。
|
|
687
|
+
|
|
688
|
+
与 `_normalize_cjk_bold` 同款状态机:围栏代码块、行内代码、GFM 表格块内
|
|
689
|
+
一律不改写——代码示例中的 `` 必须原样保留;表格单元格在 0.6.3
|
|
690
|
+
中本就不解析行内 Markdown(图片语法以字面文本出现),本期维持该行为。
|
|
691
|
+
alt 内含 `]` 等不符合常规写法的图片语法不匹配,保持原样(即 0.6.3 的
|
|
692
|
+
alt 超链接降级行为),不做兜底改写。
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
markdown: 已完成 CJK 加粗预处理的 Markdown 文本
|
|
696
|
+
|
|
697
|
+
Returns:
|
|
698
|
+
tuple[str, list[tuple[str, str]]]: (改写后文本, [(src, alt), ...])
|
|
699
|
+
"""
|
|
700
|
+
if "![" not in markdown:
|
|
701
|
+
return markdown, []
|
|
702
|
+
|
|
703
|
+
prefix = _MD_IMG_MARK
|
|
704
|
+
if prefix in markdown:
|
|
705
|
+
prefix = _MD_IMG_MARK_ALT
|
|
706
|
+
if prefix in markdown:
|
|
707
|
+
logger.warning(
|
|
708
|
+
"Markdown 已含图片占位标记前缀,跳过图片回填: "
|
|
709
|
+
f"{_MD_IMG_MARK!r} / {_MD_IMG_MARK_ALT!r}"
|
|
710
|
+
)
|
|
711
|
+
return markdown, []
|
|
712
|
+
|
|
713
|
+
refs: list[tuple[str, str]] = []
|
|
714
|
+
out_lines: list[str] = []
|
|
715
|
+
lines = markdown.splitlines(keepends=True)
|
|
716
|
+
in_fence = False
|
|
717
|
+
fence_marker = ""
|
|
718
|
+
in_table = False
|
|
719
|
+
for idx, line in enumerate(lines):
|
|
720
|
+
stripped = line.lstrip()
|
|
721
|
+
if not in_fence and (stripped.startswith("```") or stripped.startswith("~~~")):
|
|
722
|
+
in_fence = True
|
|
723
|
+
fence_marker = stripped[:3]
|
|
724
|
+
in_table = False
|
|
725
|
+
out_lines.append(line)
|
|
726
|
+
continue
|
|
727
|
+
if in_fence:
|
|
728
|
+
if stripped.startswith(fence_marker):
|
|
729
|
+
in_fence = False
|
|
730
|
+
out_lines.append(line)
|
|
731
|
+
continue
|
|
732
|
+
if in_table:
|
|
733
|
+
is_table = _is_table_row(line)
|
|
734
|
+
else:
|
|
735
|
+
is_table = (
|
|
736
|
+
_is_table_row(line)
|
|
737
|
+
and idx + 1 < len(lines)
|
|
738
|
+
and _is_table_delim(lines[idx + 1])
|
|
739
|
+
)
|
|
740
|
+
in_table = is_table
|
|
741
|
+
if is_table:
|
|
742
|
+
out_lines.append(line)
|
|
743
|
+
continue
|
|
744
|
+
# 行内代码段(1~3 个反引号包裹)保护:不做改写
|
|
745
|
+
segments = re.split(r"(`+[^`]*`+)", line)
|
|
746
|
+
pieces: list[str] = []
|
|
747
|
+
for seg in segments:
|
|
748
|
+
if seg.startswith("`"):
|
|
749
|
+
pieces.append(seg)
|
|
750
|
+
continue
|
|
751
|
+
|
|
752
|
+
def _sub(match: re.Match[str]) -> str:
|
|
753
|
+
refs.append((match.group("src"), match.group("alt")))
|
|
754
|
+
return f"{prefix}{len(refs) - 1}@@"
|
|
755
|
+
|
|
756
|
+
pieces.append(_MD_IMAGE_RE.sub(_sub, seg))
|
|
757
|
+
out_lines.append("".join(pieces))
|
|
758
|
+
return "".join(out_lines), refs
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _html_img_tag(src: str, alt: str) -> str:
|
|
762
|
+
"""构造 `<img>` 标签:路径反斜杠归一为 `/`,src/alt 均做 HTML 转义"""
|
|
763
|
+
safe_src = _html_escape(src.replace("\\", "/"))
|
|
764
|
+
safe_alt = _html_escape(alt)
|
|
765
|
+
return f'<img src="{safe_src}" alt="{safe_alt}">'
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _inject_html_images(
|
|
769
|
+
html: str, refs: list[tuple[str, str]], base_dir: Path
|
|
770
|
+
) -> str:
|
|
771
|
+
"""把 HTML 中的图片占位标记替换为 `<figure><img>`(HTML 后处理)
|
|
772
|
+
|
|
773
|
+
PiMD(markdown-it)会把行内图片降级为 `<a href="src">alt</a>`,本函数按
|
|
774
|
+
占位标记重建图片:
|
|
775
|
+
|
|
776
|
+
- 独立成段的标记 → `<figure class="md2doc-img"><img …></figure>`,整体
|
|
777
|
+
替换 `<p>标记</p>`,避免出现 `<p><figure>` 非法嵌套;
|
|
778
|
+
- 段内混排(含 `[](url)` 的链接内图片)→ 原位替换为 `<img>`;
|
|
779
|
+
- 本地图片不可用(缺失/越界/非白名单)→ 回落纯 alt 文本,不输出破图;
|
|
780
|
+
- 远程地址 → 输出 `<img src="https://…">` 交浏览器加载(服务端不取网)。
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
html: PiMD 转换后的完整 HTML 文本
|
|
784
|
+
refs: `_extract_images` 返回的 (src, alt) 序列
|
|
785
|
+
base_dir: 相对路径基准目录(Markdown 源文件所在目录)
|
|
786
|
+
|
|
787
|
+
Returns:
|
|
788
|
+
str: 替换后的 HTML 文本
|
|
789
|
+
"""
|
|
790
|
+
for idx, (src, alt) in enumerate(refs):
|
|
791
|
+
marker = f"{_MD_IMG_MARK}{idx}@@"
|
|
792
|
+
if marker not in html:
|
|
793
|
+
marker = f"{_MD_IMG_MARK_ALT}{idx}@@"
|
|
794
|
+
if marker not in html:
|
|
795
|
+
continue
|
|
796
|
+
path = _resolve_local_image(src, base_dir)
|
|
797
|
+
parts = urlsplit(unquote(src.strip().strip("<>")))
|
|
798
|
+
is_remote = bool(parts.scheme or parts.netloc) or src.strip().startswith(
|
|
799
|
+
"//"
|
|
800
|
+
)
|
|
801
|
+
if path is not None:
|
|
802
|
+
rel = path.relative_to(Path(base_dir).resolve()).as_posix()
|
|
803
|
+
figure = (
|
|
804
|
+
f'<figure class="md2doc-img">{_html_img_tag(rel, alt)}</figure>'
|
|
805
|
+
)
|
|
806
|
+
inline = _html_img_tag(rel, alt)
|
|
807
|
+
elif is_remote:
|
|
808
|
+
figure = (
|
|
809
|
+
f'<figure class="md2doc-img">'
|
|
810
|
+
f"{_html_img_tag(src.strip(), alt)}</figure>"
|
|
811
|
+
)
|
|
812
|
+
inline = _html_img_tag(src.strip(), alt)
|
|
813
|
+
else:
|
|
814
|
+
figure = f"<p>{_html_escape(alt)}</p>" if alt else ""
|
|
815
|
+
inline = _html_escape(alt)
|
|
816
|
+
# 独立成段先整体替换,剩余(段内/链接内)标记再原位替换
|
|
817
|
+
html = re.sub(
|
|
818
|
+
r"<p>\s*" + re.escape(marker) + r"\s*</p>", lambda _m: figure, html
|
|
819
|
+
)
|
|
820
|
+
html = html.replace(marker, inline)
|
|
821
|
+
return html
|
|
822
|
+
|
|
823
|
+
|
|
431
824
|
class _PdfMarkdownRenderer:
|
|
432
825
|
"""markdown-it token 流 → fpdf2 渲染器(与 convert_md_to_html 同源解析)
|
|
433
826
|
|
|
@@ -440,7 +833,11 @@ class _PdfMarkdownRenderer:
|
|
|
440
833
|
行推进模型:fpdf2 的 write 结束后光标停在最后一行顶部(当前行高度
|
|
441
834
|
未消费),凡经 write 渲染的块收尾必须 ln(整行高 + 块间隙),否则
|
|
442
835
|
相邻文本行纵向重叠;multi_cell 系块(表格/代码块)收尾 y 已在块
|
|
443
|
-
底部,其后 ln
|
|
836
|
+
底部,其后 ln 仅为纯块间距。图片按块级独占一行绘制(等比限宽居中),
|
|
837
|
+
块尾自行 ln(高度 + 间隙),其所在段落不再按文本行推进。
|
|
838
|
+
|
|
839
|
+
图片仅渲染本地相对路径(基准为本 Markdown 源文件目录);缺图、越界、
|
|
840
|
+
非白名单格式、远程地址一律回落为 alt 文本,保证导出可用。
|
|
444
841
|
"""
|
|
445
842
|
|
|
446
843
|
_BODY_SIZE = 11
|
|
@@ -458,10 +855,18 @@ class _PdfMarkdownRenderer:
|
|
|
458
855
|
# 手动排版(表格/代码块)以同一阈值判断分页,避免越界
|
|
459
856
|
_BOTTOM_LIMIT = 20
|
|
460
857
|
|
|
461
|
-
def __init__(
|
|
858
|
+
def __init__(
|
|
859
|
+
self,
|
|
860
|
+
pdf: FPDF,
|
|
861
|
+
font_key: str | None,
|
|
862
|
+
has_cjk_font: bool,
|
|
863
|
+
base_dir: Path | None = None,
|
|
864
|
+
) -> None:
|
|
462
865
|
self.pdf = pdf
|
|
463
866
|
self.font_key = font_key
|
|
464
867
|
self.has_cjk_font = has_cjk_font
|
|
868
|
+
# 图片相对路径基准目录;None 表示不渲染图片(等价 0.6.3 行为)
|
|
869
|
+
self.base_dir = base_dir
|
|
465
870
|
self._bold = False
|
|
466
871
|
self._italic = False
|
|
467
872
|
self._href: str | None = None
|
|
@@ -555,16 +960,33 @@ class _PdfMarkdownRenderer:
|
|
|
555
960
|
pdf.write(h, label, link=self._href or "")
|
|
556
961
|
pdf.set_text_color(*self._text_color)
|
|
557
962
|
|
|
558
|
-
def _render_inline(self, children: list[Token], size: int) ->
|
|
963
|
+
def _render_inline(self, children: list[Token], size: int) -> bool:
|
|
964
|
+
"""渲染行内 token 流;返回「末个有效 token 是否为块级图片」
|
|
965
|
+
|
|
966
|
+
返回值供段落收尾判断:图片块已自行推进整行高,段落若再按文本行推进
|
|
967
|
+
会多出约一个行高的空白;行内混排(文字 + 图片 + 文字)仍返回 False。
|
|
968
|
+
|
|
969
|
+
Args:
|
|
970
|
+
children: 行内 token 序列(paragraph/heading/list_item 内的 children)
|
|
971
|
+
size: 当前字号(pt)
|
|
972
|
+
|
|
973
|
+
Returns:
|
|
974
|
+
bool: 末个有效 token 是否为已绘制成功的块级图片
|
|
975
|
+
"""
|
|
976
|
+
last_image = False
|
|
559
977
|
for tok in children:
|
|
560
978
|
t = tok.type
|
|
561
979
|
if t == "text":
|
|
562
980
|
self._flow_run(tok.content, size)
|
|
981
|
+
if tok.content.strip():
|
|
982
|
+
last_image = False
|
|
563
983
|
elif t == "softbreak":
|
|
564
984
|
self._write_run(" ", size)
|
|
985
|
+
last_image = False
|
|
565
986
|
elif t == "hardbreak":
|
|
566
987
|
self.pdf.ln(self._line_h(size))
|
|
567
988
|
self.pdf.set_x(self.pdf.l_margin)
|
|
989
|
+
last_image = False
|
|
568
990
|
elif t == "strong_open":
|
|
569
991
|
self._bold = True
|
|
570
992
|
elif t == "strong_close":
|
|
@@ -579,8 +1001,65 @@ class _PdfMarkdownRenderer:
|
|
|
579
1001
|
self._href = None
|
|
580
1002
|
elif t == "code_inline":
|
|
581
1003
|
self._render_code_span(tok.content, size)
|
|
1004
|
+
last_image = False
|
|
582
1005
|
elif t == "image":
|
|
583
|
-
self.
|
|
1006
|
+
last_image = self._render_image(tok, size)
|
|
1007
|
+
return last_image
|
|
1008
|
+
|
|
1009
|
+
def _render_image(self, tok: Token, size: int) -> bool:
|
|
1010
|
+
"""块级图片:按正文可用宽限宽、等比居中绘制;不可用时回落 alt 文本
|
|
1011
|
+
|
|
1012
|
+
图片独占一行(与 `_render_code_span` 的手动排版模型一致):当前行已有
|
|
1013
|
+
内容先换行,剩余空间不足先分页,绘制后 `ln(高度 + 间隙)` 推进整行并
|
|
1014
|
+
把光标复位到当前左边距(列表/引用块内自动跟随缩进)。
|
|
1015
|
+
|
|
1016
|
+
宽高同时显式传给 fpdf2(`w`/`h`),不使用「只给 w 由 fpdf2 反推高度」
|
|
1017
|
+
的方式——后者按 `horz_dpi` 换算,与像素比/`vert_dpi` 均可能不一致。
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
tok: markdown-it 的 image token(`src` 属性 + alt 作为 content)
|
|
1021
|
+
size: 当前字号(pt),用于换行与回落文本排版
|
|
1022
|
+
|
|
1023
|
+
Returns:
|
|
1024
|
+
bool: 是否成功绘制图片(False 表示已回落为 alt 文本)
|
|
1025
|
+
"""
|
|
1026
|
+
alt = tok.content or ""
|
|
1027
|
+
path = _resolve_local_image(tok.attrGet("src"), self.base_dir)
|
|
1028
|
+
if path is None:
|
|
1029
|
+
self._flow_run(alt, size)
|
|
1030
|
+
return False
|
|
1031
|
+
try:
|
|
1032
|
+
# 图片尺寸读取:Pillow 由 fpdf2 硬依赖提供,缺失时按不可用处理
|
|
1033
|
+
from PIL import Image
|
|
1034
|
+
|
|
1035
|
+
with Image.open(path) as image:
|
|
1036
|
+
px_w, px_h = image.size
|
|
1037
|
+
except Exception as e: # noqa: BLE001 - 缺 Pillow / 坏图统一回落
|
|
1038
|
+
logger.warning(f"图片不可读,回落为 alt 文本: {path.name}: {e}")
|
|
1039
|
+
self._flow_run(alt, size)
|
|
1040
|
+
return False
|
|
1041
|
+
|
|
1042
|
+
pdf = self.pdf
|
|
1043
|
+
avail_w = pdf.w - pdf.l_margin - pdf.r_margin
|
|
1044
|
+
max_h = pdf.h - pdf.t_margin - self._BOTTOM_LIMIT - 2
|
|
1045
|
+
fitted = _fit_size(px_w, px_h, avail_w, max_h)
|
|
1046
|
+
if fitted is None:
|
|
1047
|
+
self._flow_run(alt, size)
|
|
1048
|
+
return False
|
|
1049
|
+
width, height = fitted
|
|
1050
|
+
|
|
1051
|
+
if pdf.get_x() > pdf.l_margin:
|
|
1052
|
+
pdf.ln(self._line_h(size))
|
|
1053
|
+
self._ensure_space(height + 3)
|
|
1054
|
+
x = pdf.l_margin + (avail_w - width) / 2
|
|
1055
|
+
try:
|
|
1056
|
+
pdf.image(str(path), x=x, y=pdf.get_y(), w=width, h=height)
|
|
1057
|
+
except Exception as e: # noqa: BLE001 - 解码/格式异常统一回落
|
|
1058
|
+
logger.warning(f"图片渲染失败,回落为 alt 文本: {path.name}: {e}")
|
|
1059
|
+
self._flow_run(alt, size)
|
|
1060
|
+
return False
|
|
1061
|
+
pdf.ln(height + 3)
|
|
1062
|
+
return True
|
|
584
1063
|
|
|
585
1064
|
# ---- 块级渲染 ----
|
|
586
1065
|
|
|
@@ -599,10 +1078,14 @@ class _PdfMarkdownRenderer:
|
|
|
599
1078
|
return i
|
|
600
1079
|
if t == "paragraph_open":
|
|
601
1080
|
self._ensure_space(self._line_h(self._BODY_SIZE) * 2)
|
|
602
|
-
self._render_inline(
|
|
1081
|
+
ended_with_image = self._render_inline(
|
|
1082
|
+
tokens[i + 1].children or [], self._BODY_SIZE
|
|
1083
|
+
)
|
|
603
1084
|
# fpdf2 的 write 结束后光标停在最后一行顶部(未消费行高),
|
|
604
|
-
#
|
|
605
|
-
|
|
1085
|
+
# 须推进整行高结束该行,再加段间隙;只推进小间隙会与后文重叠。
|
|
1086
|
+
# 图片块已自行 ln(高度 + 间隙),此处只补段间隙,避免双倍留白。
|
|
1087
|
+
gap = 1.2 if ended_with_image else self._line_h(self._BODY_SIZE) + 1.2
|
|
1088
|
+
self.pdf.ln(gap)
|
|
606
1089
|
i += 3
|
|
607
1090
|
elif t.startswith("heading_"):
|
|
608
1091
|
level = int(tok.tag[1])
|
|
@@ -760,10 +1243,13 @@ class _PdfMarkdownRenderer:
|
|
|
760
1243
|
if not first_para:
|
|
761
1244
|
self._ensure_space(self._line_h(self._BODY_SIZE) * 2)
|
|
762
1245
|
pdf.set_x(left)
|
|
763
|
-
self._render_inline(
|
|
1246
|
+
ended_with_image = self._render_inline(
|
|
1247
|
+
tokens[i + 1].children or [], self._BODY_SIZE
|
|
1248
|
+
)
|
|
764
1249
|
# write 后光标停在最后一行顶部:推进整行高 + 项内段间隙,
|
|
765
|
-
#
|
|
766
|
-
|
|
1250
|
+
# 保证下一列表项/段落不与本项末行重叠;图片块已自行推进,
|
|
1251
|
+
# 此处只补项内段间隙。
|
|
1252
|
+
pdf.ln(0.8 if ended_with_image else self._line_h(self._BODY_SIZE) + 0.8)
|
|
767
1253
|
first_para = False
|
|
768
1254
|
i += 3
|
|
769
1255
|
continue
|
|
@@ -946,13 +1432,18 @@ class _PdfMarkdownRenderer:
|
|
|
946
1432
|
pdf.set_y(y0 + row_h)
|
|
947
1433
|
|
|
948
1434
|
|
|
949
|
-
def _pdf_render_markdown(
|
|
1435
|
+
def _pdf_render_markdown(
|
|
1436
|
+
md_text: str, dest: Path, mainfont: str, base_dir: Path | None = None
|
|
1437
|
+
) -> None:
|
|
950
1438
|
"""markdown-it token 流驱动 fpdf2 渲染 Markdown 为 PDF(与 HTML 同源解析)
|
|
951
1439
|
|
|
952
1440
|
- 解析:MarkdownIt(commonmark)+table,与 convert_md_to_html(PiMD 内部
|
|
953
1441
|
同为 markdown-it)结构理解一致;支持标题、嵌套列表(有序/无序)、
|
|
954
1442
|
引用块、围栏代码块、GFM 表格、分隔线,及行内粗体/斜体/行内代码/
|
|
955
|
-
|
|
1443
|
+
链接/图片。
|
|
1444
|
+
- 图片:`base_dir` 为相对路径基准(调用方传 Markdown 源文件目录),
|
|
1445
|
+
渲染为等比限宽居中的块级图片;缺图/越界/远程一律回落 alt 文本。
|
|
1446
|
+
`base_dir=None` 时不解析图片,行为与 0.6.3 完全一致。
|
|
956
1447
|
- 中文不可走 PiMD 自带 PDF 通道:其 PDF 引擎为回退链(docx2pdf →
|
|
957
1448
|
libreoffice → pandoc → weasyprint → fpdf2),末端 fpdf2 实现为
|
|
958
1449
|
Helvetica 文本提取,均不支持 CJK;本渲染器按 mainfont 注册系统
|
|
@@ -977,7 +1468,10 @@ def _pdf_render_markdown(md_text: str, dest: Path, mainfont: str) -> None:
|
|
|
977
1468
|
pdf.add_font(font_key, style, font_path)
|
|
978
1469
|
|
|
979
1470
|
tokens = _MD_PARSER.parse(md_text)
|
|
980
|
-
|
|
1471
|
+
renderer = _PdfMarkdownRenderer(
|
|
1472
|
+
pdf, font_key, font_path is not None, base_dir=base_dir
|
|
1473
|
+
)
|
|
1474
|
+
renderer.render(tokens)
|
|
981
1475
|
pdf.output(str(dest))
|
|
982
1476
|
|
|
983
1477
|
|
|
@@ -1093,6 +1587,11 @@ class Md2DocKit:
|
|
|
1093
1587
|
排版默认值:A4 纵向、正文 11pt、h1~h6 分级加粗、表格撑满页宽、
|
|
1094
1588
|
单元格换行、多页自适应分页。
|
|
1095
1589
|
|
|
1590
|
+
图片(``)渲染为块级图片:相对路径以本 Markdown
|
|
1591
|
+
源文件所在目录为基准,按 96dpi 还原自然尺寸后等比限宽居中,超高
|
|
1592
|
+
先分页、不放大;缺图、越界路径、非图片格式、远程地址一律回落为
|
|
1593
|
+
alt 文本,不抛异常。表格单元格内的图片仍按 alt 文本处理。
|
|
1594
|
+
|
|
1096
1595
|
转换前自动做 CJK 加粗预处理(详见 convert_md_to_docx)。
|
|
1097
1596
|
|
|
1098
1597
|
Args:
|
|
@@ -1120,7 +1619,8 @@ class Md2DocKit:
|
|
|
1120
1619
|
processed = _normalize_cjk_bold(src.read_text(encoding="utf-8"))
|
|
1121
1620
|
|
|
1122
1621
|
try:
|
|
1123
|
-
|
|
1622
|
+
# 图片相对路径基准 = 源文件目录(与 images/ 同级导出结构天然匹配)
|
|
1623
|
+
_pdf_render_markdown(processed, dest, mainfont, src.parent)
|
|
1124
1624
|
except Exception as e: # noqa: BLE001 - fpdf2 渲染异常统一包装
|
|
1125
1625
|
logger.error(f"PDF 渲染失败: {src.name}: {e}")
|
|
1126
1626
|
raise RuntimeError(f"PDF 渲染失败: {src.name}: {e}") from e
|
|
@@ -1145,6 +1645,12 @@ class Md2DocKit:
|
|
|
1145
1645
|
(body `max-width: 900px` 等):正文容器放大至 60em 并居中,中文
|
|
1146
1646
|
优先字体栈(宋体系/黑体系),表格撑满页宽、表头加粗,适配宽屏阅读。
|
|
1147
1647
|
|
|
1648
|
+
图片(``)输出为 `<figure class="md2doc-img"><img>`:
|
|
1649
|
+
本地路径以源文件所在目录为基准判定存在性,存在则输出**相对路径**
|
|
1650
|
+
(故 HTML 需与 `images/` 处于同一目录层级才能在浏览器中正常显示);
|
|
1651
|
+
缺图/越界回落为 alt 文本(不输出破图);远程地址直接输出
|
|
1652
|
+
`<img src="https://…">` 交由浏览器加载(服务端不取网)。
|
|
1653
|
+
|
|
1148
1654
|
转换前自动做 CJK 加粗预处理(详见 convert_md_to_docx)。
|
|
1149
1655
|
|
|
1150
1656
|
Args:
|
|
@@ -1171,6 +1677,8 @@ class Md2DocKit:
|
|
|
1171
1677
|
|
|
1172
1678
|
# CJK 加粗预处理;临时 md 与源文件同目录,保证相对图片路径仍然可用
|
|
1173
1679
|
processed = _normalize_cjk_bold(src.read_text(encoding="utf-8"))
|
|
1680
|
+
# 图片语法先换占位标记(PiMD 会把它降级为 alt 超链接),转换后回填
|
|
1681
|
+
processed, img_refs = _extract_images(processed)
|
|
1174
1682
|
tmp_md = src.with_name(f".{src.stem}.pimd-tmp.md")
|
|
1175
1683
|
tmp_md.write_text(processed, encoding="utf-8")
|
|
1176
1684
|
|
|
@@ -1201,6 +1709,9 @@ class Md2DocKit:
|
|
|
1201
1709
|
f"{html[head_close:]}"
|
|
1202
1710
|
)
|
|
1203
1711
|
html = html.replace("</head>", f"{_MD_HTML_CSS}\n</head>", 1)
|
|
1712
|
+
# 图片回填:把占位标记还原为 <figure><img>(缺图回落 alt 文本)
|
|
1713
|
+
if img_refs:
|
|
1714
|
+
html = _inject_html_images(html, img_refs, src.parent)
|
|
1204
1715
|
dest.write_text(html, encoding="utf-8")
|
|
1205
1716
|
|
|
1206
1717
|
logger.info(f"✅ HTML 生成成功: {dest}")
|
|
@@ -1225,6 +1736,11 @@ class Md2DocKit:
|
|
|
1225
1736
|
reference_doc 参数仅保留签名兼容(PiMD 的 md_to_docx 不接收参考
|
|
1226
1737
|
模板,传入不生效)。
|
|
1227
1738
|
|
|
1739
|
+
图片(``)以内嵌图片段落回填:PiMD 会把行内图片
|
|
1740
|
+
降级为「alt 文本 + 超链接」,此处按占位标记替换为真实图片并居中,
|
|
1741
|
+
宽度按节内容区等比限幅(相对路径以源文件所在目录为基准);图片
|
|
1742
|
+
缺失、越界或 python-docx 不支持的格式(如 webp)回落为 alt 文本。
|
|
1743
|
+
|
|
1228
1744
|
转换前自动做 CJK 加粗预处理:`**加粗:**后接` 这类闭合 ** 前为
|
|
1229
1745
|
全角标点的写法不满足 CommonMark 定界规则(会以字面星号进入
|
|
1230
1746
|
Word),内部将其改写为 `**加粗**:后接`,代码块内容不受影响;
|
|
@@ -1261,6 +1777,13 @@ class Md2DocKit:
|
|
|
1261
1777
|
|
|
1262
1778
|
# CJK 加粗预处理;临时 md 与源文件同目录,保证相对图片路径仍然可用
|
|
1263
1779
|
processed = _normalize_cjk_bold(src.read_text(encoding="utf-8"))
|
|
1780
|
+
# 图片语法先换占位标记(PiMD 会把它降级为 alt 超链接),转换后回填;
|
|
1781
|
+
# python-docx 不可用时不做标记替换,保持 0.6.3 的 alt 超链接行为
|
|
1782
|
+
img_refs: list[tuple[str, str]] = []
|
|
1783
|
+
if _docx_available():
|
|
1784
|
+
processed, img_refs = _extract_images(processed)
|
|
1785
|
+
else:
|
|
1786
|
+
logger.warning("python-docx 不可用,Word 输出保留 alt 文本超链接(无图片)")
|
|
1264
1787
|
tmp_md = src.with_name(f".{src.stem}.pimd-tmp.md")
|
|
1265
1788
|
tmp_md.write_text(processed, encoding="utf-8")
|
|
1266
1789
|
|
|
@@ -1275,6 +1798,9 @@ class Md2DocKit:
|
|
|
1275
1798
|
finally:
|
|
1276
1799
|
tmp_md.unlink(missing_ok=True)
|
|
1277
1800
|
|
|
1801
|
+
# 图片回填:无图片时直接跳过(避免 python-docx 往返改变无图产物)
|
|
1802
|
+
if img_refs:
|
|
1803
|
+
_inject_docx_images(dest, img_refs, src.parent)
|
|
1278
1804
|
# 表格版式后处理:撑满页宽并整体居中
|
|
1279
1805
|
_polish_table_layout(dest)
|
|
1280
1806
|
# 全局字体后处理:正文/标题统一为 mainfont(代码块保持等宽)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|