@topmindspace/tms-skills 0.1.18 → 0.1.19
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/CHANGELOG.md +45 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/package.json +1 -1
- package/top-ppt-html/README.en.md +2 -2
- package/top-ppt-html/README.md +1 -1
- package/top-ppt-html/SKILL.md +4 -4
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +24 -12
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +24 -12
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +24 -12
- package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.html +94 -165
- package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.model.json +32 -38
- package/top-ppt-html/assets/pptx-export.js +3 -2
- package/top-ppt-html/assets/style-gallery.html +2 -1
- package/top-ppt-html/assets/templates/architecture.html +38 -15
- package/top-ppt-html/assets/templates/engine.css +20 -9
- package/top-ppt-html/assets/templates/presentation.html +38 -17
- package/top-ppt-html/assets/templates/research.html +44 -23
- package/top-ppt-html/package-lock.json +2 -2
- package/top-ppt-html/package.json +1 -1
- package/top-ppt-html/references/components-atoms.md +1 -1
- package/top-ppt-html/references/content-rules.md +17 -11
- package/top-ppt-html/references/failure-modes.md +6 -4
- package/top-ppt-html/references/high-fidelity.md +14 -1
- package/top-ppt-html/references/icons.md +2 -2
- package/top-ppt-html/references/layout-grammar.md +3 -1
- package/top-ppt-html/references/layouts-research.md +1 -1
- package/top-ppt-html/references/pptx-export.md +2 -2
- package/top-ppt-html/references/tech-design.md +2 -0
- package/top-ppt-html/scripts/build_icon_assets.js +92 -0
- package/top-ppt-html/scripts/build_pptx.js +125 -29
- package/top-ppt-html/scripts/extract_model.py +21 -1
- package/top-ppt-html/scripts/icon_lib.js +53 -0
- package/top-ppt-html/scripts/icon_raster.js +86 -0
- package/top-ppt-html/scripts/layout-constants.json +4 -1
- package/top-ppt-html/scripts/lib_layout_regions.js +3 -1
- package/top-ppt-html/scripts/model-schema.json +7 -3
- package/top-ppt-html/scripts/package_skill.py +1 -0
- package/top-ppt-html/scripts/render_from_model.py +125 -24
- package/top-ppt-html/scripts/scaffold_report.py +47 -24
- package/top-ppt-html/scripts/sync_runtime.py +2 -1
- package/top-ppt-html/scripts/validate_pptx.py +14 -3
- package/top-ppt-html/scripts/validate_report.py +1 -1
|
@@ -109,7 +109,9 @@ function regionOf(pageType, slotId, opts) {
|
|
|
109
109
|
const y = PT.chartY || 2.95;
|
|
110
110
|
const bottom = (opts && opts.bottom != null) ? opts.bottom
|
|
111
111
|
: ((opts && opts.withNote) ? L.contentBottomWithNote : L.contentBottom);
|
|
112
|
-
|
|
112
|
+
/* chartX/chartW 是历史误用:与 donut/table/hbar/exhibit 同口径,图表盒对齐版心 (mx, cw)。
|
|
113
|
+
此前 `mx+chartX` / `cw-chartW` 造成 bar 图右移 0.6in,与标题/表格左边距不齐。 */
|
|
114
|
+
return { x: mx, y, w: cw, h: Math.max(1.2, bottom - y) };
|
|
113
115
|
}
|
|
114
116
|
if (slot === 'hbar') {
|
|
115
117
|
const y0 = PT.hbarY0 || 2.6;
|
|
@@ -26,9 +26,12 @@
|
|
|
26
26
|
"research": 3,
|
|
27
27
|
"architecture": 0
|
|
28
28
|
},
|
|
29
|
+
"agendaMax": 7,
|
|
30
|
+
"agendaMaxWithRefs": 8,
|
|
31
|
+
"agendaHint": "agenda 条目 = 章(篇/section),3–7 条;每章可含多页(sections[] 按页建模)。禁止把逐页标题当大纲。sections[].eyebrow 的 `NN ·` 前缀 = 章键,须与某条 agenda 对应;跳转锚点 = 该章第一页。含「参考资料」条目时 +1 仍 ≤8。",
|
|
29
32
|
"sectionsMin": 1,
|
|
30
33
|
"sectionsRecommended": 3,
|
|
31
|
-
"agendaComfortMax":
|
|
34
|
+
"agendaComfortMax": 8
|
|
32
35
|
},
|
|
33
36
|
"commonSectionFields": [
|
|
34
37
|
"eyebrow",
|
|
@@ -276,7 +279,7 @@
|
|
|
276
279
|
]
|
|
277
280
|
},
|
|
278
281
|
"cards": {
|
|
279
|
-
"label": "卡片网格页(cards 元素须为 {title, points:[[k,v]…]},不接受元组)",
|
|
282
|
+
"label": "卡片网格页(cards 元素须为 {title, points:[[k,v]…], icon?},不接受元组)",
|
|
280
283
|
"modes": [
|
|
281
284
|
"presentation",
|
|
282
285
|
"research"
|
|
@@ -286,7 +289,8 @@
|
|
|
286
289
|
],
|
|
287
290
|
"optional": [
|
|
288
291
|
"columns"
|
|
289
|
-
]
|
|
292
|
+
],
|
|
293
|
+
"cardItemHint": "cards[] 元素:{title:str, points:[[k,v]…]|[{t,d}…]|[str…], icon?:str}。icon = 语义图标名(icon_lib.js 键:增长/下降/数据/图表/趋势/占比/表格/效率/成果/安全/权限/检查/风险/团队/流程/计划/智能/洞察/工具/清单),HTML 与 PPTX 同名真导出;缺省按标题语义自动挑选。"
|
|
290
294
|
},
|
|
291
295
|
"split": {
|
|
292
296
|
"label": "双区组合页(left / right 各可为 要点|图表|表格|图片:type = points(默认)| table | image | 图表类型名;图表/表格字段直接挂在对应侧)",
|
|
@@ -142,6 +142,7 @@ REQUIRED = [
|
|
|
142
142
|
'scripts/render_compare.py', 'scripts/capture_theme_overview.js', 'scripts/package_skill.py',
|
|
143
143
|
'scripts/layout-constants.json', 'scripts/model-schema.json',
|
|
144
144
|
'scripts/lib_layout_regions.js', 'scripts/extract_snippet.py', 'scripts/quality_gate.py',
|
|
145
|
+
'scripts/icon_lib.js', 'scripts/icon_raster.js', 'scripts/build_icon_assets.js',
|
|
145
146
|
'scripts/checks_html.py', 'scripts/section-file-map.json',
|
|
146
147
|
'scripts/measure_height.py', 'scripts/audit_css.py', 'scripts/negative_tests.py',
|
|
147
148
|
'references/components-atoms.md', 'references/layouts-research.md',
|
|
@@ -140,9 +140,20 @@ def pick_icon(title: str = '', idx: int = 0) -> str:
|
|
|
140
140
|
return ICONS[_ICONS_ORDER[idx % len(_ICONS_ORDER)]]
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
def
|
|
144
|
-
"""
|
|
145
|
-
|
|
143
|
+
def pick_icon_name(title: str = '', idx: int = 0) -> str:
|
|
144
|
+
"""图标名(与 icon_lib.js / PPTX 真导出同名)。"""
|
|
145
|
+
t = str(title or '')
|
|
146
|
+
for key in ICONS:
|
|
147
|
+
if key in t:
|
|
148
|
+
return key
|
|
149
|
+
return _ICONS_ORDER[idx % len(_ICONS_ORDER)]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def card_head(title: str, idx: int = 0, icon_name: str | None = None) -> str:
|
|
153
|
+
"""卡片头:图标路标 + 标题(与 scaffold 的 .card__hd + .card__ico 同源)。
|
|
154
|
+
icon_name 写入 data-icon,供 extract_model / build_pptx 真导出同名图标。"""
|
|
155
|
+
name = icon_name or pick_icon_name(title, idx)
|
|
156
|
+
return (f'<div class="card__hd"><div class="card__ico" data-icon="{esc(name)}">{pick_icon(title, idx)}</div>'
|
|
146
157
|
f'<h3 class="t-h3">{esc(title)}</h3></div>')
|
|
147
158
|
|
|
148
159
|
|
|
@@ -320,14 +331,23 @@ def r_cards(i, sec):
|
|
|
320
331
|
cards = sec.get('cards') or []
|
|
321
332
|
n = int(sec.get('columns') or min(3, max(1, len(cards))))
|
|
322
333
|
cls = {1: 'g-2', 2: 'g-2', 3: 'g-3', 4: 'g-4'}.get(n, 'g-3')
|
|
334
|
+
# 纯卡片栅格强制等高(g-2--equal / g-3--equal),与 CSS / layout-grammar「同行卡片 stretch」一致
|
|
335
|
+
if cls in ('g-2', 'g-3', 'g-4'):
|
|
336
|
+
cls = cls + '--equal'
|
|
323
337
|
blocks = []
|
|
324
338
|
for ci, cd in enumerate(cards):
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
if isinstance(cd, dict):
|
|
340
|
+
title = cd.get('title')
|
|
341
|
+
points = cd.get('points') or []
|
|
342
|
+
icon_name = cd.get('icon')
|
|
343
|
+
elif isinstance(cd, (list, tuple)) and len(cd) > 1:
|
|
344
|
+
title, points, icon_name = cd[0], [['', cd[1]]], None
|
|
345
|
+
else:
|
|
346
|
+
title, points, icon_name = (cd or ''), [], None
|
|
327
347
|
blocks.append(
|
|
328
|
-
f' <div class="card">{card_head(str(title), ci)}\n'
|
|
348
|
+
f' <div class="card">{card_head(str(title), ci, icon_name)}\n'
|
|
329
349
|
f' {pts_list(points)}</div>')
|
|
330
|
-
body = f' <div class="grid {cls} rv
|
|
350
|
+
body = f' <div class="grid {cls} rv">\n' + '\n'.join(blocks) + '\n </div>\n'
|
|
331
351
|
return wrap(i, 'cards', body, sec)
|
|
332
352
|
|
|
333
353
|
|
|
@@ -682,20 +702,64 @@ def render_body(model: dict) -> str:
|
|
|
682
702
|
</section>''')
|
|
683
703
|
agenda = model.get('agenda') or []
|
|
684
704
|
secs = [s for s in (model.get('sections') or []) if isinstance(s, dict)]
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
705
|
+
|
|
706
|
+
def _chapter_key(sec, idx):
|
|
707
|
+
"""从 eyebrow 的 `NN ·` 前缀或 chapter 字段取章键;无则退回页码。"""
|
|
708
|
+
ch = sec.get('chapter')
|
|
709
|
+
if ch not in (None, ''):
|
|
710
|
+
return str(ch)
|
|
711
|
+
eb = (sec.get('eyebrow') or '').strip()
|
|
712
|
+
m = re.match(r'^(\d{1,2})\s*[·・\-—]', eb)
|
|
713
|
+
if m:
|
|
714
|
+
return m.group(1).zfill(2)
|
|
715
|
+
return f'{idx:02d}'
|
|
716
|
+
|
|
717
|
+
def _chapter_title(sec):
|
|
718
|
+
"""章标题 = eyebrow 去掉 `NN ·` 前缀;无则退回页标题。"""
|
|
719
|
+
eb = (sec.get('eyebrow') or '').strip()
|
|
720
|
+
m = re.match(r'^\d{1,2}\s*[·・\-—]\s*(.+)$', eb)
|
|
721
|
+
if m and m.group(1).strip():
|
|
722
|
+
return m.group(1).strip()
|
|
723
|
+
return sec.get('title') or ''
|
|
724
|
+
|
|
725
|
+
# Agenda = 章节大纲(3–7 章),不是逐页标题罗列。
|
|
726
|
+
# 模型给了合法 agenda(≤8 条)就原样用;否则按 eyebrow 章前缀归并,而不是按页重建。
|
|
727
|
+
MAX_CH = 8
|
|
728
|
+
if not agenda or len(agenda) > MAX_CH or len(agenda) == len(secs) and len(secs) > MAX_CH:
|
|
729
|
+
chapters = {}
|
|
730
|
+
order = []
|
|
731
|
+
for i, s in enumerate(secs, 1):
|
|
732
|
+
key = _chapter_key(s, i)
|
|
733
|
+
if key not in chapters:
|
|
734
|
+
chapters[key] = {'num': key, 'title': _chapter_title(s), 'desc': (s.get('eyebrow') or ''),
|
|
735
|
+
'first': i, 'pages': 0}
|
|
736
|
+
order.append(key)
|
|
737
|
+
chapters[key]['pages'] += 1
|
|
738
|
+
model['agenda'] = [[c['num'], c['title'], c['desc']] for c in (chapters[k] for k in order)]
|
|
689
739
|
agenda = model['agenda']
|
|
740
|
+
chapter_first = {c['num']: c['first'] for c in (chapters[k] for k in order)}
|
|
741
|
+
else:
|
|
742
|
+
# 模型 agenda 已是章级:锚点落到该章第一页(按 eyebrow 对齐)
|
|
743
|
+
chapter_first = {}
|
|
744
|
+
for i, s in enumerate(secs, 1):
|
|
745
|
+
key = _chapter_key(s, i)
|
|
746
|
+
chapter_first.setdefault(key, i)
|
|
747
|
+
# 同时用序号兜底
|
|
748
|
+
chapter_first.setdefault(f'{i:02d}', i)
|
|
749
|
+
for gi, ag in enumerate(agenda, 1):
|
|
750
|
+
if isinstance(ag, (list, tuple)) and ag:
|
|
751
|
+
num = str(ag[0]).zfill(2) if str(ag[0]).isdigit() else str(ag[0])
|
|
752
|
+
chapter_first.setdefault(num, gi)
|
|
753
|
+
|
|
690
754
|
if agenda or not (model.get('mode') == 'architecture' and len(secs) <= 4):
|
|
691
755
|
lis = []
|
|
692
|
-
for
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
756
|
+
for gi, ag in enumerate(agenda, 1):
|
|
757
|
+
num = ag[0] if isinstance(ag, (list, tuple)) and ag else f'{gi:02d}'
|
|
758
|
+
title = ag[1] if isinstance(ag, (list, tuple)) and len(ag) > 1 else ''
|
|
759
|
+
desc = ag[2] if isinstance(ag, (list, tuple)) and len(ag) > 2 else ''
|
|
760
|
+
href_i = chapter_first.get(str(num).zfill(2)) or chapter_first.get(str(num)) or gi
|
|
697
761
|
lis.append(
|
|
698
|
-
f' <li class="agenda__i"><a class="agenda__a" href="#s{
|
|
762
|
+
f' <li class="agenda__i"><a class="agenda__a" href="#s{href_i}">'
|
|
699
763
|
f'<span class="agenda__n">{esc(num)}</span>'
|
|
700
764
|
f'<span><span class="agenda__t">{esc(title)}</span>'
|
|
701
765
|
f'<div class="agenda__d">{esc(desc)}</div></span></a></li>')
|
|
@@ -731,18 +795,45 @@ def render_body(model: dict) -> str:
|
|
|
731
795
|
<h2 class="t-h1 shead__title">{esc(closing.get("title") or "收尾")}</h2></div>
|
|
732
796
|
{body} </div>
|
|
733
797
|
</section>''')
|
|
734
|
-
#
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
for
|
|
738
|
-
|
|
739
|
-
|
|
798
|
+
# 参考资料:只列模型给的真实来源;无真实来源整节省略(禁止占位条目)
|
|
799
|
+
refs_src = model.get('refs') or model.get('references') or []
|
|
800
|
+
real_refs = []
|
|
801
|
+
for r in refs_src:
|
|
802
|
+
if isinstance(r, dict):
|
|
803
|
+
name = (r.get('name') or r.get('title') or '').strip()
|
|
804
|
+
time_ = (r.get('time') or r.get('date') or '').strip()
|
|
805
|
+
caliber = (r.get('caliber') or r.get('note') or '').strip()
|
|
806
|
+
url = (r.get('url') or r.get('link') or '').strip()
|
|
807
|
+
elif isinstance(r, (list, tuple)) and r:
|
|
808
|
+
name = str(r[0]).strip() if r[0] else ''
|
|
809
|
+
time_ = str(r[1]).strip() if len(r) > 1 and r[1] else ''
|
|
810
|
+
caliber = str(r[2]).strip() if len(r) > 2 and r[2] else ''
|
|
811
|
+
url = str(r[3]).strip() if len(r) > 3 and r[3] else ''
|
|
812
|
+
else:
|
|
813
|
+
name = str(r or '').strip()
|
|
814
|
+
time_ = caliber = url = ''
|
|
815
|
+
# 拦占位串:宁缺毋假
|
|
816
|
+
if not name or any(p in name for p in ('来源名称', '替换为真实来源', '来源 N', '某行业报告')):
|
|
817
|
+
continue
|
|
818
|
+
if time_ and any(p in time_ for p in ('YYYY-MM', 'YYYY', '2026-01;口径')):
|
|
819
|
+
time_ = ''
|
|
820
|
+
real_refs.append((name, time_, caliber, url))
|
|
821
|
+
if real_refs:
|
|
822
|
+
lis = []
|
|
823
|
+
for n, (name, time_, caliber, url) in enumerate(real_refs, 1):
|
|
824
|
+
meta = ','.join(x for x in (time_, caliber) if x)
|
|
825
|
+
label = esc(name)
|
|
826
|
+
if url and url.startswith(('http://', 'https://', './', '../')) and 'example.com' not in url:
|
|
827
|
+
label = f'<a class="ref-link" href="{esc(url)}" target="_blank" rel="noopener">{esc(name)}</a>'
|
|
828
|
+
lis.append(f' <li id="ref-{n}">[{n}] {label}' + (f',{esc(meta)}' if meta else '') + '。</li>')
|
|
829
|
+
parts.append(f'''<section class="band band--flow" id="refs">
|
|
740
830
|
<div class="wrap">
|
|
741
831
|
<div class="shead rv"><div class="t-eyebrow">附录</div>
|
|
742
832
|
<h2 class="t-h1 shead__title">参考资料</h2></div>
|
|
743
|
-
<ol class="refs">{''.join(
|
|
833
|
+
<ol class="refs">{''.join(lis)}</ol>
|
|
744
834
|
</div>
|
|
745
835
|
</section>''')
|
|
836
|
+
# 无真实来源 → 不渲染 #refs 整节(也不写占位)
|
|
746
837
|
return '\n\n'.join(parts)
|
|
747
838
|
|
|
748
839
|
|
|
@@ -756,6 +847,14 @@ def load_model(path: Path) -> dict:
|
|
|
756
847
|
return json.loads(m.group(1))
|
|
757
848
|
|
|
758
849
|
|
|
850
|
+
def _strip_refs_nav(html: str, body: str) -> str:
|
|
851
|
+
"""无真实来源(正文不含 #refs 节)时,摘掉导航/收尾的「参考资料」入口,避免悬空锚点。"""
|
|
852
|
+
if 'id="refs"' in body:
|
|
853
|
+
return html
|
|
854
|
+
html = re.sub(r'\s*<a[^>]*href="#refs"[^>]*>[\s\S]*?</a>', '', html)
|
|
855
|
+
return html
|
|
856
|
+
|
|
857
|
+
|
|
759
858
|
def main() -> int:
|
|
760
859
|
ap = argparse.ArgumentParser(description='从 REPORT_MODEL 渲染 HTML 正文(模型驱动生成)')
|
|
761
860
|
ap.add_argument('src', help='model.json 或含 REPORT_MODEL 的 .html')
|
|
@@ -783,6 +882,7 @@ def main() -> int:
|
|
|
783
882
|
# 同步模型(确保与渲染源一致)
|
|
784
883
|
t = MODEL_RE.sub('window.REPORT_MODEL = ' +
|
|
785
884
|
json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
|
|
885
|
+
t = _strip_refs_nav(t, body)
|
|
786
886
|
src.write_text(t, encoding='utf-8')
|
|
787
887
|
print(f'已按模型重渲染正文:{src}({len(body)} 字符)')
|
|
788
888
|
print(f' 下一步: python scripts/validate_report.py "{src}" --strict')
|
|
@@ -801,6 +901,7 @@ def main() -> int:
|
|
|
801
901
|
t = CONTENT_RE.sub(r'\1\n' + body.replace('\\', '\\\\') + r'\n\2', t, count=1)
|
|
802
902
|
t = MODEL_RE.sub('window.REPORT_MODEL = ' +
|
|
803
903
|
json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
|
|
904
|
+
t = _strip_refs_nav(t, body)
|
|
804
905
|
out = Path(args.out or (src.with_suffix('.html')))
|
|
805
906
|
out.write_text(t, encoding='utf-8')
|
|
806
907
|
print(f'已从模型生成:{out} sections={len(model.get("sections") or [])}')
|
|
@@ -320,7 +320,7 @@ def pg_exhibit(i, sec):
|
|
|
320
320
|
<div class="exhibit__hd"><span class="exhibit__no">Exhibit {sec.get('exhibitNo', 1)}</span>
|
|
321
321
|
<span class="exhibit__t">图表标题(结论式)</span></div>
|
|
322
322
|
{chart_svg(ct, i)}
|
|
323
|
-
<div class="exhibit__src"
|
|
323
|
+
<div class="exhibit__src">来源:<真实来源名>,YYYY-MM;口径说明<a class="cite" href="#ref-1">[1]</a></div>
|
|
324
324
|
</div>
|
|
325
325
|
{SIDE_NOTES}
|
|
326
326
|
</div>
|
|
@@ -333,7 +333,7 @@ def pg_exhibit(i, sec):
|
|
|
333
333
|
'chart': {'type': ct, 'labels': ['A', 'B', 'C'], 'values': [42, 61, 35],
|
|
334
334
|
'dataTable': 'notes'},
|
|
335
335
|
'soWhat': '一行含义或建议(≤60 字)。',
|
|
336
|
-
'footnote': '
|
|
336
|
+
'footnote': '来源:<真实来源名>,YYYY-MM;口径说明'}
|
|
337
337
|
|
|
338
338
|
|
|
339
339
|
def pg_twocol(i, sec):
|
|
@@ -389,7 +389,7 @@ def pg_halftable(i, sec):
|
|
|
389
389
|
<div class="exhibit__hd"><span class="exhibit__no">Exhibit {sec.get('exhibitNo', 1)}</span>
|
|
390
390
|
<span class="exhibit__t">右栏图表标题(结论式)</span></div>
|
|
391
391
|
{chart_svg(ct, i)}
|
|
392
|
-
<div class="exhibit__src"
|
|
392
|
+
<div class="exhibit__src">来源:<真实来源名>,YYYY-MM;口径说明<a class="cite" href="#ref-1">[1]</a></div>
|
|
393
393
|
</div>
|
|
394
394
|
</div>
|
|
395
395
|
</div>
|
|
@@ -705,7 +705,7 @@ def pg_info(i, sec):
|
|
|
705
705
|
'marimekko': '双重编码(列宽 ∝ 规模,列高 = 构成占比)',
|
|
706
706
|
'streamgraph': '构成演变(基线居中 · 带宽 ∝ 规模)'}[t]
|
|
707
707
|
# 信息图页承载在 .fig 里(不是 .exhibit 框)→ 来源行走 .footnote,避免与 Exhibit 来源行统计混淆
|
|
708
|
-
src = (' <div class="footnote"
|
|
708
|
+
src = (' <div class="footnote">来源:<真实来源名>,YYYY-MM;口径说明'
|
|
709
709
|
'<a class="cite" href="#ref-1">[1]</a></div>\n')
|
|
710
710
|
html = f'''<section class="band" id="s{i}">
|
|
711
711
|
<div class="wrap">
|
|
@@ -824,15 +824,15 @@ def closing(mode, title, points):
|
|
|
824
824
|
|
|
825
825
|
|
|
826
826
|
def refs(mode, items):
|
|
827
|
-
"""
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
return f'''<!--
|
|
827
|
+
"""参考资料:只列真实来源条目;无真实来源时整节省略(禁止占位条目误导读者)。
|
|
828
|
+
items 元素 = (来源名, 时间;口径) 且来源名必须是真实机构/报告/文档名。"""
|
|
829
|
+
if not items:
|
|
830
|
+
# 宁缺毋假:scaffold 不生成假「参考资料」页;正文无 [n] 时也不需要该节
|
|
831
|
+
return ''
|
|
832
|
+
body = (' <ul class="ul ul--num rv">\n' + '\n'.join(
|
|
833
|
+
f''' <li id="ref-{n}">{esc(s)} <span class="t-xs" style="color:var(--text-3)">{esc(t)}</span></li>'''
|
|
834
|
+
for n, (s, t) in enumerate(items, 1)) + '\n </ul>')
|
|
835
|
+
return f'''<!-- 参考资料(只列真实来源;无真实来源请整节删除并去掉正文 [n] 与导航入口) -->
|
|
836
836
|
<section class="band band--tint" id="refs">
|
|
837
837
|
<div class="wrap">
|
|
838
838
|
<div class="shead rv">
|
|
@@ -985,7 +985,25 @@ def main() -> int:
|
|
|
985
985
|
if p.get('lead'):
|
|
986
986
|
m['lead'] = p['lead']
|
|
987
987
|
model_secs.append(m)
|
|
988
|
-
|
|
988
|
+
|
|
989
|
+
# Agenda = 章节大纲(3–7 章),不是逐页标题罗列:按 eyebrow 的 `NN ·` 前缀归并
|
|
990
|
+
def _chap_key(eb, idx):
|
|
991
|
+
mm = re.match(r'^(\d{1,2})\s*[·・\-—]', (eb or '').strip())
|
|
992
|
+
return mm.group(1).zfill(2) if mm else f'{idx:02d}'
|
|
993
|
+
|
|
994
|
+
def _chap_title(eb, title):
|
|
995
|
+
mm = re.match(r'^\d{1,2}\s*[·・\-—]\s*(.+)$', (eb or '').strip())
|
|
996
|
+
return mm.group(1).strip() if mm and mm.group(1).strip() else (title or '')
|
|
997
|
+
|
|
998
|
+
chapters, ch_order = {}, []
|
|
999
|
+
for i, p in enumerate(pages, 1):
|
|
1000
|
+
key = _chap_key(p.get('eyebrow', ''), i)
|
|
1001
|
+
if key not in chapters:
|
|
1002
|
+
chapters[key] = {'num': key, 'title': _chap_title(p.get('eyebrow', ''), p['title']),
|
|
1003
|
+
'desc': p.get('eyebrow', ''), 'first': i}
|
|
1004
|
+
ch_order.append(key)
|
|
1005
|
+
agenda_items = [(f's{chapters[k]["first"]}', chapters[k]['title'], chapters[k]['desc'])
|
|
1006
|
+
for k in ch_order]
|
|
989
1007
|
|
|
990
1008
|
show_agenda = not (mode == 'architecture' and len(pages) <= 4)
|
|
991
1009
|
closing_points = [['动作一', '一句话说明。'], ['动作二', '一句话说明。'], ['动作三', '一句话说明。']]
|
|
@@ -997,28 +1015,33 @@ def main() -> int:
|
|
|
997
1015
|
body.append(closing(mode, '收尾主张(回应封面问题)', closing_points))
|
|
998
1016
|
head = '\n\n'.join(body)
|
|
999
1017
|
|
|
1000
|
-
#
|
|
1018
|
+
# 参考资料:只在有真实来源条目时生成;scaffold 不再写「来源 N(替换为真实来源名称)」占位
|
|
1019
|
+
# 交付前由内容作者补 model.refs = [[来源名, 时间;口径]…](真实机构/报告/文档名),再跑 render_from_model
|
|
1001
1020
|
ref_ids = sorted(set(re.findall(r'class="cite" href="#(ref-\d)"', head)))
|
|
1002
|
-
ref_items = [
|
|
1003
|
-
content = head
|
|
1021
|
+
ref_items = [] # 真实来源由内容作者填入;空 = 不生成参考资料节(宁缺毋假)
|
|
1022
|
+
content = head
|
|
1023
|
+
if ref_items:
|
|
1024
|
+
content = head + '\n\n' + refs(mode, ref_items)
|
|
1025
|
+
content = content + '\n\n' + footer(args.title, args.subtitle, args.meta)
|
|
1004
1026
|
|
|
1005
1027
|
assert CONTENT_RE.search(t), f'{tpl_path.name} 缺少 __TOPPPT_CONTENT__ 标记'
|
|
1006
1028
|
t = CONTENT_RE.sub('<!-- __TOPPPT_CONTENT_START__ -->\n' + content +
|
|
1007
1029
|
'\n<!-- __TOPPPT_CONTENT_END__ -->', t, count=1)
|
|
1008
1030
|
|
|
1009
|
-
# ③ 顶栏导航与 Agenda
|
|
1010
|
-
nav_links = '\n'.join(
|
|
1011
|
-
|
|
1031
|
+
# ③ 顶栏导航与 Agenda 锚点一致(章级,不是逐页标题)
|
|
1032
|
+
nav_links = '\n'.join(
|
|
1033
|
+
f' <a href="#s{chapters[k]["first"]}">{esc(chapters[k]["title"][:10])}</a>'
|
|
1034
|
+
for k in ch_order[:5])
|
|
1012
1035
|
assert NAV_RE.search(t), '模板缺少顶栏导航'
|
|
1013
|
-
|
|
1014
|
-
|
|
1036
|
+
nav_extra = '\n <a href="#refs">参考资料</a>' if ref_items else ''
|
|
1037
|
+
t = NAV_RE.sub(f'<nav class="nav">\n{nav_links}{nav_extra}\n </nav>', t, count=1)
|
|
1015
1038
|
|
|
1016
1039
|
# ④ REPORT_MODEL 骨架(与正文一一对应 · 严格 JSON)
|
|
1017
1040
|
model = {
|
|
1018
1041
|
'mode': mode, 'style': style, 'theme': theme,
|
|
1019
1042
|
'title': args.title, 'subtitle': args.subtitle, 'meta': args.meta,
|
|
1020
|
-
'agenda': [[
|
|
1021
|
-
|
|
1043
|
+
'agenda': [[chapters[k]['num'], chapters[k]['title'], chapters[k]['desc']]
|
|
1044
|
+
for k in ch_order] if show_agenda else [],
|
|
1022
1045
|
'sections': model_secs,
|
|
1023
1046
|
'closing': {'title': '收尾主张(回应封面问题)',
|
|
1024
1047
|
'points': [list(x) for x in closing_points]},
|
|
@@ -164,7 +164,8 @@ function regionOf(pageType, slotId, opts) {
|
|
|
164
164
|
if (type === 'bar' && slot === 'primary') {
|
|
165
165
|
var by = P.chartY || 2.95;
|
|
166
166
|
var bb = opts.bottom != null ? opts.bottom : bottomDefault;
|
|
167
|
-
|
|
167
|
+
/* chartX/chartW 历史误用已纠正:与 donut/table/hbar/exhibit 同口径,图表盒对齐版心 (MX, CW) */
|
|
168
|
+
return { x: MX, y: by, w: CW, h: Math.max(1.2, bb - by) };
|
|
168
169
|
}
|
|
169
170
|
if (type === 'bar' && slot === 'hbar') {
|
|
170
171
|
var y0 = P.hbarY0 || 2.6;
|
|
@@ -1021,6 +1021,14 @@ def inspect_slide(
|
|
|
1021
1021
|
picture_area_ratios: list[float] = []
|
|
1022
1022
|
margin_emu = int(MIN_EDGE_MARGIN_IN * 914400)
|
|
1023
1023
|
|
|
1024
|
+
# 图标语义图(objectName `icon:` 前缀)单独计数:不进 pictures 内容图门禁
|
|
1025
|
+
def _is_icon_pic(el) -> bool:
|
|
1026
|
+
nv = el.find('.//p:cNvPr', NS)
|
|
1027
|
+
name = (nv.get('name') if nv is not None else '') or ''
|
|
1028
|
+
return name.startswith('icon:')
|
|
1029
|
+
icon_pictures = [p for p in pictures if _is_icon_pic(p)]
|
|
1030
|
+
content_pictures = [p for p in pictures if not _is_icon_pic(p)]
|
|
1031
|
+
|
|
1024
1032
|
for element in all_elements:
|
|
1025
1033
|
box = shape_bounds(element)
|
|
1026
1034
|
if box is None:
|
|
@@ -1193,7 +1201,8 @@ def inspect_slide(
|
|
|
1193
1201
|
"slide": slide_number,
|
|
1194
1202
|
"native_text_shapes": native_text_shapes,
|
|
1195
1203
|
"native_graphic_shapes": len(shapes) + len(graphic_frames),
|
|
1196
|
-
"pictures": len(
|
|
1204
|
+
"pictures": len(content_pictures),
|
|
1205
|
+
"icon_pictures": len(icon_pictures),
|
|
1197
1206
|
"charts": len(charts),
|
|
1198
1207
|
"tables": len(tables),
|
|
1199
1208
|
"element_count": len(all_elements),
|
|
@@ -1557,6 +1566,7 @@ def empty_report(path: Path) -> dict[str, Any]:
|
|
|
1557
1566
|
"native_text_shapes": 0,
|
|
1558
1567
|
"native_graphic_shapes": 0,
|
|
1559
1568
|
"pictures": 0,
|
|
1569
|
+
"icon_pictures": 0,
|
|
1560
1570
|
"charts": 0,
|
|
1561
1571
|
"tables": 0,
|
|
1562
1572
|
},
|
|
@@ -1720,7 +1730,7 @@ def validate_pptx(
|
|
|
1720
1730
|
slide_chart_texts.append(slide_chart_text(archive, slide_name))
|
|
1721
1731
|
|
|
1722
1732
|
report["summary"]["slide_count"] = len(slide_names)
|
|
1723
|
-
for field in ("native_text_shapes", "native_graphic_shapes", "pictures", "charts", "tables"):
|
|
1733
|
+
for field in ("native_text_shapes", "native_graphic_shapes", "pictures", "icon_pictures", "charts", "tables"):
|
|
1724
1734
|
report["summary"][field] = sum(slide[field] for slide in report["slides"])
|
|
1725
1735
|
|
|
1726
1736
|
# P1-6 chrome 一致性:页脚/页码 y 相对中位数漂移
|
|
@@ -1730,7 +1740,8 @@ def validate_pptx(
|
|
|
1730
1740
|
# 仅当模型显式声明 section.image / split.right.image 时才允许对应数量的图片落地。
|
|
1731
1741
|
# 配图占位(image.placeholder)走原生形状,不计入图片数;版式/裁切/多图数量另做硬门禁。
|
|
1732
1742
|
declared_images = count_model_images(model) if model_path is not None else 0
|
|
1733
|
-
n_pics = report["summary"]["pictures"]
|
|
1743
|
+
n_pics = report["summary"]["pictures"] # 内容图(已扣除 icon: 语义图标图)
|
|
1744
|
+
n_icons = int(report["summary"].get("icon_pictures", 0) or 0)
|
|
1734
1745
|
img_issues: list[tuple[str, str]] = []
|
|
1735
1746
|
if model_path is not None and model is not None:
|
|
1736
1747
|
img_issues = model_image_issues(model, _image_spec(),
|
|
@@ -1693,7 +1693,7 @@ def main():
|
|
|
1693
1693
|
ag_cfg = (LC.get('contentQuality') or {}).get('agenda') or {}
|
|
1694
1694
|
split_max = int(ag_cfg.get('splitMax') or 16)
|
|
1695
1695
|
title_max = int(ag_cfg.get('titleMaxChars') or 36)
|
|
1696
|
-
chk(f"Agenda 条数 ≤{split_max}
|
|
1696
|
+
chk(f"Agenda 条数 ≤{split_max}(大纲=章,3–7 条;超出说明在列页,请归并回章)",
|
|
1697
1697
|
n_items <= split_max,
|
|
1698
1698
|
f"{n_items} 条超上限——PPTX 已自动分页,HTML 会撑出一屏,建议改章节级大纲 6–8 条")
|
|
1699
1699
|
long_titles = re.findall(r'class="agenda__t"[^>]*>([^<]{%d,})' % (title_max + 1), txt)
|