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,575 @@
1
+ """命令行入口。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from typing import List, Optional
10
+
11
+ from . import __version__
12
+ from . import imagegen
13
+ from .builtin_template import build_builtin_template
14
+ from .layoutmap import build_map, describe_map
15
+ from .profile import describe as describe_profile
16
+ from .profile import learn_profile
17
+ from .render import Renderer
18
+ from .spec import TYPE_LABELS, SpecError, load_deck
19
+ from .template import describe as describe_template
20
+ from .template import load_template
21
+ from .util import inch_from_emu, pt_from_emu
22
+
23
+ DEFAULT_TEMPLATE_DIR = "templates"
24
+ DEFAULT_OUT_DIR = "out"
25
+
26
+
27
+ # --------------------------------------------------------------------------
28
+ def main(argv: Optional[List[str]] = None) -> int:
29
+ parser = argparse.ArgumentParser(
30
+ prog="pptxgen",
31
+ description="用你自己的 PPT 模板,从中小学课程大纲生成 pptx 课件。",
32
+ )
33
+ parser.add_argument("--version", action="version", version=f"pptxgen {__version__}")
34
+ sub = parser.add_subparsers(dest="cmd", required=True)
35
+
36
+ p_ins = sub.add_parser("inspect", help="解析模板:版式、配色、字体、背景、装饰层")
37
+ p_ins.add_argument("template")
38
+ p_ins.add_argument("-v", "--verbose", action="store_true", help="列出每个版式的占位符细节")
39
+ p_ins.add_argument("--json", action="store_true", help="以 JSON 输出")
40
+ p_ins.add_argument("--map", action="store_true", help="同时输出页型->版式 的自动映射")
41
+ p_ins.add_argument("--write-map", metavar="FILE", help="把自动映射写入 YAML 文件")
42
+ p_ins.set_defaults(func=cmd_inspect)
43
+
44
+ p_build = sub.add_parser("build", help="按课件规格生成 pptx")
45
+ p_build.add_argument("deck", help="课件规格文件(.yaml / .json / .md)")
46
+ p_build.add_argument("-t", "--template", help="模板 pptx(覆盖课件文件里的 template 字段)")
47
+ p_build.add_argument("-o", "--out", help="输出 pptx 路径")
48
+ p_build.add_argument("--no-chrome", action="store_true", help="不复制模板的装饰层/背景图")
49
+ p_build.add_argument("--keep-template-slides", action="store_true",
50
+ help="保留模板里原有的示例幻灯片")
51
+ p_build.add_argument("--show-map", action="store_true", help="输出页型->版式 映射")
52
+ p_build.add_argument("--open", action="store_true", help="生成后用系统默认程序打开")
53
+ p_build.set_defaults(func=cmd_build)
54
+
55
+ p_chk = sub.add_parser("check", help="自检生成的 pptx(页数、溢出风险、文字重叠)")
56
+ p_chk.add_argument("pptx")
57
+ p_chk.add_argument("--render", action="store_true",
58
+ help="额外渲染成 PDF 做真实排版检查(能发现文字重叠,需要 LibreOffice)")
59
+ p_chk.set_defaults(func=cmd_check)
60
+
61
+ p_rev = sub.add_parser("review", help="设计审查:版式多样性、信息密度、视觉锚点、润色落实")
62
+ p_rev.add_argument("deck", help="课件规格文件(.yaml / .json / .md)")
63
+ p_rev.add_argument("--pptx", help="同时检查已生成的 pptx(能测算字号与配图面积)")
64
+ p_rev.set_defaults(func=cmd_review)
65
+
66
+ p_prev = sub.add_parser("preview", help="把 pptx 渲染成 PNG(需要 LibreOffice)")
67
+ p_prev.add_argument("pptx")
68
+ p_prev.add_argument("-o", "--out-dir", default=None, help="图片输出目录(默认 out/preview)")
69
+ p_prev.add_argument("--dpi", type=int, default=110, help="渲染分辨率,默认 110")
70
+ p_prev.add_argument("--no-sheet", action="store_true", help="不生成总览拼图")
71
+ p_prev.add_argument("--open", action="store_true", help="渲染后打开总览图")
72
+ p_prev.set_defaults(func=cmd_preview)
73
+
74
+ p_img = sub.add_parser("images", help="按课件里的 image_prompt 生成配图(需要图像模型 API key)")
75
+ p_img.add_argument("deck", nargs="?", help="课件规格文件")
76
+ p_img.add_argument("--dry-run", action="store_true", help="只列出会生成哪些图,不发请求")
77
+ p_img.add_argument("--init-config", nargs="?", const=".pptxgen.yaml", metavar="PATH",
78
+ help="生成配置文件模板;传 global 则写到 ~/.pptxgen/config.yaml(全局生效)")
79
+ p_img.add_argument("--backend", help=f"图像后端:{' / '.join(imagegen.BACKENDS)}")
80
+ p_img.add_argument("--limit", type=int, help="只先生成前 N 张")
81
+ p_img.add_argument("--force", action="store_true", help="忽略缓存,强制重新生成")
82
+ p_img.set_defaults(func=cmd_images)
83
+
84
+ p_demo = sub.add_parser("demo", help="用内置模板生成一份完整示例课件")
85
+ p_demo.add_argument("-o", "--out-dir", default="examples", help="输出目录")
86
+ p_demo.set_defaults(func=cmd_demo)
87
+
88
+ p_init = sub.add_parser("init-deck", help="生成一份课件规格模板文件")
89
+ p_init.add_argument("--title", default="我的课件", help="课件标题")
90
+ p_init.add_argument("-o", "--out", default=None, help="输出 yaml 路径")
91
+ p_init.set_defaults(func=cmd_init_deck)
92
+
93
+ args = parser.parse_args(argv)
94
+ try:
95
+ return args.func(args)
96
+ except SpecError as exc:
97
+ print(f"错误:{exc}", file=sys.stderr)
98
+ return 2
99
+ except FileNotFoundError as exc:
100
+ print(f"错误:找不到文件 {exc}", file=sys.stderr)
101
+ return 2
102
+
103
+
104
+ # --------------------------------------------------------------------------
105
+ def cmd_inspect(args) -> int:
106
+ if not os.path.exists(args.template):
107
+ print(f"错误:找不到模板文件 {args.template}", file=sys.stderr)
108
+ return 2
109
+ tpl = load_template(args.template)
110
+ if args.json:
111
+ print(json.dumps(_template_to_dict(tpl), ensure_ascii=False, indent=2))
112
+ return 0
113
+ print(describe_template(tpl, verbose=args.verbose))
114
+ print()
115
+ try:
116
+ print(describe_profile(learn_profile(tpl)))
117
+ except Exception as exc:
118
+ print(f"样式档案:学习失败({exc})")
119
+ mapping = build_map(tpl)
120
+ if args.map or args.write_map:
121
+ print()
122
+ print(describe_map(tpl, mapping))
123
+ if args.write_map:
124
+ import yaml
125
+
126
+ with open(args.write_map, "w", encoding="utf-8") as f:
127
+ yaml.safe_dump({k: int(v) for k, v in mapping.items()}, f,
128
+ allow_unicode=True, sort_keys=False)
129
+ print(f"\n映射已写入 {args.write_map}(可手工修改后放进课件的 layout_map 字段)")
130
+ return 0
131
+
132
+
133
+ def _template_to_dict(tpl) -> dict:
134
+ return {
135
+ "path": tpl.path,
136
+ "slide_width_emu": tpl.width,
137
+ "slide_height_emu": tpl.height,
138
+ "slide_size_in": [round(inch_from_emu(tpl.width), 3), round(inch_from_emu(tpl.height), 3)],
139
+ "aspect": round(tpl.aspect, 4),
140
+ "sample_slides": tpl.sample_slide_count,
141
+ "theme_colors": tpl.theme_colors,
142
+ "theme_fonts": tpl.theme_fonts,
143
+ "master_background": _bg_to_dict(tpl.master_background),
144
+ "slide_background_override": (
145
+ {"kind": tpl.slide_background.get("kind"), "count": tpl.slide_background.get("count")}
146
+ if tpl.slide_background else None
147
+ ),
148
+ "style_profile": _profile_to_dict(tpl),
149
+ "global_chrome": [
150
+ {"name": it.name, "full_bleed": it.is_full_bleed, "occurrences": it.occurrences,
151
+ "box_in": [round(inch_from_emu(v), 3) if v is not None else None for v in it.box]}
152
+ for it in tpl.global_chrome
153
+ ],
154
+ "layouts": [
155
+ {
156
+ "index": lay.index,
157
+ "name": lay.name,
158
+ "background": _bg_to_dict(lay.background),
159
+ "chrome_count": len(lay.chrome),
160
+ "placeholders": [
161
+ {
162
+ "idx": ph.idx,
163
+ "type": ph.ph_type,
164
+ "name": ph.name,
165
+ "box_in": [round(inch_from_emu(v), 3) for v in ph.box],
166
+ "font_size_pt": ph.font_size_pt,
167
+ "font_name": ph.font_name,
168
+ "color": ph.color,
169
+ }
170
+ for ph in lay.placeholders
171
+ ],
172
+ }
173
+ for lay in tpl.layouts
174
+ ],
175
+ }
176
+
177
+
178
+ def _profile_to_dict(tpl) -> Optional[dict]:
179
+ try:
180
+ prof = learn_profile(tpl)
181
+ except Exception:
182
+ return None
183
+ if prof is None:
184
+ return None
185
+
186
+ def sk(s):
187
+ if s is None:
188
+ return None
189
+ return {
190
+ "box_in": [round(v / 914400, 3) for v in s.box],
191
+ "occurrences": s.occurrences,
192
+ "font": s.style.font,
193
+ "size_pt": s.style.size_pt,
194
+ "align": s.style.align,
195
+ "fill": s.fill,
196
+ "line": s.line,
197
+ }
198
+
199
+ def box(b):
200
+ return [round(v / 914400, 3) for v in b] if b else None
201
+
202
+ return {
203
+ "module_label": sk(prof.module_label),
204
+ "title_chip": sk(prof.title_chip),
205
+ "content_box_in": box(prof.content_box),
206
+ "cover_title_box_in": box(prof.cover_title_box),
207
+ "end_title_box_in": box(prof.end_title_box),
208
+ }
209
+
210
+
211
+ def _bg_to_dict(bg) -> Optional[dict]:
212
+ if not bg:
213
+ return None
214
+ return {k: v for k, v in bg.items() if k not in ("element", "owner", "blob")}
215
+
216
+
217
+ # --------------------------------------------------------------------------
218
+ def cmd_build(args) -> int:
219
+ deck = load_deck(args.deck)
220
+ template_path = _resolve_template(args.template or deck.get("template"), deck)
221
+ if template_path is None:
222
+ print("错误:没有可用的模板。请用 -t 指定,或在课件文件里写 template: 路径。",
223
+ file=sys.stderr)
224
+ return 2
225
+
226
+ tpl = load_template(template_path)
227
+ if args.no_chrome:
228
+ deck["options"]["chrome"] = "off"
229
+ if args.show_map:
230
+ print(describe_map(tpl, build_map(tpl, deck.get("layout_map"))))
231
+ print()
232
+
233
+ out = args.out or deck.get("output") or _default_output(deck, args.deck)
234
+ renderer = Renderer(tpl, deck)
235
+ report = renderer.render(out, keep_template_slides=args.keep_template_slides)
236
+
237
+ print(f"模板:{template_path}")
238
+ print(f"输出:{os.path.abspath(out)}")
239
+ print(report.text())
240
+ if args.open:
241
+ _open_file(os.path.abspath(out))
242
+ return 0
243
+
244
+
245
+ def _resolve_template(value: Optional[str], deck: dict) -> Optional[str]:
246
+ if value:
247
+ cand = value if os.path.isabs(value) else os.path.join(deck.get("_dir", "."), value)
248
+ if os.path.exists(cand):
249
+ return cand
250
+ if os.path.exists(value):
251
+ return value
252
+ print(f"警告:课件里指定的模板不存在:{value}", file=sys.stderr)
253
+ # 自动发现:templates/ 下只有一个 pptx 时直接用它
254
+ for d in (deck.get("_dir", "."), DEFAULT_TEMPLATE_DIR, "."):
255
+ full = os.path.join(d, DEFAULT_TEMPLATE_DIR)
256
+ if os.path.isdir(full):
257
+ found = sorted(f for f in os.listdir(full) if f.lower().endswith(".pptx")
258
+ and not f.startswith("~$"))
259
+ if len(found) == 1:
260
+ return os.path.join(full, found[0])
261
+ if len(found) > 1:
262
+ print(f"提示:{full} 下有多个模板,请在课件里用 template: 指定,或用 -t 指定。",
263
+ file=sys.stderr)
264
+ return None
265
+ return None
266
+
267
+
268
+ def _default_output(deck: dict, deck_path: str) -> str:
269
+ title = (deck.get("meta") or {}).get("title") or os.path.splitext(os.path.basename(deck_path))[0]
270
+ safe = "".join(c for c in str(title) if c not in '\\/:*?"<>|').strip() or "output"
271
+ base = os.path.dirname(os.path.abspath(deck_path))
272
+ root = base
273
+ for _ in range(3):
274
+ if os.path.basename(root) in ("decks", "examples", "input"):
275
+ root = os.path.dirname(root)
276
+ break
277
+ root = os.path.dirname(root)
278
+ return os.path.join(root, DEFAULT_OUT_DIR, f"{safe}.pptx")
279
+
280
+
281
+ def _open_file(path: str) -> None:
282
+ try:
283
+ if sys.platform == "darwin":
284
+ os.system(f'open "{path}"')
285
+ elif os.name == "nt":
286
+ os.startfile(path) # type: ignore[attr-defined]
287
+ else:
288
+ os.system(f'xdg-open "{path}"')
289
+ except Exception:
290
+ pass
291
+
292
+
293
+ # --------------------------------------------------------------------------
294
+ def cmd_check(args) -> int:
295
+ from pptx import Presentation
296
+
297
+ from .util import wrap_lines
298
+
299
+ if not os.path.exists(args.pptx):
300
+ print(f"错误:找不到文件 {args.pptx}", file=sys.stderr)
301
+ return 2
302
+ prs = Presentation(args.pptx)
303
+ W, H = prs.slide_width, prs.slide_height
304
+ problems = []
305
+ slides = list(prs.slides)
306
+ print(f"文件:{os.path.abspath(args.pptx)}")
307
+ print(f"画布:{inch_from_emu(W):.2f} x {inch_from_emu(H):.2f} in")
308
+ print(f"页数:{len(slides)}")
309
+ print()
310
+ for i, slide in enumerate(slides, 1):
311
+ texts = []
312
+ shapes = 0
313
+ pics = 0
314
+ for shp in slide.shapes:
315
+ shapes += 1
316
+ if shp.__class__.__name__ == "Picture":
317
+ pics += 1
318
+ if shp.has_text_frame:
319
+ t = shp.text_frame.text.strip()
320
+ if t:
321
+ texts.append((shp, t))
322
+ title = texts[0][1].split("\n")[0][:42] if texts else "(无文字)"
323
+ print(f"P{i:>2} 形状={shapes:<3} 图片={pics:<2} 文本块={len(texts):<2} | {title}")
324
+ if not texts and pics == 0:
325
+ problems.append(f"P{i}: 整页没有任何文字或图片")
326
+
327
+ for shp, t in texts:
328
+ try:
329
+ w = pt_from_emu(shp.width)
330
+ h = pt_from_emu(shp.height)
331
+ left = pt_from_emu(shp.left)
332
+ top = pt_from_emu(shp.top)
333
+ except Exception:
334
+ continue
335
+ if w <= 0 or h <= 0:
336
+ continue
337
+ sizes = [r.font.size.pt for p in shp.text_frame.paragraphs
338
+ for r in p.runs if r.font.size]
339
+ base = max(sizes) if sizes else 18.0
340
+ n_lines = max(1, wrap_lines(t, base, w))
341
+ # 单行文本只占一行字高(居中的数字/角标靠字形高度,不需要整行行高)
342
+ need = base * 0.95 if n_lines == 1 else n_lines * base * 1.32
343
+ if need > h * 1.05:
344
+ problems.append(
345
+ f"P{i}: 文本可能溢出(约需 {need:.0f}pt,框高 {h:.0f}pt,{n_lines} 行):{t[:28]!r}"
346
+ )
347
+ if left < -2 or top < -2 or left + w > pt_from_emu(W) + 2 or top + h > pt_from_emu(H) + 2:
348
+ problems.append(f"P{i}: 文本块超出画布边界:{t[:24]!r}")
349
+ print()
350
+ if args.render:
351
+ try:
352
+ rendered = _render_audit(args.pptx)
353
+ except Exception as exc:
354
+ print(f"(跳过渲染检查:{exc})")
355
+ rendered = []
356
+ if rendered:
357
+ problems.extend(rendered)
358
+ else:
359
+ print("渲染检查:未发现文字重叠或越界。")
360
+ # 严格模式:OOXML Schema 校验(PowerPoint 同源规则,抓 LibreOffice 看不出的结构错误)
361
+ from .schemacheck import check_schema
362
+
363
+ schema_failures, schema_skipped, schema_ok = check_schema(args.pptx)
364
+ if not schema_ok:
365
+ print("严格模式校验(OOXML Schema):不可用(缺少 Schema 文件),已跳过")
366
+ elif schema_failures:
367
+ print(f"严格模式校验(OOXML Schema):发现 {len(schema_failures)} 个问题")
368
+ for f in schema_failures[:12]:
369
+ print(f" ! {f}")
370
+ if len(schema_failures) > 12:
371
+ print(f" ... 其余 {len(schema_failures) - 12} 个")
372
+ problems.append("严格模式校验发现 OOXML 结构错误(PowerPoint 会因此弹修复框)")
373
+ else:
374
+ print("严格模式校验(OOXML Schema):全部部件通过")
375
+ if schema_skipped:
376
+ print(f" ({len(schema_skipped)} 个部件含微软扩展命名空间,已跳过严格校验)")
377
+ if problems:
378
+ print(f"发现 {len(problems)} 个潜在问题:")
379
+ for p in problems:
380
+ print(f" ! {p}")
381
+ return 1
382
+ print("自检通过:未发现明显溢出、越界或空白页。")
383
+ return 0
384
+
385
+
386
+ # --------------------------------------------------------------------------
387
+ def cmd_images(args) -> int:
388
+ from . import imagegen
389
+
390
+ if args.init_config:
391
+ target = args.init_config
392
+ if target.lower() in ("global", "user"):
393
+ target = imagegen.CONFIG_PATHS[1] # ~/.pptxgen/config.yaml
394
+ if os.path.exists(target):
395
+ print(f"配置文件已存在,未覆盖:{os.path.abspath(target)}")
396
+ print("需要重新生成就先删掉它,或直接编辑。")
397
+ return 0
398
+ path = imagegen.write_config_template(target)
399
+ print(f"已生成配置模板:{os.path.abspath(path)}")
400
+ print("填好 base_url / model / api_key 后,运行:pptxgen images <课件.yaml>")
401
+ return 0
402
+
403
+ if not args.deck:
404
+ print("错误:请给出课件文件,或用 --init-config 生成配置模板", file=sys.stderr)
405
+ return 2
406
+ deck = load_deck(args.deck)
407
+ if args.backend:
408
+ os.environ["PPTXGEN_IMAGE_BACKEND"] = args.backend
409
+
410
+ opts = (deck.get("options") or {}).get("images") or {}
411
+ cache_dir = imagegen.normalize_dir(opts.get("cache_dir"), deck.get("_dir", "."))
412
+
413
+ # dry-run 不需要 key 也能算清单
414
+ try:
415
+ cfg = imagegen.load_config(opts)
416
+ except imagegen.ImageGenError as exc:
417
+ if not args.dry_run:
418
+ print(f"错误:{exc}", file=sys.stderr)
419
+ return 3
420
+ cfg = imagegen.ImageConfig(cache_dir=cache_dir)
421
+ cfg.backend = args.backend or opts.get("backend") or "openai-compatible"
422
+ cfg.model = opts.get("model") or "(未配置)"
423
+ cfg.size = opts.get("size") or imagegen.BACKENDS[cfg.backend].default_size
424
+ cfg.style = opts.get("style") or imagegen.DEFAULT_STYLE
425
+ cfg.cache_dir = cache_dir
426
+
427
+ jobs = imagegen.collect_jobs(deck, cfg)
428
+ if not jobs:
429
+ print("这份课件里没有 image_prompt,无需生成配图。")
430
+ print("在需要配图的页面加上一行即可,例如:")
431
+ print(" - type: content")
432
+ print(" image_prompt: 一间气象观测站,卫星天线和风速仪")
433
+ print(" image_side: right")
434
+ return 0
435
+
436
+ print(f"后端:{cfg.backend} 模型:{cfg.model} 尺寸:{cfg.size}")
437
+ print(f"缓存目录:{cfg.cache_dir}")
438
+ print(f"配图任务:{len(jobs)} 张(已缓存 {sum(1 for j in jobs if j['cached'])} 张)")
439
+ print()
440
+ for j in jobs:
441
+ mark = "[有]" if j["cached"] else "[待生成]"
442
+ print(f" {mark} P{j['index']:>2} {j['title'][:28]:<30} {j['prompt'][:44]}")
443
+
444
+ if args.dry_run:
445
+ todo = [j for j in jobs if not j["cached"]]
446
+ print()
447
+ print(f"dry-run:本次会真实生成 {len(todo)} 张(另有 {len(jobs) - len(todo)} 张命中缓存)。")
448
+ print("确认后运行:pptxgen images " + args.deck)
449
+ return 0
450
+
451
+ print()
452
+ stats = imagegen.generate_for_deck(deck, cfg, force=args.force, limit=args.limit)
453
+ print()
454
+ print(f"完成:新生成 {stats['done']} 张,命中缓存 {stats['cached']} 张,失败 {len(stats['failed'])} 张")
455
+ for idx, err in stats["failed"]:
456
+ print(f" ! P{idx}: {err}")
457
+ if stats["done"] or stats["cached"]:
458
+ print()
459
+ print("现在重新生成课件即可自动插入配图:")
460
+ print(f" pptxgen build {args.deck}")
461
+ return 1 if stats["failed"] else 0
462
+
463
+
464
+ def cmd_preview(args) -> int:
465
+ from . import preview as pv
466
+
467
+ if not os.path.exists(args.pptx):
468
+ print(f"错误:找不到文件 {args.pptx}", file=sys.stderr)
469
+ return 2
470
+ out_dir = args.out_dir or os.path.join(DEFAULT_OUT_DIR, "preview")
471
+ sheet_name = None if args.no_sheet else "_总览.png"
472
+ try:
473
+ pngs, sheet, pdf = pv.render(args.pptx, out_dir, dpi=args.dpi, sheet_name=sheet_name)
474
+ except RuntimeError as exc:
475
+ print(f"错误:{exc}", file=sys.stderr)
476
+ return 3
477
+ print(f"PDF :{os.path.abspath(pdf)}")
478
+ print(f"共渲染 {len(pngs)} 页 PNG 到 {os.path.abspath(out_dir)}")
479
+ if sheet:
480
+ print(f"总览图:{os.path.abspath(sheet)}")
481
+ if args.open:
482
+ _open_file(os.path.abspath(sheet))
483
+ return 0
484
+
485
+
486
+ def cmd_review(args) -> int:
487
+ from .review import format_findings, review_deck
488
+ from .spec import SpecError, load_deck
489
+
490
+ try:
491
+ deck = load_deck(args.deck)
492
+ except SpecError as exc:
493
+ print(f"错误:{exc}", file=sys.stderr)
494
+ return 2
495
+ findings, report = review_deck(deck, getattr(args, "pptx", None))
496
+ print(report)
497
+ print()
498
+ print(format_findings(findings))
499
+ return 1 if any(f.level == "错误" for f in findings) else 0
500
+
501
+
502
+ def _render_audit(pptx_path: str) -> List[str]:
503
+ """用真实渲染结果(PDF 的文字块位置)检查重叠与越界。"""
504
+ import tempfile
505
+
506
+ import pymupdf as fitz
507
+
508
+ from . import preview as pv
509
+
510
+ problems: List[str] = []
511
+ with tempfile.TemporaryDirectory() as tmp:
512
+ pdf = pv.to_pdf(pptx_path, tmp)
513
+ with fitz.open(pdf) as doc:
514
+ for pno, page in enumerate(doc, 1):
515
+ W, H = page.rect.width, page.rect.height
516
+ blocks = [b for b in page.get_text("blocks") if b[4].strip()]
517
+ for b in blocks:
518
+ x0, y0, x1, y1 = b[0], b[1], b[2], b[3]
519
+ if x0 < -1.5 or y0 < -1.5 or x1 > W + 1.5 or y1 > H + 1.5:
520
+ problems.append(f"P{pno}: 文字跑出页面:{_short(b[4])}")
521
+ for i in range(len(blocks)):
522
+ for j in range(i + 1, len(blocks)):
523
+ a, c = blocks[i], blocks[j]
524
+ ox = min(a[2], c[2]) - max(a[0], c[0])
525
+ oy = min(a[3], c[3]) - max(a[1], c[1])
526
+ if ox <= 3 or oy <= 3:
527
+ continue
528
+ small = min((a[2] - a[0]) * (a[3] - a[1]), (c[2] - c[0]) * (c[3] - c[1]))
529
+ if small > 0 and (ox * oy) / small > 0.25:
530
+ problems.append(
531
+ f"P{pno}: 文字互相重叠:{_short(a[4])} <-> {_short(c[4])}")
532
+ return problems
533
+
534
+
535
+ def _short(text: str, n: int = 18) -> str:
536
+ return repr(" ".join(str(text).split())[:n])
537
+
538
+
539
+ def cmd_demo(args) -> int:
540
+ out_dir = os.path.abspath(args.out_dir)
541
+ tpl_dir = os.path.join(out_dir, "assets")
542
+ os.makedirs(tpl_dir, exist_ok=True)
543
+ tpl_path = os.path.join(tpl_dir, "内置模板_16x9.pptx")
544
+ build_builtin_template(tpl_path)
545
+ print(f"已生成内置模板:{tpl_path}")
546
+
547
+ from .examples import DEMO_DECK_YAML
548
+
549
+ deck_path = os.path.join(out_dir, "示例课件_分数的初步认识.yaml")
550
+ with open(deck_path, "w", encoding="utf-8") as f:
551
+ f.write(DEMO_DECK_YAML)
552
+ print(f"已生成示例课件规格:{deck_path}")
553
+
554
+ tpl = load_template(tpl_path)
555
+ deck = load_deck(deck_path)
556
+ out_pptx = os.path.join(out_dir, "示例课件_分数的初步认识.pptx")
557
+ report = Renderer(tpl, deck).render(out_pptx)
558
+ print(f"已生成示例 pptx:{out_pptx}")
559
+ print(report.text())
560
+ return 0
561
+
562
+
563
+ def cmd_init_deck(args) -> int:
564
+ from .examples import deck_template
565
+
566
+ out = args.out or f"{args.title}.yaml"
567
+ with open(out, "w", encoding="utf-8") as f:
568
+ f.write(deck_template(args.title))
569
+ print(f"已生成课件规格模板:{os.path.abspath(out)}")
570
+ print("填好后运行:pptxgen build " + out + " -t 你的模板.pptx")
571
+ return 0
572
+
573
+
574
+ if __name__ == "__main__":
575
+ raise SystemExit(main())