@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/asset_catalog.py +159 -0
  4. package/assets/base_template.pptx +0 -0
  5. package/assets/cover_deco.png +0 -0
  6. package/assets/logo.png +0 -0
  7. package/assets/slide2_right.png +0 -0
  8. package/assets/theme_apricot.jpg +0 -0
  9. package/assets/theme_blush.jpg +0 -0
  10. package/assets/theme_coral.jpg +0 -0
  11. package/assets/theme_glow.jpg +0 -0
  12. package/assets/theme_mauve.jpg +0 -0
  13. package/assets/theme_peach.jpg +0 -0
  14. package/assets/theme_rose.jpg +0 -0
  15. package/assets/theme_sunset.jpg +0 -0
  16. package/assets/topright_deco.png +0 -0
  17. package/build_deck.py +2288 -0
  18. package/charts.py +329 -0
  19. package/check.py +236 -0
  20. package/config.json +5 -0
  21. package/contact_sheet.py +88 -0
  22. package/cordis.patch.yml +457 -0
  23. package/deck_baseline.py +335 -0
  24. package/delivery.py +143 -0
  25. package/density-recipe.md +110 -0
  26. package/example.json +26 -0
  27. package/formula.py +125 -0
  28. package/gen_image.py +395 -0
  29. package/generate_consensus.py +892 -0
  30. package/generate_ppt.py +1356 -0
  31. package/grasp_evidence.py +150 -0
  32. package/image_check.py +148 -0
  33. package/image_fetch.py +253 -0
  34. package/ingest.py +93 -0
  35. package/media.py +470 -0
  36. package/package.json +68 -4
  37. package/preset.yml +2 -0
  38. package/quality_check.py +196 -0
  39. package/render_ppt.py +91 -0
  40. package/requirements.txt +7 -0
  41. package/restyle.py +80 -0
  42. package/run_pptfast.sh +27 -0
  43. package/run_py.sh +43 -0
  44. package/slide_ops.py +160 -0
  45. package/slide_patch.py +153 -0
  46. package/speaker_notes.py +245 -0
  47. package/style-recipes.md +166 -0
  48. package/video_gif.py +164 -0
  49. package/vision_check.py +79 -0
package/slide_patch.py ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env python3
2
+ """Inspect shape ids and patch selected slide geometry/text frames only."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import copy
7
+ import json
8
+ import math
9
+ import tempfile
10
+ from pathlib import Path
11
+ from zipfile import ZipFile
12
+
13
+ from pptx import Presentation
14
+ from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, PP_ALIGN
15
+ from pptx.util import Inches, Pt
16
+
17
+ from speaker_notes import dump, slide_parts
18
+
19
+ ANCHORS = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM}
20
+ ALIGNMENTS = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}
21
+ FRAME_KEYS = {"word_wrap", "vertical_anchor", "margin_inches", "line_spacing",
22
+ "space_before_pt", "space_after_pt", "alignment"}
23
+
24
+
25
+ def inspect(pptx: Path) -> dict:
26
+ """List top-level shape ids and inch-based boxes in presentation order."""
27
+ prs = Presentation(pptx)
28
+ return {"size_inches": [prs.slide_width / 914400, prs.slide_height / 914400],
29
+ "pages": [{"page": page, "shapes": [
30
+ {"shape_id": shape.shape_id, "name": shape.name,
31
+ "type": str(shape.shape_type), "text": shape.text if shape.has_text_frame else None,
32
+ "box_inches": [value / 914400 for value in
33
+ (shape.left, shape.top, shape.width, shape.height)]}
34
+ for shape in slide.shapes]} for page, slide in enumerate(prs.slides, 1)]}
35
+
36
+
37
+ def number(value, label: str, *, positive: bool = False) -> float:
38
+ if (type(value) not in (int, float) or not math.isfinite(value)
39
+ or value < 0 or (positive and value == 0)):
40
+ raise ValueError(f"{label} must be a finite {'positive' if positive else 'nonnegative'} number")
41
+ return value
42
+
43
+
44
+ def apply_frame(shape, spec: dict) -> None:
45
+ """Repair spacing without replacing paragraphs, runs, fonts or text."""
46
+ if not isinstance(spec, dict) or not spec or spec.keys() - FRAME_KEYS:
47
+ raise ValueError("text_frame must contain supported, explicit settings")
48
+ if not shape.has_text_frame:
49
+ raise ValueError("selected shape has no text frame")
50
+ frame = shape.text_frame
51
+ frame.auto_size = MSO_AUTO_SIZE.NONE
52
+ if "word_wrap" in spec:
53
+ if type(spec["word_wrap"]) is not bool:
54
+ raise ValueError("word_wrap must be boolean")
55
+ frame.word_wrap = spec["word_wrap"]
56
+ if "vertical_anchor" in spec:
57
+ if spec["vertical_anchor"] not in ANCHORS:
58
+ raise ValueError("vertical_anchor must be top, middle or bottom")
59
+ frame.vertical_anchor = ANCHORS[spec["vertical_anchor"]]
60
+ if "margin_inches" in spec:
61
+ margins = spec["margin_inches"]
62
+ if not isinstance(margins, list) or len(margins) != 4:
63
+ raise ValueError("margin_inches must be [left, top, right, bottom]")
64
+ values = [Inches(number(value, "margin")) for value in margins]
65
+ if values[0] + values[2] >= shape.width or values[1] + values[3] >= shape.height:
66
+ raise ValueError("margins must leave positive text space")
67
+ for key, value in zip(("margin_left", "margin_top", "margin_right", "margin_bottom"), values):
68
+ setattr(frame, key, value)
69
+ for paragraph in frame.paragraphs:
70
+ if "line_spacing" in spec:
71
+ paragraph.line_spacing = float(number(spec["line_spacing"], "line_spacing", positive=True))
72
+ for key in ("space_before_pt", "space_after_pt"):
73
+ if key in spec:
74
+ setattr(paragraph, key.removesuffix("_pt"), Pt(number(spec[key], key)))
75
+ if "alignment" in spec:
76
+ if spec["alignment"] not in ALIGNMENTS:
77
+ raise ValueError("alignment must be left, center or right")
78
+ paragraph.alignment = ALIGNMENTS[spec["alignment"]]
79
+
80
+
81
+ def patch(pptx: Path, specification: Path, output: Path) -> dict:
82
+ """Atomically replace selected slide XML; retain all other ZIP payloads."""
83
+ if pptx.resolve() == output.resolve() or specification.resolve() == output.resolve():
84
+ raise ValueError("output must be separate from the inputs")
85
+ operations = json.loads(specification.read_text(encoding="utf-8"))
86
+ if not isinstance(operations, list) or not operations:
87
+ raise ValueError("specification must be a nonempty list")
88
+ with ZipFile(pptx) as archive:
89
+ infos = archive.infolist()
90
+ if len(infos) != len({item.filename for item in infos}) or archive.testzip():
91
+ raise ValueError("invalid or duplicate PPT ZIP entries")
92
+ before = {item.filename: archive.read(item) for item in infos}
93
+ parts = slide_parts(before)
94
+ prs = Presentation(pptx)
95
+ selected, seen = set(), set()
96
+ for operation in operations:
97
+ if (not isinstance(operation, dict) or operation.keys() - {"page", "shape_id", "box_inches", "text_frame"}
98
+ or not ({"box_inches", "text_frame"} & operation.keys())):
99
+ raise ValueError("operation requires page, shape_id and box_inches or text_frame")
100
+ page, shape_id = operation.get("page"), operation.get("shape_id")
101
+ if type(page) is not int or not 1 <= page <= len(prs.slides) or type(shape_id) is not int:
102
+ raise ValueError("invalid page or shape_id")
103
+ if (page, shape_id) in seen:
104
+ raise ValueError("duplicate page/shape_id operation")
105
+ seen.add((page, shape_id))
106
+ matches = [shape for shape in prs.slides[page - 1].shapes if shape.shape_id == shape_id]
107
+ if len(matches) != 1:
108
+ raise ValueError(f"P{page}: shape_id {shape_id} is not a unique top-level shape")
109
+ shape = matches[0]
110
+ if "box_inches" in operation:
111
+ box = operation["box_inches"]
112
+ if not isinstance(box, list) or len(box) != 4:
113
+ raise ValueError("box_inches must be [left, top, width, height]")
114
+ values = [Inches(number(value, "box", positive=index >= 2)) for index, value in enumerate(box)]
115
+ if values[0] + values[2] > prs.slide_width or values[1] + values[3] > prs.slide_height:
116
+ raise ValueError("box is outside slide bounds")
117
+ for key, value in zip(("left", "top", "width", "height"), values):
118
+ setattr(shape, key, value)
119
+ if "text_frame" in operation:
120
+ apply_frame(shape, operation["text_frame"])
121
+ selected.add(page)
122
+ updated = {parts[page - 1]: dump(prs.slides[page - 1]._element) for page in selected}
123
+ output.parent.mkdir(parents=True, exist_ok=True)
124
+ with tempfile.TemporaryDirectory(dir=output.parent, prefix=".slide-patch-") as directory:
125
+ temporary = Path(directory) / "presentation.pptx"
126
+ with ZipFile(temporary, "w") as archive:
127
+ for info in infos:
128
+ archive.writestr(copy.copy(info), updated.get(info.filename, before[info.filename]))
129
+ with ZipFile(temporary) as archive:
130
+ if archive.testzip() or any(archive.read(name) != data for name, data in before.items() if name not in updated):
131
+ raise ValueError("patch changed an unselected package part")
132
+ Presentation(temporary)
133
+ temporary.replace(output)
134
+ return {"patched_pages": sorted(selected), "patched_shapes": len(seen), "other_parts_preserved": True}
135
+
136
+
137
+ def main() -> int:
138
+ parser = argparse.ArgumentParser(description=__doc__)
139
+ commands = parser.add_subparsers(dest="command", required=True)
140
+ reader = commands.add_parser("inspect")
141
+ reader.add_argument("pptx", type=Path)
142
+ writer = commands.add_parser("apply")
143
+ writer.add_argument("pptx", type=Path)
144
+ writer.add_argument("specification", type=Path)
145
+ writer.add_argument("output", type=Path)
146
+ args = parser.parse_args()
147
+ result = inspect(args.pptx) if args.command == "inspect" else patch(args.pptx, args.specification, args.output)
148
+ print(json.dumps(result, ensure_ascii=False, indent=2))
149
+ return 0
150
+
151
+
152
+ if __name__ == "__main__":
153
+ raise SystemExit(main())
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env python3
2
+ """Export speaker-script Markdown or embed it without rewriting slide content."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import copy
7
+ import hashlib
8
+ import json
9
+ import posixpath
10
+ import re
11
+ import tempfile
12
+ from pathlib import Path
13
+ from zipfile import ZipFile
14
+
15
+ from lxml import etree as ET
16
+ from pptx import Presentation
17
+
18
+ P = "http://schemas.openxmlformats.org/presentationml/2006/main"
19
+ A = "http://schemas.openxmlformats.org/drawingml/2006/main"
20
+ R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
21
+ PR = "http://schemas.openxmlformats.org/package/2006/relationships"
22
+ CT = "http://schemas.openxmlformats.org/package/2006/content-types"
23
+ NS = {"p": P, "a": A}
24
+
25
+
26
+ def xml(data: bytes):
27
+ """Parse local OOXML without resolving entities or fetching resources."""
28
+ return ET.fromstring(data, ET.XMLParser(resolve_entities=False, no_network=True))
29
+
30
+
31
+ def dump(element) -> bytes:
32
+ return ET.tostring(element, encoding="UTF-8", xml_declaration=True, standalone=True)
33
+
34
+
35
+ def child(parent, namespace, tag, **attrs):
36
+ return ET.SubElement(parent, f"{{{namespace}}}{tag}", **attrs)
37
+
38
+
39
+ def relationship(root, kind):
40
+ return next((item for item in root if item.get("Type") == R + "/" + kind), None)
41
+
42
+
43
+ def add_relationship(root, kind, target):
44
+ ids = {item.get("Id") for item in root}
45
+ number = 1
46
+ while f"rId{number}" in ids:
47
+ number += 1
48
+ return child(root, PR, "Relationship", Id=f"rId{number}", Type=R + "/" + kind, Target=target)
49
+
50
+
51
+ def slide_parts(entries: dict[str, bytes]) -> list[str]:
52
+ """Resolve presentation order through relationships, including reordered slides."""
53
+ targets = {item.get("Id"): item.get("Target") for item in xml(entries["ppt/_rels/presentation.xml.rels"])}
54
+ return [resolve_part("ppt/presentation.xml", targets[item.get(f"{{{R}}}id")])
55
+ for item in xml(entries["ppt/presentation.xml"]).find(f"{{{P}}}sldIdLst")]
56
+
57
+
58
+ def resolve_part(source: str, target: str) -> str:
59
+ """Resolve relative or package-absolute OOXML relationship targets."""
60
+ return posixpath.normpath(target.lstrip("/") if target.startswith("/")
61
+ else posixpath.join(posixpath.dirname(source), target))
62
+
63
+
64
+ def parse_script(path: Path, count: int) -> tuple[dict[int, str], int]:
65
+ """Require one nonempty P-numbered section per slide; reject unfinished drafts."""
66
+ raw = re.sub(r"<!--.*?-->", "", path.read_text(encoding="utf-8"), flags=re.S)
67
+ sections, budget = {}, 0
68
+ pattern = r"^## P(\d+)\s*[||::]\s*([^\n]+)\n(.*?)(?=^## |\Z)"
69
+ for match in re.finditer(pattern, raw, re.M | re.S):
70
+ number = int(match[1])
71
+ title = match[2].strip()
72
+ body = re.sub(r"<!--.*?-->", "", match[3], flags=re.S).strip()
73
+ if number in sections or not body or "TODO speaker script" in body:
74
+ raise ValueError(f"P{number}: duplicate, empty or unfinished speaker script")
75
+ timing = re.search(r"[((](\d+)\s*(?:秒|s|seconds)[))]", title)
76
+ budget += int(timing[1]) if timing else 0
77
+ sections[number] = f"P{number}|{title}\n\n{body}"
78
+ if set(sections) != set(range(1, count + 1)):
79
+ raise ValueError(f"script must cover P1 through P{count} exactly")
80
+ return sections, budget
81
+
82
+
83
+ def notes_document(text: str):
84
+ root = ET.Element(f"{{{P}}}notes", nsmap={"p": P, "a": A, "r": R})
85
+ tree = child(child(root, P, "cSld"), P, "spTree")
86
+ nv = child(tree, P, "nvGrpSpPr")
87
+ child(nv, P, "cNvPr", id="1", name="")
88
+ child(nv, P, "cNvGrpSpPr")
89
+ child(nv, P, "nvPr")
90
+ child(tree, P, "grpSpPr")
91
+ sp = child(tree, P, "sp")
92
+ nv = child(sp, P, "nvSpPr")
93
+ child(nv, P, "cNvPr", id="2", name="Notes")
94
+ child(nv, P, "cNvSpPr")
95
+ child(child(nv, P, "nvPr"), P, "ph", type="body", idx="1")
96
+ child(sp, P, "spPr")
97
+ tx = child(sp, P, "txBody")
98
+ child(tx, A, "bodyPr")
99
+ child(tx, A, "lstStyle")
100
+ replace_text(tx, text)
101
+ child(child(root, P, "clrMapOvr"), A, "masterClrMapping")
102
+ return root
103
+
104
+
105
+ def replace_text(tx, text: str):
106
+ for paragraph in list(tx):
107
+ if paragraph.tag == f"{{{A}}}p":
108
+ tx.remove(paragraph)
109
+ for line in text.split("\n"):
110
+ paragraph = child(tx, A, "p")
111
+ run = child(paragraph, A, "r")
112
+ child(run, A, "rPr", lang="zh-CN", sz="1100")
113
+ child(run, A, "t").text = line
114
+
115
+
116
+ def semantic_part(data: bytes) -> bytes:
117
+ """Remove only notes declarations for preservation comparisons."""
118
+ root = xml(data)
119
+ for item in list(root):
120
+ if (item.tag == f"{{{P}}}notesMasterIdLst"
121
+ or item.get("Type") in (R + "/notesSlide", R + "/notesMaster")
122
+ or item.get("ContentType", "").endswith((".notesSlide+xml", ".notesMaster+xml"))):
123
+ root.remove(item)
124
+ return ET.tostring(root, method="c14n")
125
+
126
+
127
+ def preservation_changes(before: dict[str, bytes], after: dict[str, bytes]) -> list[str]:
128
+ """Return changes outside notes parts and their required declarations."""
129
+ problems = []
130
+ for name in set(before) | set(after):
131
+ if name.startswith("ppt/notesSlides/") or name.startswith("ppt/notesMasters/"):
132
+ continue
133
+ old, new = before.get(name), after.get(name)
134
+ if old == new:
135
+ continue
136
+ declarations = name in ("[Content_Types].xml", "ppt/presentation.xml", "ppt/_rels/presentation.xml.rels")
137
+ slide_rels = name.startswith("ppt/slides/_rels/") and name.endswith(".rels")
138
+ if declarations or slide_rels:
139
+ empty = dump(ET.Element(f"{{{PR}}}Relationships"))
140
+ if new is not None and semantic_part(old or empty) == semantic_part(new):
141
+ continue
142
+ problems.append(name)
143
+ return sorted(problems)
144
+
145
+
146
+ def embed(pptx: Path, script: Path, output: Path) -> dict:
147
+ """Atomically write complete notes after verifying all other content is preserved."""
148
+ with ZipFile(pptx) as source:
149
+ infos = source.infolist()
150
+ if len({item.filename for item in infos}) != len(infos) or source.testzip():
151
+ raise ValueError("invalid or duplicate ZIP entries")
152
+ before = {item.filename: source.read(item) for item in infos}
153
+ entries = before.copy()
154
+ parts = slide_parts(entries)
155
+ sections, budget = parse_script(script, len(parts))
156
+ content_types = xml(entries["[Content_Types].xml"])
157
+ for page, slide in enumerate(parts, 1):
158
+ relfile = posixpath.join(posixpath.dirname(slide), "_rels", posixpath.basename(slide) + ".rels")
159
+ rels = xml(entries[relfile]) if relfile in entries else ET.Element(f"{{{PR}}}Relationships", nsmap={None: PR})
160
+ rel = relationship(rels, "notesSlide")
161
+ if rel is None:
162
+ number = 1
163
+ while f"ppt/notesSlides/notesSlide{number}.xml" in entries:
164
+ number += 1
165
+ note_file = f"ppt/notesSlides/notesSlide{number}.xml"
166
+ add_relationship(rels, "notesSlide", f"../notesSlides/notesSlide{number}.xml")
167
+ entries[relfile] = dump(rels)
168
+ root = notes_document(sections[page])
169
+ note_rels = ET.Element(f"{{{PR}}}Relationships", nsmap={None: PR})
170
+ add_relationship(note_rels, "slide", posixpath.relpath(slide, "ppt/notesSlides"))
171
+ # A notes master is optional. Reuse it when the deck already has one.
172
+ masters = sorted(n for n in entries if re.fullmatch(r"ppt/notesMasters/notesMaster\d+\.xml", n))
173
+ if masters:
174
+ add_relationship(note_rels, "notesMaster", posixpath.relpath(masters[0], "ppt/notesSlides"))
175
+ entries[f"ppt/notesSlides/_rels/notesSlide{number}.xml.rels"] = dump(note_rels)
176
+ child(content_types, CT, "Override", PartName="/" + note_file,
177
+ ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml")
178
+ else:
179
+ note_file = resolve_part(slide, rel.get("Target"))
180
+ root = xml(entries[note_file])
181
+ bodies = root.xpath('.//p:sp[p:nvSpPr/p:nvPr/p:ph[@type="body"]]/p:txBody', namespaces=NS)
182
+ if len(bodies) != 1:
183
+ raise ValueError(f"P{page}: expected one notes body placeholder")
184
+ replace_text(bodies[0], sections[page])
185
+ entries[note_file] = dump(root)
186
+ entries["[Content_Types].xml"] = dump(content_types)
187
+ changes = preservation_changes(before, entries)
188
+ if changes:
189
+ raise ValueError(f"notes update changed presentation content: {changes}")
190
+ output.parent.mkdir(parents=True, exist_ok=True)
191
+ with tempfile.NamedTemporaryFile(dir=output.parent, suffix=".pptx", delete=False) as stream:
192
+ temporary = Path(stream.name)
193
+ try:
194
+ with ZipFile(temporary, "w") as archive:
195
+ for info in infos:
196
+ archive.writestr(copy.copy(info), entries[info.filename])
197
+ for name in entries.keys() - before.keys():
198
+ archive.writestr(name, entries[name])
199
+ result = Presentation(temporary)
200
+ for page, slide in enumerate(result.slides, 1):
201
+ if not slide.has_notes_slide or slide.notes_slide.notes_text_frame.text != sections[page]:
202
+ raise ValueError(f"P{page}: notes readback mismatch")
203
+ temporary.replace(output)
204
+ finally:
205
+ temporary.unlink(missing_ok=True)
206
+ return {"pages": len(parts), "notes_written": len(sections), "budget_seconds": budget,
207
+ "content_preserved": True, "sha256": hashlib.sha256(output.read_bytes()).hexdigest()}
208
+
209
+
210
+ def export_script(pptx: Path, output: Path):
211
+ """Create a draft from slide text and existing notes, with no invented achievements."""
212
+ if pptx.resolve() == output.resolve():
213
+ raise ValueError("Markdown output must be separate from the source deck")
214
+ prs = Presentation(pptx)
215
+ lines = ["# Speaker script", "", "<!-- Set per-page timing, rehearse and verify evidence before embedding. -->", ""]
216
+ for page, slide in enumerate(prs.slides, 1):
217
+ title = slide.shapes.title.text if slide.shapes.title else f"Slide {page}"
218
+ title = " ".join(title.split())
219
+ body = slide.notes_slide.notes_text_frame.text if slide.has_notes_slide else ""
220
+ text = "\n".join(s.text for s in slide.shapes if s.has_text_frame).replace("--", "- -")
221
+ lines += [f"## P{page}|{title}", "", f"<!-- Slide content:\n{text}\n-->", "", body.strip() or "[TODO speaker script]", ""]
222
+ output.parent.mkdir(parents=True, exist_ok=True)
223
+ output.write_text("\n".join(lines), encoding="utf-8")
224
+
225
+
226
+ def main() -> int:
227
+ parser = argparse.ArgumentParser(description=__doc__)
228
+ commands = parser.add_subparsers(dest="command", required=True)
229
+ export = commands.add_parser("export")
230
+ export.add_argument("pptx", type=Path)
231
+ export.add_argument("markdown", type=Path)
232
+ write = commands.add_parser("embed")
233
+ write.add_argument("pptx", type=Path)
234
+ write.add_argument("markdown", type=Path)
235
+ write.add_argument("output", type=Path)
236
+ args = parser.parse_args()
237
+ if args.command == "export":
238
+ export_script(args.pptx, args.markdown)
239
+ else:
240
+ print(json.dumps(embed(args.pptx, args.markdown, args.output), ensure_ascii=False))
241
+ return 0
242
+
243
+
244
+ if __name__ == "__main__":
245
+ raise SystemExit(main())
@@ -0,0 +1,166 @@
1
+ # 视觉配方(PPT 助手 · 视觉总监层)
2
+
3
+ 这不是模板库,是六套完整的视觉判断。先选一套当主配方,再用第二套做辅助(只借它的 1–2 个特征,不平均混合)。把 `meta.recipe` 记进 spec,保证整套不漂。
4
+
5
+ ## 用法
6
+
7
+ 1. 先写风格简报(见文末),明确受众、要促成的判断、内容类型、场景、情绪。
8
+ 2. 只选**一套**最接近任务的配方;没有完全匹配就用简报创造新配方,或有意识地「主 + 辅」。
9
+ 3. 把配方里的 tokens 抄进 `meta`;版式按 `rhythm` 排列,密度按 `density` 控制。
10
+ 4. 配方是起点,不是验收规则。证据、受众或场景提出更强要求时,让内容覆盖配方。
11
+
12
+ ---
13
+
14
+ ## 1. 咨询研报 · consulting
15
+
16
+ - **三个形容词**:克制、证据密、结论先行
17
+ - **设计张力**:高信息密度 vs 大量留白——用留白分区,不用留白填空
18
+ - **密度**:高。每页 3–5 个证据模块,标题即结论
19
+ - **配色逻辑**:冷灰纸底 + 单一深青强调;红只标风险;禁止第二个高饱和色
20
+ - **字体逻辑**:无衬线;标题 28–32、正文 14–16、标签 11;数字用表格/原生图表,不用巨大数字占半页
21
+ - **图片行为**:图表、表格、截图优先;照片只作证据,满幅时加 0.85 白蒙层
22
+ - **空间节奏**:页眉结论带 → 证据网格 → 底部 takeaway;相邻页在表格/图表/卡片间切换
23
+
24
+ ```json
25
+ "meta": {"theme": "paper", "recipe": "consulting", "accent": "176C88",
26
+ "colors": {"bg": "F4F6FA", "panel": "FFFFFF", "text": "15253F", "muted": "4D627A"},
27
+ "font": "Noto Sans CJK SC"}
28
+ ```
29
+
30
+ 版式池:`kpi_showcase` `table` `chart` `heat_bars` `comparison` `cards` `image_split` `image_annotated`
31
+ 骨架:结论页 → 证据 1(表)→ 证据 2(图)→ 拆解(卡)→ 建议(process)→ 收尾
32
+ 避免:装饰性大图封面、每页一个巨型数字、彩色卡片墙。
33
+
34
+ ---
35
+
36
+ ## 2. 正式工作汇报 · formal
37
+
38
+ - **三个形容词**:庄重、清晰、可追溯
39
+ - **设计张力**:正式感 vs 可读性——用大标题和短正文扛住,不靠装饰
40
+ - **密度**:中高。每页一个判断 + 三条支撑 + 来源
41
+ - **配色逻辑**:冷白底 + 深蓝主色;红只用于风险/待确认;页脚常驻
42
+ - **字体逻辑**:无衬线;标题 26–30、正文 14–16;数字带单位与口径
43
+ - **图片行为**:实拍、截图、流程图;满幅照片加暗色蒙版;不用 AI 插画替代现场
44
+ - **空间节奏**:封面 → 目录 → 背景 → 进展 → 问题 → 计划 → 结束;章节页用 `section_number`
45
+
46
+ ```json
47
+ "meta": {"theme": "paper", "recipe": "formal", "accent": "0B5FA5",
48
+ "colors": {"bg": "F7F9FC", "panel": "FFFFFF", "text": "15253F", "muted": "4D627A"},
49
+ "font": "Noto Sans CJK SC"}
50
+ ```
51
+
52
+ 版式池:`cover` `section_number` `cards` `table` `heat_bars` `image_overlay` `process` `closing`
53
+ 骨架:封面 → 目录 → 背景(图/时间线)→ 进展(表)→ 问题(对比)→ 计划(步骤)→ 收尾
54
+ 避免:一页塞两个结论、用装饰图代替数据、标题写成名词短语。
55
+
56
+ ---
57
+
58
+ ## 3. 社区党建 · civic
59
+
60
+ - **三个形容词**:温暖、庄重、在地
61
+ - **设计张力**:传统红金 vs 现代留白——红只做重音,不做满屏底
62
+ - **密度**:中。活动实拍 + 短说明 + 一句群众原话
63
+ - **配色逻辑**:暖纸底 + 深红主色 + 少量金;文字用暖黑,不用纯黑
64
+ - **字体逻辑**:标题可用衬线(Noto Serif CJK SC),正文无衬线;标题 30–36、正文 15–17
65
+ - **图片行为**:活动/场景/物件实拍,暖调;人物不特写、不摆拍;可用拱形/圆角裁切
66
+ - **空间节奏**:封面 → 主题 → 活动图墙 → 成效数字 → 群众声音(金句)→ 下一步
67
+
68
+ ```json
69
+ "meta": {"theme": "paper", "recipe": "civic", "accent": "9E2A2B",
70
+ "colors": {"bg": "FBF7F2", "panel": "FFFFFF", "text": "2B2118", "muted": "6B5B4D"},
71
+ "font": "Noto Sans CJK SC", "titleFont": "Noto Serif CJK SC"}
72
+ ```
73
+
74
+ 版式池:`image_hero` `quote_stage` `image_wall` `image_arch` `cards` `closing`
75
+ 骨架:红调封面 → 主题 → 活动实拍(图墙)→ 成效(数字)→ 群众声音(金句)→ 下一步
76
+ 避免:大红大金铺满、口号式空话、把群众照片当装饰。
77
+
78
+ ---
79
+
80
+ ## 4. 艺术市集 · artfair
81
+
82
+ - **三个形容词**:高饱和、海报感、手作
83
+ - **设计张力**:粗大字 vs 细信息——大字压版,小字密排,中间不留温吞灰区
84
+ - **密度**:中低到中;一页一个视觉主张 + 一条实用信息
85
+ - **配色逻辑**:奶油底 + 橙红主色 + 青绿辅助;色块可以撞,但只撞一次
86
+ - **字体逻辑**:无衬线粗体;标题 40–56、正文 14–16;标签用大写+字距
87
+ - **图片行为**:作品、摊位、人流、手作特写;透明抠图压版、拱窗、瀑布流;可以高饱和
88
+ - **空间节奏**:海报封面 → 图墙 → 日程 → 地图/标注 → 主理人金句 → 收尾
89
+
90
+ ```json
91
+ "meta": {"theme": "paper", "recipe": "artfair", "accent": "E4572E",
92
+ "colors": {"bg": "F6E7D8", "panel": "FFF8F0", "text": "1A1A1A", "muted": "6B5B4B"},
93
+ "font": "Noto Sans CJK SC"}
94
+ ```
95
+
96
+ 版式池:`cutout_statement` `image_wall` `image_triptych` `section_number` `quote_stage` `image_statement`
97
+ 骨架:海报封面 → 图墙 → 日程(步骤)→ 现场标注 → 金句 → 收尾
98
+ 避免:低饱和莫兰迪、居中对齐的安全牌、把作品缩进小卡片。
99
+
100
+ ---
101
+
102
+ ## 5. 编辑画册 · editorial
103
+
104
+ - **三个形容词**:安静、留白、文学
105
+ - **设计张力**:极大留白 vs 一张大图——图占 55–65%,文字退到边上
106
+ - **密度**:低。一页一段话或一张图,标题克制
107
+ - **配色逻辑**:象牙纸底 + 陶土色强调;几乎不用第三个颜色
108
+ - **字体逻辑**:标题衬线(Noto Serif CJK SC),正文无衬线;标题 32–44、正文 16–18
109
+ - **图片行为**:单张大图、拱形/圆角、双色调;不拼贴、不堆图
110
+ - **空间节奏**:封面 → 长文 → 单图 → 引文 → 索引 → 收尾;页与页之间留呼吸
111
+
112
+ ```json
113
+ "meta": {"theme": "paper", "recipe": "editorial", "accent": "B4552D",
114
+ "colors": {"bg": "F7F3EC", "panel": "FFFDF8", "text": "1C1B19", "muted": "6F675C"},
115
+ "font": "Noto Sans CJK SC", "titleFont": "Noto Serif CJK SC"}
116
+ ```
117
+
118
+ 版式池:`image_hero` `image_arch` `image_triptych` `quote_stage` `image_statement` `image_split` `closing`
119
+ 骨架:大图封面 → 长文 → 单图 → 引文 → 索引 → 收尾
120
+ 避免:满屏卡片、彩色标签、把每页都填满。
121
+
122
+ ---
123
+
124
+ ## 6. 历史教学 · history
125
+
126
+ - **三个形容词**:时间感、叙事、克制
127
+ - **设计张力**:年表密度 vs 图像叙事——用一条时间轴/一组标注把散点串起来
128
+ - **密度**:中高。事件、年份、因果要能一眼读出
129
+ - **配色逻辑**:羊皮纸底 + 赭石/暗红;照片做旧(mono/duotone),不用现代高饱和
130
+ - **字体逻辑**:标题衬线,正文无衬线;年份用等宽或加粗数字
131
+ - **图片行为**:档案照、地图、器物;标注圆点+引线;避免现代 stock 照片
132
+ - **空间节奏**:时代背景 → 关键事件(标注)→ 人物/器物 → 影响 → 讨论
133
+
134
+ ```json
135
+ "meta": {"theme": "paper", "recipe": "history", "accent": "8C3B2E",
136
+ "colors": {"bg": "EDE4D3", "panel": "F8F1E3", "text": "2A2118", "muted": "6B5A44"},
137
+ "font": "Noto Sans CJK SC", "titleFont": "Noto Serif CJK SC"}
138
+ ```
139
+
140
+ 版式池:`split_diagonal` `image_annotated` `cards` `image_arch` `quote_stage` `closing`
141
+ 骨架:时代背景 → 关键事件(标注)→ 人物/器物 → 影响 → 讨论
142
+ 避免:把年表写成流水账、用彩色图标替代史料、照片不做旧也不标注来源。
143
+
144
+ ---
145
+
146
+ ## 风格简报模板(没有匹配配方时)
147
+
148
+ 写七行,不超过 120 字:
149
+
150
+ 1. **受众 + 要促成的判断/行动**
151
+ 2. **三个形容词**
152
+ 3. **一个设计张力**(例如「密度 vs 留白」「传统 vs 现代」)
153
+ 4. **密度**(低/中/高,每页几个模块)
154
+ 5. **配色逻辑**(底色 + 主色 + 辅助色 + 禁用色)
155
+ 6. **字体逻辑**(标题/正文/数字,字号范围)
156
+ 7. **图片行为 + 空间节奏**(大图/图表/实拍;页与页怎么换气)
157
+
158
+ ## 验收清单(视觉总监)
159
+
160
+ - 每页只有一个主要结论,标题是结论句。
161
+ - 垂直结构完整:页眉 → 标题 → 间距 → 主体 → 结论/来源 → 页脚安全区。
162
+ - 密度靠分组/对齐/证据结构,不靠缩小字号。
163
+ - 字体、外边距、颜色角色、线条、页脚规则一致;版式轮廓有变化。
164
+ - 先看缩略图总览,再抽封面、最密页、特殊媒体页、结束页全尺寸检查。
165
+ - 低成本结构检查(完整性、占位符、讲稿、溢出)常做;跨渲染只在有风险时做。
166
+ - 意外变化能加强沟通就保留;混合风格要声明主/辅,不平均折中。