@topmindspace/tms-skills 0.1.3 → 0.1.4
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 +24 -0
- package/README.md +4 -4
- package/package.json +2 -2
- package/top-ppt-html/README.md +5 -5
- package/top-ppt-html/SKILL.md +11 -11
- package/top-ppt-html/agents/openai.yaml +2 -2
- package/top-ppt-html/assets/pptx-export.js +69 -25
- package/top-ppt-html/assets/style-gallery.html +1 -1
- package/top-ppt-html/assets/templates/architecture.html +69 -25
- package/top-ppt-html/assets/templates/presentation.html +69 -25
- package/top-ppt-html/assets/templates/research.html +69 -25
- package/top-ppt-html/package.json +2 -2
- package/top-ppt-html/references/default-surface.md +1 -1
- package/top-ppt-html/references/modes.md +1 -1
- package/top-ppt-html/references/playbook.md +9 -8
- package/top-ppt-html/references/pptx-export.md +1 -1
- package/top-ppt-html/references/presentation-craft.md +3 -2
- package/top-ppt-html/scripts/audit_skill.py +11 -0
- package/top-ppt-html/scripts/build_pptx.js +24 -10
- package/top-ppt-html/scripts/quality_gate.py +30 -19
- package/top-ppt-html/scripts/test_feedback_gates.py +180 -0
- package/top-ppt-html/scripts/validate_pptx.py +157 -0
|
@@ -108,6 +108,9 @@ STRICT_FAILURE_CODES = {
|
|
|
108
108
|
"UNBALANCED_EMPTY_SPACE",
|
|
109
109
|
"UNJUSTIFIED_LARGE_IMAGE",
|
|
110
110
|
"TEXT_OVERFLOW_ESTIMATE",
|
|
111
|
+
"TEXT_OVERFLOW_VERTICAL",
|
|
112
|
+
"ANNOTATION_BAND_OVERLAP",
|
|
113
|
+
"FONT_SIZE_NOT_SNAPPED",
|
|
111
114
|
"TEXT_INCOMPLETE",
|
|
112
115
|
"CHART_SKEW_INVALID",
|
|
113
116
|
"CHART_OVERSIZE",
|
|
@@ -207,9 +210,17 @@ def slide_chart_text(archive: zipfile.ZipFile, slide_name: str) -> str:
|
|
|
207
210
|
|
|
208
211
|
|
|
209
212
|
def shape_bounds(element: ET.Element) -> tuple[int, int, int, int] | None:
|
|
213
|
+
"""Read shape geometry from DrawingML ``a:xfrm`` **or** PresentationML ``p:xfrm``.
|
|
214
|
+
|
|
215
|
+
Native charts live in ``p:graphicFrame``, which carries ``p:xfrm`` (not ``a:xfrm``).
|
|
216
|
+
Skipping ``p:xfrm`` made chart overflow / overlap gates blind.
|
|
217
|
+
"""
|
|
210
218
|
xfrm = element.find(".//a:xfrm", NS)
|
|
219
|
+
if xfrm is None:
|
|
220
|
+
xfrm = element.find(".//p:xfrm", NS)
|
|
211
221
|
if xfrm is None:
|
|
212
222
|
return None
|
|
223
|
+
# p:xfrm and a:xfrm both nest a:off / a:ext
|
|
213
224
|
offset = xfrm.find("a:off", NS)
|
|
214
225
|
extent = xfrm.find("a:ext", NS)
|
|
215
226
|
if offset is None or extent is None:
|
|
@@ -298,6 +309,147 @@ def text_overflow_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]
|
|
|
298
309
|
return out
|
|
299
310
|
|
|
300
311
|
|
|
312
|
+
def text_overflow_vertical_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]]:
|
|
313
|
+
"""Multi-segment cumulative vertical overflow (TEXT_OVERFLOW_VERTICAL).
|
|
314
|
+
|
|
315
|
+
Per-paragraph area checks miss the case where each line fits individually but
|
|
316
|
+
the *sum* of estimated line heights exceeds the text-frame height.
|
|
317
|
+
"""
|
|
318
|
+
out: list[dict[str, Any]] = []
|
|
319
|
+
box = shape_bounds(shape)
|
|
320
|
+
if box is None:
|
|
321
|
+
return out
|
|
322
|
+
_, _, cx, cy = box
|
|
323
|
+
w_in, h_in = cx / 914400.0, cy / 914400.0
|
|
324
|
+
if w_in <= 0 or h_in <= 0:
|
|
325
|
+
return out
|
|
326
|
+
cfg = _containers_cfg()
|
|
327
|
+
lf = float(cfg.get("lineFactor") or 1.35)
|
|
328
|
+
total_h = 0.0
|
|
329
|
+
segments = 0
|
|
330
|
+
for para in shape.findall(".//a:p", NS):
|
|
331
|
+
line = "".join((t.text or "") for t in para.findall(".//a:t", NS))
|
|
332
|
+
if not line.strip():
|
|
333
|
+
continue
|
|
334
|
+
sizes = font_sizes_pt(para)
|
|
335
|
+
fz = min(sizes) if sizes else 12.0
|
|
336
|
+
est_w = est_text_width_in(line, fz)
|
|
337
|
+
lines_needed = max(1, int((est_w / max(w_in, 0.01)) + 0.999))
|
|
338
|
+
total_h += lines_needed * (fz / 72.0) * lf
|
|
339
|
+
segments += 1
|
|
340
|
+
# Single-segment cases already covered by TEXT_OVERFLOW_ESTIMATE; this gate
|
|
341
|
+
# targets multi-para cumulative overflow (tolerance mirrors horizontal gate).
|
|
342
|
+
if segments >= 2 and total_h > h_in * TEXT_OVERFLOW_TOLERANCE:
|
|
343
|
+
out.append(issue(
|
|
344
|
+
"TEXT_OVERFLOW_VERTICAL",
|
|
345
|
+
f"多段文本累计高度估算 {total_h:.2f}in > 文本框 {h_in:.2f}in"
|
|
346
|
+
f"({segments} 段,容差 {TEXT_OVERFLOW_TOLERANCE:.0%})——"
|
|
347
|
+
"请拆段/缩字号阶梯/换页,禁止静默截断。",
|
|
348
|
+
slide=slide_no,
|
|
349
|
+
))
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def annotation_band_overlap_check(
|
|
354
|
+
elements: list[ET.Element],
|
|
355
|
+
slide_no: int,
|
|
356
|
+
slide_height_emu: int,
|
|
357
|
+
) -> list[dict[str, Any]]:
|
|
358
|
+
"""Detect primary content crushing into the annotation band.
|
|
359
|
+
|
|
360
|
+
Annotation band ≈ [contentBottomWithNote, contentBottom] (default 6.4–6.9in).
|
|
361
|
+
Shapes that start in the body and extend into the band (e.g. streamgraph
|
|
362
|
+
legends stacked below the plot) fire ANNOTATION_BAND_OVERLAP.
|
|
363
|
+
"""
|
|
364
|
+
out: list[dict[str, Any]] = []
|
|
365
|
+
try:
|
|
366
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
367
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
368
|
+
lay = ((lc.get("pageTypes") or {}).get("layout") or {})
|
|
369
|
+
band_top_in = float(lay.get("contentBottomWithNote") or 6.4)
|
|
370
|
+
band_bot_in = float(lay.get("contentBottom") or 6.9)
|
|
371
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
372
|
+
band_top_in, band_bot_in = 6.4, 6.9
|
|
373
|
+
band_top = int(band_top_in * 914400)
|
|
374
|
+
band_bot = int(band_bot_in * 914400)
|
|
375
|
+
# Ignore footer/page-number chrome near the very bottom
|
|
376
|
+
pager_floor = int(min(band_bot_in + 0.05, slide_height_emu / 914400.0 - 0.05) * 914400)
|
|
377
|
+
min_overlap = int(0.08 * 914400) # 0.08in
|
|
378
|
+
for el in elements:
|
|
379
|
+
box = shape_bounds(el)
|
|
380
|
+
if box is None:
|
|
381
|
+
continue
|
|
382
|
+
x, y, cx, cy = box
|
|
383
|
+
if cx <= 0 or cy <= 0:
|
|
384
|
+
continue
|
|
385
|
+
bottom = y + cy
|
|
386
|
+
# Legitimate annotation/note content sits entirely inside the band
|
|
387
|
+
if y >= band_top - int(0.02 * 914400):
|
|
388
|
+
continue
|
|
389
|
+
# Page chrome (pager) — tiny shapes near bottom edge
|
|
390
|
+
if y >= pager_floor:
|
|
391
|
+
continue
|
|
392
|
+
overlap = min(bottom, band_bot) - max(y, band_top)
|
|
393
|
+
if overlap >= min_overlap and bottom > band_top:
|
|
394
|
+
out.append(issue(
|
|
395
|
+
"ANNOTATION_BAND_OVERLAP",
|
|
396
|
+
f"主内容侵入注释带(元素底边 {bottom/914400:.2f}in 越过注释带顶 "
|
|
397
|
+
f"{band_top_in:.2f}in,重叠 {overlap/914400:.2f}in)——"
|
|
398
|
+
"图例/系列请收入主图区或压缩系列数,禁止压进 so-what/来源行。",
|
|
399
|
+
slide=slide_no,
|
|
400
|
+
))
|
|
401
|
+
# One finding per slide is enough to gate
|
|
402
|
+
break
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def font_size_snap_check(root: ET.Element, slide_no: int) -> list[dict[str, Any]]:
|
|
407
|
+
"""Font sizes must sit on the fontShrink ladder (0.5pt snap grid).
|
|
408
|
+
|
|
409
|
+
fitFont already selects from the ladder; this gate catches callers that
|
|
410
|
+
arithmetic-shift sizes (e.g. fz-1) or hardcode off-ladder values without
|
|
411
|
+
re-snapping through the ladder / modeSize path.
|
|
412
|
+
"""
|
|
413
|
+
out: list[dict[str, Any]] = []
|
|
414
|
+
try:
|
|
415
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
416
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
417
|
+
ladder = ((lc.get("containers") or {}).get("fontShrink") or {}).get("ladder") or []
|
|
418
|
+
allowed = {round(float(v), 2) for v in ladder}
|
|
419
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
420
|
+
allowed = {15, 14, 13.5, 13, 12.5, 12, 11.5, 11, 10.5, 10, 9.5, 9, 8.5}
|
|
421
|
+
# Also allow common display sizes used by cover/hero (not on shrink ladder)
|
|
422
|
+
allowed |= {44, 36, 30, 28, 24, 22, 20, 18, 16, 8.0, 7.5}
|
|
423
|
+
alien: list[float] = []
|
|
424
|
+
for run in root.findall(".//a:r", NS):
|
|
425
|
+
text = "".join(t.text or "" for t in run.findall("a:t", NS))
|
|
426
|
+
if not text.strip():
|
|
427
|
+
continue
|
|
428
|
+
pr = run.find("a:rPr", NS)
|
|
429
|
+
raw = pr.get("sz") if pr is not None else None
|
|
430
|
+
if raw is None:
|
|
431
|
+
continue
|
|
432
|
+
try:
|
|
433
|
+
pt = int(raw) / 100.0
|
|
434
|
+
except (TypeError, ValueError):
|
|
435
|
+
continue
|
|
436
|
+
if round(pt, 2) not in allowed and abs(pt * 2 - round(pt * 2)) > 0.01:
|
|
437
|
+
alien.append(pt)
|
|
438
|
+
elif round(pt, 2) not in allowed:
|
|
439
|
+
# On 0.5 grid but not on declared ladder / display set — still flag
|
|
440
|
+
# when far from any allowed value (>0.26pt)
|
|
441
|
+
if min(abs(pt - a) for a in allowed) > 0.26:
|
|
442
|
+
alien.append(pt)
|
|
443
|
+
if alien:
|
|
444
|
+
out.append(issue(
|
|
445
|
+
"FONT_SIZE_NOT_SNAPPED",
|
|
446
|
+
f"页内出现未对齐字号阶梯的字号 {sorted(set(round(v,2) for v in alien))[:8]}——"
|
|
447
|
+
"fitFont/modeSize 须回落到 containers.fontShrink.ladder(或封面展示档)。",
|
|
448
|
+
slide=slide_no,
|
|
449
|
+
))
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
301
453
|
@lru_cache(maxsize=1)
|
|
302
454
|
def _containers_cfg() -> dict[str, Any]:
|
|
303
455
|
"""容器内边距与锚点容差(单源 scripts/layout-constants.json 的 containers / anchorTolerance)。"""
|
|
@@ -628,8 +780,13 @@ def inspect_slide(
|
|
|
628
780
|
|
|
629
781
|
for shape in shapes:
|
|
630
782
|
warnings.extend(text_overflow_check(shape, slide_number))
|
|
783
|
+
warnings.extend(text_overflow_vertical_check(shape, slide_number))
|
|
631
784
|
warnings.extend(container_overflow_check(shape, slide_number))
|
|
632
785
|
|
|
786
|
+
warnings.extend(annotation_band_overlap_check(
|
|
787
|
+
[*shapes, *pictures, *graphic_frames], slide_number, height))
|
|
788
|
+
warnings.extend(font_size_snap_check(root, slide_number))
|
|
789
|
+
|
|
633
790
|
for table in tables:
|
|
634
791
|
warnings.extend(table_checks(table, slide_number))
|
|
635
792
|
|