@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.1

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 +2082 -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/charts.py ADDED
@@ -0,0 +1,329 @@
1
+ """Validated native charts with embedded workbooks, shared styling and CSV input."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import csv
6
+ import math
7
+ from pathlib import Path
8
+
9
+ from pptx.chart.data import BubbleChartData, CategoryChartData, XyChartData
10
+ from pptx.dml.color import RGBColor
11
+ from pptx.enum.chart import (XL_CHART_TYPE, XL_DATA_LABEL_POSITION,
12
+ XL_LEGEND_POSITION, XL_MARKER_STYLE, XL_TICK_MARK)
13
+ from pptx.util import Inches, Pt
14
+ from pptx.oxml.xmlchemy import OxmlElement
15
+
16
+ KINDS = {
17
+ 'column': XL_CHART_TYPE.COLUMN_CLUSTERED,
18
+ 'bar': XL_CHART_TYPE.BAR_CLUSTERED,
19
+ 'line': XL_CHART_TYPE.LINE_MARKERS,
20
+ 'area': XL_CHART_TYPE.AREA,
21
+ 'stacked_column': XL_CHART_TYPE.COLUMN_STACKED,
22
+ 'stacked_bar': XL_CHART_TYPE.BAR_STACKED,
23
+ 'percent_column': XL_CHART_TYPE.COLUMN_STACKED_100,
24
+ 'percent_bar': XL_CHART_TYPE.BAR_STACKED_100,
25
+ 'donut': XL_CHART_TYPE.DOUGHNUT,
26
+ 'pie': XL_CHART_TYPE.PIE,
27
+ 'scatter': XL_CHART_TYPE.XY_SCATTER,
28
+ 'bubble': XL_CHART_TYPE.BUBBLE,
29
+ }
30
+ PALETTE = ['176C88', 'D97706', '6366F1', '2A9D8F', 'C0392B', '64748B']
31
+
32
+
33
+ def palette(colors: dict) -> list[str]:
34
+ """Lead every chart with the deck's accent so brand color stays consistent."""
35
+ ordered = [colors.get('accent', PALETTE[0]), *PALETTE[1:]]
36
+ result = []
37
+ for color in ordered:
38
+ if color not in result:
39
+ result.append(color)
40
+ return result
41
+
42
+
43
+ def _numeric(value):
44
+ return type(value) in {int, float} and math.isfinite(value)
45
+
46
+
47
+ def _color(value):
48
+ return (isinstance(value, str) and len(value) == 6
49
+ and all(c in '0123456789abcdefABCDEF' for c in value))
50
+
51
+
52
+ def resolve_chart(spec: dict, base: Path) -> dict:
53
+ """Copy, load optional CSV and validate; never infer or synthesize missing values."""
54
+ if not isinstance(spec, dict):
55
+ raise ValueError('chart must be an object')
56
+ chart = copy.deepcopy(spec)
57
+ kind = chart.setdefault('kind', 'column')
58
+ if not isinstance(kind, str) or kind not in KINDS:
59
+ raise ValueError(f'unsupported chart kind: {kind}')
60
+ series = chart.get('series')
61
+ if not isinstance(series, list) or not 1 <= len(series) <= 6:
62
+ raise ValueError('chart requires 1–6 series')
63
+ if any(not isinstance(s, dict) or not isinstance(s.get('name'), str) or not s['name'].strip()
64
+ for s in series):
65
+ raise ValueError('every chart series needs a name')
66
+ if 'number_format' in chart and (not isinstance(chart['number_format'], str)
67
+ or not chart['number_format']):
68
+ raise ValueError('number_format must be a non-empty Excel format string')
69
+ if 'data_labels' in chart and type(chart['data_labels']) is not bool:
70
+ raise ValueError('data_labels must be boolean')
71
+ if 'data_file' in chart:
72
+ if kind in {'scatter', 'bubble'}:
73
+ raise ValueError('XY charts use explicit points, not category CSV')
74
+ if 'categories' in chart or any('values' in s for s in chart.get('series', [])):
75
+ raise ValueError('CSV and inline chart values cannot be mixed')
76
+ if not isinstance(chart['data_file'], str) or not chart['data_file'].strip():
77
+ raise ValueError('data_file must be a CSV path')
78
+ path = base / chart['data_file']
79
+ if path.suffix.lower() != '.csv':
80
+ raise ValueError('chart data_file must be a CSV file')
81
+ with path.open(encoding='utf-8-sig', newline='') as stream:
82
+ reader = csv.DictReader(stream)
83
+ rows = list(reader)
84
+ columns = [chart.get('category_column')] + [s.get('column') for s in chart.get('series', [])]
85
+ if any(not isinstance(c, str) or not c or c not in (reader.fieldnames or []) for c in columns):
86
+ raise ValueError('CSV category/series column is missing')
87
+ if len(set(reader.fieldnames)) != len(reader.fieldnames) or any(
88
+ None in row or any(value is None for value in row.values()) for row in rows):
89
+ raise ValueError('CSV requires unique headers and complete rows; use empty cells for gaps')
90
+ chart['categories'] = [row[chart['category_column']] for row in rows]
91
+ for item in series:
92
+ try:
93
+ item['values'] = [float(row[item['column']]) if row[item['column']].strip()
94
+ else None for row in rows]
95
+ except (ValueError, AttributeError, KeyError) as error:
96
+ raise ValueError(f"CSV series {item.get('name')} contains invalid cells") from error
97
+ if kind in {'scatter', 'bubble'}:
98
+ count = 3 if kind == 'bubble' else 2
99
+ for s in series:
100
+ points = s.get('points')
101
+ if not isinstance(points, list) or not 1 <= len(points) <= 500:
102
+ raise ValueError('XY series requires 1–500 points')
103
+ if any(not isinstance(p, list) or len(p) != count or not all(_numeric(v) for v in p)
104
+ or (count == 3 and p[2] <= 0) for p in points):
105
+ raise ValueError('XY points must be finite [x,y] or [x,y,positive_size]')
106
+ else:
107
+ categories = chart.get('categories')
108
+ if (not isinstance(categories, list) or not 1 <= len(categories) <= 40
109
+ or any(not isinstance(c, str) or not c.strip() for c in categories)):
110
+ raise ValueError('chart requires 1–40 non-empty category labels')
111
+ for s in series:
112
+ values = s.get('values')
113
+ if (not isinstance(values, list) or len(values) != len(categories)
114
+ or any(v is not None and not _numeric(v) for v in values)
115
+ or all(v is None for v in values)):
116
+ raise ValueError('chart values must match categories and contain finite numbers or null gaps')
117
+ if kind in {'pie', 'donut'}:
118
+ values = series[0]['values']
119
+ if (len(series) != 1 or len(categories) > 8
120
+ or any(v is None or v < 0 for v in values) or sum(values) <= 0):
121
+ raise ValueError('pie/donut requires one non-negative series, positive total and at most 8 slices')
122
+ if kind.startswith('percent_'):
123
+ if '%' in chart.get('number_format', ''):
124
+ raise ValueError('percent stacks retain source counts; use y_axis.number_format for percentages')
125
+ if any(v is None or v < 0 for s in series for v in s['values']):
126
+ raise ValueError('percent stacks require non-negative, complete data')
127
+ if any(sum(s['values'][i] for s in series) <= 0 for i in range(len(categories))):
128
+ raise ValueError('each percent stack needs a positive total')
129
+ if chart.get('sort'):
130
+ if kind not in {'bar', 'column'} or len(series) != 1 or chart['sort'] not in ('ascending', 'descending'):
131
+ raise ValueError('sorting requires a single bar/column series and ascending/descending')
132
+ order = sorted(range(len(categories)), key=lambda i: (
133
+ series[0]['values'][i] is None,
134
+ (-series[0]['values'][i] if chart['sort'] == 'descending' else series[0]['values'][i])
135
+ if series[0]['values'][i] is not None else 0))
136
+ chart['categories'] = [categories[i] for i in order]
137
+ series[0]['values'] = [series[0]['values'][i] for i in order]
138
+ for s in series:
139
+ if 'color' in s and not _color(s['color']):
140
+ raise ValueError('series.color must be six hexadecimal digits')
141
+ for field in ('highlight',):
142
+ if not isinstance(chart.get(field, []), list) or any(
143
+ c not in chart.get('categories', []) for c in chart.get(field, [])):
144
+ raise ValueError('highlight must contain existing category labels')
145
+ for axis_name in ('x_axis', 'y_axis'):
146
+ axis = chart.get(axis_name, {})
147
+ if not isinstance(axis, dict):
148
+ raise ValueError(f'{axis_name} must be an object')
149
+ for field in ('title', 'number_format'):
150
+ if field in axis and not isinstance(axis[field], str):
151
+ raise ValueError(f'{axis_name}.{field} must be a string')
152
+ if 'font_size' in axis and (not _numeric(axis['font_size']) or not 9 <= axis['font_size'] <= 18):
153
+ raise ValueError('axis font_size must be between 9 and 18 points')
154
+ if 'label_interval' in axis and (axis_name != 'x_axis' or kind in {'scatter', 'bubble', 'pie', 'donut'}
155
+ or type(axis['label_interval']) is not int
156
+ or not 1 <= axis['label_interval'] <= 40):
157
+ raise ValueError('label_interval requires a category axis and an integer from 1 to 40')
158
+ for field in ('min', 'max', 'major_unit'):
159
+ if field in axis and not _numeric(axis[field]):
160
+ raise ValueError(f'{axis_name}.{field} must be finite')
161
+ if 'major_unit' in axis and axis['major_unit'] <= 0:
162
+ raise ValueError('axis major_unit must be positive')
163
+ if kind.startswith('percent_') and axis_name == 'y_axis' and (
164
+ axis.get('min', 0) != 0 or axis.get('max', 1) != 1
165
+ or axis.get('major_unit', .2) > 1):
166
+ raise ValueError('percent stack value axis uses 0–1, displayed as 0–100%')
167
+ if 'min' in axis and 'max' in axis and axis['min'] >= axis['max']:
168
+ raise ValueError('axis minimum must be below maximum')
169
+ if kind not in {'scatter', 'bubble', 'pie', 'donut'} and axis_name == 'x_axis' and any(
170
+ field in axis for field in ('min', 'max', 'major_unit')):
171
+ raise ValueError('category axis has no numeric bounds; use y_axis for value bounds, including bar charts')
172
+ if kind not in {'pie', 'donut'} and (axis_name == 'y_axis' or kind in {'scatter', 'bubble'}):
173
+ if kind.startswith('percent_'):
174
+ values = [0., 1.]
175
+ elif kind in {'scatter', 'bubble'}:
176
+ dimension = 0 if axis_name == 'x_axis' else 1
177
+ values = [p[dimension] for s in series for p in s['points']]
178
+ else:
179
+ values = [v for s in series for v in s['values'] if v is not None]
180
+ if 'stacked' in kind:
181
+ values += [sum(max(s['values'][i] or 0, 0) for s in series)
182
+ for i in range(len(chart['categories']))]
183
+ values += [sum(min(s['values'][i] or 0, 0) for s in series)
184
+ for i in range(len(chart['categories']))]
185
+ if any(v < axis.get('min', -math.inf) or v > axis.get('max', math.inf) for v in values):
186
+ raise ValueError('axis bounds would hide source values')
187
+ if kind in {'column', 'bar', 'area', 'stacked_column', 'stacked_bar'} and (
188
+ axis.get('min', 0) > 0 or axis.get('max', 0) < 0):
189
+ raise ValueError('bar and area value axes must include zero')
190
+ return chart
191
+
192
+
193
+ def draw_chart(slide, spec: dict, base: Path, box: tuple, font: str, colors: dict):
194
+ """Add a native PowerPoint chart and its editable workbook; box uses inches."""
195
+ spec = resolve_chart(spec, base)
196
+ chart_palette = palette(colors)
197
+ kind = spec['kind']
198
+ fmt = spec.get('number_format', '0.##')
199
+ if kind in {'scatter', 'bubble'}:
200
+ data = BubbleChartData() if kind == 'bubble' else XyChartData()
201
+ for s in spec['series']:
202
+ target = data.add_series(s['name'])
203
+ for point in s['points']:
204
+ target.add_data_point(*point)
205
+ else:
206
+ data = CategoryChartData()
207
+ data.categories = spec['categories']
208
+ for s in spec['series']:
209
+ data.add_series(s['name'], s['values'], fmt)
210
+ x, y, w, h = box
211
+ if kind in {'bar', 'stacked_bar', 'percent_bar'} and len(spec['categories']) > max(6, int(h * 3)):
212
+ raise ValueError('too many bar categories for the panel height; split the chart across pages')
213
+ chart = slide.shapes.add_chart(KINDS[kind], Inches(x), Inches(y), Inches(w), Inches(h), data).chart
214
+ # Explicit chart-area fill avoids theme white backgrounds in dark decks.
215
+ properties = OxmlElement('c:spPr')
216
+ fill = OxmlElement('a:solidFill')
217
+ rgb = OxmlElement('a:srgbClr')
218
+ rgb.set('val', colors['panel'])
219
+ fill.append(rgb)
220
+ properties.append(fill)
221
+ line = OxmlElement('a:ln')
222
+ line.append(OxmlElement('a:noFill'))
223
+ properties.append(line)
224
+ properties.append(OxmlElement('a:effectLst'))
225
+ chart._chartSpace.insert_element_before(properties, 'c:txPr', 'c:externalData',
226
+ 'c:printSettings', 'c:userShapes', 'c:extLst')
227
+ chart.has_title = False
228
+ chart.has_legend = len(spec['series']) > 1 or kind in {'pie', 'donut'}
229
+ compact_composition = kind in {'pie', 'donut'} and h < 2.6
230
+ if chart.has_legend:
231
+ chart.legend.position = XL_LEGEND_POSITION.RIGHT if compact_composition else XL_LEGEND_POSITION.BOTTOM
232
+ chart.legend.include_in_layout = False
233
+ chart.legend.font.name = font
234
+ chart.legend.font.size = Pt(11 if w < 7 else 13)
235
+ chart.legend.font.color.rgb = RGBColor.from_string(colors['text'])
236
+ plot = chart.plots[0]
237
+ if kind in {'pie', 'donut'}:
238
+ area = chart._chartSpace.xpath('c:chart/c:plotArea')[0]
239
+ layout = OxmlElement('c:layout')
240
+ area.insert(0, layout)
241
+ manual = OxmlElement('c:manualLayout')
242
+ for tag, value in [('layoutTarget', 'inner'), ('xMode', 'factor'), ('yMode', 'factor'),
243
+ ('wMode', 'factor'), ('hMode', 'factor'),
244
+ ('x', '.05'), ('y', '.02'),
245
+ ('w', '.7' if compact_composition else '.9'),
246
+ ('h', '.95' if compact_composition else '.78')]:
247
+ element = OxmlElement('c:' + tag)
248
+ element.set('val', value)
249
+ manual.append(element)
250
+ layout.append(manual)
251
+ if kind == 'donut':
252
+ plot.hole_size = 65
253
+ if kind not in {'scatter', 'bubble', 'area'}:
254
+ plot.has_data_labels = spec.get('data_labels', not kind.startswith('percent_')
255
+ and len(spec.get('categories', [])) <= 8)
256
+ if plot.has_data_labels:
257
+ labels = plot.data_labels
258
+ labels.font.name, labels.font.size = font, Pt(10 if w < 7 else 13)
259
+ labels.font.color.rgb = RGBColor.from_string(colors['text'])
260
+ if kind in {'pie', 'donut'}:
261
+ labels.show_value, labels.show_percentage = False, True
262
+ labels.position = XL_DATA_LABEL_POSITION.CENTER
263
+ labels.font.color.rgb = RGBColor.from_string('FFFFFF')
264
+ else:
265
+ labels.number_format = fmt
266
+ labels.show_value = True
267
+ labels.position = (XL_DATA_LABEL_POSITION.CENTER if 'stacked' in kind or kind.startswith('percent_')
268
+ else XL_DATA_LABEL_POSITION.OUTSIDE_END if kind in {'bar', 'column'}
269
+ else XL_DATA_LABEL_POSITION.ABOVE)
270
+ if 'stacked' in kind or kind.startswith('percent_'):
271
+ labels.font.color.rgb = RGBColor.from_string('FFFFFF')
272
+ for i, series in enumerate(chart.series):
273
+ color = spec['series'][i].get('color', chart_palette[i % len(chart_palette)])
274
+ series.format.fill.solid()
275
+ series.format.fill.fore_color.rgb = RGBColor.from_string(color)
276
+ series.format.line.color.rgb = RGBColor.from_string(color)
277
+ if kind in {'line', 'scatter'}:
278
+ series.marker.style = XL_MARKER_STYLE.CIRCLE
279
+ series.marker.size = 6
280
+ series.marker.format.fill.solid()
281
+ series.marker.format.fill.fore_color.rgb = RGBColor.from_string(color)
282
+ series.marker.format.line.color.rgb = RGBColor.from_string(color)
283
+ series.format.line.width = Pt(2.2)
284
+ if kind == 'scatter':
285
+ series.format.line.fill.background()
286
+ if kind in {'pie', 'donut'} or (len(chart.series) == 1 and spec.get('highlight')):
287
+ for j, point in enumerate(series.points):
288
+ point_color = chart_palette[j % len(chart_palette)] if kind in {'pie', 'donut'} else (
289
+ colors['accent'] if spec['categories'][j] in spec['highlight'] else '94A3B8')
290
+ point.format.fill.solid()
291
+ point.format.fill.fore_color.rgb = RGBColor.from_string(point_color)
292
+ point.format.line.fill.background()
293
+ if kind not in {'pie', 'donut'}:
294
+ for name, axis in [('x_axis', chart.category_axis), ('y_axis', chart.value_axis)]:
295
+ options = spec.get(name, {})
296
+ axis.tick_labels.font.name = font
297
+ axis.tick_labels.font.size = Pt(options.get('font_size', 10 if w < 7 else 12))
298
+ axis.tick_labels.font.color.rgb = RGBColor.from_string(colors['muted'])
299
+ axis.format.line.color.rgb = RGBColor.from_string(colors['muted'])
300
+ axis.major_tick_mark = XL_TICK_MARK.NONE
301
+ axis.minor_tick_mark = XL_TICK_MARK.NONE
302
+ axis.has_title = bool(options.get('title'))
303
+ if axis.has_title:
304
+ frame = axis.axis_title.text_frame
305
+ frame.text = options['title']
306
+ for p in frame.paragraphs:
307
+ p.font.name, p.font.size = font, Pt(11)
308
+ p.font.color.rgb = RGBColor.from_string(colors['muted'])
309
+ if name == 'y_axis' or kind in {'scatter', 'bubble'}:
310
+ axis.tick_labels.number_format = options.get('number_format', '0%' if kind.startswith('percent_') else fmt)
311
+ for field, attribute in [('min', 'minimum_scale'), ('max', 'maximum_scale'), ('major_unit', 'major_unit')]:
312
+ if field in options:
313
+ setattr(axis, attribute, options[field])
314
+ if name == 'y_axis' and kind not in {'scatter', 'bubble', 'line'} and 'min' not in options:
315
+ if all(v is None or v >= 0 for s in spec['series'] for v in s['values']):
316
+ axis.minimum_scale = 0
317
+ axis.has_major_gridlines = name == 'y_axis'
318
+ if axis.has_major_gridlines:
319
+ axis.major_gridlines.format.line.color.rgb = RGBColor.from_string('D5DCE5' if colors['panel'] == 'FFFFFF' else '3D506B')
320
+ axis.major_gridlines.format.line.width = Pt(.5)
321
+ else:
322
+ interval = options.get('label_interval', max(1, math.ceil(len(spec['categories']) / max(4, int(w * 1.2)))))
323
+ skip = OxmlElement('c:tickLblSkip')
324
+ skip.set('val', str(interval))
325
+ axis._element.insert_element_before(skip, 'c:tickMarkSkip', 'c:noMultiLvlLbl', 'c:extLst')
326
+ if kind in {'bar', 'stacked_bar', 'percent_bar'}:
327
+ chart.category_axis.reverse_order = True
328
+ chart.value_axis._element.xpath('c:crosses')[0].set('val', 'max')
329
+ return chart
package/check.py ADDED
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ check.py —— PPT 检查器:排版一致性 + 文字遮挡/溢出。
4
+
5
+ 检查项:
6
+ 1. 字体一致性:是否出现模板外的字体。
7
+ 2. 配色一致性:文字是否出现模板调色板外的颜色。
8
+ 3. 标题对齐:各页主标题(最大字号)横向位置是否一致。
9
+ 4. 文字遮挡:按「估算文字实际占用高度」检测文本框/图片重叠。
10
+ 5. 文本溢出:按字号+框宽高估算文本是否放不下(启发式,非像素级精确)。
11
+ 6. 出界:形状是否超出幻灯片边界。
12
+
13
+ 用法:
14
+ python3 check.py <input.pptx> [--json]
15
+ """
16
+ import sys, math, json
17
+ from pptx import Presentation
18
+
19
+ # 模板允许的字体(含主题引用)
20
+ OK_FONTS = {"华文中宋", "Noto Serif SC", "Noto Serif SC Bold", "微软雅黑", "等线",
21
+ "Microsoft YaHei", "Arial", "Calibri", "Calibri Light", "Wingdings"}
22
+ # 允许的文字色(hex 大写):中性色 + 全部 palette + 页面底色/封面辅助色
23
+ OK_COLORS = {
24
+ "000000", "595959", "FFFFFF", "A5A5A5", "F4F6F9",
25
+ # original
26
+ "192D77", "C00000", "D24726", "E7E6E6", "44546A", "5B9BD5",
27
+ # coastal
28
+ "1B4965", "E07A5F", "62B6CB", "E3EEF6", "5FA8D3", "81B29A",
29
+ # ocean
30
+ "1D3557", "E63946", "457B9D", "F1FAEE", "A8DADC", "F4A261",
31
+ # aurora
32
+ "2A9D8F", "E76F51", "48CAE4", "E8F4F2", "90E0EF",
33
+ # indigo
34
+ "312E81", "E11D48", "6366F1", "EEF2FF", "818CF8", "F59E0B",
35
+ # institutional 体制内庄重
36
+ "1F3A5F", "C0392B", "C9A227", "EEF1F5", "6B7A99", "A8B4C8",
37
+ # rose 暖玫瑰
38
+ "8C3B4C", "C2576B", "E08E6F", "F6ECE7", "D9A08A", "9C7A6B",
39
+ # 封面辅助
40
+ "9FB3DB", "FFC799", "E6ECF6", "CFD9E9", "E2CE8C",
41
+ # 图片署名/辅助灰蓝/投影
42
+ "8A93A6", "C7CDD9",
43
+ # probation native_v2 paper theme
44
+ "15253F", "4D627A", "176C88", "F4F6FA",
45
+ "2DBE62", "4285E8", "F36C2B",
46
+ }
47
+
48
+ SLIDE_W = 12192000
49
+ SLIDE_H = 6858000
50
+
51
+ def _run_info(shape):
52
+ out = []
53
+ if not shape.has_text_frame:
54
+ return out
55
+ for para in shape.text_frame.paragraphs:
56
+ for r in para.runs:
57
+ size = r.font.size.pt if r.font.size else 18
58
+ font = r.font.name or ''
59
+ color = None
60
+ try:
61
+ s = str(r.font.color.rgb)
62
+ if len(s) == 6:
63
+ color = s.upper()
64
+ except Exception:
65
+ pass
66
+ out.append((font, size, color))
67
+ return out
68
+
69
+ def _bbox(shape):
70
+ try:
71
+ return (shape.left, shape.top, shape.width, shape.height)
72
+ except Exception:
73
+ return None
74
+
75
+ def _disp_width(text):
76
+ """估算文本显示宽度(em 数):CJK 等全角字符≈1,拉丁/数字≈0.55。"""
77
+ w = 0.0
78
+ for ch in text:
79
+ w += 1.0 if ord(ch) > 0x2E80 else 0.55
80
+ return w
81
+
82
+
83
+ def _est_extent(shape):
84
+ """估算形状实际占用框:文字用估算高度,图片用全框。空文本返回 None。"""
85
+ bb = _bbox(shape)
86
+ if not bb:
87
+ return None
88
+ if not shape.has_text_frame:
89
+ return bb
90
+ txt = shape.text_frame.text
91
+ if not txt.strip():
92
+ return None
93
+ font_pt = 0
94
+ for _, size, _ in _run_info(shape):
95
+ if size > font_pt:
96
+ font_pt = size
97
+ if font_pt <= 0:
98
+ font_pt = 18
99
+ w_pt = bb[2] / 914400 * 72
100
+ cpl_em = max(0.5, w_pt / font_pt) # 每行可容纳的 em 数
101
+ n_lines = 0
102
+ for line in txt.split('\n'):
103
+ n_lines += max(1, math.ceil(_disp_width(line) / cpl_em))
104
+ line_h_emu = int(font_pt * 1.25 / 72 * 914400)
105
+ est_h = n_lines * line_h_emu
106
+ return (bb[0], bb[1], bb[2], est_h)
107
+
108
+ def _overlap(a, b):
109
+ if a[2] < 10000 or a[3] < 10000 or b[2] < 10000 or b[3] < 10000:
110
+ return False
111
+ ax2, ay2 = a[0]+a[2], a[1]+a[3]
112
+ bx2, by2 = b[0]+b[2], b[1]+b[3]
113
+ return not (ax2 <= b[0] or bx2 <= a[0] or ay2 <= b[1] or by2 <= a[1])
114
+
115
+ def _overflow_est(text, font_pt, box):
116
+ if not text.strip() or not box:
117
+ return None
118
+ w_pt = box[2] / 914400 * 72
119
+ h_pt = box[3] / 914400 * 72
120
+ if font_pt <= 0:
121
+ font_pt = 18
122
+ cpl_em = max(0.5, w_pt / font_pt)
123
+ n_lines = 0
124
+ for line in text.split('\n'):
125
+ n_lines += max(1, math.ceil(_disp_width(line) / cpl_em))
126
+ fit_lines = max(1, int(h_pt / (font_pt * 1.25)))
127
+ if n_lines > fit_lines:
128
+ return {'need_lines': n_lines, 'fit_lines': fit_lines, 'font_pt': font_pt}
129
+ return None
130
+
131
+ def check(path):
132
+ prs = Presentation(path)
133
+ report = {
134
+ 'slides': len(prs.slides),
135
+ 'fonts': {'unexpected': []},
136
+ 'colors': {'unexpected': []},
137
+ 'titles': [],
138
+ 'overlap': [],
139
+ 'overflow': [],
140
+ 'out_of_bounds': [],
141
+ }
142
+ for i, slide in enumerate(prs.slides, 1):
143
+ shapes = list(slide.shapes)
144
+ # 主标题:本页最大字号文本(>=24pt)
145
+ best = None
146
+ for sh in shapes:
147
+ if not sh.has_text_frame or not sh.text_frame.text.strip():
148
+ continue
149
+ for _, size, _ in _run_info(sh):
150
+ if size >= 24 and (best is None or size > best[1]):
151
+ best = (sh, size)
152
+ if best:
153
+ bb = _bbox(best[0])
154
+ if bb:
155
+ report['titles'].append({'slide': i, 'x_in': round(bb[0]/914400, 2),
156
+ 'size': best[1], 'shape': best[0].name})
157
+ # 字体/颜色/出界
158
+ for sh in shapes:
159
+ name = sh.name or ''
160
+ for font, size, color in _run_info(sh):
161
+ if font and font not in OK_FONTS and not font.startswith('+'):
162
+ report['fonts']['unexpected'].append((i, name, font, size))
163
+ if color and color not in OK_COLORS:
164
+ report['colors']['unexpected'].append((i, name, color))
165
+ bb = _bbox(sh)
166
+ if bb and (sh.has_text_frame or sh.shape_type == 13):
167
+ if bb[0] < 0 or bb[1] < 0 or bb[0]+bb[2] > SLIDE_W or bb[1]+bb[3] > SLIDE_H:
168
+ report['out_of_bounds'].append((i, name))
169
+ # 重叠(用估算占用框;跳过图片,因为图片作为背景/图文混排是刻意的)
170
+ extents = [(_est_extent(sh), sh) for sh in shapes]
171
+ for a in range(len(extents)):
172
+ for b in range(a+1, len(extents)):
173
+ ea, sa = extents[a]
174
+ eb, sb = extents[b]
175
+ if not ea or not eb:
176
+ continue
177
+ if sa.shape_type == 13 or sb.shape_type == 13:
178
+ continue
179
+ # A wireframe's segments intentionally meet and cross. That
180
+ # is not text obstruction; keep line/text and text/text checks.
181
+ has_text_a = sa.has_text_frame and sa.text_frame.text.strip()
182
+ has_text_b = sb.has_text_frame and sb.text_frame.text.strip()
183
+ if not has_text_a and not has_text_b:
184
+ continue
185
+ if _overlap(ea, eb):
186
+ report['overlap'].append((i, sa.name, sb.name))
187
+ # 溢出
188
+ for sh in shapes:
189
+ if not sh.has_text_frame or not sh.text_frame.text.strip():
190
+ continue
191
+ font_pt = 18
192
+ for _, size, _ in _run_info(sh):
193
+ font_pt = size
194
+ break
195
+ est = _overflow_est(sh.text_frame.text, font_pt, _bbox(sh))
196
+ if est:
197
+ report['overflow'].append((i, sh.name, est))
198
+ return report
199
+
200
+ def _fmt(report):
201
+ L = []
202
+ L.append("=== PPT 检查报告 ===")
203
+ L.append(f"页数: {report['slides']}")
204
+ L.append(f"\n【1. 字体】非模板字体: {len(report['fonts']['unexpected'])} 处")
205
+ for slide, sh, font, size in report['fonts']['unexpected']:
206
+ L.append(f" 第{slide}页 [{sh}] 字体={font} ({size}pt)")
207
+ L.append(f"\n【2. 配色】非模板文字色: {len(report['colors']['unexpected'])} 处")
208
+ for slide, sh, color in report['colors']['unexpected']:
209
+ L.append(f" 第{slide}页 [{sh}] 颜色=#{color}")
210
+ L.append("\n【3. 标题对齐】各页主标题横向位置:")
211
+ xs = [t['x_in'] for t in report['titles']]
212
+ for t in report['titles']:
213
+ L.append(f" 第{t['slide']}页 x={t['x_in']}in ({t['size']}pt)")
214
+ if xs:
215
+ mode = max(set(xs), key=xs.count)
216
+ dev = [t for t in report['titles'] if abs(t['x_in'] - mode) > 0.5]
217
+ L.append(f" 主流位置 {mode}in,偏离>0.5in: {len(dev)} 页")
218
+ for t in dev:
219
+ L.append(f" 第{t['slide']}页 x={t['x_in']}in")
220
+ L.append(f"\n【4. 文字遮挡】重叠: {len(report['overlap'])} 处")
221
+ for slide, na, nb in report['overlap']:
222
+ L.append(f" 第{slide}页: [{na}] 与 [{nb}] 重叠")
223
+ L.append(f"\n【5. 文本溢出】可能溢出: {len(report['overflow'])} 处")
224
+ for slide, sh, info in report['overflow']:
225
+ L.append(f" 第{slide}页 [{sh}] 需 {info['need_lines']} 行 > 可容 {info['fit_lines']} 行 ({info['font_pt']}pt)")
226
+ L.append(f"\n【6. 出界】: {len(report['out_of_bounds'])} 处")
227
+ for slide, sh in report['out_of_bounds']:
228
+ L.append(f" 第{slide}页 [{sh}] 超出边界")
229
+ return "\n".join(L)
230
+
231
+ if __name__ == '__main__':
232
+ if len(sys.argv) < 2:
233
+ print("用法: python3 check.py <input.pptx> [--json]")
234
+ sys.exit(1)
235
+ rep = check(sys.argv[1])
236
+ print(_fmt(rep) if '--json' not in sys.argv else json.dumps(rep, ensure_ascii=False, indent=2))
package/config.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "pexels_key": "",
3
+ "template": "",
4
+ "logo": ""
5
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """Compose rendered slides into one contact sheet for a thumbnail review.
3
+
4
+ Visual direction requires looking at the deck as a whole before inspecting a
5
+ single page at full size. This tool is deliberately small: it only arranges the
6
+ PNGs produced by ``render_ppt.py`` and writes one image plus optional page
7
+ labels, so the reviewer can judge rhythm and density in one glance.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from PIL import Image, ImageDraw, ImageFont
16
+
17
+
18
+ def natural_key(path: Path):
19
+ parts = re.split(r"(\d+)", path.stem)
20
+ return [int(part) if part.isdigit() else part for part in parts]
21
+
22
+
23
+ def load_pages(source: Path) -> list[Path]:
24
+ if source.is_dir():
25
+ candidates = sorted(source.glob("*.png"), key=natural_key)
26
+ elif source.is_file() and source.suffix.lower() in {".png", ".jpg", ".jpeg"}:
27
+ candidates = [source]
28
+ else:
29
+ raise SystemExit("contact_sheet: pass a render directory (or one PNG)")
30
+ pages = [path for path in candidates if path.is_file()]
31
+ if not pages:
32
+ raise SystemExit(f"contact_sheet: no PNG pages in {source}")
33
+ return pages
34
+
35
+
36
+ def label_font(size: int):
37
+ for path in ("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
38
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"):
39
+ try:
40
+ return ImageFont.truetype(path, size)
41
+ except OSError:
42
+ continue
43
+ return ImageFont.load_default()
44
+
45
+
46
+ def main() -> int:
47
+ parser = argparse.ArgumentParser(description=__doc__,
48
+ formatter_class=argparse.RawDescriptionHelpFormatter)
49
+ parser.add_argument("source", type=Path, help="directory from render_ppt.py")
50
+ parser.add_argument("--out", type=Path, required=True)
51
+ parser.add_argument("--cols", type=int, default=4)
52
+ parser.add_argument("--width", type=int, default=480, help="thumbnail width in px")
53
+ parser.add_argument("--gap", type=int, default=14)
54
+ parser.add_argument("--background", default="#0B1324")
55
+ parser.add_argument("--labels", action="store_true", help="draw page numbers")
56
+ args = parser.parse_args()
57
+
58
+ pages = load_pages(args.source)
59
+ thumbs = []
60
+ for page in pages:
61
+ with Image.open(page) as source:
62
+ image = source.convert("RGB")
63
+ height = max(1, round(image.height * args.width / image.width))
64
+ thumbs.append(image.resize((args.width, height), Image.LANCZOS))
65
+ label_h = 24 if args.labels else 0
66
+ cell_h = max(image.height for image in thumbs) + label_h
67
+ cols = max(1, min(args.cols, len(thumbs)))
68
+ rows = (len(thumbs) + cols - 1) // cols
69
+ width = cols * args.width + (cols + 1) * args.gap
70
+ height = rows * cell_h + (rows + 1) * args.gap
71
+ sheet = Image.new("RGB", (width, height), args.background)
72
+ draw = ImageDraw.Draw(sheet)
73
+ font = label_font(max(12, args.width // 32))
74
+ for index, image in enumerate(thumbs):
75
+ left = args.gap + (index % cols) * (args.width + args.gap)
76
+ top = args.gap + (index // cols) * (cell_h + args.gap)
77
+ sheet.paste(image, (left, top + label_h + (cell_h - label_h - image.height) // 2))
78
+ if args.labels:
79
+ text = f"{index + 1:02d}"
80
+ draw.text((left + 2, top + 3), text, fill="#FFFFFF", font=font)
81
+ args.out.parent.mkdir(parents=True, exist_ok=True)
82
+ sheet.save(args.out, "PNG")
83
+ print(f"contact_sheet: {len(thumbs)} pages -> {args.out} ({sheet.width}x{sheet.height})")
84
+ return 0
85
+
86
+
87
+ if __name__ == "__main__":
88
+ raise SystemExit(main())