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.
Files changed (54) hide show
  1. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/PKG-INFO +12 -6
  2. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/README.md +11 -5
  3. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/md2doc_kit.py +540 -14
  5. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/.gitignore +0 -0
  6. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/__init__.py +0 -0
  7. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/base_service.py +0 -0
  8. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/chat_service.py +0 -0
  9. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/embedding_service.py +0 -0
  10. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/exceptions.py +0 -0
  11. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/failover.py +0 -0
  12. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/image_service.py +0 -0
  13. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/model_builder.py +0 -0
  14. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/model_client.py +0 -0
  15. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/pool.py +0 -0
  16. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/profiles.py +0 -0
  17. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/rerank_service.py +0 -0
  18. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/runner.py +0 -0
  19. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/ai/types.py +0 -0
  20. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/common_kit.py +0 -0
  21. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/config/__init__.py +0 -0
  22. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/config/kit_config.py +0 -0
  23. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/config/log_config.py +0 -0
  24. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/__init__.py +0 -0
  25. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/article_io.py +0 -0
  26. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/article_models.py +0 -0
  27. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/deepseek_kit.py +0 -0
  28. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/web_article_kit.py +0 -0
  29. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/crawl/wx_article_kit.py +0 -0
  30. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/__init__.py +0 -0
  31. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/audit.py +0 -0
  32. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/base_db_kit.py +0 -0
  33. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/crud.py +0 -0
  34. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/engine.py +0 -0
  35. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/exceptions.py +0 -0
  36. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/models/__init__.py +0 -0
  37. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/models/base.py +0 -0
  38. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/mysql_kit.py +0 -0
  39. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/pg_kit.py +0 -0
  40. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/query.py +0 -0
  41. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/session.py +0 -0
  42. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/sqlite_kit.py +0 -0
  43. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/db/transaction.py +0 -0
  44. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/__init__.py +0 -0
  45. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/anydoc_kit.py +0 -0
  46. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/docx_kit.py +0 -0
  47. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/marker_kit.py +0 -0
  48. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/mineru_kit.py +0 -0
  49. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/docs/rc_docparser_kit.py +0 -0
  50. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/file_kit.py +0 -0
  51. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/laya_kit.py +0 -0
  52. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/license_kit.py +0 -0
  53. {sdpy_kit-0.6.3 → sdpy_kit-0.6.4}/kit/redis_kit.py +0 -0
  54. {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
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) | `pip install "sdpy-kit[md2doc]"` |
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
+ **图片**:`![alt](相对路径)` 以 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) | `pip install "sdpy-kit[md2doc]"` |
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
+ **图片**:`![alt](相对路径)` 以 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 文档解析
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.6.3"
26
+ __version__ = "0.6.4"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -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
+ # 同段多张图(`![a](x) ![b](y)`)逐个处理:锚点跟随新插入的段落,
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
+ 一律不改写——代码示例中的 `![x](y)` 必须原样保留;表格单元格在 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
+ - 段内混排(含 `[![x](y)](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__(self, pdf: FPDF, font_key: str | None, has_cjk_font: bool) -> None:
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) -> None:
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._flow_run(tok.content or "", size)
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(tokens[i + 1].children or [], self._BODY_SIZE)
1081
+ ended_with_image = self._render_inline(
1082
+ tokens[i + 1].children or [], self._BODY_SIZE
1083
+ )
603
1084
  # fpdf2 的 write 结束后光标停在最后一行顶部(未消费行高),
604
- # 须推进整行高结束该行,再加段间隙;只推进小间隙会与后文重叠
605
- self.pdf.ln(self._line_h(self._BODY_SIZE) + 1.2)
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(tokens[i + 1].children or [], self._BODY_SIZE)
1246
+ ended_with_image = self._render_inline(
1247
+ tokens[i + 1].children or [], self._BODY_SIZE
1248
+ )
764
1249
  # write 后光标停在最后一行顶部:推进整行高 + 项内段间隙,
765
- # 保证下一列表项/段落不与本项末行重叠
766
- pdf.ln(self._line_h(self._BODY_SIZE) + 0.8)
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(md_text: str, dest: Path, mainfont: str) -> None:
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
- 链接/图片 alt 文本。
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
- _PdfMarkdownRenderer(pdf, font_key, font_path is not None).render(tokens)
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
+ 图片(`![alt](相对路径)`)渲染为块级图片:相对路径以本 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
- _pdf_render_markdown(processed, dest, mainfont)
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
+ 图片(`![alt](相对路径)`)输出为 `<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
+ 图片(`![alt](相对路径)`)以内嵌图片段落回填: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