@lark-apaas/coding-steering 0.1.31 → 0.1.32-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,679 @@
1
+ #!/usr/bin/env python3
2
+ """页面光栅器:把 shape-facts 重放成页面图(PNG)/绝对定位 HTML。
3
+
4
+ draft.py 用它渲染各 archetype 的代表页,拼成 layout-sheet.png——版式命名要看得见页面。
5
+
6
+ 用法: render_pages.py <extract 输出目录> [--pages all|slides|layouts] [--only 1,3,9] [--no-html]
7
+
8
+ 读 <outdir>/extract.json + <outdir>/ref/shapes.json(旧产物回退读
9
+ extract.json 的 shapes 键),写 <outdir>/ref/rebuild/:
10
+ index.html 全部页面纵向排列(缩放到 960 宽便于通览)
11
+ slide-<n>.html 每张实例页一个 1920x1080 精确视口文件(供截图对比原图)
12
+ layout-<n>.html 每个版式一个,同上
13
+
14
+ 页面 = master 形状 → layout 形状 → slide 形状 三层叠加(PowerPoint 的渲染顺序);
15
+ 版式页只叠 master → layout。图片 src 指 media-out 相对路径;extract 未导出的
16
+ 媒体(内容图不属于风格资产)画虚线占位框并标注原始文件名。
17
+
18
+ 占位符继承按 PowerPoint 语义处理:实例页填了某个 ph 槽位时,压掉版式里同槽位的
19
+ 提示文字;实例页 ph 自身无 xfrm(extract 记 placement=inherited)时,从版式同槽位
20
+ 借几何与 lstStyle。缺省文字色取 master clrMap 解析出的 tx1(深色母版=白,浅色=黑)。
21
+
22
+ 纯脚本重建,不调用任何模型。LLM 只参与「看重建图 vs 看原图」的对比判断。
23
+ """
24
+ import argparse
25
+ import html as html_lib
26
+ import json
27
+ import os
28
+ import re
29
+ import sys
30
+
31
+ LAYER_ORDER = {'master': 0, 'layout': 1, 'slide': 2}
32
+ ALIGN = {'l': 'left', 'ctr': 'center', 'r': 'right', 'just': 'justify'}
33
+ VANCHOR = {'t': 'flex-start', 'ctr': 'center', 'b': 'flex-end'}
34
+ # 源字族本地多半装不上(商业中文字体 / 品牌字体),按类目兜底——
35
+ # 不分类目会让衬线标题渲染成无衬线,对比环节就会误报「字体抽错了」
36
+ CJK_STACK = "'Noto Sans SC','PingFang SC','Microsoft YaHei',sans-serif"
37
+ SERIF_STACK = "'Noto Serif SC',Georgia,'Songti SC',serif"
38
+ MONO_STACK = "'IBM Plex Mono',ui-monospace,Menlo,monospace"
39
+ SERIF_HINT = ('serif', 'georgia', 'times', 'song', 'ming', 'garamond', 'baskerville')
40
+ MONO_HINT = ('mono', 'consol', 'courier', 'code')
41
+
42
+
43
+ def fallback_stack(family):
44
+ low = family.lower()
45
+ if any(hint in low for hint in MONO_HINT):
46
+ return MONO_STACK
47
+ if any(hint in low for hint in SERIF_HINT) and 'sans' not in low:
48
+ return SERIF_STACK
49
+ return CJK_STACK
50
+
51
+
52
+ def esc(text):
53
+ return html_lib.escape(str(text), quote=False)
54
+
55
+
56
+ def attr(value):
57
+ return html_lib.escape(str(value), quote=True)
58
+
59
+
60
+ def style_attr(styles):
61
+ return attr(';'.join(styles))
62
+
63
+
64
+ def css_color(color):
65
+ """{raw, hex, alpha, resolved} → CSS 颜色串;extract 已算好 resolved,直接用。"""
66
+ if not color:
67
+ return None
68
+ if color.get('resolved'):
69
+ return color['resolved']
70
+ if color.get('hex'):
71
+ return color['hex']
72
+ return None
73
+
74
+
75
+ def css_gradient(grad):
76
+ """OOXML gradFill → CSS linear-gradient。ang 是自 +x 轴顺时针度数,CSS 是自 12 点顺时针,故 +90。"""
77
+ stops = sorted(grad.get('stops') or [], key=lambda s: s.get('pos', 0))
78
+ if not stops:
79
+ return None
80
+ parts = []
81
+ for stop in stops:
82
+ color = css_color(stop.get('color')) or 'transparent'
83
+ parts.append('%s %.1f%%' % (color, stop.get('pos', 0)))
84
+ angle = (grad.get('angle_deg') or 0) + 90
85
+ return 'linear-gradient(%.1fdeg, %s)' % (angle, ', '.join(parts))
86
+
87
+
88
+ def background_css(bg):
89
+ """页底 <p:bg> 描述符 → CSS background 值。解析不出返回 None,交上层回退到下一层底色。
90
+
91
+ bgPr solid / bgRef 都带 `color`;gradient 与形状渐变共用 css_gradient;
92
+ 背景图不重建(媒体未必导出),按无底色处理。
93
+ """
94
+ if not bg:
95
+ return None
96
+ kind = bg.get('type')
97
+ if kind == 'gradient':
98
+ return css_gradient(bg)
99
+ if kind in ('image', 'none', 'pattern'):
100
+ return None
101
+ return css_color(bg.get('color'))
102
+
103
+
104
+ def fill_style(fill):
105
+ """形状填充 → CSS 声明列表。"""
106
+ if not fill:
107
+ return []
108
+ kind = fill.get('type')
109
+ if kind == 'solid':
110
+ color = css_color(fill.get('color'))
111
+ return ['background:%s' % color] if color else []
112
+ if kind == 'gradient':
113
+ grad = css_gradient(fill)
114
+ return ['background-image:%s' % grad] if grad else []
115
+ return []
116
+
117
+
118
+ def line_style(line):
119
+ """描边 → CSS。渐变描边走 border-image(源模板的卡片边框就是这个)。"""
120
+ if not line or line.get('none'):
121
+ return []
122
+ width = max(1, round(line.get('w_px') or 1))
123
+ if line.get('gradient'):
124
+ grad = css_gradient(line['gradient'])
125
+ if grad:
126
+ return ['border:%dpx solid transparent' % width,
127
+ 'border-image:%s 1' % grad]
128
+ color = css_color(line.get('color'))
129
+ if color:
130
+ dash = 'dashed' if (line.get('dash') or '').startswith('dash') else 'solid'
131
+ return ['border:%dpx %s %s' % (width, dash, color)]
132
+ return []
133
+
134
+
135
+ def crop_style(crop, url):
136
+ """srcRect 裁切用 background-position/size 模拟。
137
+ background-position 的百分比是相对 (容器 - 图) 的溢出量解析的,
138
+ 所以偏移分数是 l/(l+r),不是 l/可见宽。"""
139
+ left, right = crop.get('l', 0) or 0, crop.get('r', 0) or 0
140
+ top, bottom = crop.get('t', 0) or 0, crop.get('b', 0) or 0
141
+ vis_w, vis_h = 100 - left - right, 100 - top - bottom
142
+ if vis_w <= 0 or vis_h <= 0:
143
+ vis_w, vis_h = 100, 100
144
+ return ['background-image:url(%s)' % url,
145
+ 'background-size:%.3f%% %.3f%%' % (100 / vis_w * 100, 100 / vis_h * 100),
146
+ 'background-position:%.3f%% %.3f%%' % (
147
+ left / (left + right) * 100 if (left + right) else 0,
148
+ top / (top + bottom) * 100 if (top + bottom) else 0),
149
+ 'background-repeat:no-repeat']
150
+
151
+
152
+ def run_props(lvl1, para, run):
153
+ """有效文本属性 = lstStyle.lvl1pPr ← 段落 defRPr ← 段属性 ← run 覆写。
154
+
155
+ 段落的 a:pPr/a:defRPr 是「该段 run 的默认值」,位置在 lstStyle 与 run 之间;
156
+ Mac Office 导出的 deck 把字号写在这一层,漏掉它整页标题就没有 font-size。
157
+ """
158
+ props = dict(lvl1 or {})
159
+ props.update((para or {}).get('defRPr') or {})
160
+ for src in (para or {}), (run or {}):
161
+ for key, val in src.items():
162
+ if key not in ('runs', 'text', 'defRPr') and val is not None:
163
+ props[key] = val
164
+ return props
165
+
166
+
167
+ def run_css(props):
168
+ styles = []
169
+ size = props.get('sz_px')
170
+ if size:
171
+ styles.append('font-size:%.1fpx' % size)
172
+ family = props.get('latin') or props.get('ea')
173
+ if family:
174
+ styles.append("font-family:'%s',%s" % (family.replace("'", ''),
175
+ fallback_stack(family)))
176
+ weight = props.get('weight') or (700 if props.get('bold') else None)
177
+ if weight:
178
+ styles.append('font-weight:%d' % weight)
179
+ if props.get('italic'):
180
+ styles.append('font-style:italic')
181
+ spc = props.get('spc_px')
182
+ if spc:
183
+ styles.append('letter-spacing:%.2fpx' % spc)
184
+ if props.get('underline'):
185
+ styles.append('text-decoration:underline')
186
+ fill = props.get('fill')
187
+ if isinstance(fill, dict) and fill.get('type') == 'gradient':
188
+ # 渐变填字:本模板的强调机制。重建里必须显式还原,否则看不出漏抽
189
+ grad = css_gradient(fill)
190
+ if grad:
191
+ styles += ['background-image:%s' % grad,
192
+ '-webkit-background-clip:text', 'background-clip:text',
193
+ 'color:transparent']
194
+ else:
195
+ color = None
196
+ if isinstance(fill, dict) and fill.get('type') == 'solid':
197
+ color = css_color(fill.get('color'))
198
+ color = color or css_color(props.get('color'))
199
+ if color:
200
+ styles.append('color:%s' % color)
201
+ return styles
202
+
203
+
204
+ def para_css(para, lvl1):
205
+ styles = []
206
+ align = para.get('algn') or (lvl1 or {}).get('algn') or 'l'
207
+ styles.append('text-align:%s' % ALIGN.get(align, 'left'))
208
+ lnspc = para.get('lnSpc') or (lvl1 or {}).get('lnSpc') or {}
209
+ if lnspc.get('mult'):
210
+ # OOXML spcPct 是「单倍行距」的百分比,单倍 ≈ 1.2em
211
+ styles.append('line-height:%.3f' % (lnspc['mult'] * 1.2))
212
+ return styles
213
+
214
+
215
+ def render_text(text_obj):
216
+ lvl1 = ((text_obj.get('lstStyle') or {}).get('lvl1pPr')) or {}
217
+ paras = text_obj.get('paragraphs')
218
+ if not isinstance(paras, list):
219
+ return ''
220
+ html = ''
221
+ for para in paras:
222
+ if not isinstance(para, dict):
223
+ continue
224
+ html += '<p style="%s">' % style_attr(para_css(para, lvl1))
225
+ runs = para.get('runs') or []
226
+ if not runs:
227
+ html += '<br>'
228
+ for run in runs:
229
+ props = run_props(lvl1, para, run)
230
+ html += '<span style="%s">%s</span>' % (
231
+ style_attr(run_css(props)), esc(run.get('text') or ''))
232
+ html += '</p>'
233
+ return html
234
+
235
+
236
+ def render_shape(shape, media_url, stats):
237
+ if shape['kind'] == 'grpSp':
238
+ return '' # 组合本身无视觉,子形状已带绝对坐标
239
+ box = shape.get('box')
240
+ if not box:
241
+ stats['no_box'] += 1
242
+ return ''
243
+ styles = ['left:%.1fpx' % box['x'], 'top:%.1fpx' % box['y'],
244
+ 'width:%.1fpx' % box['w'], 'height:%.1fpx' % box['h']]
245
+ if (shape.get('degenerate_axis') or '') == 'w' or box['w'] == 0:
246
+ styles[2] = 'width:1px' # 竖直连接符:0 宽渲染不出来
247
+ if (shape.get('degenerate_axis') or '') == 'h' or box['h'] == 0:
248
+ styles[3] = 'height:1px'
249
+ radius = shape.get('radius_px')
250
+ if radius:
251
+ styles.append('border-radius:%.1fpx' % radius)
252
+ styles += fill_style(shape.get('fill'))
253
+ styles += line_style(shape.get('line'))
254
+ inner = ''
255
+ media = shape.get('media_svg') or shape.get('media')
256
+ if media:
257
+ url = media_url.get(media)
258
+ if url:
259
+ styles += crop_style(shape.get('crop') or {}, url)
260
+ stats['img_ok'] += 1
261
+ else:
262
+ styles += ['outline:2px dashed rgba(255,0,128,.7)', 'outline-offset:-2px']
263
+ inner = ('<span class="ph">%s</span>' % esc(os.path.basename(media)))
264
+ stats['img_missing'] += 1
265
+ text_obj = shape.get('text')
266
+ if text_obj:
267
+ body = text_obj.get('bodyPr') or {}
268
+ ins = body.get('insets_px') or {}
269
+ styles.append('padding:%.1fpx %.1fpx %.1fpx %.1fpx' % (
270
+ ins.get('tIns', 0) or 0, ins.get('rIns', 0) or 0,
271
+ ins.get('bIns', 0) or 0, ins.get('lIns', 0) or 0))
272
+ styles += ['display:flex', 'flex-direction:column',
273
+ 'justify-content:%s' % VANCHOR.get(body.get('anchor', 't'), 'flex-start')]
274
+ inner += render_text(text_obj)
275
+ stats['text'] += 1
276
+ stats['shapes'] += 1
277
+ return '<div class="sp" style="%s">%s</div>' % (style_attr(styles), inner)
278
+
279
+
280
+ HEAD = """<style>
281
+ body{margin:0;background:#2b2b2b;font-family:%s}
282
+ .page{position:relative;width:1920px;height:1080px;overflow:hidden}
283
+ .sp{position:absolute;box-sizing:border-box}
284
+ .sp p{margin:0}
285
+ .ph{position:absolute;left:6px;top:4px;font:16px/1.2 monospace;color:#ff2b88;background:#fff9;padding:1px 4px}
286
+ </style>
287
+ """ % CJK_STACK
288
+
289
+ INDEX_EXTRA = """<style>
290
+ .wrap{width:960px;margin:0 auto;padding:16px 0}
291
+ .lbl{color:#eee;font:13px/1.6 monospace;margin:14px 0 4px}
292
+ .box{width:960px;height:540px;overflow:hidden;margin-bottom:6px}
293
+ .box .page{transform:scale(.5);transform-origin:top left}
294
+ </style>
295
+ """
296
+
297
+
298
+ def ph_key(shape):
299
+ ph = shape.get('ph')
300
+ if not ph:
301
+ return None
302
+ return (ph.get('type'), ph.get('idx'))
303
+
304
+
305
+ def resolve_inheritance(layout_shapes, slide_shapes):
306
+ """实例页填了的 ph 槽位压掉版式提示文字;实例页缺 xfrm 的 ph 从版式借几何 + lstStyle。"""
307
+ layout_by_ph = {}
308
+ for shape in layout_shapes:
309
+ key = ph_key(shape)
310
+ if key and key not in layout_by_ph:
311
+ layout_by_ph[key] = shape
312
+ filled = {ph_key(s) for s in slide_shapes if ph_key(s)}
313
+ kept_layout = [s for s in layout_shapes if ph_key(s) not in filled]
314
+ resolved_slide = []
315
+ for shape in slide_shapes:
316
+ key = ph_key(shape)
317
+ donor = layout_by_ph.get(key)
318
+ if donor and (shape.get('placement') == 'inherited' or not shape.get('box')):
319
+ merged = dict(donor)
320
+ merged.update({k: v for k, v in shape.items() if k != 'text'})
321
+ merged['box'] = donor.get('box')
322
+ donor_text = donor.get('text') or {}
323
+ own_text = shape.get('text') or {}
324
+ merged['text'] = {'bodyPr': own_text.get('bodyPr') or donor_text.get('bodyPr'),
325
+ 'lstStyle': own_text.get('lstStyle') or donor_text.get('lstStyle'),
326
+ 'paragraphs': own_text.get('paragraphs')
327
+ or donor_text.get('paragraphs')}
328
+ resolved_slide.append(merged)
329
+ else:
330
+ resolved_slide.append(shape)
331
+ return kept_layout, resolved_slide
332
+
333
+
334
+ def page_html(parts_shapes, default_color, page_bg, media_url, stats):
335
+ body = ['<div class="page" style="%s">' % style_attr([
336
+ 'color:%s' % default_color,
337
+ 'background:%s' % page_bg,
338
+ ])]
339
+ for shapes in parts_shapes:
340
+ for shape in shapes:
341
+ body.append(render_shape(shape, media_url, stats))
342
+ body.append('</div>')
343
+ return '\n'.join(b for b in body if b)
344
+
345
+
346
+ def load_shapes(outdir, data):
347
+ """S4 shape-facts, sidecar first.
348
+
349
+ `ref/shapes.json` is where the extractor writes them; the extract.json
350
+ fallback keeps older output directories (which carried a `shapes` key)
351
+ replayable without re-running extraction.
352
+ """
353
+ sidecar = os.path.join(outdir, 'ref', 'shapes.json')
354
+ if os.path.exists(sidecar):
355
+ with open(sidecar, encoding='utf-8') as f:
356
+ return json.load(f)['shapes'], 'ref/shapes.json'
357
+ if 'shapes' in data:
358
+ return data['shapes'], 'extract.json (legacy)'
359
+ raise SystemExit('no shape facts: neither %s nor extract.json["shapes"]' % sidecar)
360
+
361
+
362
+
363
+ # ---------------------------------------------------------------- PNG 光栅(无浏览器成像)
364
+ FONT_CANDIDATES = [
365
+ '/System/Library/Fonts/PingFang.ttc',
366
+ '/System/Library/Fonts/Hiragino Sans GB.ttc',
367
+ '/System/Library/Fonts/STHeiti Light.ttc',
368
+ '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
369
+ '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
370
+ '/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc',
371
+ ]
372
+
373
+ _CSS_RGBA = re.compile(r'rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+))?\s*\)')
374
+ _CSS_GRAD = re.compile(r'linear-gradient\((?:([0-9.+-]+)deg\s*,)?(.*)\)\s*$')
375
+ _GRAD_STOP = re.compile(r'(#[0-9A-Fa-f]{6}|rgba?\([^)]*\)|transparent)\s*([0-9.]+)%')
376
+
377
+
378
+ def _rgba(css, default=None):
379
+ if not css:
380
+ return default
381
+ css = css.strip()
382
+ if css == 'transparent':
383
+ return (0, 0, 0, 0)
384
+ if css.startswith('#') and len(css) >= 7:
385
+ return (int(css[1:3], 16), int(css[3:5], 16), int(css[5:7], 16), 255)
386
+ m = _CSS_RGBA.match(css)
387
+ if m:
388
+ a = float(m.group(4)) if m.group(4) is not None else 1.0
389
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3)), int(round(a * 255)))
390
+ return default
391
+
392
+
393
+ def _grad_stops(css):
394
+ """解析 css_gradient 自产的 linear-gradient 串 → (angle, [(pos01, rgba)]);解析不了返回 None。"""
395
+ m = _CSS_GRAD.search(css or '')
396
+ if not m:
397
+ return None
398
+ angle = float(m.group(1)) if m.group(1) else 180.0
399
+ stops = [( float(p) / 100.0, _rgba(c, (0, 0, 0, 0)) )
400
+ for c, p in _GRAD_STOP.findall(m.group(2))]
401
+ return (angle, stops) if stops else None
402
+
403
+
404
+ def _grad_image(Image, w, h, angle, stops):
405
+ """按角度投影的线性渐变位图。t = 像素在渐变轴上的归一化位置。"""
406
+ import math
407
+ w, h = max(int(w), 1), max(int(h), 1)
408
+ rad = math.radians((angle or 180) - 90) # CSS 角 → 数学向量
409
+ vx, vy = math.cos(rad), math.sin(rad)
410
+ img = Image.new('RGBA', (w, h))
411
+ px = img.load()
412
+ span = abs(w * vx) + abs(h * vy) or 1.0
413
+ x0 = 0 if vx >= 0 else w
414
+ y0 = 0 if vy >= 0 else h
415
+ stops = sorted(stops)
416
+ for y in range(h):
417
+ for x in range(0, w, max(1, w // 256)):
418
+ t = ((x - x0) * vx + (y - y0) * vy) / span
419
+ t = min(max(t, 0.0), 1.0)
420
+ lo = stops[0]
421
+ hi = stops[-1]
422
+ for i in range(len(stops) - 1):
423
+ if stops[i][0] <= t <= stops[i + 1][0]:
424
+ lo, hi = stops[i], stops[i + 1]
425
+ break
426
+ f = 0.0 if hi[0] == lo[0] else (t - lo[0]) / (hi[0] - lo[0])
427
+ col = tuple(int(lo[1][k] + (hi[1][k] - lo[1][k]) * f) for k in range(4))
428
+ for xx in range(x, min(x + max(1, w // 256), w)):
429
+ px[xx, y] = col
430
+ return img
431
+
432
+
433
+ class _Fonts:
434
+ def __init__(self, ImageFont):
435
+ self.ImageFont = ImageFont
436
+ self.path = next((p for p in FONT_CANDIDATES if os.path.exists(p)), None)
437
+ self.cache = {}
438
+
439
+ def get(self, size):
440
+ size = max(int(size), 6)
441
+ if size not in self.cache:
442
+ try:
443
+ self.cache[size] = self.ImageFont.truetype(self.path, size) if self.path \
444
+ else self.ImageFont.load_default()
445
+ except Exception:
446
+ self.cache[size] = self.ImageFont.load_default()
447
+ return self.cache[size]
448
+
449
+
450
+ def _run_color(props, default_color):
451
+ fill = props.get('fill')
452
+ if isinstance(fill, dict):
453
+ if fill.get('type') == 'gradient':
454
+ g = _grad_stops(css_gradient(fill) or '')
455
+ if g and g[1]:
456
+ mid = g[1][len(g[1]) // 2][1]
457
+ return mid
458
+ if fill.get('type') == 'solid':
459
+ c = _rgba(css_color(fill.get('color')))
460
+ if c:
461
+ return c
462
+ return _rgba(css_color(props.get('color'))) or _rgba(default_color, (0, 0, 0, 255))
463
+
464
+
465
+ def _draw_text(draw, fonts, text_obj, box, scale, default_color):
466
+ lvl1 = ((text_obj.get('lstStyle') or {}).get('lvl1pPr')) or {}
467
+ paras = text_obj.get('paragraphs')
468
+ if not isinstance(paras, list):
469
+ return
470
+ bx, by, bw = box['x'] * scale, box['y'] * scale, box['w'] * scale
471
+ lines = [] # (runs[(text, size, color)], align, line_h)
472
+ for para in paras:
473
+ if not isinstance(para, dict):
474
+ continue
475
+ runs = para.get('runs') or []
476
+ algn = para.get('algn') or lvl1.get('algn') or 'l'
477
+ lnspc = (para.get('lnSpc') or lvl1.get('lnSpc') or {})
478
+ mult = lnspc.get('mult') or 1.0
479
+ items, maxsz = [], 12
480
+ for run in runs:
481
+ props = run_props(lvl1, para, run)
482
+ size = (props.get('sz_px') or 18) * scale
483
+ maxsz = max(maxsz, size)
484
+ items.append((run.get('text') or '', size, _run_color(props, default_color)))
485
+ lines.append((items, algn, maxsz * 1.2 * mult))
486
+ total_h = sum(l[2] for l in lines)
487
+ anchor = ((text_obj.get('bodyPr') or {}).get('anchor')) or 't'
488
+ y = by + {'t': 0, 'ctr': (box['h'] * scale - total_h) / 2,
489
+ 'b': box['h'] * scale - total_h}.get(anchor, 0)
490
+ for items, algn, line_h in lines:
491
+ width = sum(draw.textlength(t, font=fonts.get(s)) for t, s, _ in items if t)
492
+ x = bx + {'l': 0, 'ctr': (bw - width) / 2, 'r': bw - width}.get(algn, 0)
493
+ for t, s, col in items:
494
+ if t:
495
+ draw.text((x, y), t, font=fonts.get(s), fill=col)
496
+ x += draw.textlength(t, font=fonts.get(s))
497
+ y += line_h
498
+
499
+
500
+ def render_pages_png(pages, outdir, data, scale=0.5):
501
+ try:
502
+ from PIL import Image, ImageDraw, ImageFont
503
+ except Exception:
504
+ print('png: Pillow 缺失,跳过(extract 已降级,产物记 gaps)')
505
+ return None
506
+ fonts = _Fonts(ImageFont)
507
+ media_local = {m['media']: os.path.join(outdir, m['out'])
508
+ for m in data.get('media') or [] if m.get('exported') and m.get('out')}
509
+ png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
510
+ os.makedirs(png_dir, exist_ok=True)
511
+ W, H = int(1920 * scale), int(1080 * scale)
512
+ for kind, n, label, layers, default_color, page_bg in pages:
513
+ canvas = Image.new('RGBA', (W, H), _rgba(page_bg) or (136, 136, 136, 255))
514
+ g = _grad_stops(page_bg or '')
515
+ if g:
516
+ canvas.alpha_composite(_grad_image(Image, W, H, g[0], g[1]))
517
+ draw = ImageDraw.Draw(canvas, 'RGBA')
518
+ for shapes in layers:
519
+ for sp in shapes:
520
+ if sp.get('kind') == 'grpSp':
521
+ continue
522
+ box = sp.get('box')
523
+ if not box:
524
+ continue
525
+ x, y = box['x'] * scale, box['y'] * scale
526
+ w, h = max(box['w'] * scale, 1), max(box['h'] * scale, 1)
527
+ rect = [x, y, x + w, y + h]
528
+ media = sp.get('media_svg') or sp.get('media')
529
+ if media:
530
+ p = media_local.get(media)
531
+ drawn = False
532
+ if p and os.path.exists(p) and not p.endswith('.svg'):
533
+ try:
534
+ im = Image.open(p).convert('RGBA').resize((int(w), int(h)))
535
+ canvas.alpha_composite(im, (int(x), int(y)))
536
+ drawn = True
537
+ except Exception:
538
+ pass
539
+ if not drawn:
540
+ draw.rectangle(rect, outline=(255, 43, 136, 255), width=1)
541
+ draw.text((x + 3, y + 2), os.path.basename(media),
542
+ font=fonts.get(11), fill=(255, 43, 136, 255))
543
+ fill = sp.get('fill') or {}
544
+ if fill.get('type') == 'solid':
545
+ col = _rgba(css_color(fill.get('color')))
546
+ if col:
547
+ a = fill.get('color', {}).get('alpha')
548
+ if a is not None and a < 100:
549
+ col = col[:3] + (int(a / 100 * 255),)
550
+ layer = Image.new('RGBA', (W, H))
551
+ ImageDraw.Draw(layer).rectangle(rect, fill=col)
552
+ canvas.alpha_composite(layer)
553
+ elif fill.get('type') == 'gradient':
554
+ gg = _grad_stops(css_gradient(fill) or '')
555
+ if gg:
556
+ canvas.alpha_composite(
557
+ _grad_image(Image, w, h, gg[0], gg[1]), (int(x), int(y)))
558
+ line = sp.get('line') or {}
559
+ if line and not line.get('none'):
560
+ lc = _rgba(css_color(line.get('color')))
561
+ if not lc and line.get('gradient'):
562
+ gg = _grad_stops(css_gradient(line['gradient']) or '')
563
+ lc = gg[1][-1][1] if gg else None
564
+ if lc:
565
+ draw.rectangle(rect, outline=lc,
566
+ width=max(1, int((line.get('w_px') or 1) * scale)))
567
+ if sp.get('text'):
568
+ _draw_text(draw, fonts, sp['text'], box, scale, default_color)
569
+ out = os.path.join(png_dir, '%s-%d.png' % (kind, n))
570
+ canvas.convert('RGB').save(out)
571
+ return png_dir
572
+
573
+ def main():
574
+ ap = argparse.ArgumentParser()
575
+ ap.add_argument('outdir')
576
+ ap.add_argument('--pages', choices=('all', 'slides', 'layouts'), default='all')
577
+ ap.add_argument('--no-png', action='store_true', help='跳过 PNG 光栅(默认渲染,供 Read 工具直接看图)')
578
+ ap.add_argument('--only', default=None, help='只渲染这些实例页,逗号分隔页号(draft.py 取代表页用)')
579
+ ap.add_argument('--no-html', action='store_true', help='只出 PNG,不写 HTML')
580
+ args = ap.parse_args()
581
+ only = {int(x) for x in args.only.split(',') if x.strip()} if args.only else None
582
+
583
+ outdir = os.path.abspath(args.outdir)
584
+ with open(os.path.join(outdir, 'extract.json'), encoding='utf-8') as f:
585
+ data = json.load(f)
586
+ rebuild = os.path.join(outdir, 'ref', 'rebuild')
587
+ os.makedirs(rebuild, exist_ok=True)
588
+
589
+ shapes, shapes_from = load_shapes(outdir, data)
590
+ # media-out/ sits at the output root, two levels up from ref/rebuild/.
591
+ media_url = {m['media']: '../../' + m['out']
592
+ for m in data.get('media') or [] if m.get('exported') and m.get('out')}
593
+ by_part = {}
594
+ for shape in shapes:
595
+ by_part.setdefault(shape['part'], []).append(shape)
596
+ graph = data.get('reference_graph') or {}
597
+ layout_of = graph.get('layout_of_slide') or {}
598
+ master_of = graph.get('master_of_layout') or {}
599
+ layout_name = {l['part']: l.get('name') or '' for l in data.get('layouts') or []}
600
+
601
+ # 缺省文字色 = master clrMap 的 tx1 → theme clrScheme(PowerPoint 的实际缺省)
602
+ theme_scheme = {t['part']: t.get('clrScheme') or {} for t in data.get('themes') or []}
603
+ theme_of_master = (graph.get('theme_of_master') or {})
604
+ tx1_of_master = {}
605
+ for entry in ((data.get('theme_topology') or {}).get('per_master') or []):
606
+ scheme = theme_scheme.get(theme_of_master.get(entry['master']), {})
607
+ tx1_of_master[entry['master']] = scheme.get(entry.get('tx1_slot') or 'dk1', '#000000')
608
+
609
+ def num(part):
610
+ m = re.search(r'(\d+)\.xml$', part)
611
+ return int(m.group(1)) if m else 0
612
+
613
+ # 页底色两层:实例页自己的 <p:bg> 优先,没有才退版式底色。PptxGenJS 那类 deck
614
+ # 每页自设纯色底,只看版式层会把整叠页渲染成同一个底色(反白页就变成白底白字)。
615
+ layout_bg = {}
616
+ for layout in data.get('layouts') or []:
617
+ layout_bg[layout['part']] = background_css(layout.get('background')) or '#888'
618
+ slide_bg = {}
619
+ for slide in data.get('slides') or []:
620
+ css = background_css(slide.get('background'))
621
+ if css:
622
+ slide_bg[slide['part']] = css
623
+
624
+ pages = [] # (kind, n, label, [层形状], 缺省色, 底色)
625
+ if args.pages in ('all', 'slides'):
626
+ for part in sorted((p for p in by_part if '/slides/' in p), key=num):
627
+ layout = layout_of.get(part)
628
+ master = master_of.get(layout) if layout else None
629
+ kept_layout, slide_shapes = resolve_inheritance(
630
+ by_part.get(layout, []), by_part.get(part, []))
631
+ pages.append(('slide', num(part),
632
+ 'SLIDE %d ← %s' % (num(part), layout_name.get(layout, layout or '-')),
633
+ [by_part.get(master, []), kept_layout, slide_shapes],
634
+ tx1_of_master.get(master, '#000000'),
635
+ slide_bg.get(part) or layout_bg.get(layout, '#888')))
636
+ if args.pages in ('all', 'layouts'):
637
+ for part in sorted((l['part'] for l in data.get('layouts') or []), key=num):
638
+ master = master_of.get(part)
639
+ pages.append(('layout', num(part),
640
+ 'LAYOUT %d %s' % (num(part), layout_name.get(part, '')),
641
+ [by_part.get(master, []), by_part.get(part, [])],
642
+ tx1_of_master.get(master, '#000000'),
643
+ layout_bg.get(part, '#888')))
644
+
645
+ if only is not None:
646
+ pages = [p for p in pages if p[0] != 'slide' or p[1] in only]
647
+
648
+ stats = {'shapes': 0, 'text': 0, 'img_ok': 0, 'img_missing': 0, 'no_box': 0}
649
+ index = [HEAD, INDEX_EXTRA, '<div class="wrap">']
650
+ for kind, n, label, layers, default_color, page_bg in (() if args.no_html else pages):
651
+ html = page_html(layers, default_color, page_bg, media_url, stats)
652
+ with open(os.path.join(rebuild, '%s-%d.html' % (kind, n)), 'w', encoding='utf-8') as f:
653
+ f.write(HEAD + html)
654
+ index += ['<div class="lbl">%s</div>' % esc(label),
655
+ '<div class="box">%s</div>' % html]
656
+ index.append('</div>')
657
+ with open(os.path.join(rebuild, 'index.html'), 'w', encoding='utf-8') as f:
658
+ f.write('\n'.join(index))
659
+
660
+ total = sum(os.path.getsize(os.path.join(rebuild, f))
661
+ for f in os.listdir(rebuild) if f.endswith('.html'))
662
+ n_slides = sum(1 for p in pages if p[0] == 'slide')
663
+ n_layouts = sum(1 for p in pages if p[0] == 'layout')
664
+ if not args.no_png:
665
+ png_dir = render_pages_png(pages, outdir, data)
666
+ if png_dir:
667
+ print('png -> %s (%d pages)' % (os.path.relpath(png_dir, outdir), len(pages)))
668
+
669
+ print('rebuild → %s (shape facts from %s)' % (rebuild, shapes_from))
670
+ print('pages: %d (slides %d + layouts %d), files %d, html %.1f KB'
671
+ % (len(pages), n_slides, n_layouts, len(pages) + 1, total / 1024))
672
+ print('shapes drawn %d (text %d), images ok %d / placeholder %d, skipped no-box %d'
673
+ % (stats['shapes'], stats['text'], stats['img_ok'], stats['img_missing'],
674
+ stats['no_box']))
675
+ return 0
676
+
677
+
678
+ if __name__ == '__main__':
679
+ sys.exit(main())