@lark-apaas/coding-steering 0.1.32-dev.5abff3b → 0.1.32-dev.942e73f
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/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +12 -5
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +8 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +67 -13
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +229 -10
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +19 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +4 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +113 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +308 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +5 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +102 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +148 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +188 -0
package/package.json
CHANGED
|
@@ -74,15 +74,15 @@ mkdir -p tmp/pptx-style-extract/<unique-id> && PYTHONDONTWRITEBYTECODE=1 python3
|
|
|
74
74
|
1. 读 `<outdir>/l-out/BRIEF.md` 一次,确定待判断文件、视觉组和页型代表页;需要查候选的全部原始位置、尺寸、重复次数、透明度或近白比例时读 `asset-vision-groups.json`。没有候选图片的页无需做图片判断。
|
|
75
75
|
2. 一次并行看全部 `vision-group-*.jpg` 与 `layout-sheet.png`。每张视觉组拼版都同时给出页面语境和带编号的候选卡;透明或近白候选会同时显示棋盘格和深灰底。除非拼版把图片标成 unreadable,不打开单张素材、不再读旧的 `contact-sheet` / `asset-context-sheet`。
|
|
76
76
|
3. 在 `manifest.yaml` 按每个 `asset_vision_groups` 候选实例填写 `visual_kind`;同一素材在不同页型或位置可分别判断。仅在预算外候选或需要改写既有判断时追加 `asset_decisions`;位置例外写 `box: [x, y, w, h]`,同图同位置跨页型不同时再写 `layout`。可用值固定为 `logo|slogan|background|texture|icon|decorative|illustration|photo|chart|screenshot|footer-copyright|page-number|watermark|content-image|unknown`,不新造值;`package.py` 会把它映射回现有 v2 资产类型,视觉组字段不会进入最终包。
|
|
77
|
-
4. 按页面语境判断用途:本 deck 自己的品牌标志才是 `logo
|
|
77
|
+
4. 按页面语境判断用途:本 deck 自己的品牌标志才是 `logo`;图表、截图、logo 墙,以及用于解释当前页具体产品、案例或信息的图片是 `content-image`;封面、目录、章节或封底中承担构图主视觉的局部图,即使是产品或摄影图,也用 `decorative` 或 `illustration` 保留为版式素材。页码、水印和版权标记用对应的 omit 类值。脚本已直接保留不透明满屏背景和封面主视觉,不为它们补判断;半透明满屏叠加层仍要在视觉组中定性。拿不准的候选用 `unknown`,不要按品牌名、页码或图片数量猜用途。
|
|
78
78
|
5. 用一次 `multi_edit` 批量完成其余 TODO:风格气质,以及 `layout-controls.yaml` 中的页型/文本角色、布局模式和背景规则。普通文本槽默认 `body`,只把确实属于 `title|subtitle|header|footer` 的例外写进 `text_roles`;有 flow 与 slots 两种形态时默认保留 slots,只有样张明确需要内容随高度重排才填 `layout_modes: <页型>: flow`。不补不存在的标题、页眉或页脚。直接编辑草案,不写脚本改 YAML,也不修改 Skill 源码。
|
|
79
|
-
6. `layout-controls.yaml` 的 `bg_rules:`
|
|
79
|
+
6. `layout-controls.yaml` 的 `bg_rules:` 按**背景资产**分组(不是按页型),只编辑草案已有且被页型 `background:` 引用的真实图片背景;没有 `bg_rules` 就不要新增。纯色、渐变、外框、几何装饰和透明叠层不是这里的背景资产。逐张背景填 `text_safe` / `avoid` / `pairing_rule`:看图标清视觉主体、强光斑、深色透明区,打包时自动并入用该背景的所有页型。标题、正文、关键数字、图表、卡片、时间线及其容器的外接矩形都不得压住背景主体,透明容器也不能跨进禁放区。
|
|
80
80
|
|
|
81
81
|
`layout-controls.yaml` 是唯一需要编辑的版式判断文件,只含 `names`、`roles`、`text_roles`、`layout_modes` 和 `bg_rules`。`layouts.yaml` 是兼容用的坐标事实——页型、坐标、字号、色值、对齐、以及各页型自带的 `role: logo` 资产槽全部直读,**不要打开或修改它**。打包时控制文件会覆盖旧文件中的同名判断区。
|
|
82
82
|
|
|
83
83
|
判断口径:
|
|
84
84
|
|
|
85
|
-
-
|
|
85
|
+
- **图片按用途三分**——整幅替换底图的艺术图/摄影图属**背景族**;服务于当前页具体内容的图表、截图、产品说明图是**内容图,不进包**;用于封面、目录、章节或封底构图的局部主视觉不解释内容,即使画面是产品或摄影图,也属**装饰图,标 `decorative` 或 `illustration` 进 assets**;其余纹理、插画、色块、几何点缀同属装饰图。不透明满屏图默认走背景;局部图和半透明满屏叠加层结合候选图与整页语境逐张定性,避免把重要装饰误当内容丢弃。首页、末页优先保留样张,但是否为 `cover` 或 `closing` 仍按页面意图判断。
|
|
86
86
|
- **logo 特指这份 deck 自己的品牌标志**——看图确认是本 PPT 的品牌文字或标志图形才留 `logo`;一页若是 logo 墙(客户/合作方 logo 罗列),那些是内容图、不当风格 logo。拿不准就保留该候选并填 `unknown`,在 `gaps` 写一句;不要删除或合并 `asset_vision_groups` 条目。
|
|
87
87
|
- **anchors 是事实不是结论**——草案里每条只报测到的数(覆盖率、计数、占比),没有「这套风格的特征是 X」这种断言。这一段在 design.md 里读起来像总纲,消费端会照它建全局样式,所以脚本不敢替你下结论:一条 1/8 覆盖率的元素被描述成「跨页不动」,消费端就会每页都摆它。看过重建图后,把真正是这套模板特征的那几条改写成设计要点,不是特征的直接删掉。
|
|
88
88
|
- **数值只改名不改值**——草案里的坐标/色值/字号来自普查;确需推导值(CJK 行高转译、投影尺度上抬)在 manifest 写 `derived:` 声明理由,机检认声明。
|
|
@@ -130,13 +130,20 @@ Read `<pack_dir>/design.md` first, especially `## Usage`, `## Hard Rules`, color
|
|
|
130
130
|
When generating a deck:
|
|
131
131
|
|
|
132
132
|
1. Call `copy_starter_component` with `kind: "deck-stage.js"`.
|
|
133
|
-
2. Build `<deck-stage width="<canvas width>" height="<canvas height>">` using the `canvas` declared in `layouts.md` — source decks are not always 16:9, and a default-sized stage shifts every coordinate on the page. Each slide is one static `<section>`.
|
|
133
|
+
2. Build `<deck-stage width="<canvas width>" height="<canvas height>">` using the `canvas` declared in `layouts.md` — source decks are not always 16:9, and a default-sized stage shifts every coordinate on the page. Each slide is one static `<section data-pptx-layout="<chosen archetype>">`.
|
|
134
134
|
3. Inline CSS variables from `design.md` into the HTML `<style>` block using a `--ppt-*` prefix.
|
|
135
135
|
4. **位移动画用独立的 `translate` 属性**:`@keyframes fadeUp { from{opacity:0; translate:0 24px} to{opacity:1; translate:0 0} }`。`transform` 是单一属性,动画里碰它会覆盖掉元素原有的那条(`left:50%; transform:translateX(-50%)` 的居中就此丢失);`translate` / `rotate` / `scale` 各自独立,与已有 `transform` 叠加。
|
|
136
136
|
5. Map `layouts.md` slots to absolute-positioned elements inside each section: expand `box: [x,y,w,h]` mechanically to `left/top/width/height`, then apply the slot's `css` declaration string unchanged. `box` owns geometry; `css` owns all rendering style, including the template's text padding, typography, alignment, line height, letter spacing, and rotation. Do not reinterpret PPTX fields or replace slot CSS with your own type scale. A slot with `asset` is a fixed image element (logo, mark): place that asset at that box on that archetype only. Do not reflow any of this as generic web grids.
|
|
137
137
|
6. Copy `<pack_dir>/assets/` into the project, for example `assets/pptx-style/<pack-name>/`, and reference those copied files with relative URLs. The scratch `tmp/pptx-style-extract/...` path must never appear in the final HTML.
|
|
138
138
|
7. Use the font stacks and fallback rules from `design.md`; do not install fonts or dependencies at runtime.
|
|
139
|
-
8.
|
|
139
|
+
8. Before the regular slide preflight, verify every template logo against the selected archetype:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/verify_logo_scope.py <pack_dir> <index.html> --asset-prefix <copied-assets-prefix>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`PPTX_LOGO_SCOPE: FAIL` means remove or move every listed logo according to `layouts.md`, then rerun this command.
|
|
146
|
+
9. Run the slide preflight checks: no resource failures, no section overflow, and sampled screenshots follow the package colors, typography, layouts, assets, and Hard Rules.
|
|
140
147
|
|
|
141
148
|
## Export Consumer Attachments
|
|
142
149
|
|
|
@@ -66,6 +66,7 @@ def image_census(shape_recs, bg_images, units):
|
|
|
66
66
|
'crop': r.get('crop'), 'placement': r.get('placement'),
|
|
67
67
|
'w_pct': r.get('w_pct'), 'h_pct': r.get('h_pct'),
|
|
68
68
|
'in_group': bool(r.get('group_path')), 'svg': r.get('media_svg'),
|
|
69
|
+
'visible_instance_blocked': r.get('visible_instance_blocked'),
|
|
69
70
|
})
|
|
70
71
|
for r in shape_recs:
|
|
71
72
|
f = r.get('fill') or {}
|
|
@@ -124,6 +125,8 @@ def image_census(shape_recs, bg_images, units):
|
|
|
124
125
|
'crop_variants': sorted(crop_sigs),
|
|
125
126
|
'stitch_candidate': len(crop_sigs) > 1,
|
|
126
127
|
'svg_companion': next((o.get('svg') for o in occs if o.get('svg')), None),
|
|
128
|
+
'visible_instance_blocked': any(
|
|
129
|
+
o.get('visible_instance_blocked') for o in occs),
|
|
127
130
|
})
|
|
128
131
|
images.sort(key=lambda i: (-i['n'], i['media']))
|
|
129
132
|
|
|
@@ -606,6 +609,8 @@ def layout_inventory(pkg, graph, shapes, bg_by_part):
|
|
|
606
609
|
for lp in pkg.layouts:
|
|
607
610
|
root = pkg.xml(lp)
|
|
608
611
|
cSld = root.find('p:cSld', NS)
|
|
612
|
+
master = graph['master_of_layout'].get(lp)
|
|
613
|
+
background = bg_by_part.get(lp) or bg_by_part.get(master)
|
|
609
614
|
phs = Counter()
|
|
610
615
|
for r in shapes:
|
|
611
616
|
if r['part'] == lp and r.get('ph'):
|
|
@@ -614,11 +619,12 @@ def layout_inventory(pkg, graph, shapes, bg_by_part):
|
|
|
614
619
|
'part': lp,
|
|
615
620
|
'name': cSld.get('name') if cSld is not None else None,
|
|
616
621
|
'type_attr': root.get('type', 'cust'),
|
|
617
|
-
'master':
|
|
622
|
+
'master': master,
|
|
618
623
|
'used_by_slides': graph['slides_per_layout'].get(lp, 0),
|
|
619
624
|
'placeholders': dict(phs),
|
|
620
625
|
'shape_n': sum(1 for r in shapes if r['part'] == lp),
|
|
621
|
-
'background':
|
|
626
|
+
'background': background,
|
|
627
|
+
'background_source': lp if bg_by_part.get(lp) else master,
|
|
622
628
|
'ph_signature': _ph_signature(shapes, lp),
|
|
623
629
|
'guides': [],
|
|
624
630
|
})
|
|
@@ -648,14 +648,11 @@ def probe_image(path):
|
|
|
648
648
|
def needs_asset_judgment(candidate):
|
|
649
649
|
"""局部图和半透明满屏叠加层需要看图定性;不透明满屏图按背景处理。"""
|
|
650
650
|
effective_alpha = candidate.get('effective_alpha_mean')
|
|
651
|
-
if ((candidate.get('probe') or {}).get('near_blank')
|
|
652
|
-
or (effective_alpha is not None and effective_alpha < 13)):
|
|
653
|
-
return False
|
|
654
651
|
if not candidate.get('fullscreen'):
|
|
655
652
|
return True
|
|
656
653
|
alpha = (effective_alpha if effective_alpha is not None
|
|
657
654
|
else (candidate.get('probe') or {}).get('alpha_mean'))
|
|
658
|
-
return alpha is not None and alpha < OPAQUE_ENOUGH
|
|
655
|
+
return alpha is not None and 13 <= alpha < OPAQUE_ENOUGH
|
|
659
656
|
|
|
660
657
|
|
|
661
658
|
def fullscreen_effective_alpha(data, outdir, shapes):
|
|
@@ -844,8 +841,8 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None,
|
|
|
844
841
|
canvas_w, canvas_h = d['canvas']['px']
|
|
845
842
|
for c in kept:
|
|
846
843
|
effective_am = c.get('effective_alpha_mean')
|
|
847
|
-
if (c['probe'].get('near_blank')
|
|
848
|
-
|
|
844
|
+
if (c['fullscreen'] and (c['probe'].get('near_blank')
|
|
845
|
+
or (effective_am is not None and effective_am < 13))):
|
|
849
846
|
rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见'
|
|
850
847
|
% (effective_am if effective_am is not None
|
|
851
848
|
else c['probe']['alpha_mean'])))
|
|
@@ -962,6 +959,28 @@ def cover_background_media(archetypes):
|
|
|
962
959
|
if archetype['name'] == 'cover'), None)
|
|
963
960
|
|
|
964
961
|
|
|
962
|
+
def background_decor(background, canvas):
|
|
963
|
+
"""把可直接重放的 PPT 背景声明落到 layouts.md 的最底层装饰。
|
|
964
|
+
|
|
965
|
+
图片背景继续走 `background:` 资产引用;纯色和线性渐变没有可复制的素材文件,
|
|
966
|
+
但同样是页型视觉的一部分,必须随页型输出。path 渐变不能由 CSS 线性渐变准确表达,
|
|
967
|
+
保留给图片/渲染链路而不伪造。
|
|
968
|
+
"""
|
|
969
|
+
if not isinstance(background, dict):
|
|
970
|
+
return None
|
|
971
|
+
if background.get('type') not in ('solid', 'gradient'):
|
|
972
|
+
return None
|
|
973
|
+
if background.get('path'):
|
|
974
|
+
return None
|
|
975
|
+
css = _load_query()._recipe_css(background, None, [], [])
|
|
976
|
+
css = [re.sub(r'\s*\n\s*', ' ', value).strip() for value in css if value]
|
|
977
|
+
if not css:
|
|
978
|
+
return None
|
|
979
|
+
width, height = canvas
|
|
980
|
+
return {'box': [0, 0, width, height], 'geom': 'rect', 'css': '; '.join(css),
|
|
981
|
+
'trace': 'canvas-background'}
|
|
982
|
+
|
|
983
|
+
|
|
965
984
|
# ---------------------------------------------------------------- 版式聚类
|
|
966
985
|
DECOR_MIN = 40.0
|
|
967
986
|
|
|
@@ -1273,7 +1292,17 @@ def layouts_from_template(d, shapes, cW, cH):
|
|
|
1273
1292
|
slots, bgm = instance_override(
|
|
1274
1293
|
shapes, inst, slots, bgm, cW, cH, composites)
|
|
1275
1294
|
taken = {tuple(s['box']) for s in slots}
|
|
1276
|
-
decor =
|
|
1295
|
+
decor = []
|
|
1296
|
+
inherited_background = l.get('background')
|
|
1297
|
+
direct_background = next(
|
|
1298
|
+
(slide.get('background') for slide in d.get('slides') or []
|
|
1299
|
+
if slide.get('part') == inst and slide.get('background')),
|
|
1300
|
+
None)
|
|
1301
|
+
background = direct_background or inherited_background
|
|
1302
|
+
background_layer = background_decor(background, (cW, cH))
|
|
1303
|
+
if background_layer:
|
|
1304
|
+
decor.append(background_layer)
|
|
1305
|
+
decor += collect_decor(shapes, inst or l['part'], taken, (cW, cH))
|
|
1277
1306
|
named_role = role_of_name(l.get('name'))
|
|
1278
1307
|
rows.append({'zh': clean_layout_name(l.get('name')),
|
|
1279
1308
|
'role': named_role or 'content', 'role_guessed': named_role is None,
|
|
@@ -1510,13 +1539,13 @@ def draft_layouts(d, outdir, effective_alpha=None):
|
|
|
1510
1539
|
image_marks = slide_image_marks(d, overlay_media)
|
|
1511
1540
|
|
|
1512
1541
|
bg_of_slide, layout_of_slide = {}, {}
|
|
1542
|
+
bg_of_layout = {row['part']: row.get('background') for row in d.get('layouts') or []}
|
|
1513
1543
|
for s in d.get('slides', []):
|
|
1514
1544
|
bg = s.get('background')
|
|
1515
1545
|
bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
|
|
1516
1546
|
layout_of_slide[s['part']] = s.get('layout')
|
|
1517
1547
|
# 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
|
|
1518
1548
|
composites = d.get('background_composites') or {}
|
|
1519
|
-
bg_of_layout = {}
|
|
1520
1549
|
for s in shapes:
|
|
1521
1550
|
if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
|
|
1522
1551
|
bg_of_layout[s['part']] = s['media']
|
|
@@ -1571,9 +1600,13 @@ def draft_layouts(d, outdir, effective_alpha=None):
|
|
|
1571
1600
|
if key not in seen_marks:
|
|
1572
1601
|
seen_marks.add(key)
|
|
1573
1602
|
marks.append(mark)
|
|
1603
|
+
background = next((s.get('background') for s in d.get('slides') or []
|
|
1604
|
+
if s.get('part') == part and s.get('background')), None)
|
|
1605
|
+
background = background or bg_of_layout.get(layout_part)
|
|
1574
1606
|
pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
|
|
1575
1607
|
'rendered_bg': rendered_bg,
|
|
1576
|
-
'bg_color': bg_of_slide.get(part), '
|
|
1608
|
+
'bg_color': bg_of_slide.get(part), 'background': background,
|
|
1609
|
+
'texts': texts, 'pic_n': len(pics),
|
|
1577
1610
|
'marks': marks, 'shape_n': len(visible_shapes), 'layout': layout_part})
|
|
1578
1611
|
|
|
1579
1612
|
# 页型的**角色**(封面 / 章节页 / 内容页……)不在这里判:那是看图才能下的结论,
|
|
@@ -1688,6 +1721,9 @@ def draft_layouts(d, outdir, effective_alpha=None):
|
|
|
1688
1721
|
round(b.get('w', 0)), round(b.get('h', 0))]})
|
|
1689
1722
|
taken = {tuple(s['box']) for s in slots}
|
|
1690
1723
|
decor = []
|
|
1724
|
+
background_layer = background_decor(rep.get('background'), (cW, cH))
|
|
1725
|
+
if background_layer:
|
|
1726
|
+
decor.append(background_layer)
|
|
1691
1727
|
seen_decor = set()
|
|
1692
1728
|
for source_part in (rep.get('layout'), rep['part']):
|
|
1693
1729
|
for item in collect_decor(shapes, source_part, taken, (cW, cH)):
|
|
@@ -2338,7 +2374,7 @@ def visual_slot_candidates(candidates, archetypes):
|
|
|
2338
2374
|
return rows
|
|
2339
2375
|
|
|
2340
2376
|
|
|
2341
|
-
def emit_manifest(d, assets, vision_groups, ldir):
|
|
2377
|
+
def emit_manifest(d, assets, vision_groups, ldir, archetypes=()):
|
|
2342
2378
|
L = ['version: alpha',
|
|
2343
2379
|
'name: TODO-style-name # 英文 kebab,体现气质,不要用文件名',
|
|
2344
2380
|
'name_zh: TODO中文名',
|
|
@@ -2381,6 +2417,15 @@ def emit_manifest(d, assets, vision_groups, ldir):
|
|
|
2381
2417
|
' # - {source_media: example.png, visual_kind: chart}',
|
|
2382
2418
|
' # - {source_media: example.png, layout: layout-2, box: [0, 0, 100, 100], visual_kind: decorative}',
|
|
2383
2419
|
]
|
|
2420
|
+
if any(decor.get('trace') == 'canvas-background'
|
|
2421
|
+
for archetype in archetypes
|
|
2422
|
+
for decor in archetype.get('decor') or []):
|
|
2423
|
+
canvas = d['canvas']['px']
|
|
2424
|
+
L += [
|
|
2425
|
+
'derived:',
|
|
2426
|
+
' - value: "[0, 0, %d, %d]"' % (canvas[0], canvas[1]),
|
|
2427
|
+
' reason: "PPT 背景铺满画布"',
|
|
2428
|
+
]
|
|
2384
2429
|
write(os.path.join(ldir, 'manifest.yaml'), '\n'.join(L) + '\n')
|
|
2385
2430
|
|
|
2386
2431
|
|
|
@@ -2727,7 +2772,11 @@ def emit_layout_controls(layout_lines, ldir, text_role_candidates, flow_archetyp
|
|
|
2727
2772
|
]
|
|
2728
2773
|
controls.extend('# %s: flow' % archetype['name'] for archetype in flow_archetypes)
|
|
2729
2774
|
if 'bg_rules' in blocks:
|
|
2730
|
-
controls
|
|
2775
|
+
controls += [
|
|
2776
|
+
'',
|
|
2777
|
+
'# 只编辑本草案已列出的真实图片背景;它们都已被 layouts 中的 background: 引用。',
|
|
2778
|
+
'# 纯色、渐变、外框、几何装饰和透明叠层不新建 bg_rules;没有本段就保持没有。',
|
|
2779
|
+
] + blocks['bg_rules']
|
|
2731
2780
|
write(os.path.join(ldir, 'layout-controls.yaml'), '\n'.join(controls) + '\n')
|
|
2732
2781
|
|
|
2733
2782
|
|
|
@@ -2982,6 +3031,10 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
2982
3031
|
% len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
|
|
2983
3032
|
('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
|
|
2984
3033
|
L += ['', '## Usage', '',
|
|
3034
|
+
'**生成前必须完整阅读本 `design.md` 和 %s,确认全部页型后再开始搭页。**'
|
|
3035
|
+
'不能只看摘要、前几个页型或 `## Layouts` 清单;后续页型同样可能定义背景、'
|
|
3036
|
+
'安全区、资产和固定元素。' % sidecar,
|
|
3037
|
+
'',
|
|
2985
3038
|
'搭一页 PPT 六步,中间四步的数据都在 %s:' % sidecar, '']
|
|
2986
3039
|
L += ['1. **定画布** —— 舞台按 `layouts.md` 的 `canvas` 设成 %d×%d,'
|
|
2987
3040
|
'别套用默认尺寸:源模板的长宽比不一定是 16:9,套错了整页坐标全偏。'
|
|
@@ -2990,7 +3043,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
2990
3043
|
'2. **挑页型** —— 在 %s 里按用途选一个 archetype(清单见下面 Layouts 段)。'
|
|
2991
3044
|
'页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
|
|
2992
3045
|
'内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
|
|
2993
|
-
|
|
3046
|
+
'每个生成页面的 `<section>` 都写 `data-pptx-layout="<页型名>"`,'
|
|
3047
|
+
'交付前据此核验 logo 只出现在该页型的资产槽中。' % sidecar,
|
|
2994
3048
|
'3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
|
|
2995
3049
|
'两者只会出现一个。'
|
|
2996
3050
|
'**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
|
|
@@ -3357,7 +3411,7 @@ def main(argv=None):
|
|
|
3357
3411
|
exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
|
|
3358
3412
|
% '、'.join(map(str, leftover)))
|
|
3359
3413
|
|
|
3360
|
-
emit_manifest(d, assets, selected_vision_groups, ldir)
|
|
3414
|
+
emit_manifest(d, assets, selected_vision_groups, ldir, archetypes)
|
|
3361
3415
|
emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
|
|
3362
3416
|
# 每张背景量一次局部对比度,作为「哪里不能压文字」的客观依据摆进判断单。
|
|
3363
3417
|
# 只报测到的数,不替人填 avoid——哪块算主体、要不要避让,是看图才能定的。
|
|
@@ -181,6 +181,8 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
|
181
181
|
reasons.append('variant_group')
|
|
182
182
|
if info['stitch_candidate']:
|
|
183
183
|
reasons.append('crop_stitch')
|
|
184
|
+
if info.get('visible_instance_blocked'):
|
|
185
|
+
reasons.append('visible_instance_fallback')
|
|
184
186
|
if 0 < (info.get('max_w_pct') or 0) < SMALL_IMG_W_PCT and not info['fullscreen']:
|
|
185
187
|
# 小图(页内图标、角标)。不导出的话 L 层只能看着装饰容器里的空洞
|
|
186
188
|
# 自己编图形,编出来的与模板无关。
|
|
@@ -261,6 +263,30 @@ def _is_full_canvas_picture(shape):
|
|
|
261
263
|
and (shape.get('h_pct') or 0) >= 95)
|
|
262
264
|
|
|
263
265
|
|
|
266
|
+
def _shape_has_visible_text(shape):
|
|
267
|
+
text = shape.get('text') or {}
|
|
268
|
+
for paragraph in text.get('paragraphs') or []:
|
|
269
|
+
for run in paragraph.get('runs') or []:
|
|
270
|
+
if str(run.get('text') or '').strip():
|
|
271
|
+
return True
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _is_full_canvas_fill_overlay(shape):
|
|
276
|
+
fill = shape.get('fill') or {}
|
|
277
|
+
geometry = shape.get('geom') or {}
|
|
278
|
+
line = shape.get('line') or {}
|
|
279
|
+
return (shape.get('kind') == 'sp' and not shape.get('hidden')
|
|
280
|
+
and fill.get('type') in ('solid', 'gradient')
|
|
281
|
+
and not fill.get('path')
|
|
282
|
+
and geometry.get('prst') == 'rect'
|
|
283
|
+
and not shape.get('rot')
|
|
284
|
+
and not line.get('color') and not line.get('gradient')
|
|
285
|
+
and not _shape_has_visible_text(shape)
|
|
286
|
+
and (shape.get('w_pct') or 0) >= 95
|
|
287
|
+
and (shape.get('h_pct') or 0) >= 95)
|
|
288
|
+
|
|
289
|
+
|
|
264
290
|
def _picture_has_appearance(shape):
|
|
265
291
|
return bool(shape.get('flipH') or shape.get('flipV') or shape.get('rot')
|
|
266
292
|
or shape.get('crop')
|
|
@@ -333,6 +359,147 @@ def _draw_picture(canvas, shape, pkg, image_cache=None):
|
|
|
333
359
|
return True
|
|
334
360
|
|
|
335
361
|
|
|
362
|
+
def _visible_picture_instance(shape, pkg, image_cache=None):
|
|
363
|
+
"""Render one non-background picture exactly as its PPT instance appears."""
|
|
364
|
+
from PIL import Image
|
|
365
|
+
|
|
366
|
+
bounds = shape.get('box') or {}
|
|
367
|
+
source_box = shape.get('box_unrotated') or bounds
|
|
368
|
+
width = max(1, int(round(bounds.get('w') or 0)))
|
|
369
|
+
height = max(1, int(round(bounds.get('h') or 0)))
|
|
370
|
+
if not source_box.get('w') or not source_box.get('h'):
|
|
371
|
+
return None
|
|
372
|
+
local = dict(shape)
|
|
373
|
+
local['box_unrotated'] = {
|
|
374
|
+
'x': float(source_box.get('x') or 0) - float(bounds.get('x') or 0),
|
|
375
|
+
'y': float(source_box.get('y') or 0) - float(bounds.get('y') or 0),
|
|
376
|
+
'w': source_box['w'],
|
|
377
|
+
'h': source_box['h'],
|
|
378
|
+
}
|
|
379
|
+
canvas = Image.new('RGBA', (width, height))
|
|
380
|
+
if not _draw_picture(canvas, local, pkg, image_cache):
|
|
381
|
+
return None
|
|
382
|
+
return canvas if canvas.getchannel('A').getbbox() else None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _save_picture_instance_webp(canvas, path):
|
|
386
|
+
"""Keep the instance alpha and shrink only after lossless output exceeds budget."""
|
|
387
|
+
def write(options):
|
|
388
|
+
temporary = path + '.tmp'
|
|
389
|
+
try:
|
|
390
|
+
canvas.save(temporary, 'WEBP', **options)
|
|
391
|
+
size = os.path.getsize(temporary)
|
|
392
|
+
os.replace(temporary, path)
|
|
393
|
+
return size
|
|
394
|
+
finally:
|
|
395
|
+
if os.path.exists(temporary):
|
|
396
|
+
os.unlink(temporary)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
size = write({'lossless': True, 'method': 0})
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
return 'pillow-error: %s: %s' % (exc.__class__.__name__, exc)
|
|
402
|
+
if size <= ASSET_BUDGET_BYTES:
|
|
403
|
+
return None
|
|
404
|
+
for quality in (95, 90, 85, 75):
|
|
405
|
+
try:
|
|
406
|
+
size = write({'quality': quality, 'method': 6})
|
|
407
|
+
except Exception:
|
|
408
|
+
return None
|
|
409
|
+
if size <= ASSET_BUDGET_BYTES:
|
|
410
|
+
return None
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def export_visible_picture_instances(pkg, shapes, outdir, pillow_ok, blocked=None):
|
|
415
|
+
"""Replace transformed local pictures with rendered media consumers can reuse.
|
|
416
|
+
|
|
417
|
+
A raw media file cannot reproduce an instance-level crop, rotation, flip, or
|
|
418
|
+
opacity. Flattening only those non-fullscreen instances keeps the downstream
|
|
419
|
+
contract simple: every asset still resolves through ordinary ``source_media``.
|
|
420
|
+
"""
|
|
421
|
+
if not pillow_ok:
|
|
422
|
+
return []
|
|
423
|
+
try:
|
|
424
|
+
from PIL import Image # noqa: F401
|
|
425
|
+
except Exception:
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
media_dir = os.path.join(outdir, 'media-out')
|
|
429
|
+
os.makedirs(media_dir, exist_ok=True)
|
|
430
|
+
blocked = blocked if blocked is not None else {}
|
|
431
|
+
rows_by_media, rendered_specs, image_cache = {}, {}, {}
|
|
432
|
+
for shape in shapes:
|
|
433
|
+
if (shape.get('kind') != 'pic' or shape.get('hidden')
|
|
434
|
+
or not shape.get('media') or shape.get('media') not in pkg.names
|
|
435
|
+
or _is_full_canvas_picture(shape) or not _picture_has_appearance(shape)):
|
|
436
|
+
continue
|
|
437
|
+
source = shape['media']
|
|
438
|
+
source_box = shape.get('box_unrotated') or shape.get('box') or {}
|
|
439
|
+
visible_box = shape.get('box') or {}
|
|
440
|
+
spec = json.dumps({
|
|
441
|
+
'source': source,
|
|
442
|
+
'source_size': [source_box.get('w'), source_box.get('h')],
|
|
443
|
+
'visible_size': [visible_box.get('w'), visible_box.get('h')],
|
|
444
|
+
'crop': shape.get('crop') or {},
|
|
445
|
+
'flipH': bool(shape.get('flipH')),
|
|
446
|
+
'flipV': bool(shape.get('flipV')),
|
|
447
|
+
'rot': shape.get('rot') or 0,
|
|
448
|
+
'opacity': shape.get('opacity', 1.0),
|
|
449
|
+
}, ensure_ascii=False, sort_keys=True)
|
|
450
|
+
media = rendered_specs.get(spec)
|
|
451
|
+
if media is None:
|
|
452
|
+
canvas = _visible_picture_instance(shape, pkg, image_cache)
|
|
453
|
+
if canvas is None:
|
|
454
|
+
continue
|
|
455
|
+
digest = hashlib.sha256(spec.encode('utf-8')).hexdigest()[:16]
|
|
456
|
+
media = 'generated/instance/picture-%s.webp' % digest
|
|
457
|
+
out = 'media-out/' + os.path.basename(media)
|
|
458
|
+
path = os.path.join(outdir, out)
|
|
459
|
+
if not os.path.exists(path):
|
|
460
|
+
reason = _save_picture_instance_webp(canvas, path)
|
|
461
|
+
if reason:
|
|
462
|
+
row = blocked.setdefault(source, {
|
|
463
|
+
'reason': reason, 'attempted_specs': 0, 'part_refs': [],
|
|
464
|
+
})
|
|
465
|
+
row['attempted_specs'] += 1
|
|
466
|
+
row['part_refs'].append(shape.get('part'))
|
|
467
|
+
shape['visible_instance_blocked'] = reason
|
|
468
|
+
rendered_specs[spec] = False
|
|
469
|
+
continue
|
|
470
|
+
rendered_specs[spec] = media
|
|
471
|
+
rows_by_media[media] = {
|
|
472
|
+
'media': media,
|
|
473
|
+
'ext': 'webp',
|
|
474
|
+
'bytes': os.path.getsize(path),
|
|
475
|
+
'used_n': 0,
|
|
476
|
+
'reasons': ['visible_picture_instance'],
|
|
477
|
+
'candidate': True,
|
|
478
|
+
'exported': True,
|
|
479
|
+
'out': out,
|
|
480
|
+
'out_bytes': os.path.getsize(path),
|
|
481
|
+
'transcoded': False,
|
|
482
|
+
'generated': True,
|
|
483
|
+
'rendered_from': source,
|
|
484
|
+
'rendered_with': {
|
|
485
|
+
key: shape[key] for key in ('crop', 'flipH', 'flipV', 'rot', 'opacity')
|
|
486
|
+
if shape.get(key) not in (None, False, {}, 0, 1.0)
|
|
487
|
+
},
|
|
488
|
+
'part_refs': [],
|
|
489
|
+
}
|
|
490
|
+
elif media is False:
|
|
491
|
+
blocked[source]['part_refs'].append(shape.get('part'))
|
|
492
|
+
shape['visible_instance_blocked'] = blocked[source]['reason']
|
|
493
|
+
continue
|
|
494
|
+
row = rows_by_media[media]
|
|
495
|
+
row['used_n'] += 1
|
|
496
|
+
row['part_refs'].append(shape.get('part'))
|
|
497
|
+
shape['media'] = media
|
|
498
|
+
shape.pop('media_svg', None)
|
|
499
|
+
shape['rendered_from'] = source
|
|
500
|
+
return list(rows_by_media.values())
|
|
501
|
+
|
|
502
|
+
|
|
336
503
|
def _draw_background(canvas, background, pkg, image_cache=None):
|
|
337
504
|
"""Draw the effective p:bg underneath picture layers."""
|
|
338
505
|
from PIL import Image
|
|
@@ -353,6 +520,48 @@ def _draw_background(canvas, background, pkg, image_cache=None):
|
|
|
353
520
|
}, pkg, image_cache)
|
|
354
521
|
|
|
355
522
|
|
|
523
|
+
def _draw_background_fill_overlay(canvas, shape):
|
|
524
|
+
"""Draw one full-canvas fill shape that visually modifies a background image."""
|
|
525
|
+
from PIL import Image
|
|
526
|
+
from render_pages import _grad_image, _grad_stops, _rgba, css_color, css_gradient
|
|
527
|
+
|
|
528
|
+
fill = shape.get('fill') or {}
|
|
529
|
+
box = shape.get('box_unrotated') or shape.get('box') or {}
|
|
530
|
+
width = max(1, int(round(box.get('w') or 0)))
|
|
531
|
+
height = max(1, int(round(box.get('h') or 0)))
|
|
532
|
+
x = int(round(box.get('x') or 0))
|
|
533
|
+
y = int(round(box.get('y') or 0))
|
|
534
|
+
if fill.get('type') == 'solid':
|
|
535
|
+
color = _rgba(css_color(fill.get('color')))
|
|
536
|
+
if color is None:
|
|
537
|
+
return False
|
|
538
|
+
overlay = Image.new('RGBA', (width, height), color)
|
|
539
|
+
elif fill.get('type') == 'gradient':
|
|
540
|
+
gradient = _grad_stops(css_gradient(fill) or '')
|
|
541
|
+
if not gradient:
|
|
542
|
+
return False
|
|
543
|
+
overlay = _grad_image(Image, width, height, gradient[0], gradient[1])
|
|
544
|
+
else:
|
|
545
|
+
return False
|
|
546
|
+
canvas.alpha_composite(overlay, (x, y))
|
|
547
|
+
return True
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _background_layer_source(shape):
|
|
551
|
+
if shape.get('kind') == 'pic':
|
|
552
|
+
return {
|
|
553
|
+
'part': shape['part'], 'kind': 'picture', 'media': shape['media'],
|
|
554
|
+
'crop': shape.get('crop'), 'flipH': bool(shape.get('flipH')),
|
|
555
|
+
'flipV': bool(shape.get('flipV')), 'rot': shape.get('rot') or 0,
|
|
556
|
+
'opacity': shape.get('opacity', 1.0),
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
'part': shape['part'], 'kind': 'fill_overlay',
|
|
560
|
+
'box': shape.get('box_unrotated') or shape.get('box'),
|
|
561
|
+
'fill': shape.get('fill'),
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
356
565
|
def _save_background_webp(canvas, path):
|
|
357
566
|
"""Prefer lossless output; fall back to a quality ladder when oversized."""
|
|
358
567
|
canvas.convert('RGB').save(path, 'WEBP', lossless=True, method=0)
|
|
@@ -398,15 +607,12 @@ def compose_backgrounds(pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok
|
|
|
398
607
|
image_cache = {}
|
|
399
608
|
for target, chain in chains.items():
|
|
400
609
|
layers = [shape for part in chain for shape in by_part.get(part, [])
|
|
401
|
-
if _is_full_canvas_picture(shape)]
|
|
402
|
-
|
|
610
|
+
if _is_full_canvas_picture(shape) or _is_full_canvas_fill_overlay(shape)]
|
|
611
|
+
pictures = [shape for shape in layers if shape.get('kind') == 'pic']
|
|
612
|
+
if not pictures or (len(pictures) == 1 and not _picture_has_appearance(pictures[0])
|
|
613
|
+
and len(layers) == 1):
|
|
403
614
|
continue
|
|
404
|
-
source_layers = [
|
|
405
|
-
'part': shape['part'], 'media': shape['media'],
|
|
406
|
-
'crop': shape.get('crop'), 'flipH': bool(shape.get('flipH')),
|
|
407
|
-
'flipV': bool(shape.get('flipV')), 'rot': shape.get('rot') or 0,
|
|
408
|
-
'opacity': shape.get('opacity', 1.0),
|
|
409
|
-
} for shape in layers]
|
|
615
|
+
source_layers = [_background_layer_source(shape) for shape in layers]
|
|
410
616
|
spec = json.dumps({
|
|
411
617
|
'background': _effective_background(chain, bg_by_part),
|
|
412
618
|
'layers': [{k: v for k, v in layer.items() if k != 'part'}
|
|
@@ -417,8 +623,12 @@ def compose_backgrounds(pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok
|
|
|
417
623
|
canvas = Image.new('RGBA', (units.w, units.h))
|
|
418
624
|
_draw_background(
|
|
419
625
|
canvas, _effective_background(chain, bg_by_part), pkg, image_cache)
|
|
420
|
-
drawn = [
|
|
421
|
-
|
|
626
|
+
drawn = [
|
|
627
|
+
shape for shape in layers
|
|
628
|
+
if (_draw_picture(canvas, shape, pkg, image_cache)
|
|
629
|
+
if shape.get('kind') == 'pic'
|
|
630
|
+
else _draw_background_fill_overlay(canvas, shape))
|
|
631
|
+
]
|
|
422
632
|
if len(drawn) != len(layers):
|
|
423
633
|
continue
|
|
424
634
|
digest = hashlib.sha256(canvas.tobytes()).hexdigest()[:16]
|
|
@@ -826,6 +1036,9 @@ def extract(pptx, outdir, export_all=False):
|
|
|
826
1036
|
'box_emu': {'x': 0, 'y': 0, 'cx': cx, 'cy': cy},
|
|
827
1037
|
'crop': bg.get('crop'), 'placement': 'inside',
|
|
828
1038
|
'w_pct': 100.0, 'h_pct': 100.0, 'in_group': False})
|
|
1039
|
+
instance_blocked = {}
|
|
1040
|
+
instance_media = export_visible_picture_instances(
|
|
1041
|
+
pkg, shapes, outdir, pillow_ok, instance_blocked)
|
|
829
1042
|
images, variant_groups = image_census(shapes, bg_images, units) # S5
|
|
830
1043
|
t = mark('S5_image_census', t)
|
|
831
1044
|
|
|
@@ -845,6 +1058,10 @@ def extract(pptx, outdir, export_all=False):
|
|
|
845
1058
|
t = mark('derived_censuses', t)
|
|
846
1059
|
|
|
847
1060
|
media_rows = export_media(pkg, images, outdir, pillow_ok, export_all) # S9
|
|
1061
|
+
for row in media_rows:
|
|
1062
|
+
if row['media'] in instance_blocked:
|
|
1063
|
+
row['visible_instance_blocked'] = instance_blocked[row['media']]
|
|
1064
|
+
media_rows += instance_media
|
|
848
1065
|
background_composites, composite_media, composite_images = compose_backgrounds(
|
|
849
1066
|
pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok)
|
|
850
1067
|
media_rows += composite_media
|
|
@@ -949,6 +1166,8 @@ def extract(pptx, outdir, export_all=False):
|
|
|
949
1166
|
'content_clusters_multi_media': sum(1 for c in clusters if c['member_n'] > 1),
|
|
950
1167
|
'media_exported': sum(1 for m in media_rows
|
|
951
1168
|
if m.get('exported') and not m.get('generated')),
|
|
1169
|
+
'media_instances': len(instance_media),
|
|
1170
|
+
'media_instance_blocked': len(instance_blocked),
|
|
952
1171
|
'media_transcoded': sum(1 for m in media_rows
|
|
953
1172
|
if m.get('transcoded') and not m.get('generated')),
|
|
954
1173
|
'media_transcode_blocked': sum(1 for m in media_rows
|
|
@@ -299,6 +299,22 @@ NV_TAGS = {'sp': 'p:nvSpPr', 'pic': 'p:nvPicPr', 'grpSp': 'p:nvGrpSpPr',
|
|
|
299
299
|
'cxnSp': 'p:nvCxnSpPr', 'graphicFrame': 'p:nvGraphicFramePr'}
|
|
300
300
|
|
|
301
301
|
|
|
302
|
+
def blip_opacity(blip):
|
|
303
|
+
"""Combine the alpha modulation transforms attached to one local picture."""
|
|
304
|
+
opacity = 1.0
|
|
305
|
+
found = False
|
|
306
|
+
for effect in blip:
|
|
307
|
+
if local(effect.tag) not in ('alphaMod', 'alphaModFix'):
|
|
308
|
+
continue
|
|
309
|
+
try:
|
|
310
|
+
amount = int(effect.get('amt'))
|
|
311
|
+
except (TypeError, ValueError):
|
|
312
|
+
continue
|
|
313
|
+
opacity *= max(0.0, min(amount / 100000.0, 1.0))
|
|
314
|
+
found = True
|
|
315
|
+
return round(opacity, 6) if found else None
|
|
316
|
+
|
|
317
|
+
|
|
302
318
|
def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
|
|
303
319
|
sx, sy, dx, dy = xf
|
|
304
320
|
U, W, H = ctx.units, ctx.units.w, ctx.units.h
|
|
@@ -393,9 +409,9 @@ def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
|
|
|
393
409
|
blip = sp.find('p:blipFill/a:blip', NS)
|
|
394
410
|
if blip is not None:
|
|
395
411
|
rec['media'] = ctx.media_of(blip.get(R_EMBED)) or ctx.media_of(blip.get(R_LINK))
|
|
396
|
-
|
|
397
|
-
if
|
|
398
|
-
rec['opacity'] =
|
|
412
|
+
opacity = blip_opacity(blip)
|
|
413
|
+
if opacity is not None:
|
|
414
|
+
rec['opacity'] = opacity
|
|
399
415
|
svg = blip.find('a:extLst//asvg:svgBlip', NS)
|
|
400
416
|
if svg is not None:
|
|
401
417
|
rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
|
|
@@ -88,13 +88,15 @@ def css_gradient(grad):
|
|
|
88
88
|
def background_css(bg):
|
|
89
89
|
"""页底 <p:bg> 描述符 → CSS background 值。解析不出返回 None,交上层回退到下一层底色。
|
|
90
90
|
|
|
91
|
-
bgPr solid / bgRef 都带 `color
|
|
92
|
-
|
|
91
|
+
bgPr solid / bgRef 都带 `color`;线性 gradient 与形状渐变共用 css_gradient;
|
|
92
|
+
path 渐变和背景图不重建,按无底色处理,不能伪装成线性渐变。
|
|
93
93
|
"""
|
|
94
94
|
if not bg:
|
|
95
95
|
return None
|
|
96
96
|
kind = bg.get('type')
|
|
97
97
|
if kind == 'gradient':
|
|
98
|
+
if bg.get('path'):
|
|
99
|
+
return None
|
|
98
100
|
return css_gradient(bg)
|
|
99
101
|
if kind in ('image', 'none', 'pattern'):
|
|
100
102
|
return None
|