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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/cordis.patch.yml +9 -0
- package/engine/pptxgen/__init__.py +8 -0
- package/engine/pptxgen/__main__.py +4 -0
- package/engine/pptxgen/builtin_template.py +218 -0
- package/engine/pptxgen/cli.py +575 -0
- package/engine/pptxgen/examples.py +290 -0
- package/engine/pptxgen/imagegen.py +564 -0
- package/engine/pptxgen/layoutmap.py +211 -0
- package/engine/pptxgen/patterns.py +106 -0
- package/engine/pptxgen/preview.py +126 -0
- package/engine/pptxgen/profile.py +456 -0
- package/engine/pptxgen/render.py +1673 -0
- package/engine/pptxgen/review.py +184 -0
- package/engine/pptxgen/schemacheck.py +122 -0
- package/engine/pptxgen/schemas/XAdES.xsd +466 -0
- package/engine/pptxgen/schemas/XAdESv141.xsd +15 -0
- package/engine/pptxgen/schemas/chartEx.xsd +838 -0
- package/engine/pptxgen/schemas/dml-chart.xsd +1499 -0
- package/engine/pptxgen/schemas/dml-chartDrawing.xsd +146 -0
- package/engine/pptxgen/schemas/dml-diagram.xsd +1085 -0
- package/engine/pptxgen/schemas/dml-drawing.xsd +63 -0
- package/engine/pptxgen/schemas/dml-lockedCanvas.xsd +11 -0
- package/engine/pptxgen/schemas/dml-main.xsd +3081 -0
- package/engine/pptxgen/schemas/dml-picture.xsd +23 -0
- package/engine/pptxgen/schemas/dml-spreadsheetDrawing.xsd +185 -0
- package/engine/pptxgen/schemas/dml-wordprocessingDrawing.xsd +287 -0
- package/engine/pptxgen/schemas/drawing-chart2012.xsd +129 -0
- package/engine/pptxgen/schemas/markup-compatibility.xsd +95 -0
- package/engine/pptxgen/schemas/opc-digSig.xsd +49 -0
- package/engine/pptxgen/schemas/opc-relationships.xsd +33 -0
- package/engine/pptxgen/schemas/pml.xsd +1676 -0
- package/engine/pptxgen/schemas/shared-additionalCharacteristics.xsd +28 -0
- package/engine/pptxgen/schemas/shared-bibliography.xsd +144 -0
- package/engine/pptxgen/schemas/shared-commonSimpleTypes.xsd +172 -0
- package/engine/pptxgen/schemas/shared-customXmlDataProperties.xsd +25 -0
- package/engine/pptxgen/schemas/shared-customXmlSchemaProperties.xsd +18 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesCustom.xsd +59 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesExtended.xsd +56 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/engine/pptxgen/schemas/shared-math.xsd +582 -0
- package/engine/pptxgen/schemas/shared-relationshipReference.xsd +25 -0
- package/engine/pptxgen/schemas/signatureInfo.xsd +103 -0
- package/engine/pptxgen/schemas/sml.xsd +4439 -0
- package/engine/pptxgen/schemas/visio.xsd +829 -0
- package/engine/pptxgen/schemas/vml-main.xsd +570 -0
- package/engine/pptxgen/schemas/vml-officeDrawing.xsd +509 -0
- package/engine/pptxgen/schemas/vml-presentationDrawing.xsd +12 -0
- package/engine/pptxgen/schemas/vml-spreadsheetDrawing.xsd +108 -0
- package/engine/pptxgen/schemas/vml-wordprocessingDrawing.xsd +96 -0
- package/engine/pptxgen/schemas/vmlDrawing.xsd +36 -0
- package/engine/pptxgen/schemas/wml.xsd +3643 -0
- package/engine/pptxgen/schemas/word12.xsd +66 -0
- package/engine/pptxgen/schemas/xlThreadedComments.xsd +59 -0
- package/engine/pptxgen/schemas/xlThreadedComments2.xsd +22 -0
- package/engine/pptxgen/schemas/xmldsig-core-schema.xsd +318 -0
- package/engine/pptxgen/shapes.py +417 -0
- package/engine/pptxgen/spec.py +529 -0
- package/engine/pptxgen/template.py +834 -0
- package/engine/pptxgen/util.py +218 -0
- package/engine/pptxgen/xmlutil.py +298 -0
- package/engine/pptxgen.sh +7 -0
- package/engine/requirements.txt +9 -0
- package/lib/index.js +186 -0
- package/package.json +34 -0
- package/skills/courseware/SKILL.md +130 -0
- package/skills/courseware/references/content-polish.md +121 -0
- package/skills/courseware/references/deck-spec.md +155 -0
- package/skills/courseware/references/design-review.md +121 -0
- package/skills/courseware/references/image-backends.md +94 -0
- package/skills/courseware/references/quality-gates.md +71 -0
- package/skills/courseware/references/roadmap.md +55 -0
- package/skills/courseware/references/template-fidelity.md +100 -0
- package/skills/courseware/workflows/generate.md +172 -0
- package/skills/courseware/workflows/quick.md +41 -0
- package/skills/courseware/workflows/revise.md +40 -0
- package/skills/courseware/workflows/routing.md +49 -0
- package/skills/courseware/workflows/stages/generate-images.md +74 -0
- 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
|