dsh-courseware 0.1.0

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/cordis.patch.yml +9 -0
  4. package/engine/pptxgen/__init__.py +8 -0
  5. package/engine/pptxgen/__main__.py +4 -0
  6. package/engine/pptxgen/builtin_template.py +218 -0
  7. package/engine/pptxgen/cli.py +575 -0
  8. package/engine/pptxgen/examples.py +290 -0
  9. package/engine/pptxgen/imagegen.py +564 -0
  10. package/engine/pptxgen/layoutmap.py +211 -0
  11. package/engine/pptxgen/patterns.py +106 -0
  12. package/engine/pptxgen/preview.py +126 -0
  13. package/engine/pptxgen/profile.py +456 -0
  14. package/engine/pptxgen/render.py +1673 -0
  15. package/engine/pptxgen/review.py +184 -0
  16. package/engine/pptxgen/schemacheck.py +122 -0
  17. package/engine/pptxgen/schemas/XAdES.xsd +466 -0
  18. package/engine/pptxgen/schemas/XAdESv141.xsd +15 -0
  19. package/engine/pptxgen/schemas/chartEx.xsd +838 -0
  20. package/engine/pptxgen/schemas/dml-chart.xsd +1499 -0
  21. package/engine/pptxgen/schemas/dml-chartDrawing.xsd +146 -0
  22. package/engine/pptxgen/schemas/dml-diagram.xsd +1085 -0
  23. package/engine/pptxgen/schemas/dml-drawing.xsd +63 -0
  24. package/engine/pptxgen/schemas/dml-lockedCanvas.xsd +11 -0
  25. package/engine/pptxgen/schemas/dml-main.xsd +3081 -0
  26. package/engine/pptxgen/schemas/dml-picture.xsd +23 -0
  27. package/engine/pptxgen/schemas/dml-spreadsheetDrawing.xsd +185 -0
  28. package/engine/pptxgen/schemas/dml-wordprocessingDrawing.xsd +287 -0
  29. package/engine/pptxgen/schemas/drawing-chart2012.xsd +129 -0
  30. package/engine/pptxgen/schemas/markup-compatibility.xsd +95 -0
  31. package/engine/pptxgen/schemas/opc-digSig.xsd +49 -0
  32. package/engine/pptxgen/schemas/opc-relationships.xsd +33 -0
  33. package/engine/pptxgen/schemas/pml.xsd +1676 -0
  34. package/engine/pptxgen/schemas/shared-additionalCharacteristics.xsd +28 -0
  35. package/engine/pptxgen/schemas/shared-bibliography.xsd +144 -0
  36. package/engine/pptxgen/schemas/shared-commonSimpleTypes.xsd +172 -0
  37. package/engine/pptxgen/schemas/shared-customXmlDataProperties.xsd +25 -0
  38. package/engine/pptxgen/schemas/shared-customXmlSchemaProperties.xsd +18 -0
  39. package/engine/pptxgen/schemas/shared-documentPropertiesCustom.xsd +59 -0
  40. package/engine/pptxgen/schemas/shared-documentPropertiesExtended.xsd +56 -0
  41. package/engine/pptxgen/schemas/shared-documentPropertiesVariantTypes.xsd +195 -0
  42. package/engine/pptxgen/schemas/shared-math.xsd +582 -0
  43. package/engine/pptxgen/schemas/shared-relationshipReference.xsd +25 -0
  44. package/engine/pptxgen/schemas/signatureInfo.xsd +103 -0
  45. package/engine/pptxgen/schemas/sml.xsd +4439 -0
  46. package/engine/pptxgen/schemas/visio.xsd +829 -0
  47. package/engine/pptxgen/schemas/vml-main.xsd +570 -0
  48. package/engine/pptxgen/schemas/vml-officeDrawing.xsd +509 -0
  49. package/engine/pptxgen/schemas/vml-presentationDrawing.xsd +12 -0
  50. package/engine/pptxgen/schemas/vml-spreadsheetDrawing.xsd +108 -0
  51. package/engine/pptxgen/schemas/vml-wordprocessingDrawing.xsd +96 -0
  52. package/engine/pptxgen/schemas/vmlDrawing.xsd +36 -0
  53. package/engine/pptxgen/schemas/wml.xsd +3643 -0
  54. package/engine/pptxgen/schemas/word12.xsd +66 -0
  55. package/engine/pptxgen/schemas/xlThreadedComments.xsd +59 -0
  56. package/engine/pptxgen/schemas/xlThreadedComments2.xsd +22 -0
  57. package/engine/pptxgen/schemas/xmldsig-core-schema.xsd +318 -0
  58. package/engine/pptxgen/shapes.py +417 -0
  59. package/engine/pptxgen/spec.py +529 -0
  60. package/engine/pptxgen/template.py +834 -0
  61. package/engine/pptxgen/util.py +218 -0
  62. package/engine/pptxgen/xmlutil.py +298 -0
  63. package/engine/pptxgen.sh +7 -0
  64. package/engine/requirements.txt +9 -0
  65. package/lib/index.js +186 -0
  66. package/package.json +34 -0
  67. package/skills/courseware/SKILL.md +130 -0
  68. package/skills/courseware/references/content-polish.md +121 -0
  69. package/skills/courseware/references/deck-spec.md +155 -0
  70. package/skills/courseware/references/design-review.md +121 -0
  71. package/skills/courseware/references/image-backends.md +94 -0
  72. package/skills/courseware/references/quality-gates.md +71 -0
  73. package/skills/courseware/references/roadmap.md +55 -0
  74. package/skills/courseware/references/template-fidelity.md +100 -0
  75. package/skills/courseware/workflows/generate.md +172 -0
  76. package/skills/courseware/workflows/quick.md +41 -0
  77. package/skills/courseware/workflows/revise.md +40 -0
  78. package/skills/courseware/workflows/routing.md +49 -0
  79. package/skills/courseware/workflows/stages/generate-images.md +74 -0
  80. package/skills/courseware/workflows/template-intake.md +112 -0
@@ -0,0 +1,211 @@
1
+ """页型 -> 模板版式 的自动映射。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List, Optional
6
+
7
+ from .template import LayoutInfo, TemplateInfo
8
+
9
+ # 版式名关键词(中英双语,覆盖 Office 默认主题命名)
10
+ KEYWORDS: Dict[str, List[str]] = {
11
+ "cover": ["封面", "标题幻灯片", "标题页", "首页", "title slide", "title & subtitle", "cover"],
12
+ "toc": ["目录", "內容", "内容页", "agenda", "contents", "table of contents", "toc"],
13
+ "section": ["节标题", "章节", "过渡", "分隔", "部分", "section header", "section", "divider"],
14
+ "objectives": ["目标", "objective", "学习目标", "goals"],
15
+ "content": [
16
+ "标题和内容", "标题与内容", "内容与标题", "两栏内容", "正文", "图文",
17
+ "title and content", "content with caption", "two content", "picture with caption",
18
+ ],
19
+ "example": ["例题", "示例", "范例", "example", "sample"],
20
+ "practice": ["练习", "习题", "测验", "检测", "practice", "quiz", "exercise"],
21
+ "summary": ["小结", "总结", "summary", "思维导图", "mindmap", "回顾"],
22
+ "homework": ["作业", "homework", "assignment", "课后"],
23
+ "end": ["结束", "谢谢", "谢谢观看", "end", "thanks", "thank you", "空白", "blank"],
24
+ }
25
+
26
+ # 这些页型只需要一个标题占位符
27
+ TITLE_ONLY_TYPES = {"cover", "section", "end"}
28
+
29
+
30
+ def build_map(tpl: TemplateInfo, overrides: Optional[dict] = None) -> Dict[str, int]:
31
+ """为每个页型挑选最合适的版式索引。
32
+
33
+ 优先顺序:用户显式覆盖 > 样张学到的角色版式 > 主母版内的关键词/结构打分。
34
+ 多母版模板里这一步很关键——套错母版会让整套配色和背景都不对。
35
+ """
36
+ overrides = overrides or {}
37
+ result: Dict[str, int] = {}
38
+
39
+ # 1) 用户显式覆盖优先
40
+ for stype, value in overrides.items():
41
+ idx = resolve_layout_ref(tpl, value)
42
+ if idx is not None:
43
+ result[stype] = idx
44
+
45
+ # 2) 样张给出的角色先验:封面/结束页常常和内容页不是同一套母版
46
+ role = getattr(tpl, "role_layout", None) or {}
47
+ for stype in ("cover", "end"):
48
+ if stype not in result and stype in role:
49
+ result[stype] = role[stype]
50
+
51
+ # 3) 其余页型:限定在"样张内容页所属母版"内打分
52
+ preferred_master = None
53
+ if "content" in role:
54
+ lay = tpl.layout_by_index(role["content"])
55
+ if lay is not None:
56
+ preferred_master = lay.master_index
57
+ pool = [l for l in tpl.layouts
58
+ if preferred_master is None or l.master_index == preferred_master] or list(tpl.layouts)
59
+
60
+ for stype, kws in KEYWORDS.items():
61
+ if stype in result:
62
+ continue
63
+ best_idx, best_score = None, -10 ** 9
64
+ for lay in pool:
65
+ sc = _score(tpl, lay, stype, kws)
66
+ if sc > best_score:
67
+ best_idx, best_score = lay.index, sc
68
+ if best_idx is not None and best_score > 0:
69
+ result[stype] = best_idx
70
+
71
+ # 3.5) 样张内容页版式先验:关键词挑的版式若在样张里从没被用过,
72
+ # 而样张内容页明显集中在另一个版式(如"标题幻灯片"上画装饰)
73
+ # —— 套用样张实际用的版式,否则会丢掉模板的装饰层(背景渐变等)。
74
+ content_role = role.get("content")
75
+ if content_role is not None:
76
+ role_lay = tpl.layout_by_index(content_role)
77
+ if role_lay is not None and role_lay.find_title() is not None:
78
+ usage = _sample_layout_usage(tpl)
79
+ for stype in ("content", "example", "practice", "summary",
80
+ "homework", "toc", "objectives", "section"):
81
+ cur = result.get(stype)
82
+ if cur is None or cur == content_role:
83
+ continue
84
+ if usage.get(cur, 0) == 0 and usage.get(content_role, 0) > 0:
85
+ result[stype] = content_role
86
+
87
+ # 4) 兜底
88
+ fallback = _generic_layout(tpl, pool)
89
+ for stype in KEYWORDS:
90
+ result.setdefault(stype, fallback)
91
+ return result
92
+
93
+
94
+ def _score(tpl: TemplateInfo, lay: LayoutInfo, stype: str, keywords: List[str]) -> int:
95
+ name = (lay.name or "").lower()
96
+ s = 0
97
+ for kw in keywords:
98
+ if kw.lower() in name:
99
+ s += 20
100
+ break
101
+
102
+ title = lay.find_title()
103
+ body = lay.find_body()
104
+ total_area = float((tpl.width or 1) * (tpl.height or 1))
105
+
106
+ if stype in TITLE_ONLY_TYPES:
107
+ if title:
108
+ s += 4
109
+ else:
110
+ s -= 25 # 章节/封面/结束页没有标题占位符基本不可用
111
+ if body:
112
+ s -= 3
113
+ if len(lay.placeholders) <= 2:
114
+ s += 2
115
+ else:
116
+ if title:
117
+ s += 4
118
+ else:
119
+ s -= 8
120
+ if body:
121
+ s += 6
122
+ # 正文区域越大越适合做通用内容页
123
+ area = float((body.width or 0) * (body.height or 0)) / total_area
124
+ s += int(round(max(0.0, min(area, 0.75)) * 12))
125
+ else:
126
+ s -= 14
127
+ # 除标题/正文/页眉页脚之外的大占位符,说明这是专用版式
128
+ extras = [
129
+ ph for ph in lay.placeholders
130
+ if not ph.is_title and not ph.is_body and not ph.is_decoration
131
+ and ph.width and ph.height and ph.width * ph.height > 0.05 * total_area
132
+ ]
133
+ s -= 4 * len(extras)
134
+ if len(lay.placeholders) > 6:
135
+ s -= 3
136
+
137
+ # 名字里带"竖排"的一般不用
138
+ if "竖排" in (lay.name or "") or "vertical" in name:
139
+ s -= 12
140
+ return s
141
+
142
+
143
+ def _sample_layout_usage(tpl: TemplateInfo) -> Dict[int, int]:
144
+ """统计样张里每张幻灯片实际挂在哪个版式上(跨多母版全局下标)。"""
145
+ usage: Dict[int, int] = {}
146
+ try:
147
+ prs = tpl._prs
148
+ for slide in prs.slides:
149
+ partname = str(slide.slide_layout.part.partname)
150
+ for i, lo in enumerate(tpl.layout_objects):
151
+ try:
152
+ if str(lo.part.partname) == partname:
153
+ usage[i] = usage.get(i, 0) + 1
154
+ break
155
+ except Exception:
156
+ continue
157
+ except Exception:
158
+ pass
159
+ return usage
160
+
161
+
162
+ def _generic_layout(tpl: TemplateInfo, pool=None) -> int:
163
+ """挑一个最保险的通用版式:有标题+最大正文占位符。"""
164
+ best, best_score = 0, -10 ** 9
165
+ for lay in (pool or tpl.layouts):
166
+ title = lay.find_title()
167
+ body = lay.find_body()
168
+ s = 0
169
+ if body:
170
+ s += (body.width or 0) * (body.height or 0) / 1e12
171
+ if title:
172
+ s += 2
173
+ if len(lay.placeholders) == 0:
174
+ s -= 1
175
+ if s > best_score:
176
+ best, best_score = lay.index, s
177
+ return best
178
+
179
+
180
+ def resolve_layout_ref(tpl: TemplateInfo, value) -> Optional[int]:
181
+ return _resolve_layout_ref(tpl, value)
182
+
183
+
184
+ def _resolve_layout_ref(tpl: TemplateInfo, value) -> Optional[int]:
185
+ if value is None:
186
+ return None
187
+ if isinstance(value, int):
188
+ return value if tpl.layout_by_index(value) else None
189
+ text = str(value).strip()
190
+ if text.isdigit():
191
+ idx = int(text)
192
+ return idx if tpl.layout_by_index(idx) else None
193
+ low = text.lower()
194
+ for lay in tpl.layouts:
195
+ if (lay.name or "").lower() == low:
196
+ return lay.index
197
+ for lay in tpl.layouts:
198
+ if low in (lay.name or "").lower():
199
+ return lay.index
200
+ return None
201
+
202
+
203
+ def describe_map(tpl: TemplateInfo, mapping: Dict[str, int]) -> str:
204
+ from .spec import TYPE_LABELS
205
+
206
+ lines = ["页型 -> 模板版式 映射"]
207
+ for stype, idx in mapping.items():
208
+ lay = tpl.layout_by_index(idx)
209
+ label = TYPE_LABELS.get(stype, stype)
210
+ lines.append(f" {label:<8} ({stype:<10}) -> [{idx}] {lay.name if lay else '?'}")
211
+ return "\n".join(lines)
@@ -0,0 +1,106 @@
1
+ """版面骨架(pattern)的判定与清单。
2
+
3
+ 渲染器和设计审查共用同一套判定,避免"审查说的"和"渲染做的"不一致。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import List, Optional, Sequence
10
+
11
+ # 步骤型文字特征:第1步 / 步骤一 / ① / 1.
12
+ STEP_RE = re.compile(
13
+ r"^\s*(第\s*[一二三四五六七八九十\d]+\s*步|步骤\s*[一二三四五六七八九十\d]+|"
14
+ r"[①②③④⑤⑥⑦⑧⑨]|\d+\s*[.、.))])"
15
+ )
16
+
17
+ # 全部可用骨架
18
+ PATTERNS = ("bullets", "steps", "flow", "compare", "cards", "levels", "timeline",
19
+ "callout", "image_left", "image_right")
20
+
21
+ # 单页最适合的骨架(内容形态 -> 骨架)
22
+ CONTENT_MATCH = {
23
+ "流程/步骤/环节": "flow",
24
+ "有序步骤": "steps",
25
+ "并列要点": "cards",
26
+ "分级/强弱梯度": "levels",
27
+ "两者对照": "compare",
28
+ "先后/因果": "timeline",
29
+ "核心问题/结论": "callout",
30
+ "一般叙述": "bullets",
31
+ }
32
+
33
+
34
+ def guess_pattern(spec: dict, bullets: Optional[Sequence[dict]] = None) -> str:
35
+ """显式 pattern 优先;没有就按内容形态自动挑一个(确定性)。"""
36
+ explicit = str(spec.get("pattern") or "").strip().lower()
37
+ if explicit and explicit != "auto":
38
+ return explicit
39
+ if [t for t in (spec.get("timeline") or []) if t.get("text")]:
40
+ return "timeline"
41
+ if spec.get("compare"):
42
+ return "compare"
43
+ if [s for s in (spec.get("steps") or []) if s.get("text")]:
44
+ return "steps"
45
+ if spec.get("cards"):
46
+ return "cards"
47
+ items = bullets if bullets is not None else (spec.get("bullets") or [])
48
+ lvl0 = [b.get("text", "") for b in items if int(b.get("level", 0) or 0) == 0]
49
+ if len(lvl0) >= 3 and sum(1 for t in lvl0 if STEP_RE.match(t)) >= 2:
50
+ return "steps"
51
+ return "bullets"
52
+
53
+
54
+ def collect_texts(spec: dict) -> List[str]:
55
+ """把一页里所有会显示出来的文字收集起来(用于密度/润色检查)。"""
56
+ out: List[str] = []
57
+
58
+ def add(v):
59
+ if isinstance(v, str) and v.strip():
60
+ out.append(v.strip())
61
+
62
+ add(spec.get("title"))
63
+ add(spec.get("subtitle"))
64
+ add(spec.get("key"))
65
+ add(spec.get("image_caption"))
66
+ for key in ("bullets", "steps", "points", "items", "goals", "key_points", "hard_points"):
67
+ for b in spec.get(key) or []:
68
+ if isinstance(b, dict):
69
+ add(b.get("text"))
70
+ else:
71
+ add(b)
72
+ for c in spec.get("cards") or []:
73
+ add(c.get("title"))
74
+ for it in c.get("items") or []:
75
+ add(it)
76
+ cmp = spec.get("compare") or {}
77
+ for side in ("left", "right"):
78
+ d = cmp.get(side) or {}
79
+ add(d.get("title"))
80
+ for it in d.get("items") or []:
81
+ add(it)
82
+ for t in spec.get("timeline") or []:
83
+ add(t.get("time"))
84
+ add(t.get("text"))
85
+ for q in spec.get("questions") or []:
86
+ add(q.get("q"))
87
+ for o in q.get("options") or []:
88
+ add(o)
89
+ add(q.get("answer"))
90
+ add(q.get("analysis"))
91
+ return out
92
+
93
+
94
+ def text_len(texts: Sequence[str]) -> int:
95
+ """中文字符按 1 计,连续西文按 1 个词计。"""
96
+ n = 0
97
+ for t in texts:
98
+ t = re.sub(r"\*\*", "", str(t))
99
+ cn = len(re.findall(r"[\u4e00-\u9fff]", t))
100
+ en = len(re.findall(r"[A-Za-z0-9]+", t))
101
+ n += cn + en
102
+ return n
103
+
104
+
105
+ def highlight_count(spec: dict) -> int:
106
+ return sum(len(re.findall(r"\*\*.+?\*\*", t)) for t in collect_texts(spec))
@@ -0,0 +1,126 @@
1
+ """渲染预览:用 LibreOffice 把 pptx 转成 PDF,再用 PyMuPDF 出 PNG。
2
+
3
+ 没有 LibreOffice 时会给出清晰提示,不影响生成 pptx 本身。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import glob
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from typing import List, Optional, Tuple
14
+
15
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
16
+
17
+ SOFFICE_CANDIDATES = [
18
+ os.environ.get("PPTXGEN_SOFFICE", ""),
19
+ os.path.join(ROOT, ".tools", "LibreOffice.app", "Contents", "MacOS", "soffice"),
20
+ "/Applications/LibreOffice.app/Contents/MacOS/soffice",
21
+ "/opt/homebrew/bin/soffice",
22
+ "/usr/local/bin/soffice",
23
+ "/usr/bin/soffice",
24
+ "/usr/bin/libreoffice",
25
+ ]
26
+
27
+
28
+ def find_soffice() -> Optional[str]:
29
+ for cand in SOFFICE_CANDIDATES:
30
+ if cand and os.path.exists(cand):
31
+ return cand
32
+ for name in ("soffice", "libreoffice"):
33
+ found = shutil.which(name)
34
+ if found:
35
+ return found
36
+ return None
37
+
38
+
39
+ def to_pdf(pptx_path: str, out_dir: str, timeout: int = 300) -> str:
40
+ """用 LibreOffice 转 PDF,返回 PDF 路径。"""
41
+ soffice = find_soffice()
42
+ if not soffice:
43
+ raise RuntimeError(
44
+ "未找到 LibreOffice,无法渲染预览。\n"
45
+ " 安装方式一:brew install --cask libreoffice\n"
46
+ " 安装方式二:把 LibreOffice.app 放到 /Applications 下\n"
47
+ " 或设置环境变量 PPTXGEN_SOFFICE=/path/to/soffice"
48
+ )
49
+ os.makedirs(out_dir, exist_ok=True)
50
+ out_dir = os.path.abspath(out_dir)
51
+ # profile 放在 .tools 下复用(加速第二次转换),不污染输出目录
52
+ profile_root = os.path.join(ROOT, ".tools")
53
+ os.makedirs(profile_root, exist_ok=True)
54
+ profile = os.path.join(profile_root, "lo_profile")
55
+ cmd = [
56
+ soffice, "--headless", "--norestore", "--invisible", "--nolockcheck",
57
+ f"-env:UserInstallation=file://{profile}",
58
+ "--convert-to", "pdf",
59
+ "--outdir", out_dir, os.path.abspath(pptx_path),
60
+ ]
61
+ try:
62
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
63
+ except subprocess.TimeoutExpired:
64
+ raise RuntimeError(f"LibreOffice 转换超时(>{timeout}s)。可尝试手动运行:\n {' '.join(cmd)}")
65
+ pdf = os.path.join(out_dir, os.path.splitext(os.path.basename(pptx_path))[0] + ".pdf")
66
+ if not os.path.exists(pdf):
67
+ raise RuntimeError(
68
+ f"LibreOffice 转换失败(returncode={proc.returncode})\n"
69
+ f"stdout: {proc.stdout[-800:]}\nstderr: {proc.stderr[-800:]}"
70
+ )
71
+ return pdf
72
+
73
+
74
+ def pdf_to_pngs(pdf_path: str, out_dir: str, dpi: int = 110) -> List[str]:
75
+ import pymupdf as fitz
76
+
77
+ os.makedirs(out_dir, exist_ok=True)
78
+ base = os.path.splitext(os.path.basename(pdf_path))[0]
79
+ paths: List[str] = []
80
+ with fitz.open(pdf_path) as doc:
81
+ for i, page in enumerate(doc, 1):
82
+ pix = page.get_pixmap(dpi=dpi)
83
+ out = os.path.join(out_dir, f"{base}_{i:02d}.png")
84
+ pix.save(out)
85
+ paths.append(out)
86
+ return paths
87
+
88
+
89
+ def contact_sheet(png_paths: List[str], out_path: str, cols: int = 3,
90
+ cell_w: int = 560, pad: int = 12) -> str:
91
+ """把多页预览拼成一张总览图。"""
92
+ from PIL import Image, ImageDraw
93
+
94
+ if not png_paths:
95
+ raise ValueError("没有可拼接的预览图")
96
+ imgs = []
97
+ for p in png_paths:
98
+ with Image.open(p) as im:
99
+ im = im.convert("RGB")
100
+ ratio = cell_w / float(im.width)
101
+ imgs.append(im.resize((cell_w, max(int(im.height * ratio), 1)), Image.LANCZOS))
102
+ cell_h = max(i.height for i in imgs)
103
+ rows = (len(imgs) + cols - 1) // cols
104
+ W = cols * cell_w + (cols + 1) * pad
105
+ H = rows * cell_h + (rows + 1) * pad
106
+ sheet = Image.new("RGB", (W, H), (238, 241, 246))
107
+ draw = ImageDraw.Draw(sheet)
108
+ for idx, im in enumerate(imgs):
109
+ r, c = divmod(idx, cols)
110
+ x = pad + c * (cell_w + pad)
111
+ y = pad + r * (cell_h + pad)
112
+ sheet.paste(im, (x, y))
113
+ draw.rectangle([x - 1, y - 1, x + im.width, y + im.height], outline=(180, 190, 205))
114
+ sheet.save(out_path)
115
+ return out_path
116
+
117
+
118
+ def render(pptx_path: str, out_dir: str, *, dpi: int = 110,
119
+ sheet_name: Optional[str] = None) -> Tuple[List[str], Optional[str], str]:
120
+ """完整流程:pptx -> pdf -> 每页 png (+ 总览图)。"""
121
+ pdf = to_pdf(pptx_path, out_dir)
122
+ pngs = pdf_to_pngs(pdf, out_dir, dpi=dpi)
123
+ sheet = None
124
+ if sheet_name and pngs:
125
+ sheet = contact_sheet(pngs, os.path.join(out_dir, sheet_name))
126
+ return pngs, sheet, pdf