@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.
@@ -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