@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/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
package/contact_sheet.py
ADDED
|
@@ -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())
|