@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.2
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 +131 -2
- package/asset_catalog.py +159 -0
- package/assets/base_template.pptx +0 -0
- package/assets/cover_deco.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/slide2_right.png +0 -0
- package/assets/theme_apricot.jpg +0 -0
- package/assets/theme_blush.jpg +0 -0
- package/assets/theme_coral.jpg +0 -0
- package/assets/theme_glow.jpg +0 -0
- package/assets/theme_mauve.jpg +0 -0
- package/assets/theme_peach.jpg +0 -0
- package/assets/theme_rose.jpg +0 -0
- package/assets/theme_sunset.jpg +0 -0
- package/assets/topright_deco.png +0 -0
- package/build_deck.py +2288 -0
- package/charts.py +329 -0
- package/check.py +236 -0
- package/config.json +5 -0
- package/contact_sheet.py +88 -0
- package/cordis.patch.yml +457 -0
- package/deck_baseline.py +335 -0
- package/delivery.py +143 -0
- package/density-recipe.md +110 -0
- package/example.json +26 -0
- package/formula.py +125 -0
- package/gen_image.py +395 -0
- package/generate_consensus.py +892 -0
- package/generate_ppt.py +1356 -0
- package/grasp_evidence.py +150 -0
- package/image_check.py +148 -0
- package/image_fetch.py +253 -0
- package/ingest.py +93 -0
- package/media.py +470 -0
- package/package.json +68 -4
- package/preset.yml +2 -0
- package/quality_check.py +196 -0
- package/render_ppt.py +91 -0
- package/requirements.txt +7 -0
- package/restyle.py +80 -0
- package/run_pptfast.sh +27 -0
- package/run_py.sh +43 -0
- package/slide_ops.py +160 -0
- package/slide_patch.py +153 -0
- package/speaker_notes.py +245 -0
- package/style-recipes.md +166 -0
- package/video_gif.py +164 -0
- package/vision_check.py +79 -0
package/deck_baseline.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Baseline snapshot and page-by-page change ledger for scoped deck edits.
|
|
3
|
+
|
|
4
|
+
A local edit is only trustworthy when it can be proven. This tool implements the
|
|
5
|
+
contract used by careful human editors:
|
|
6
|
+
|
|
7
|
+
1. ``snapshot`` before touching the deck: stable page id + position + title +
|
|
8
|
+
body lines + numbers + notes.
|
|
9
|
+
2. edit a working copy, never the original.
|
|
10
|
+
3. ``compare`` after rebuilding: exactly which pages changed, which moved,
|
|
11
|
+
which protected pages were left untouched, and whether any change fell
|
|
12
|
+
outside the authorised scope.
|
|
13
|
+
|
|
14
|
+
Stable ids are the slide names written by ``build_deck.py`` (``cSld/@name`` =
|
|
15
|
+
``P01``, ``P02`` ...). They survive reordering; the page number does not. Decks
|
|
16
|
+
without stable names fall back to positional ids and the ledger says so.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from pptx import Presentation
|
|
30
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
31
|
+
|
|
32
|
+
NUMBER = re.compile(
|
|
33
|
+
r"-?\d[\d,,]*(?:\.\d+)?"
|
|
34
|
+
r"(?:\s*(?:%|‰|mm|cm|km|kg|mg|N·m|N|元|万元|亿元|美元|万|亿|ms|s|Hz|°))?"
|
|
35
|
+
)
|
|
36
|
+
CHROME = "chrome:"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sha256(path: Path) -> str:
|
|
40
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _max_font(shape) -> float:
|
|
44
|
+
sizes = []
|
|
45
|
+
for paragraph in shape.text_frame.paragraphs:
|
|
46
|
+
if paragraph.font.size:
|
|
47
|
+
sizes.append(paragraph.font.size.pt)
|
|
48
|
+
for run in paragraph.runs:
|
|
49
|
+
if run.font.size:
|
|
50
|
+
sizes.append(run.font.size.pt)
|
|
51
|
+
return max(sizes) if sizes else 0.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _title(slide, texts: list[dict]) -> str:
|
|
55
|
+
named = next((item for item in texts if item["shape"] == "title"), None)
|
|
56
|
+
if named:
|
|
57
|
+
return " ".join(named["text"].split())
|
|
58
|
+
placeholder = slide.shapes.title
|
|
59
|
+
if placeholder is not None and placeholder.text.strip():
|
|
60
|
+
return " ".join(placeholder.text.split())
|
|
61
|
+
if not texts:
|
|
62
|
+
return ""
|
|
63
|
+
# Largest type first, then the top-most shape: that is the visual heading.
|
|
64
|
+
ranked = sorted(texts, key=lambda item: (-item["size"], item.get("top", 0)))
|
|
65
|
+
return " ".join(ranked[0]["text"].split())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _page_record(slide, index: int) -> dict[str, Any]:
|
|
69
|
+
texts = []
|
|
70
|
+
lines: list[str] = []
|
|
71
|
+
numbers: list[str] = []
|
|
72
|
+
pictures = charts = tables = 0
|
|
73
|
+
for shape in slide.shapes:
|
|
74
|
+
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
75
|
+
pictures += 1
|
|
76
|
+
elif getattr(shape, "has_chart", False):
|
|
77
|
+
charts += 1
|
|
78
|
+
elif getattr(shape, "has_table", False):
|
|
79
|
+
tables += 1
|
|
80
|
+
if not getattr(shape, "has_text_frame", False):
|
|
81
|
+
continue
|
|
82
|
+
text = shape.text.strip()
|
|
83
|
+
if not text:
|
|
84
|
+
continue
|
|
85
|
+
chrome = shape.name.startswith(CHROME)
|
|
86
|
+
texts.append({
|
|
87
|
+
"shape": shape.name, "shape_id": shape.shape_id,
|
|
88
|
+
"chrome": chrome, "size": round(_max_font(shape), 1),
|
|
89
|
+
"top": round(shape.top / 914400, 2) if shape.top is not None else 0,
|
|
90
|
+
"text": text,
|
|
91
|
+
})
|
|
92
|
+
if chrome:
|
|
93
|
+
continue
|
|
94
|
+
for line in text.split("\n"):
|
|
95
|
+
clean = " ".join(line.split())
|
|
96
|
+
if clean:
|
|
97
|
+
lines.append(clean)
|
|
98
|
+
numbers.extend(NUMBER.findall(clean))
|
|
99
|
+
numbers = ["".join(token.split()).replace(",", "").replace(",", "") for token in numbers]
|
|
100
|
+
notes = ""
|
|
101
|
+
if slide.has_notes_slide:
|
|
102
|
+
notes = slide.notes_slide.notes_text_frame.text.strip()
|
|
103
|
+
name = slide._element.cSld.get("name")
|
|
104
|
+
return {
|
|
105
|
+
"id": name or f"P{index:02d}",
|
|
106
|
+
"stable": bool(name),
|
|
107
|
+
"index": index,
|
|
108
|
+
"title": _title(slide, texts),
|
|
109
|
+
"texts": texts,
|
|
110
|
+
"lines": lines,
|
|
111
|
+
"numbers": numbers,
|
|
112
|
+
"pictures": pictures,
|
|
113
|
+
"charts": charts,
|
|
114
|
+
"tables": tables,
|
|
115
|
+
"notes": notes,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def snapshot(pptx: Path) -> dict[str, Any]:
|
|
120
|
+
prs = Presentation(str(pptx))
|
|
121
|
+
pages = [_page_record(slide, index) for index, slide in enumerate(prs.slides, 1)]
|
|
122
|
+
return {
|
|
123
|
+
"file": str(pptx.resolve()),
|
|
124
|
+
"sha256": sha256(pptx),
|
|
125
|
+
"slides": len(pages),
|
|
126
|
+
"size_inches": [round(prs.slide_width / 914400, 3),
|
|
127
|
+
round(prs.slide_height / 914400, 3)],
|
|
128
|
+
"stable_ids": any(page["stable"] for page in pages),
|
|
129
|
+
"pages": pages,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _diff(before: list[str], after: list[str]) -> tuple[list[str], list[str]]:
|
|
134
|
+
left, right = Counter(before), Counter(after)
|
|
135
|
+
return list((left - right).elements()), list((right - left).elements())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def compare(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
|
139
|
+
before_pages = before["pages"]
|
|
140
|
+
after_pages = after["pages"]
|
|
141
|
+
before_ids = {page["id"] for page in before_pages}
|
|
142
|
+
after_by_id: dict[str, dict] = {}
|
|
143
|
+
for page in after_pages:
|
|
144
|
+
after_by_id.setdefault(page["id"], page)
|
|
145
|
+
results = []
|
|
146
|
+
matched: set[str] = set()
|
|
147
|
+
for page in before_pages:
|
|
148
|
+
current = after_by_id.get(page["id"])
|
|
149
|
+
if current is None and not before["stable_ids"]:
|
|
150
|
+
current = after_pages[page["index"] - 1] if page["index"] <= len(after_pages) else None
|
|
151
|
+
if current is None:
|
|
152
|
+
results.append({"id": page["id"], "index": page["index"], "status": "missing",
|
|
153
|
+
"title_before": page["title"], "title_after": ""})
|
|
154
|
+
continue
|
|
155
|
+
matched.add(current["id"])
|
|
156
|
+
removed_lines, added_lines = _diff(page["lines"], current["lines"])
|
|
157
|
+
removed_numbers, added_numbers = _diff(page["numbers"], current["numbers"])
|
|
158
|
+
content_changed = bool(removed_lines or added_lines or removed_numbers
|
|
159
|
+
or added_numbers or page["notes"] != current["notes"]
|
|
160
|
+
or page["pictures"] != current["pictures"])
|
|
161
|
+
moved = page["index"] != current["index"]
|
|
162
|
+
status = "changed" if content_changed else ("moved" if moved else "unchanged")
|
|
163
|
+
results.append({
|
|
164
|
+
"id": page["id"],
|
|
165
|
+
"index_before": page["index"],
|
|
166
|
+
"index_after": current["index"],
|
|
167
|
+
"status": status,
|
|
168
|
+
"moved": moved,
|
|
169
|
+
"title_before": page["title"],
|
|
170
|
+
"title_after": current["title"],
|
|
171
|
+
"title_changed": page["title"] != current["title"],
|
|
172
|
+
"lines_added": added_lines,
|
|
173
|
+
"lines_removed": removed_lines,
|
|
174
|
+
"numbers_added": added_numbers,
|
|
175
|
+
"numbers_removed": removed_numbers,
|
|
176
|
+
"pictures_before": page["pictures"],
|
|
177
|
+
"pictures_after": current["pictures"],
|
|
178
|
+
"notes_changed": page["notes"] != current["notes"],
|
|
179
|
+
})
|
|
180
|
+
added_pages = [page for page in after_pages if page["id"] not in before_ids
|
|
181
|
+
and page["id"] not in matched]
|
|
182
|
+
order_before = [page["id"] for page in before_pages]
|
|
183
|
+
order_after = [page["id"] for page in after_pages]
|
|
184
|
+
return {
|
|
185
|
+
"before": {"file": before["file"], "sha256": before["sha256"], "slides": before["slides"]},
|
|
186
|
+
"after": {"file": after["file"], "sha256": after["sha256"], "slides": after["slides"]},
|
|
187
|
+
"stable_ids": before["stable_ids"] and after["stable_ids"],
|
|
188
|
+
"order_changed": order_before != order_after,
|
|
189
|
+
"order_before": order_before,
|
|
190
|
+
"order_after": order_after,
|
|
191
|
+
"pages": results,
|
|
192
|
+
"added": [{"id": page["id"], "index": page["index"], "title": page["title"]}
|
|
193
|
+
for page in added_pages],
|
|
194
|
+
"changed": [page["id"] for page in results if page["status"] == "changed"],
|
|
195
|
+
"moved": [page["id"] for page in results if page.get("moved")],
|
|
196
|
+
"missing": [page["id"] for page in results if page["status"] == "missing"],
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def violations(report: dict[str, Any], protect: set[str], expect: set[str],
|
|
201
|
+
allow_reorder: bool) -> list[str]:
|
|
202
|
+
problems: list[str] = []
|
|
203
|
+
for page in report["pages"]:
|
|
204
|
+
page_id = page["id"]
|
|
205
|
+
touched = page["status"] in {"changed", "missing"}
|
|
206
|
+
if page_id in protect and touched:
|
|
207
|
+
problems.append(f"{page_id} 是保护页,但内容发生了变化")
|
|
208
|
+
if page_id in protect and page.get("moved"):
|
|
209
|
+
problems.append(f"{page_id} 是保护页,但位置从 {page['index_before']} "
|
|
210
|
+
f"移到了 {page['index_after']}")
|
|
211
|
+
if expect and touched and page_id not in expect:
|
|
212
|
+
problems.append(f"{page_id} 不在授权修改范围内,却发生了内容变化")
|
|
213
|
+
if expect and page.get("moved") and not allow_reorder and page_id not in expect:
|
|
214
|
+
problems.append(f"{page_id} 未获授权就调整了页序")
|
|
215
|
+
for page in report["added"]:
|
|
216
|
+
if expect and page["id"] not in expect:
|
|
217
|
+
problems.append(f"{page['id']} 是新增页,不在授权范围内")
|
|
218
|
+
return problems
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def markdown(report: dict[str, Any], protect: set[str], expect: set[str],
|
|
222
|
+
problems: list[str]) -> str:
|
|
223
|
+
lines = [
|
|
224
|
+
"# 变更台账",
|
|
225
|
+
"",
|
|
226
|
+
f"- 基线:`{report['before']['file']}` · {report['before']['slides']} 页",
|
|
227
|
+
f"- 当前:`{report['after']['file']}` · {report['after']['slides']} 页",
|
|
228
|
+
f"- 稳定页名:{'是' if report['stable_ids'] else '否(按位置比较,仅供参照)'}",
|
|
229
|
+
f"- 页序变化:{'有' if report['order_changed'] else '无'}",
|
|
230
|
+
f"- 授权页:{', '.join(sorted(expect)) if expect else '未限定'}",
|
|
231
|
+
f"- 保护页:{', '.join(sorted(protect)) if protect else '未限定'}",
|
|
232
|
+
f"- 结论:{'**通过**' if not problems else '**未通过**'}",
|
|
233
|
+
"",
|
|
234
|
+
"| 页 | 位置 | 状态 | 标题 | 文字 +/− | 数字 +/− | 图片 |",
|
|
235
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
236
|
+
]
|
|
237
|
+
status_text = {"unchanged": "未变", "changed": "有改动",
|
|
238
|
+
"moved": "仅换位", "missing": "缺失"}
|
|
239
|
+
for page in report["pages"]:
|
|
240
|
+
if page["status"] == "missing":
|
|
241
|
+
lines.append(f"| {page['id']} | {page['index']} → — | 缺失 | {page['title_before']} "
|
|
242
|
+
"| — | — | — |")
|
|
243
|
+
continue
|
|
244
|
+
added = len(page["lines_added"]) + len(page["numbers_added"])
|
|
245
|
+
removed = len(page["lines_removed"]) + len(page["numbers_removed"])
|
|
246
|
+
title = page["title_after"] or "—"
|
|
247
|
+
if page["title_changed"]:
|
|
248
|
+
title = f"{page['title_before']} → {page['title_after']}"
|
|
249
|
+
lines.append(
|
|
250
|
+
f"| {page['id']} | {page['index_before']} → {page['index_after']} "
|
|
251
|
+
f"| {status_text.get(page['status'], page['status'])} | {title} "
|
|
252
|
+
f"| +{added} / −{removed} | +{len(page['numbers_added'])} / "
|
|
253
|
+
f"−{len(page['numbers_removed'])} | {page['pictures_before']} → "
|
|
254
|
+
f"{page['pictures_after']} |")
|
|
255
|
+
if report["added"]:
|
|
256
|
+
lines.extend(["", "## 新增页", ""])
|
|
257
|
+
for page in report["added"]:
|
|
258
|
+
lines.append(f"- {page['id']}(第 {page['index']} 页):{page['title']}")
|
|
259
|
+
lines.extend(["", "## 明细", ""])
|
|
260
|
+
for page in report["pages"]:
|
|
261
|
+
if page["status"] != "changed":
|
|
262
|
+
continue
|
|
263
|
+
lines.append(f"### {page['id']} · {page['title_after'] or page['title_before']}")
|
|
264
|
+
for item in page["lines_removed"]:
|
|
265
|
+
lines.append(f"- − {item}")
|
|
266
|
+
for item in page["lines_added"]:
|
|
267
|
+
lines.append(f"- + {item}")
|
|
268
|
+
for item in page["numbers_removed"]:
|
|
269
|
+
lines.append(f"- 数字 − {item}")
|
|
270
|
+
for item in page["numbers_added"]:
|
|
271
|
+
lines.append(f"- 数字 + {item}")
|
|
272
|
+
if page["notes_changed"]:
|
|
273
|
+
lines.append("- 备注讲稿有变化")
|
|
274
|
+
lines.append("")
|
|
275
|
+
if problems:
|
|
276
|
+
lines.extend(["## 未通过项", ""])
|
|
277
|
+
lines.extend(f"- {item}" for item in problems)
|
|
278
|
+
return "\n".join(lines) + "\n"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def parse_ids(value: str | None) -> set[str]:
|
|
282
|
+
return {token.strip() for token in (value or "").split(",") if token.strip()}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main() -> int:
|
|
286
|
+
parser = argparse.ArgumentParser(description=__doc__,
|
|
287
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
288
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
289
|
+
snap = sub.add_parser("snapshot", help="write the pre-edit baseline")
|
|
290
|
+
snap.add_argument("pptx", type=Path)
|
|
291
|
+
snap.add_argument("out", type=Path)
|
|
292
|
+
cmp_ = sub.add_parser("compare", help="diff the baseline against the rebuilt deck")
|
|
293
|
+
cmp_.add_argument("baseline", type=Path)
|
|
294
|
+
cmp_.add_argument("pptx", type=Path)
|
|
295
|
+
cmp_.add_argument("--out", type=Path, help="markdown ledger path")
|
|
296
|
+
cmp_.add_argument("--json", type=Path, help="machine-readable ledger path")
|
|
297
|
+
cmp_.add_argument("--protect", help="comma-separated page ids that must not change")
|
|
298
|
+
cmp_.add_argument("--expect", help="comma-separated page ids allowed to change")
|
|
299
|
+
cmp_.add_argument("--allow-reorder", action="store_true",
|
|
300
|
+
help="treat page moves as expected even outside --expect")
|
|
301
|
+
args = parser.parse_args()
|
|
302
|
+
|
|
303
|
+
if args.command == "snapshot":
|
|
304
|
+
data = snapshot(args.pptx)
|
|
305
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
args.out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
307
|
+
ids = ", ".join(page["id"] for page in data["pages"])
|
|
308
|
+
print(f"baseline: {args.out} ({data['slides']} pages; {ids})")
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
before = json.loads(args.baseline.read_text(encoding="utf-8"))
|
|
312
|
+
after = snapshot(args.pptx)
|
|
313
|
+
report = compare(before, after)
|
|
314
|
+
protect, expect = parse_ids(args.protect), parse_ids(args.expect)
|
|
315
|
+
problems = violations(report, protect, expect, args.allow_reorder)
|
|
316
|
+
if args.out:
|
|
317
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
args.out.write_text(markdown(report, protect, expect, problems), encoding="utf-8")
|
|
319
|
+
if args.json:
|
|
320
|
+
args.json.parent.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
args.json.write_text(json.dumps({**report, "protect": sorted(protect),
|
|
322
|
+
"expect": sorted(expect),
|
|
323
|
+
"violations": problems},
|
|
324
|
+
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
325
|
+
changed = len(report["changed"])
|
|
326
|
+
print(f"changed={changed} moved={len(report['moved'])} "
|
|
327
|
+
f"added={len(report['added'])} missing={len(report['missing'])} "
|
|
328
|
+
f"violations={len(problems)}")
|
|
329
|
+
for problem in problems:
|
|
330
|
+
print(f"VIOLATION: {problem}", file=sys.stderr)
|
|
331
|
+
return 2 if problems else 0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
raise SystemExit(main())
|
package/delivery.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit a notes-only deck and synchronize explicit files with remote SHA256 checks."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from zipfile import ZipFile
|
|
14
|
+
|
|
15
|
+
from pptx import Presentation
|
|
16
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
17
|
+
|
|
18
|
+
from speaker_notes import parse_script, preservation_changes
|
|
19
|
+
from video_gif import gif_info
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sha256(path: Path) -> str:
|
|
23
|
+
with path.open("rb") as stream:
|
|
24
|
+
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def audit(pptx: Path, baseline: Path | None = None, script: Path | None = None,
|
|
28
|
+
gif_pages: tuple[int, ...] = ()) -> dict:
|
|
29
|
+
"""Check notes equality and required animated media; compare all non-notes parts."""
|
|
30
|
+
with ZipFile(pptx) as archive:
|
|
31
|
+
if archive.testzip() or len(archive.namelist()) != len(set(archive.namelist())):
|
|
32
|
+
raise ValueError("invalid PPT ZIP")
|
|
33
|
+
entries = {name: archive.read(name) for name in archive.namelist()}
|
|
34
|
+
prs = Presentation(pptx)
|
|
35
|
+
report = {"pages": len(prs.slides), "sha256": sha256(pptx), "gif_pages": {},
|
|
36
|
+
"notes_pages": sum(s.has_notes_slide for s in prs.slides)}
|
|
37
|
+
if baseline:
|
|
38
|
+
with ZipFile(baseline) as archive:
|
|
39
|
+
original = {name: archive.read(name) for name in archive.namelist()}
|
|
40
|
+
changes = preservation_changes(original, entries)
|
|
41
|
+
if changes:
|
|
42
|
+
raise ValueError(f"content changed outside notes: {changes}")
|
|
43
|
+
report["content_preserved"] = True
|
|
44
|
+
if script:
|
|
45
|
+
sections, budget = parse_script(script, len(prs.slides))
|
|
46
|
+
for page, slide in enumerate(prs.slides, 1):
|
|
47
|
+
if not slide.has_notes_slide or slide.notes_slide.notes_text_frame.text != sections[page]:
|
|
48
|
+
raise ValueError(f"P{page}: notes differ from Markdown")
|
|
49
|
+
report.update(notes_match_markdown=True, budget_seconds=budget)
|
|
50
|
+
for page in gif_pages:
|
|
51
|
+
if not 1 <= page <= len(prs.slides):
|
|
52
|
+
raise ValueError(f"P{page}: page is outside deck")
|
|
53
|
+
animated = []
|
|
54
|
+
for shape in pictures(prs.slides[page - 1].shapes):
|
|
55
|
+
if shape.image.content_type == "image/gif":
|
|
56
|
+
animated.append(gif_info(shape.image.blob))
|
|
57
|
+
if not animated:
|
|
58
|
+
raise ValueError(f"P{page}: missing animated GIF")
|
|
59
|
+
report["gif_pages"][str(page)] = animated
|
|
60
|
+
return report
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pictures(shapes):
|
|
64
|
+
"""Visit pictures actually placed on the page, including nested groups."""
|
|
65
|
+
for shape in shapes:
|
|
66
|
+
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|
67
|
+
yield from pictures(shape.shapes)
|
|
68
|
+
elif shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
69
|
+
yield shape
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sync_files(root: Path, files: list[Path], host: str, destination: str, timeout: float = 300) -> dict:
|
|
73
|
+
"""Copy only requested regular files, never delete; verify remote SHA256 via SSH.
|
|
74
|
+
|
|
75
|
+
Uses the host's SSH configuration, agent or terminal-supported authentication.
|
|
76
|
+
Passwords are never accepted as parameters or persisted. Remote filenames are
|
|
77
|
+
shell-quoted.
|
|
78
|
+
"""
|
|
79
|
+
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", host) or host.startswith("-"):
|
|
80
|
+
raise ValueError("host must be an SSH alias or user@host")
|
|
81
|
+
if not destination.startswith("/") or any(char in destination for char in "\n\r\\"):
|
|
82
|
+
raise ValueError("remote destination must be an absolute POSIX path")
|
|
83
|
+
root = root.resolve()
|
|
84
|
+
relative = []
|
|
85
|
+
for path in files:
|
|
86
|
+
if path.is_symlink() or not path.is_file():
|
|
87
|
+
raise ValueError(f"not a regular deliverable file: {path}")
|
|
88
|
+
name = path.resolve().relative_to(root).as_posix()
|
|
89
|
+
if any(char in name for char in "\n\r\\"):
|
|
90
|
+
raise ValueError("deliverable filenames cannot contain newlines or backslashes")
|
|
91
|
+
relative.append(name)
|
|
92
|
+
if not relative or len(set(relative)) != len(relative):
|
|
93
|
+
raise ValueError("select at least one file, without duplicates")
|
|
94
|
+
ssh = ["ssh", "-o", "ConnectTimeout=10", host]
|
|
95
|
+
with tempfile.TemporaryDirectory(prefix="ppt-sync-") as directory:
|
|
96
|
+
listing = Path(directory) / "files"
|
|
97
|
+
listing.write_bytes(b"\0".join(name.encode() for name in relative) + b"\0")
|
|
98
|
+
subprocess.run([*ssh, "mkdir -p -- " + shlex.quote(destination)], check=True, timeout=timeout)
|
|
99
|
+
subprocess.run(["rsync", "-a", "--checksum", "--protect-args", "--from0",
|
|
100
|
+
f"--files-from={listing}", "-e", "ssh -o ConnectTimeout=10",
|
|
101
|
+
str(root) + "/", f"{host}:{destination.rstrip('/')}/"], check=True, timeout=timeout)
|
|
102
|
+
paths = [str(PurePosixPath(destination) / name) for name in relative]
|
|
103
|
+
command = "sha256sum -- " + " ".join(shlex.quote(path) for path in paths)
|
|
104
|
+
remote = subprocess.run([*ssh, command], capture_output=True, text=True, check=True, timeout=timeout)
|
|
105
|
+
lines = remote.stdout.splitlines()
|
|
106
|
+
expected = {str(PurePosixPath(destination) / name): sha256(root / name) for name in relative}
|
|
107
|
+
actual = {}
|
|
108
|
+
for line in lines:
|
|
109
|
+
digest, name = line.split(" ", 1)
|
|
110
|
+
actual[name] = digest
|
|
111
|
+
if expected != actual:
|
|
112
|
+
raise ValueError("remote SHA256 mismatch; synchronization is not verified")
|
|
113
|
+
return {"host": host, "destination": destination, "files": expected, "sha256_verified": True}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> int:
|
|
117
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
118
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
119
|
+
checker = commands.add_parser("audit")
|
|
120
|
+
checker.add_argument("pptx", type=Path)
|
|
121
|
+
checker.add_argument("--baseline", type=Path)
|
|
122
|
+
checker.add_argument("--script", type=Path)
|
|
123
|
+
checker.add_argument("--gif-pages", type=int, nargs="*", default=[])
|
|
124
|
+
checker.add_argument("--report", type=Path)
|
|
125
|
+
sync = commands.add_parser("sync")
|
|
126
|
+
sync.add_argument("root", type=Path)
|
|
127
|
+
sync.add_argument("--files", nargs="+", required=True, help="paths relative to root")
|
|
128
|
+
sync.add_argument("--host", required=True)
|
|
129
|
+
sync.add_argument("--destination", required=True)
|
|
130
|
+
sync.add_argument("--timeout", type=float, default=300)
|
|
131
|
+
args = parser.parse_args()
|
|
132
|
+
if args.command == "audit":
|
|
133
|
+
report = audit(args.pptx, args.baseline, args.script, tuple(args.gif_pages))
|
|
134
|
+
if args.report:
|
|
135
|
+
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
136
|
+
else:
|
|
137
|
+
report = sync_files(args.root, [args.root / name for name in args.files], args.host, args.destination, args.timeout)
|
|
138
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# PPT 助手「密度优先」配方(博采众长版)
|
|
2
|
+
|
|
3
|
+
来源:CyberPPT(高密度咨询式)、pptfast(DSH deck 引擎)、Awesome-PPT-Design-Skills(日系/未来科技编辑风)、ppt-master(工作流纪律)、dsh-office、dsh-wanghong-handwritten-ppt,以及本机多轮 DeepSeek 视觉模型的实测迭代。
|
|
4
|
+
|
|
5
|
+
核心结论一句话:**pptfast 的默认是「编辑风留白」,要「饱满充实」必须显式走密度优先配方——dense 节奏 + 满幅背景 + 富内容 + 多版式 + 禁止大白卡 + 不对称构图。**
|
|
6
|
+
|
|
7
|
+
## 0. 先定风格与结论(2026-11 补充)
|
|
8
|
+
- **先当视觉总监**:受众/行动 → 七行风格简报(三个形容词、一个设计张力、密度、配色、字体、图片、节奏)→ 只选一套主配方(`style-recipes.md`),需要时主+辅,不平均折中。
|
|
9
|
+
- **评审顺序**:先 `contact_sheet.py` 看缩略图总览,再抽高风险页全尺寸;不要一开始逐页看。
|
|
10
|
+
- **style brief 四行**:用途/受众、气质、主色(1 个)、图像风格。主色写 `meta.accent`,需要时用 `meta.colors` 覆盖 bg/panel/text/muted;整套只用一个重点色。
|
|
11
|
+
- **页计划先于 spec**:`页 / 结论句 / 证据 / 视觉类型 / 来源`。每个正文页标题必须是一句话结论,不是名词短语;一页一个思想,以上统下、顺序递进。
|
|
12
|
+
- **配色按 HSL 想**:邻近色相做区分、同色相拉明度做层级;一页一个高饱和重点色,重点色出现 1–2 处。
|
|
13
|
+
- **改稿先立合同**:`deck_baseline.py snapshot` 存基线 → 改工作副本 → `compare --protect … --expect …` 出台账;数字逐位核对,不为了好看改精度,不补材料里没有的评价词。
|
|
14
|
+
- **工具边界**:一键 AI 工具常导出整页图片稿(文字不可编辑),不能交付;`quality_check.py` 会拦。
|
|
15
|
+
|
|
16
|
+
## 1. 引擎与主题
|
|
17
|
+
- 引擎:pptfast(语义 IR → 原生可编辑 PPTX + `audit` 自动几何质检)。
|
|
18
|
+
- 主题按用途选:
|
|
19
|
+
- 实用/指南/汇报 → `consulting`(最密)或 `enterprise`;
|
|
20
|
+
- 情感/叙事 → `bloom`(暖玫瑰);
|
|
21
|
+
- 科技 → `tech`;财务 → `insight`。
|
|
22
|
+
- **narrative.pacing 一律用 `dense`**(balanced/spacious 会大量留白,用户明确拒绝「空荡荡」)。
|
|
23
|
+
- 已知坑:`deck.spec.json` 的 `theme` 只接受**字符串**(不能写 `{id, style}` 覆盖主题色);要改主题色用 `render --style <style.json>`(如把 consulting 的 muted `#786961` 加深到 `#5F5148` 级别,修掉暖纸渐变上的 0.06 对比度差)。
|
|
24
|
+
|
|
25
|
+
## 2. 背景(消灭大白板的关键)
|
|
26
|
+
- 每页必设 `background`,**禁止纯白**:
|
|
27
|
+
- 满幅照片:`{"kind":"asset","asset_id":<图>,"overlay":{"color":"#FFFFFF","opacity":0.85~0.88}}`(照片可见 + 文字可读,实测 0.88 是「饱满 vs 对比度」最优)。
|
|
28
|
+
- 或暖纸渐变(CyberPPT 8 色之一,如 `#F3F4EF`→`#F4F1EA`),用于表格/对比页(表格要最干净底)。
|
|
29
|
+
- 封面/结束页用 `background: asset` + 深色 overlay(`#0B1220` 0.55)+ 白字。
|
|
30
|
+
|
|
31
|
+
## 3. 排版尺度(数值化,来自编辑风 style-system)
|
|
32
|
+
- 封面标题 56–76px 加粗;页标题 36–48px;正文 18–24px;说明/标签 11–13px。
|
|
33
|
+
- 版心安全边距 ≥72px;12 列网格 + 24px 沟槽。
|
|
34
|
+
- 行高:大标题 1.08,正文 1.35–1.55。
|
|
35
|
+
- 线条 1–2px、直角 0–4px,**不用软阴影/霓虹/3D**。
|
|
36
|
+
|
|
37
|
+
## 4. 内容密度(每页至少 4 个元素,忌纯 bullet)
|
|
38
|
+
- 每页 content 至少 3–4 个组件:`paragraph` + `bullets` + `verdict_banner` + `image`/`image-split`;数字用 `kpi_cards`、对照用 `comparison`、流程用 `steps`。
|
|
39
|
+
- **避免「密集 bullet 列表」**(编辑风明令:bullet 墙 = 单调):把要点转成「标签块/步骤/时间线/对比模块/图标卡」。
|
|
40
|
+
- **不用「大面积默认白卡片」**(CyberPPT 明令:默认白卡/默认圆角/默认阴影 = 失败)——卡片底色交给背景,不要叠白卡。
|
|
41
|
+
|
|
42
|
+
## 5. 版式变换(每套 ≥5 种,相邻页不同)
|
|
43
|
+
- 显式 pin 常用:`image-split`(image_side left/right)、`image-top`、`quote-stage`(金句)、`two-column`、`quiet-frame`、`narrow-column`。
|
|
44
|
+
- **不对称构图**(编辑风核心):一个主导块 + 一个辅助簇,别每页都居中对称;留一侧「安静区」形成节奏。
|
|
45
|
+
- 不 pin 则交给引擎按 `seed` 自动选型(相邻页不重复)。
|
|
46
|
+
- **避开 `image-lead-split`**:它右侧带一个空「手机占位框」,只有 `device_mockup` 组件能填;普通图文别用它。
|
|
47
|
+
|
|
48
|
+
## 5.5 炫技版式(2026-11 补充)
|
|
49
|
+
- 章节页 `section_number`:巨型数字(渐变/空心/图片填字)+ mesh 底 + 网格 + 进度点;一页只放一处巨字。
|
|
50
|
+
- 满幅 `image_statement`:照片 + 巨型字艺(`art`)+ 结论标题;字艺放文字侧、透明度 0.14–0.22,别压主体。
|
|
51
|
+
- 斜切 `split_diagonal`:两张图斜接 + 主色割线,适合「两种方案/两种光」对照。
|
|
52
|
+
- 时间轴 `heat_bars`:阶段按 `minutes` 累加成甘特条,`fire/level` 映射颜色;比普通表格更适合讲顺序与时长。
|
|
53
|
+
- 三图 `image_triptych`:左一主图 + 右两辅图,一主两辅;比等宽三列更有层级,适合生料/过程/成菜。
|
|
54
|
+
- 毛玻璃 `glass_overlay`:从当前背景裁一块模糊+提亮做面板,`glass_opacity` 0.14–0.24;数字仍是真文本。
|
|
55
|
+
- 金句 `quote_stage` / 大数字 `kpi_showcase`:用来换气,数字必须带标签和单位。
|
|
56
|
+
- 透明抠图 `cutout_statement`:`--transparent` 生图 → 裁透明边 → 烘焙阴影 → 主体压版面;`image_annotated` 用原生圆点/引线/标签;`image_arch` 拱形裁切。
|
|
57
|
+
- 质感:`mono/duotone/halftone/glitch`;glitch 只做点缀。生成图必须过 `image_check.py`(≥7、无文字/水印)。
|
|
58
|
+
- 细节:`**关键词**` 主色高亮、`meta.transition` 页面切换、`sections/section_no` 进度点。
|
|
59
|
+
- 克制:一套最多 2–3 页炫技,相邻页不连续堆,炫技页必须有真信息。
|
|
60
|
+
|
|
61
|
+
## 6. 图片(图文并茂 + 切题 + 设计过)
|
|
62
|
+
|
|
63
|
+
**取图两条路,先想清楚这页要的是「证据」还是「氛围」:**
|
|
64
|
+
- 证据/实拍/真实产品 → 必应国内图片搜索(cn.bing.com,直连、无需 key、国内站点优先:苏宁/知乎/新浪/阿里/京东图床),国内找不到 → Pexels(外网、走代理)。关键词用标准说法(如「电动自行车」而非「两轮电瓶车」)命中更准。
|
|
65
|
+
- 氛围/概念/封面/章节 → 用 GPT 生图(`gen_image.py`,见 §6.5)。没有合适实拍时不要硬凑,生图比错图好。
|
|
66
|
+
|
|
67
|
+
**版式关系(图片必须「长在版面上」):**
|
|
68
|
+
- **满幅出血 + 渐变蒙版**(`image_hero`):图铺满 13.33×7.5,蒙版从文字侧压到 0.9+,标题压暗部。封面/章节/收尾首选。
|
|
69
|
+
- **单侧出血 + 渐变融边**(`image_overlay`):文字一侧融出 55–65% 渐变面板,放正文 + ≤4 要点 + 2–3 个数字。
|
|
70
|
+
- **瀑布流错位**(`image_wall`):2–4 张等宽不等高、错位 0.26–0.84 英寸、圆角 + 细阴影。
|
|
71
|
+
- **满格裁切 + 圆角/软阴影**(`image_split` / `image_grid`):一强一弱,别让图缩在框里留白边。
|
|
72
|
+
|
|
73
|
+
**硬规则:**
|
|
74
|
+
- 一律 **cover 裁切**,不拉伸、不留白边;主体不能被裁掉。
|
|
75
|
+
- 渐变色**取自图片暗部**(引擎自动采样),不要死黑;末端透明,起点 alpha 0.90–0.95。
|
|
76
|
+
- 满幅页正文 ≤2 行、要点 ≤4 条、字号 ≥15;压图文字对比度按渲染色实测(≥4.5:1),不要凭感觉。
|
|
77
|
+
- 每套至少 2 页满幅/出血图;相邻页不要连续用同一种图片关系。
|
|
78
|
+
- 抓图/生图后**逐张 vision 核验「图对不对题」**;图片放 `assets/`,PIL 归一化成真 JPEG(源图常是 PNG 却命名 .jpg)。
|
|
79
|
+
- 禁止真人/伤口(学术体制内类);情感类可用牵手/剪影(无脸)。
|
|
80
|
+
|
|
81
|
+
## 6.5 自主生图(GPT image)
|
|
82
|
+
- 配置 `~/.config/ppt-assistant/image.json`(api_base + api_key),密钥不进 spec、不进成图、不进提交。
|
|
83
|
+
- 尺寸 `1024x1024` / `1536x1024`(满幅 16:9 用这个再裁)/ `1024x1536`;单张 30 秒–4 分钟,后台跑。
|
|
84
|
+
- 提示词骨架:`Cinematic <题材> photograph, 3:2 landscape, single clear subject on the <left|right> third, deep quiet negative space on the other side for text overlay, dramatic side backlight, cool <主色> shadows with warm amber rim light, shallow depth of field, ultra-clean uncluttered set, photorealistic, subtle film grain, no text, no letters, no logo, no watermark, no people, no collage.`
|
|
85
|
+
- 一套 3–6 张,复用同一组光线/色温词;主体永远在蒙版对侧,给文字留约 40% 安静区。
|
|
86
|
+
- 生成后亲眼看:主体是否对题、有没有乱码文字/水印/畸形手指、裁 16:9 后主体是否完整、蒙版侧够不够暗。最多重生成 2 次,仍不行换构图。
|
|
87
|
+
- 真实性红线:生成图只作氛围/示意,页面与讲稿不得说成实拍、实验证据或真实产品照片。
|
|
88
|
+
|
|
89
|
+
## 7. 容量上限(实测,写死避免试错)
|
|
90
|
+
- `bullets` 最多 **6 条**(dense)。
|
|
91
|
+
- `comparison` 最多 **5 行**。
|
|
92
|
+
- `kpi_cards` 最多 **3 张**(4 张窄卡会被省略号截断文字)。
|
|
93
|
+
- `steps` 最多 **5 步**(再多会溢出、压到底部 verdict)。
|
|
94
|
+
- `image-split` 半宽侧塞不下 `paragraph + kpi(3) + verdict`;只放 `image + 3 张卡 + verdict`。
|
|
95
|
+
- 超了就拆页/换组件,别硬塞。
|
|
96
|
+
|
|
97
|
+
## 7.5 调研/分析类内容结构(学 TraeWork/WorkBuddy,天然密度高)
|
|
98
|
+
封面/目录 → **多维度对比总表**(每个维度打分 + 综合第一标绿/色块突出)→ **逐对象深度档案**(每家/每项一页,末尾一段优劣势小结)→ 关键差异点表格+色块 → 结论页 **3 条可执行建议** → 最后一页「**一页速览**」总结卡片(可直接截图转发)。这种结构比「标题+要点」密度高得多,也更有用。
|
|
99
|
+
|
|
100
|
+
## 8. 质检(闭环)
|
|
101
|
+
0. `quality_check.py <deck.pptx>` → 必须 PASS(含「整页图片、无可编辑文字」拦截)。
|
|
102
|
+
1. `pptfast audit <deck项目>` → 必须 exit 0(溢出/越界/低对比/重叠/截断全查)。
|
|
103
|
+
2. `python3 vision_check.py <render/pg-xx.png> "<问题>"`(DeepSeek `deepseek-v4-flash-vision-exp`)逐页评饱满度,修到 ≥8。
|
|
104
|
+
3. 渲染:`soffice --headless --convert-to pdf` → `pdftoppm -png`。
|
|
105
|
+
4. 改稿:`deck_baseline.py compare baseline.json 新稿.pptx --protect … --expect …` → exit 0 且台账无违规。
|
|
106
|
+
4. **查「单字成行」**:读 `pptx_read` 输出,看有没有「E / 照」「6–7 / 折」「…三 / 点」这类数字/字母后跟单字量词被断行的——有就重排/缩短标题要点。
|
|
107
|
+
5. **以 audit 为准**:consulting 主题的 banner-motif 黄色装饰条会被 vision 误判成「文字被遮挡」,但确定性 audit 是 0 问题——几何结论信 audit,vision 只做「留白/断行/单调」这类主观反馈。
|
|
108
|
+
|
|
109
|
+
## 8. 边界页(已知限制,写进预期)
|
|
110
|
+
- pptfast 的 `cover`/`chapter`/`ending` 是「边界页」:`ending` 运行时只保留 `heading`(subheading/background/decor 全被丢弃),天然简洁——把它当「谢谢页」,别指望它「饱满」;收尾的丰富度放在它前面的 content 页(quote-stage 满幅金句)。
|
package/example.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "PPT 助手示例"
|
|
4
|
+
},
|
|
5
|
+
"slides": [
|
|
6
|
+
{
|
|
7
|
+
"layout": "cover",
|
|
8
|
+
"title": "PPT 助手示例",
|
|
9
|
+
"body": "可编辑演示文稿"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"layout": "cards",
|
|
13
|
+
"title": "验证结果",
|
|
14
|
+
"items": [
|
|
15
|
+
{
|
|
16
|
+
"title": "可编辑",
|
|
17
|
+
"body": "原生文本与图表"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"title": "已校验",
|
|
21
|
+
"body": "结构检查通过"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|