@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-steering",
3
- "version": "0.1.32-dev.5abff3b",
3
+ "version": "0.1.32-dev.942e73f",
4
4
  "description": "Stack-specific steering content for miaoda-coding templates",
5
5
  "type": "module",
6
6
  "files": [
@@ -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`;客户或合作方 logo 墙、图表、截图、产品说明图是 `content-image`;非内容、非品牌的纹理和装饰图用 `decorative` 或 `illustration`;页码、水印和版权标记用对应的 omit 类值。脚本已直接保留不透明满屏背景和封面主视觉,不为它们补判断;半透明满屏叠加层仍要在视觉组中定性。拿不准的候选用 `unknown`,不要按品牌名、页码或图片数量猜用途。
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:` 按**背景资产**分组(不是按页型),逐张背景填 `text_safe` / `avoid` / `pairing_rule`:看图标清视觉主体、强光斑、深色透明区,打包时自动并入用该背景的所有页型。标题、正文、关键数字、图表、卡片、时间线及其容器的外接矩形都不得压住背景主体,透明容器也不能跨进禁放区。
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
- - **图片按用途三分**——整幅替换底图的艺术图/摄影图属**背景族**;服务于具体内容的图表、截图、产品说明图是**内容图,不进包**;剩下既不是 logo、也不服务于内容的纹理、装饰插画、色块、几何点缀是**装饰图,标 `texture` 进 assets**。不透明满屏图默认走背景;局部图和半透明满屏叠加层结合候选图与整页语境逐张定性,避免把重要装饰误当内容丢弃。首页、末页优先保留样张,但是否为 `cover` 或 `closing` 仍按页面意图判断。
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. Run the slide preflight checks: no resource failures, no section overflow, and sampled screenshots follow the package colors, typography, layouts, assets, and Hard Rules.
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': graph['master_of_layout'].get(lp),
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': bg_by_part.get(lp),
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
- or (effective_am is not None and effective_am < 13)):
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 = collect_decor(shapes, inst or l['part'], taken, (cW, cH))
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), 'texts': texts, 'pic_n': len(pics),
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.extend([''] + blocks['bg_rules'])
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
- % sidecar,
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
- if not layers or (len(layers) == 1 and not _picture_has_appearance(layers[0])):
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 = [shape for shape in layers
421
- if _draw_picture(canvas, shape, pkg, image_cache)]
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
- alpha = blip.find('a:alphaModFix', NS)
397
- if alpha is not None and alpha.get('amt') is not None:
398
- rec['opacity'] = round(int(alpha.get('amt')) / 100000.0, 6)
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`;gradient 与形状渐变共用 css_gradient;
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