@litfamily/litclaude 1.0.19 → 1.0.21

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.
Files changed (101) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -8
  3. package/README_ko-KR.md +13 -8
  4. package/docs/assets/readme/badge-version.svg +1 -1
  5. package/docs/hooks.md +3 -2
  6. package/package.json +1 -1
  7. package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
  8. package/plugins/litclaude/bin/litclaude-hook.js +4 -4
  9. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  10. package/plugins/litclaude/lib/canonical-skill-resources.mjs +91 -31
  11. package/plugins/litclaude/skills/frontend-ui-ux/ATTRIBUTION.md +3 -6
  12. package/plugins/litclaude/skills/lit-docx/SKILL.md +50 -19
  13. package/plugins/litclaude/skills/lit-docx/references/components.md +207 -0
  14. package/plugins/litclaude/skills/lit-docx/references/direction-step.md +277 -0
  15. package/plugins/litclaude/skills/lit-docx/references/examples/01-report-ko.md +166 -0
  16. package/plugins/litclaude/skills/lit-docx/references/examples/02-brief-ko.md +105 -0
  17. package/plugins/litclaude/skills/lit-docx/references/examples/03-manual-ko.md +164 -0
  18. package/plugins/litclaude/skills/lit-docx/references/examples/04-proposal-ko.md +138 -0
  19. package/plugins/litclaude/skills/lit-docx/references/examples/05-memo-en.md +55 -0
  20. package/plugins/litclaude/skills/lit-docx/references/examples/06-journal-en.md +130 -0
  21. package/plugins/litclaude/skills/lit-docx/references/examples/07-report-en.md +123 -0
  22. package/plugins/litclaude/skills/lit-docx/references/examples/08-proposal-en.md +123 -0
  23. package/plugins/litclaude/skills/lit-docx/references/examples/09-memo-ko.md +45 -0
  24. package/plugins/litclaude/skills/lit-docx/references/page-composition.md +221 -0
  25. package/plugins/litclaude/skills/lit-docx/references/tonalities/brief.md +143 -0
  26. package/plugins/litclaude/skills/lit-docx/references/tonalities/journal.md +142 -0
  27. package/plugins/litclaude/skills/lit-docx/references/tonalities/manual.md +148 -0
  28. package/plugins/litclaude/skills/lit-docx/references/tonalities/memo.md +137 -0
  29. package/plugins/litclaude/skills/lit-docx/references/tonalities/proposal.md +140 -0
  30. package/plugins/litclaude/skills/lit-docx/references/tonalities/report.md +156 -0
  31. package/plugins/litclaude/skills/lit-docx/scripts/convert_md_to_docx.py +274 -73
  32. package/plugins/litclaude/skills/lit-docx/scripts/docx_design.py +2449 -0
  33. package/plugins/litclaude/skills/lit-docx/scripts/docx_layout.py +959 -0
  34. package/plugins/litclaude/skills/lit-docx/scripts/qa_docx.py +66 -9
  35. package/plugins/litclaude/skills/lit-docx/scripts/slop_lint.py +18 -6
  36. package/plugins/litclaude/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
  37. package/plugins/litclaude/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
  38. package/plugins/litclaude/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
  39. package/plugins/litclaude/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
  40. package/plugins/litclaude/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
  41. package/plugins/litclaude/skills/lit-docx/templates/tonalities/report.yaml +26 -0
  42. package/plugins/litclaude/skills/lit-pptx/SKILL.md +105 -48
  43. package/plugins/litclaude/skills/lit-pptx/references/agents/analyst.md +1 -1
  44. package/plugins/litclaude/skills/lit-pptx/references/agents/thesis.md +1 -1
  45. package/plugins/litclaude/skills/lit-pptx/references/anti-slop-checklist.md +59 -30
  46. package/plugins/litclaude/skills/lit-pptx/references/authoring-guide.md +119 -42
  47. package/plugins/litclaude/skills/lit-pptx/references/consensus-protocol.md +1 -1
  48. package/plugins/litclaude/skills/lit-pptx/references/data-display-cookbook.md +13 -6
  49. package/plugins/litclaude/skills/lit-pptx/references/density-and-fill.md +164 -0
  50. package/plugins/litclaude/skills/lit-pptx/references/design-system-azure.md +7 -4
  51. package/plugins/litclaude/skills/lit-pptx/references/direction-step.md +186 -0
  52. package/plugins/litclaude/skills/lit-pptx/references/examples/01-pitch-signal-ko.md +210 -0
  53. package/plugins/litclaude/skills/lit-pptx/references/examples/02-pitch-studio-ko.md +147 -0
  54. package/plugins/litclaude/skills/lit-pptx/references/examples/03-business-review-ledger-ko.md +199 -0
  55. package/plugins/litclaude/skills/lit-pptx/references/examples/04-business-review-night-en.md +203 -0
  56. package/plugins/litclaude/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +179 -0
  57. package/plugins/litclaude/skills/lit-pptx/references/examples/06-lecture-chalk-ko.md +199 -0
  58. package/plugins/litclaude/skills/lit-pptx/references/examples/07-data-review-night-en.md +194 -0
  59. package/plugins/litclaude/skills/lit-pptx/references/examples/08-image-led-atlas-ko.md +175 -0
  60. package/plugins/litclaude/skills/lit-pptx/references/examples/09-briefing-gazette-ko.md +204 -0
  61. package/plugins/litclaude/skills/lit-pptx/references/examples/10-status-update-ledger-ko.md +179 -0
  62. package/plugins/litclaude/skills/lit-pptx/references/examples/11-portfolio-studio-en.md +164 -0
  63. package/plugins/litclaude/skills/lit-pptx/references/examples/12-research-tutorial-chalk-en.md +174 -0
  64. package/plugins/litclaude/skills/lit-pptx/references/interview-protocol.md +1 -1
  65. package/plugins/litclaude/skills/lit-pptx/references/layout-families.md +198 -0
  66. package/plugins/litclaude/skills/lit-pptx/references/stages/autopilot.md +1 -1
  67. package/plugins/litclaude/skills/lit-pptx/references/stages/interview.md +2 -2
  68. package/plugins/litclaude/skills/lit-pptx/references/stages/plan.md +1 -1
  69. package/plugins/litclaude/skills/lit-pptx/references/title-treatments.md +109 -0
  70. package/plugins/litclaude/skills/lit-pptx/references/tonalities/atlas.md +139 -0
  71. package/plugins/litclaude/skills/lit-pptx/references/tonalities/chalk.md +148 -0
  72. package/plugins/litclaude/skills/lit-pptx/references/tonalities/gazette.md +149 -0
  73. package/plugins/litclaude/skills/lit-pptx/references/tonalities/ledger.md +149 -0
  74. package/plugins/litclaude/skills/lit-pptx/references/tonalities/night.md +147 -0
  75. package/plugins/litclaude/skills/lit-pptx/references/tonalities/paper.md +151 -0
  76. package/plugins/litclaude/skills/lit-pptx/references/tonalities/signal.md +150 -0
  77. package/plugins/litclaude/skills/lit-pptx/references/tonalities/studio.md +132 -0
  78. package/plugins/litclaude/skills/lit-pptx/scripts/compile-deck.js +64 -16
  79. package/plugins/litclaude/skills/lit-pptx/scripts/craft_extras.py +664 -3
  80. package/plugins/litclaude/skills/lit-pptx/scripts/deck_craft.py +48 -6
  81. package/plugins/litclaude/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  82. package/plugins/litclaude/skills/lit-pptx/scripts/lib/layout-resolver.js +362 -11
  83. package/plugins/litclaude/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  84. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +211 -65
  85. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-pack.js +3559 -0
  86. package/plugins/litclaude/skills/lit-pptx/scripts/lib/slide-ast.js +10 -6
  87. package/plugins/litclaude/skills/lit-pptx/scripts/lib/template-registry.js +286 -1
  88. package/plugins/litclaude/skills/lit-pptx/scripts/qa_deck.py +39 -10
  89. package/plugins/litclaude/skills/lit-pptx/scripts/validate_pptx.py +22 -7
  90. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  91. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
  92. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
  93. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
  94. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +92 -0
  95. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +97 -0
  96. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +93 -0
  97. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +94 -0
  98. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/night/pack.yaml +93 -0
  99. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/paper/pack.yaml +98 -0
  100. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/signal/pack.yaml +94 -0
  101. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/studio/pack.yaml +92 -0
@@ -13,6 +13,18 @@ reflex defaults a designer would flag", each as a number a gate can hold:
13
13
  OF-107 no glow effect
14
14
  OF-108 no emoji bullets
15
15
  OF-109 no tall empty band below the last content block, on the slide or in a card
16
+ OF-110 a deck of 8+ slides mixes at least three title treatments; each drawn as it is named
17
+ OF-111 content slides vary their composition (title zone x body partition x dominant content)
18
+ OF-112 the median empty band under the body's last block stays at or under 0.20
19
+ OF-113 every slide that uses one title treatment keeps that treatment's frame
20
+ OF-114 titles and the cover subtitle are topic labels, not declarative sentences
21
+ OF-115 no region left empty: a bottom title on the floor, a used side rail, a takeaway column as long
22
+ as the visual beside it (its values table included), a title panel as tall as its text and no
23
+ plate under an empty page
24
+ OF-116 (with --sibling) another tonality of the same source draws a different skeleton
25
+ OF-117 no light figure card on a dark ground
26
+ OF-118 a bold run-in label is followed by its separator ("요약: …", "Summary: …")
27
+ OF-119 an agenda's title carries no count numeral beside it (its rows are numbered)
16
28
 
17
29
  Every threshold lives in THRESHOLDS. A HIGH finding fails the gate; a MEDIUM finding is
18
30
  advisory and is reported, not blocking. `tier` is "measured" when read off the OOXML and
@@ -298,7 +310,7 @@ def _band(top, bottom, boxes):
298
310
  return max(gaps[:-1], default=0.0) / height, gaps[-1] / height
299
311
 
300
312
 
301
- def check_bands(number, shapes, area, boxes, page_area):
313
+ def check_bands(number, shapes, area, boxes, page_area, W, H):
302
314
  t = THRESHOLDS["band"]
303
315
  found = []
304
316
  regions = [("slide", area, boxes)]
@@ -308,6 +320,8 @@ def check_bands(number, shapes, area, boxes, page_area):
308
320
  continue
309
321
  if not 0.5 <= box[2] * box[3] < deck_craft.FULL_BLEED * page_area:
310
322
  continue
323
+ if deck_craft._bleeds(box, W, H):
324
+ continue
311
325
  inside = [(s, b) for s in shapes for b in [deck_craft._box(s)] if b and s is not shape and deck_craft._contains(box, b, tol=0.02) and b != box]
312
326
  if not inside:
313
327
  continue
@@ -341,15 +355,662 @@ def check_bands(number, shapes, area, boxes, page_area):
341
355
  return found
342
356
 
343
357
 
358
+ # ── Deck output checks (OF-110..OF-113) ─────────────────────────────────────
359
+ #
360
+ # These read the compiled deck as a whole: which title treatment each slide really got, how the
361
+ # body is partitioned, and how much of the body is left empty. The treatment comes from the title
362
+ # frame's geometry and its companions; the name the engine writes (`title@<treatment>`) is only
363
+ # compared with it, never trusted on its own. A deck the engine did not build (no `title@` name)
364
+ # is out of scope. Decks on a legacy template report the variety checks and the deck median band
365
+ # as MEDIUM: whoever names a legacy template has asked for its single look. All geometry is in points.
366
+
367
+ TREATMENTS = ("top-rule", "top-plain-large", "side-rail", "band", "statement", "overlay", "kicker-numeral", "bottom-anchor")
368
+ ZONE_OF = {"top-rule": "top", "top-plain-large": "top", "kicker-numeral": "top", "side-rail": "side", "band": "band",
369
+ "overlay": "overlay", "bottom-anchor": "bottom", "statement": "none"}
370
+ OUTPUT = {
371
+ "tol": 2.0, "size_tol": 0.5,
372
+ "min_slides": 8, "min_treatments": 3,
373
+ "variety_cap": 5, "max_share": 0.40,
374
+ "band_floor": 486.0, "footer_top": 492.0, "band_deck_max": 0.20,
375
+ # Per-slide band caps by density (1-2, 3-4, 5-6, 7-8, 9-10); a slide over its cap is MEDIUM.
376
+ "band_caps": ((2, 0.28), (4, 0.24), (6, 0.20), (8, 0.16), (10, 0.12)),
377
+ "anchor_tol": 1.0,
378
+ }
379
+ # Widest four-column title and earliest column-5 start over the airy, standard, dense and compact grids.
380
+ RAIL = {960: (288.0, 336.0), 720: (216.0, 252.0)}
381
+ DIGITS = re.compile(r"\d+(?:[.:/-]\d+)?\.?")
382
+ STAMP = re.compile(r"\blit-pptx tonality=(\S+) density=(\d+) grid=(\w+) variance=(\d+)")
383
+ MARGIN = {960: {"airy": 60.0, "standard": 48.0, "dense": 36.0, "compact": 24.0}, 720: {"airy": 54.0, "standard": 42.0, "dense": 30.0, "compact": 24.0}}
384
+
385
+
386
+ def _pt_box(shape):
387
+ try:
388
+ if shape.left is None or shape.width is None:
389
+ return None
390
+ return (shape.left / 12700.0, shape.top / 12700.0, shape.width / 12700.0, shape.height / 12700.0)
391
+ except (TypeError, ValueError):
392
+ return None
393
+
394
+
395
+ def _run_sizes(shape):
396
+ sizes = []
397
+ if getattr(shape, "has_text_frame", False) and shape.has_text_frame:
398
+ for paragraph in shape.text_frame.paragraphs:
399
+ sizes += [run.font.size.pt for run in paragraph.runs if run.text.strip() and run.font.size is not None]
400
+ return sizes
401
+
402
+
403
+ def _elements(slide, W, H):
404
+ """Every drawn shape as a dict in points, with its kind and its role (content, card, decoration)."""
405
+ out = []
406
+ for shape in slide.shapes:
407
+ box = _pt_box(shape)
408
+ # The engine names the first shape it draws `family@<id>`: a band or a rule keeps its role;
409
+ # only an empty marker (no width and no height) is skipped.
410
+ if box is None or box[2] + box[3] <= 0 or shape.name.startswith("lit-notice"):
411
+ continue
412
+ text = deck_craft._text(shape) if getattr(shape, "has_text_frame", False) and shape.has_text_frame else ""
413
+ kind = ("picture" if shape.shape_type == MSO_SHAPE_TYPE.PICTURE else "table" if deck_craft._is_table(shape)
414
+ else "chart" if deck_craft._is_chart(shape) else "text" if text else "shape")
415
+ if kind == "table":
416
+ box = (box[0], box[1], box[2], max(box[3], sum(row.height for row in shape.table.rows) / 12700.0))
417
+ out.append({"shape": shape, "name": shape.name, "kind": kind, "x": box[0], "y": box[1], "w": box[2], "h": box[3],
418
+ "text": text, "sizes": _run_sizes(shape), "filled": kind == "shape" and deck_craft._filled(shape)})
419
+ for e in out:
420
+ if e["kind"] != "shape":
421
+ e["role"] = "content"
422
+ continue
423
+ bleeds = deck_craft._bleeds((e["x"] / 72, e["y"] / 72, e["w"] / 72, e["h"] / 72), W / 72, H / 72)
424
+ holds = any(o["kind"] != "shape" and _centre_in(o, e) for o in out if o is not e)
425
+ card = e["filled"] and holds and not bleeds and e["w"] >= 100 and e["h"] >= 40 and e["w"] * e["h"] < 0.5 * W * H
426
+ e["role"] = "card" if card else "decoration"
427
+ return out
428
+
429
+
430
+ def _centre_in(e, box, tol=1.0):
431
+ cx, cy = e["x"] + e["w"] / 2, e["y"] + e["h"] / 2
432
+ return box["x"] - tol <= cx <= box["x"] + box["w"] + tol and box["y"] - tol <= cy <= box["y"] + box["h"] + tol
433
+
434
+
435
+ def _footer(e):
436
+ return e["y"] >= OUTPUT["footer_top"]
437
+
438
+
439
+ def classify_title(title, els, W, H):
440
+ """The title treatment a slide shows, by the tests of the design spec in order; first match wins."""
441
+ tol, st = OUTPUT["tol"], OUTPUT["size_tol"]
442
+ size = max(title["sizes"] or [0.0])
443
+ bottom = title["y"] + title["h"]
444
+ others = [e for e in els if e is not title]
445
+ body = [e for e in others if not _footer(e) and e["role"] != "decoration"]
446
+
447
+ def behind(e):
448
+ return (e["x"] - tol <= title["x"] and title["x"] + title["w"] <= e["x"] + e["w"] + tol
449
+ and e["y"] - tol <= title["y"] and bottom <= e["y"] + e["h"] + tol)
450
+
451
+ if (any(e["kind"] == "picture" and e["w"] * e["h"] >= 0.8 * W * H for e in others) and title["y"] >= 300 - tol
452
+ and any(e["kind"] == "shape" and e["filled"] and behind(e) for e in others)):
453
+ return "overlay"
454
+ if any(e["kind"] == "shape" and e["filled"] and e["y"] <= 2 + tol and 96 - tol <= e["h"] <= 120 + tol
455
+ and e["w"] >= 0.9 * W and _centre_in(title, e, tol) for e in others):
456
+ return "band"
457
+ if (size >= 36 - st and 220 - tol <= title["y"] + title["h"] / 2 <= 380 + tol
458
+ and not any(e["kind"] in ("table", "chart", "picture") or e["role"] == "card" for e in body)
459
+ and sum(1 for e in body if e["kind"] == "text") <= 1):
460
+ return "statement"
461
+ if title["y"] >= 360 - tol and all(e["y"] <= title["y"] + tol for e in body if e["kind"] == "text"):
462
+ return "bottom-anchor"
463
+ rail_w, col5 = RAIL.get(round(W), RAIL[960])
464
+ right = [e for e in body if e["x"] + e["w"] / 2 > title["x"] + title["w"]]
465
+ # What stands under the title inside the rail (criteria labels, takeaways, the source) is the rail's;
466
+ # only a block that runs from the rail into the body makes the title something else.
467
+ if (title["w"] <= rail_w + tol and right and min(e["x"] for e in right) >= col5 - tol
468
+ and not any(e["x"] < col5 - tol and e["y"] > bottom and e["x"] + e["w"] > col5 + tol for e in body)):
469
+ return "side-rail"
470
+ for e in others:
471
+ if (e["kind"] == "text" and e["sizes"] and DIGITS.fullmatch(e["text"]) and max(e["sizes"]) >= 44 - st
472
+ and e["x"] + e["w"] <= title["x"] + tol and e["y"] >= 30 - tol and e["y"] + e["h"] <= 114 + tol):
473
+ return "kicker-numeral"
474
+ if title["y"] <= 48 + tol:
475
+ # A rule is a line (wider than 16 pt and four times its height), never a list-marker dot.
476
+ rule = any(e["kind"] == "shape" and e["h"] <= 8 and e["w"] >= max(16, 4 * e["h"]) and bottom - tol <= e["y"] <= bottom + 24
477
+ and e["x"] < title["x"] + title["w"] and e["x"] + e["w"] > title["x"] for e in others)
478
+ if size >= 36 - st and not rule:
479
+ return "top-plain-large"
480
+ if size <= 32 + st and rule:
481
+ return "top-rule"
482
+ return "unclassified"
483
+
484
+
485
+ def _is_number(e):
486
+ """A text run of 44 pt or more that is mostly digits is a number, not text."""
487
+ compact = re.sub(r"\s+", "", e["text"])
488
+ digits = len(re.findall(r"\d", compact))
489
+ return e["kind"] == "text" and bool(e["sizes"]) and max(e["sizes"]) >= 44 - OUTPUT["size_tol"] and digits / max(1, len(compact)) >= 0.5
490
+
491
+
492
+ def composition(title, treatment, els, W, H, margin):
493
+ """(title zone, body partition, dominant content) of one slide.
494
+
495
+ Body boxes are clustered by their x-extent: two boxes overlapping by half the narrower one share
496
+ a column. A box wider than 70 % of the body (a takeaway under two columns) never merges columns.
497
+ """
498
+ zone = ZONE_OF.get(treatment, "none")
499
+ if treatment == "statement":
500
+ return (zone, "one", "text")
501
+ col5 = RAIL.get(round(W), RAIL[960])[1]
502
+ body = []
503
+ for e in els:
504
+ if e is title or _footer(e) or e["role"] == "decoration":
505
+ continue
506
+ if treatment == "side-rail" and e["x"] + e["w"] <= col5 + OUTPUT["tol"]:
507
+ continue # what stands in the rail belongs to the title zone, not to the body's partition
508
+ if e["kind"] == "shape" and title is not None and _centre_in(title, e, 0):
509
+ continue # the panel that carries the title
510
+ if treatment == "kicker-numeral" and e["kind"] == "text" and DIGITS.fullmatch(e["text"]) and e["y"] + e["h"] <= 116:
511
+ continue
512
+ body.append(e)
513
+ cards = [e for e in body if e["role"] == "card"]
514
+ blocks = [e for e in body if not any(c is not e and _centre_in(e, c) for c in cards)]
515
+ if any(e["kind"] == "picture" and e["w"] * e["h"] >= 0.8 * W * H for e in els):
516
+ partition = "full-bleed"
517
+ elif not blocks:
518
+ partition = "one"
519
+ else:
520
+ narrow = [e for e in blocks if e["w"] < 0.7 * (W - 2 * margin)] or blocks
521
+ columns = []
522
+ for e in sorted(narrow, key=lambda e: e["x"]):
523
+ hits = [c for c in columns if min(c["r"], e["x"] + e["w"]) - max(c["l"], e["x"]) >= 0.5 * min(c["r"] - c["l"], e["w"])]
524
+ if hits:
525
+ keep = hits[0]
526
+ for other in hits[1:]:
527
+ keep["l"], keep["r"] = min(keep["l"], other["l"]), max(keep["r"], other["r"])
528
+ keep["items"] += other["items"]
529
+ columns.remove(other)
530
+ keep["l"], keep["r"] = min(keep["l"], e["x"]), max(keep["r"], e["x"] + e["w"])
531
+ keep["items"].append(e)
532
+ else:
533
+ columns.append({"l": e["x"], "r": e["x"] + e["w"], "items": [e]})
534
+ column_of = {id(item): n for n, c in enumerate(columns) for item in c["items"]}
535
+ rows = []
536
+ for e in sorted(narrow, key=lambda e: e["y"]):
537
+ if rows and e["y"] < rows[-1]["bottom"] - 2:
538
+ rows[-1]["bottom"] = max(rows[-1]["bottom"], e["y"] + e["h"])
539
+ rows[-1]["items"].append(e)
540
+ else:
541
+ rows.append({"bottom": e["y"] + e["h"], "items": [e]})
542
+ grid_rows = sum(1 for r in rows if len({column_of[id(i)] for i in r["items"]}) >= 2)
543
+ k = len(columns)
544
+ if k >= 4 or (k >= 2 and grid_rows >= 2):
545
+ partition = "grid"
546
+ elif k == 3:
547
+ partition = "three"
548
+ elif k == 2:
549
+ a, b = (c["r"] - c["l"] for c in columns)
550
+ partition = "two-even" if min(a, b) / max(a, b) >= 0.85 else "two-asym"
551
+ else:
552
+ partition = "one"
553
+ area = {}
554
+ kinds = {"picture": "image", "table": "table", "chart": "chart"}
555
+ for e in blocks:
556
+ if e["kind"] in kinds:
557
+ key = kinds[e["kind"]]
558
+ elif e["kind"] == "text":
559
+ key = "number" if _is_number(e) else "text"
560
+ else:
561
+ continue
562
+ area[key] = area.get(key, 0.0) + e["w"] * e["h"]
563
+ for c in cards:
564
+ inner = [e for e in body if e is not c and _centre_in(e, c)]
565
+ key = "number" if any(_is_number(e) for e in inner) else "text"
566
+ area[key] = area.get(key, 0.0) + c["w"] * c["h"]
567
+ dominant = max(area, key=area.get) if area else "text"
568
+ return (zone, partition, dominant)
569
+
570
+
571
+ def bottom_band(title, els, top_margin=36.0):
572
+ """Share of the body zone left empty under its lowest content, from the title bottom (or the top
573
+ margin when the title sits low) to the body floor; decoration, footer and notice excluded."""
574
+ floor = OUTPUT["band_floor"]
575
+ top = title["y"] + title["h"] if title is not None and title["y"] + title["h"] < floor * 0.6 else top_margin
576
+ if floor - top < 10:
577
+ return None
578
+ ink = [e for e in els if e["role"] != "decoration" and not _footer(e) and e["y"] < floor and e["y"] + e["h"] > top]
579
+ lowest = max((min(floor, e["y"] + e["h"]) for e in ink), default=top)
580
+ return max(0.0, (floor - lowest) / (floor - top))
581
+
582
+
583
+ def _slide_kind(family):
584
+ for kind in ("cover", "section", "closing"):
585
+ if family == kind or family.startswith(kind + "-"):
586
+ return kind
587
+ return "content"
588
+
589
+
590
+ def _variance_max(variance):
591
+ return 2 if variance <= 3 else 3 if variance == 4 else 4 if variance <= 6 else 5
592
+
593
+
594
+ def read_deck(prs) -> dict:
595
+ """Every slide's elements, family, kind, written and drawn title treatment, and the deck's dials."""
596
+ W, H = prs.slide_width / 12700.0, prs.slide_height / 12700.0
597
+ stamp = STAMP.search(prs.core_properties.subject or "")
598
+ density, variance = (int(stamp.group(2)), int(stamp.group(4))) if stamp else (5, 5)
599
+ margin = MARGIN[960 if round(W) == 960 else 720].get(stamp.group(3) if stamp else "standard", 48.0)
600
+ slides = []
601
+ for number, slide in enumerate(prs.slides, start=1):
602
+ els = _elements(slide, W, H)
603
+ family = next((s.name.split("@", 1)[1] for s in slide.shapes if s.name.startswith("family@")), "")
604
+ title = next((e for e in els if e["name"].startswith("title@")), None)
605
+ written = title["name"].split("@", 1)[1] if title else None
606
+ treatment = classify_title(title, els, W, H) if title and title["sizes"] else None
607
+ s = {"n": number, "W": W, "H": H, "slide": slide, "els": els, "family": family, "kind": _slide_kind(family), "title": title,
608
+ "written": written, "treatment": treatment}
609
+ if s["kind"] == "content":
610
+ s["signature"] = composition(title, treatment, els, W, H, margin)
611
+ slides.append(s)
612
+ return {"W": W, "H": H, "stamp": stamp, "density": density, "variance": variance, "margin": margin, "slides": slides,
613
+ "legacy": any(s["written"] == "legacy" for s in slides), "engine": any(s["written"] for s in slides)}
614
+
615
+
616
+ def analyse_output(prs) -> list:
617
+ """OF-110..OF-113 and OF-115 over the whole deck."""
618
+ deck = read_deck(prs)
619
+ W, H, stamp, density, variance, slides = deck["W"], deck["H"], deck["stamp"], deck["density"], deck["variance"], deck["slides"]
620
+ if not deck["engine"]:
621
+ return [] # not built by this engine: nothing to compare against
622
+ variety = "MEDIUM" if deck["legacy"] else "HIGH"
623
+ found = []
624
+ for s in slides:
625
+ if s["written"] in TREATMENTS and s["treatment"] not in (None, s["written"]):
626
+ found.append(_finding("OF-110", "HIGH", s["n"], s["title"]["name"], f"the title is drawn as {s['treatment']}, named {s['written']}",
627
+ "Use the treatment's own geometry and companions; the written name must match what is drawn."))
628
+ # OF-110: distinct title treatments over the slides that carry one. Cover and section titles
629
+ # are variants of their own (a section keeps the statement frame), so they are not counted.
630
+ titled = [s for s in slides if s["kind"] not in ("cover", "section") and s["treatment"]]
631
+ used = sorted({s["treatment"] for s in titled} - {"unclassified"})
632
+ unclassified = [s["n"] for s in titled if s["treatment"] == "unclassified"]
633
+ if len(slides) >= OUTPUT["min_slides"]:
634
+ if variance <= 3 and len(used) < OUTPUT["min_treatments"]:
635
+ found.append(_finding("OF-110", "MEDIUM", None, None, f"{len(used)} title treatment(s); variety lowered by the user (variance {variance})",
636
+ "Variance below 4 is set only on the user's request; say so in the reply."))
637
+ elif len(used) < OUTPUT["min_treatments"]:
638
+ found.append(_finding("OF-110", variety, None, None, f"{len(used)} title treatment(s) ({', '.join(used) or 'none'}) on {len(slides)} slides",
639
+ "Map slide roles to the pack's treatments: data under a side rail or a bottom title, sequences under a numeral, claims as statements."))
640
+ elif stamp and len(used) > _variance_max(variance):
641
+ found.append(_finding("OF-110", variety, None, None, f"{len(used)} title treatments, variance {variance} allows {_variance_max(variance)}",
642
+ "Lower the number of treatments or raise variance (by at most 2 without asking)."))
643
+ if unclassified:
644
+ found.append(_finding("OF-110", variety, unclassified[0], None, f"no treatment recognised on slide(s) {', '.join(map(str, unclassified))}",
645
+ "Place the title on one of the eight treatment frames; never position a title by hand."))
646
+ # OF-111: composition signatures over content slides (cover, section and closing excluded).
647
+ content = [s for s in slides if s["kind"] == "content"]
648
+ if content:
649
+ need = min(OUTPUT["variety_cap"], -(-len(content) * 3 // 5))
650
+ problems = []
651
+ for label, key in (("signature", lambda s: s["signature"]), ("layout", lambda s: s["signature"][:2])):
652
+ counts = {}
653
+ for s in content:
654
+ counts[key(s)] = counts.get(key(s), 0) + 1
655
+ top, top_n = max(counts.items(), key=lambda kv: kv[1])
656
+ if len(counts) < need:
657
+ problems.append(f"{label}: {len(counts)} distinct, needs {need}")
658
+ if top_n / len(content) > OUTPUT["max_share"]:
659
+ slides_hit = [s["n"] for s in content if key(s) == top]
660
+ problems.append(f"{label}: {'/'.join(top)} on {top_n} of {len(content)} content slides ({', '.join(map(str, slides_hit))})")
661
+ if problems and len(content) >= 3:
662
+ found.append(_finding("OF-111", variety, None, None, "; ".join(problems),
663
+ "Change the family or the title treatment on the repeated slides; let each slide's job pick its shape."))
664
+ # OF-112: the body is used; per slide against the density cap, the deck median against 0.20.
665
+ cap = next(c for upto, c in OUTPUT["band_caps"] if density <= upto)
666
+ bands = []
667
+ for s in slides:
668
+ if s["kind"] in ("cover", "section") or s["family"] in ("statement", "quote", "closing-statement") or s["treatment"] == "statement":
669
+ continue
670
+ band = bottom_band(s["title"], s["els"])
671
+ if band is None:
672
+ continue
673
+ bands.append(band)
674
+ if band > cap:
675
+ found.append(_finding("OF-112", "MEDIUM", s["n"], None, f"{band:.0%} of the body is empty under the last block (cap {cap:.0%} at density {density})",
676
+ "Apply the pack's fill order: larger type, a grown visual, wider gaps, or a family that fits the content."))
677
+ if bands:
678
+ bands.sort()
679
+ mid = len(bands) // 2
680
+ median = bands[mid] if len(bands) % 2 else (bands[mid - 1] + bands[mid]) / 2
681
+ if median > OUTPUT["band_deck_max"]:
682
+ # A legacy template has no fill policies: its sparse pages could only be "fixed" with
683
+ # padding, so the median is advisory there and OF-109 still blocks a hollow slide.
684
+ found.append(_finding("OF-112", variety, None, None, f"median empty band {median:.2f} over {len(bands)} slides (limit {OUTPUT['band_deck_max']:.2f})",
685
+ "Give the content slides content that reaches the floor: a takeaway under a table, the next step under an ask, a supporting fact under a claim."))
686
+ # OF-113: one geometry per treatment (a statement is centred, so only its x and width repeat; a bottom
687
+ # title stands on the floor, so its bottom edge repeats and its top follows the line count).
688
+ groups = {}
689
+ for s in titled:
690
+ if s["treatment"] in TREATMENTS and s["written"] in TREATMENTS:
691
+ groups.setdefault(s["treatment"], []).append(s)
692
+ edge = lambda t, k: t["y"] + t["h"] if k == "bottom" else t[k]
693
+ for treatment, group in groups.items():
694
+ keys = ("x", "w") if treatment == "statement" else ("x", "w", "bottom") if treatment == "bottom-anchor" else ("x", "y", "w")
695
+ modes = {}
696
+ for k in keys:
697
+ values = [round(edge(s["title"], k)) for s in group]
698
+ modes[k] = max(set(values), key=values.count)
699
+ for s in group:
700
+ off = [f"{k} {edge(s['title'], k):.0f} vs {modes[k]}" for k in keys if abs(edge(s["title"], k) - modes[k]) > OUTPUT["anchor_tol"]]
701
+ if off:
702
+ found.append(_finding("OF-113", "HIGH", s["n"], s["title"]["name"], f"{treatment} title off its frame: {', '.join(off)}",
703
+ "Every slide with one treatment keeps its title frame; fix the source, never nudge a title."))
704
+ found += empty_regions(deck)
705
+ found += agenda_numerals(deck)
706
+ return found
707
+
708
+
709
+ # ── OF-115: no region left empty ────────────────────────────────────────────
710
+ #
711
+ # The bottom band (OF-112) reads the body as one page. A region can still stand empty beside a full one:
712
+ # the floor under a bottom title, the rail under a side title, the takeaway column beside a chart that runs
713
+ # to the floor, the panel that carries a title over a picture, and the open page above a plate. Each reading
714
+ # is a share of its own region (40 %) or, under a bottom title, a distance (12 pt).
715
+ REGION = {"floor_gap": 12.0, "share": 0.40}
716
+
717
+
718
+ def _text_bottom(title):
719
+ """Where a top-aligned title's last line ends: frames keep spare lines, so the frame's own bottom does not say."""
720
+ lines = _wrapped_lines(title["shape"]) or 1
721
+ return title["y"] + lines * max(title["sizes"] or [0.0]) * 1.15
722
+
723
+
724
+ def _largest_gap(top, bottom, spans):
725
+ """The tallest stretch of [top, bottom] that no (y0, y1) span covers."""
726
+ gap, cursor = 0.0, top
727
+ for y0, y1 in sorted(spans):
728
+ if y1 <= cursor:
729
+ continue
730
+ gap = max(gap, max(0.0, min(y0, bottom) - cursor))
731
+ cursor = max(cursor, y1)
732
+ return max(gap, bottom - cursor)
733
+
734
+
735
+ def _short_column(s, content, title, treatment, body_floor, col5):
736
+ """A column of a two-column body that stops short of its neighbour: the largest empty band inside it, from
737
+ the body's top down to where the neighbour ends, is over 40 % of the body. A source or note strip that
738
+ closes the column does not hide the band above it (spec-v2 Amendments 8). Beside the figures of a kpi-row or
739
+ big-number slide the column is measured to the body floor (Amendments 9)."""
740
+ tol = OUTPUT["tol"]
741
+ # The body starts under a top title; beside a side title (in its rail) or over a bottom title it starts at the top.
742
+ top_of_title = title["y"] + title["h"] if treatment not in ("bottom-anchor", "side-rail") else 0.0
743
+ W, H = s["W"], s["H"]
744
+ fields = [e for e in s["els"] if e["kind"] == "shape" and e["filled"] and e["w"] * e["h"] >= 0.1 * W * H]
745
+ body = [e for e in content if e["y"] >= top_of_title - tol and e["y"] < body_floor and e["w"] > 0 and e["h"] > 0
746
+ and not (treatment == "side-rail" and e["x"] + e["w"] <= col5 + tol) and not any(_centre_in(e, f) for f in fields)]
747
+ if len(body) < 2:
748
+ return None
749
+ left_edge, right_edge = min(e["x"] for e in body), max(e["x"] + e["w"] for e in body)
750
+ for cut in sorted({e["x"] + e["w"] for e in body}):
751
+ a = [e for e in body if e["x"] + e["w"] <= cut + tol]
752
+ b = [e for e in body if e["x"] >= cut - tol]
753
+ # A block across most of the body (a note under both columns) belongs to neither column; a narrower block
754
+ # across the cut means there is no column line here.
755
+ across = [e for e in body if e not in a and e not in b]
756
+ if not a or not b or any(e["w"] < 0.7 * (right_edge - left_edge) for e in across):
757
+ continue
758
+ top = min(e["y"] for e in a + b)
759
+ for column, other in ((a, b), (b, a)):
760
+ # A takeaway column beside a chart that closes with the chart's values: the table is read at its drawn extent.
761
+ values = any(e["kind"] == "chart" for e in other) and any(e["kind"] == "text" for e in column) \
762
+ and not any(e["kind"] in ("chart", "picture") for e in column)
763
+ # A text column, or a picture drawn smaller than its column, beside a column that runs on.
764
+ if not any(e["kind"] in ("text", "picture") for e in column) or (any(e["kind"] in ("chart", "table") for e in column) and not values):
765
+ continue
766
+ # Row heads (a matrix's or a comparison's short labels, each level with its row) are not a column to fill.
767
+ if all(e["kind"] == "text" and e["h"] <= 56 and len(re.sub(r"\s+", "", e["text"])) <= 16 for e in column):
768
+ continue
769
+ # Measured whatever the neighbour's length: a neighbour that stops short still marks how far the column should run.
770
+ reach = min(body_floor, max(e["y"] + e["h"] for e in other))
771
+ # A takeaway column beside the figures of a kpi-row or big-number slide runs to the body floor: the figure
772
+ # rows stop where their spacing ends, not where the slide's content does (spec-v2 Amendments 9).
773
+ # The figures' own labels stand exactly level with figures below the first row; they are part of the stack.
774
+ size = lambda side: max([max(e["sizes"]) for e in side if e["kind"] == "text" and e["sizes"]] or [0.0])
775
+ figures = [e for e in other if e["kind"] == "text" and e["sizes"] and max(e["sizes"]) > size(column) + 4]
776
+ labels = any(abs(e["y"] - f["y"]) <= 0.5 and f["y"] > top + tol for e in column for f in figures)
777
+ if s["family"] in ("kpi-row", "big-number") and figures and not labels:
778
+ reach = body_floor
779
+ gap = _largest_gap(top, reach, [(e["y"], e["y"] + e["h"]) for e in column])
780
+ share = gap / max(1.0, body_floor - top)
781
+ if share > REGION["share"]:
782
+ return _finding("OF-115", "HIGH", s["n"], column[0]["name"],
783
+ f"the {'picture' if any(e['kind'] == 'picture' for e in column) else 'text'} column beside a longer one leaves {share:.0%} of the body empty inside it",
784
+ "Fill the column by layout: lead size, the source and values in the column, a narrower column with a wider visual, or the points under the visual.")
785
+ return None
786
+
787
+
788
+ def empty_regions(deck) -> list:
789
+ W, H = deck["W"], deck["H"]
790
+ floor = OUTPUT["band_floor"]
791
+ col5 = RAIL.get(round(W), RAIL[960])[1]
792
+ tol = OUTPUT["tol"]
793
+ found = []
794
+ for s in deck["slides"]:
795
+ title, treatment = s["title"], s["treatment"]
796
+ content = [e for e in s["els"] if e is not title and e["role"] != "decoration" and not _footer(e)
797
+ and not (e["kind"] == "picture" and e["w"] * e["h"] >= 0.8 * W * H)]
798
+ if s["kind"] in ("content", "closing") and title is not None and title["sizes"]:
799
+ if treatment == "bottom-anchor":
800
+ gap = floor - _text_bottom(title)
801
+ if gap > REGION["floor_gap"]:
802
+ found.append(_finding("OF-115", "HIGH", s["n"], title["name"], f"the bottom title ends {gap:.0f} pt above the body floor",
803
+ "Stand the title's last line on the floor and give the visual above the room."))
804
+ if treatment == "side-rail" and s["family"] not in ("quote", "statement"):
805
+ top = title["y"] + title["h"] # the title frame keeps a spare line for a title that wraps once more
806
+ rail = [e for e in content if e["x"] + e["w"] <= col5 + tol and e["y"] + e["h"] > top]
807
+ share = _largest_gap(top, floor, [(e["y"], e["y"] + e["h"]) for e in rail]) / max(1.0, floor - top)
808
+ if share > REGION["share"]:
809
+ found.append(_finding("OF-115", "HIGH", s["n"], title["name"], f"{share:.0%} of the side rail under the title is empty",
810
+ "Put the slide's criteria labels, takeaways or source in the rail, or take a top or bottom title."))
811
+ # A takeaway column beside a chart, table or picture that runs on below it.
812
+ body_floor = title["y"] - tol if treatment == "bottom-anchor" else floor
813
+ short = _short_column(s, content, title, treatment, body_floor, col5)
814
+ if short:
815
+ found.append(short)
816
+ for v in ([] if short else [e for e in content if e["kind"] in ("chart", "table", "picture")]):
817
+ beside = [e for e in content if e["kind"] == "text" and e["y"] < v["y"] + v["h"] and e["y"] + e["h"] > v["y"]
818
+ and (e["x"] >= v["x"] + v["w"] - tol or e["x"] + e["w"] <= v["x"] + tol)]
819
+ # Text set on a colour field (a closing's ask field) stands on its own ground, not in a column.
820
+ fields = [e for e in s["els"] if e["kind"] == "shape" and e["filled"] and e["w"] * e["h"] >= 0.1 * W * H]
821
+ beside = [e for e in beside if not any(_centre_in(e, f) for f in fields)]
822
+ if not beside:
823
+ continue
824
+ left, right = min(e["x"] for e in beside), max(e["x"] + e["w"] for e in beside)
825
+ in_column = lambda e, l=left, r=right: l - tol <= e["x"] + e["w"] / 2 <= r + tol
826
+ in_visual = lambda e, vv=v: vv["x"] - tol <= e["x"] + e["w"] / 2 <= vv["x"] + vv["w"] + tol
827
+ top = min([v["y"]] + [e["y"] for e in beside])
828
+ column = max(e["y"] + e["h"] for e in content if in_column(e))
829
+ visual = min(body_floor, max(e["y"] + e["h"] for e in content if in_visual(e)))
830
+ share = (visual - column) / max(1.0, body_floor - top)
831
+ if share > REGION["share"]:
832
+ found.append(_finding("OF-115", "HIGH", s["n"], v["name"], f"the column beside the {v['kind']} leaves {share:.0%} of the body empty under its last block",
833
+ "Close the column with the source and note lines, the chart's values or the takeaways; never stretch the visual alone."))
834
+ break
835
+ if s["kind"] in ("cover", "section") or treatment in ("statement", "overlay"):
836
+ if title is None:
837
+ continue
838
+ # A title panel over a picture, and a plate under a statement or quote; a typographic cover's
839
+ # plate is a display device of the cover, not a region its text must fill.
840
+ pictured = any(e["kind"] == "picture" and e["w"] * e["h"] >= 0.8 * W * H for e in s["els"])
841
+ for p in [e for e in s["els"] if e["kind"] == "shape" and e["filled"] and _centre_in(title, e) and e["w"] * e["h"] < 0.9 * W * H]:
842
+ inside = [e for e in content + [title] if e is not p and _centre_in(e, p)]
843
+ if p["y"] > tol and pictured:
844
+ region_floor = min(p["y"] + p["h"], floor)
845
+ share = _largest_gap(p["y"], region_floor, [(e["y"], e["y"] + e["h"]) for e in inside]) / max(1.0, region_floor - p["y"])
846
+ if share > REGION["share"]:
847
+ found.append(_finding("OF-115", "HIGH", s["n"], p["name"], f"{share:.0%} of the panel that carries the title is empty",
848
+ "Make the panel as tall as the title and its lines, standing on the page foot."))
849
+ if p["y"] > tol and treatment == "statement" and p["w"] >= 0.9 * W and not any(e["y"] + e["h"] <= p["y"] + tol for e in content):
850
+ found.append(_finding("OF-115", "HIGH", s["n"], p["name"], "a plate under part of the page leaves the open page above it empty",
851
+ "Set the sentence on the whole-page field or on the open page; a plate needs content above it."))
852
+ return found
853
+
854
+
855
+ # ── OF-116: another tonality of the same source draws another skeleton ─────
856
+ #
857
+ # A deck's skeleton is each content slide's title zone and column partition, read as OF-111 reads them (a
858
+ # kicker numeral title is a top title). Two tonalities of one source whose skeletons are identical differ in
859
+ # colour only: at least one content slide must take another zone or partition.
860
+
861
+
862
+ def skeleton(prs) -> list:
863
+ return [s["signature"][:2] for s in read_deck(prs)["slides"] if s["kind"] == "content"]
864
+
865
+
866
+ def compare_skeletons(path: Path, sibling: Path) -> list:
867
+ a, b = skeleton(Presentation(str(path))), skeleton(Presentation(str(sibling)))
868
+ n = min(len(a), len(b))
869
+ if not n:
870
+ return []
871
+ differ = sum(1 for x, y in zip(a, b) if x != y)
872
+ # Two slides at least once the deck has four content slides (spec-v2 Amendments 8): one differing slide
873
+ # left the alternatives reading as one deck in two colours.
874
+ need = 2 if n >= 4 else 1
875
+ if differ >= need:
876
+ return []
877
+ return [_finding("OF-116", "HIGH", None, None, f"{differ} of {n} content slides differ in title zone or partition from {Path(sibling).name} (needs {need})",
878
+ "Give the tonality its own structure: its role defaults, a different title zone or partition for the slides it shares with the other.")]
879
+
880
+
881
+ # ── OF-117: no light figure card on a dark ground ───────────────────────────
882
+ #
883
+ # A chart or diagram exported on white, placed on a dark ground, reads as a white card with small grey
884
+ # labels. A figure is a picture of mostly near-white pixels; a photograph rarely is.
885
+ FIGURE = {"ground_max": 0.25, "near_white": 235, "share": 0.5, "min_area": 0.03}
886
+
887
+
888
+ def light_figures(number, slide, W, H) -> list:
889
+ try:
890
+ rgb = slide.background.fill.fore_color.rgb if slide.background.fill.type == 1 else None
891
+ except (AttributeError, TypeError, ValueError):
892
+ rgb = None
893
+ if rgb is None:
894
+ return []
895
+ r, g, b = (int(str(rgb)[i:i + 2], 16) / 255 for i in (0, 2, 4))
896
+ if 0.2126 * r + 0.7152 * g + 0.0722 * b > FIGURE["ground_max"]:
897
+ return []
898
+ try:
899
+ import io
900
+ from PIL import Image
901
+ except ImportError:
902
+ return []
903
+ found = []
904
+ for shape in slide.shapes:
905
+ if shape.shape_type != MSO_SHAPE_TYPE.PICTURE:
906
+ continue
907
+ box = _pt_box(shape)
908
+ if not box or not FIGURE["min_area"] * W * H <= box[2] * box[3] < 0.8 * W * H:
909
+ continue
910
+ with Image.open(io.BytesIO(shape.image.blob)) as im:
911
+ raw = im.convert("RGB").resize((64, 64)).tobytes()
912
+ share = sum(1 for i in range(0, len(raw), 3) if min(raw[i:i + 3]) >= FIGURE["near_white"]) / (len(raw) / 3)
913
+ if share > FIGURE["share"]:
914
+ found.append(_finding("OF-117", "HIGH", number, shape.name, f"a light figure ({share:.0%} near-white) on a dark ground",
915
+ "Supply the figure's dark variant beside it (name.dark.png) or draw it as a native chart."))
916
+ return found
917
+
918
+
919
+ # ── OF-118: a run-in label keeps its separator ─────────────────────────────
920
+ #
921
+ # A point that opens with a bold label and runs on in regular weight reads as one sentence when nothing
922
+ # separates the two ("요약 앱에서 …"): the label takes a colon unless it ends in its own separator.
923
+ RUN_IN_SEPARATOR = re.compile(r"[::.)\]?!–—\-]$")
924
+
925
+
926
+ def check_run_ins(number, slide) -> list:
927
+ found = []
928
+ for shape in slide.shapes:
929
+ if not getattr(shape, "has_text_frame", False) or not shape.has_text_frame or shape.name.startswith(("title@", "lit-notice")):
930
+ continue
931
+ for paragraph in shape.text_frame.paragraphs:
932
+ runs = [r for r in paragraph.runs if r.text]
933
+ if len(runs) < 2 or not runs[0].font.bold or runs[1].font.bold:
934
+ continue
935
+ label, rest = runs[0].text, runs[1].text
936
+ if not label.strip() or not re.search(r"\w", rest) or RUN_IN_SEPARATOR.search(label.strip()) or rest.lstrip()[:1] in "::":
937
+ continue
938
+ found.append(_finding("OF-118", "HIGH", number, shape.name, f"bold run-in label without a separator: {(label + rest)[:40]}",
939
+ "Write the label with its colon (**요약:** …), or let the engine set it; never run a bold label into the sentence."))
940
+ return found
941
+
942
+
943
+ # ── OF-119: no count numeral beside an agenda title ────────────────────────
944
+
945
+
946
+ def agenda_numerals(deck) -> list:
947
+ return [_finding("OF-119", "HIGH", s["n"], s["title"]["name"], "a numeral beside the agenda title repeats the count of its numbered rows",
948
+ "An agenda takes a title without a numeral; its rows carry the numbers.")
949
+ for s in deck["slides"] if s["family"] == "agenda" and s["treatment"] == "kicker-numeral"]
950
+
951
+
952
+ # ── OF-114: titles are labels ───────────────────────────────────────────────
953
+ #
954
+ # A title names what the slide covers; the claim goes in the body. Korean is read on its last word
955
+ # (어절): a sentence ending or a nominalised claim makes the title a sentence. English is read for a
956
+ # final period or a finite verb (an auxiliary, or a report verb in the past or third person). A
957
+ # trailing parenthesis is set aside first ("… 요청 (18개월)"). A question is not a claim.
958
+
959
+ KO_SENTENCE = re.compile(r"(?:니다|[어아해세에예네지군래게]요|죠|[었았였했겠됐](?:다|음)|[가-힣]다|[가-힣](?:함|됨)|(?:있|없)음)$")
960
+ KO_NOT_SENTENCE = {"바다", "판다", "소다", "람다", "캐나다", "어젠다", "아젠다", "포함", "보다", "함", "다"}
961
+ EN_AUX = re.compile(r"\b(?:is|are|was|were|has|have|had|will|would|can|could|should|must|does|did|won't|isn't|aren't|wasn't|doesn't|didn't)\b", re.I)
962
+ EN_VERBS = {
963
+ "grew", "grows", "rose", "rises", "fell", "falls", "doubled", "doubles", "tripled", "triples", "halved", "halves",
964
+ "increased", "increases", "decreased", "decreases", "declined", "declines", "improved", "improves", "dropped", "drops",
965
+ "climbed", "climbs", "beats", "outperforms", "outperformed", "leads", "drives", "drove", "shows", "showed", "needs",
966
+ "makes", "reached", "reaches", "exceeded", "exceeds", "missed", "misses", "stays", "stayed", "remains", "remained",
967
+ "comes", "came", "takes", "took", "works", "worked", "pays", "paid", "saves", "saved", "explains", "explained",
968
+ "matters", "wins", "won", "lost", "loses", "cuts", "lifts", "lifted", "slowed", "slows", "fails", "failed",
969
+ }
970
+
971
+
972
+ def declarative_title(text: str) -> bool:
973
+ """True when a title or subtitle states a claim as a sentence instead of naming a topic."""
974
+ t = re.sub(r"\s+", " ", str(text or "")).strip()
975
+ t = re.sub(r"\s*\([^()]*\)$", "", t).strip().rstrip("\"'”’")
976
+ if not t or t.endswith("?") or t[0] in "“\"「『‘'":
977
+ return False # a question, or a quotation set as the slide's statement
978
+ if re.search(r"[가-힣]", t):
979
+ last = re.sub(r"[.!…]+$", "", t.split(" ")[-1])
980
+ return last not in KO_NOT_SENTENCE and bool(KO_SENTENCE.search(last))
981
+ if t.endswith(".") or t.endswith("!"):
982
+ return True
983
+ words = re.findall(r"[A-Za-z']+", t)
984
+ return bool(EN_AUX.search(t)) or any(w.lower() in EN_VERBS for w in words[1:])
985
+
986
+
987
+ def check_labels(prs) -> list:
988
+ """OF-114 over every engine-drawn title and the cover subtitle."""
989
+ found = []
990
+ named = [(number, shape) for number, slide in enumerate(prs.slides, start=1) for shape in slide.shapes
991
+ if shape.name.startswith(("title@", "subtitle@")) and getattr(shape, "has_text_frame", False) and shape.has_text_frame]
992
+ legacy = any(shape.name == "title@legacy" for _, shape in named)
993
+ for number, shape in named:
994
+ text = re.sub(r"\s+", " ", shape.text_frame.text).strip()
995
+ if declarative_title(text):
996
+ what = "subtitle" if shape.name.startswith("subtitle@") else "title"
997
+ found.append(_finding("OF-114", "MEDIUM" if legacy else "HIGH", number, shape.name,
998
+ f"the {what} is a sentence: {text[:60]}",
999
+ "Name the topic as a label (분기별 매출 추이; Conversion by feed rate) and move the claim into the body: the first takeaway, point or caption."))
1000
+ return found
1001
+
1002
+
344
1003
  def analyse(path: Path) -> dict:
345
1004
  prs = Presentation(str(path))
346
1005
  W, H = prs.slide_width / EMU_PER_IN, prs.slide_height / EMU_PER_IN
347
1006
  page_area = W * H
348
- findings = []
1007
+ findings = analyse_output(prs) + check_labels(prs)
349
1008
  for number, slide in enumerate(prs.slides, start=1):
350
1009
  shapes = [s for s in slide.shapes if not s.name.startswith("lit-notice")]
351
1010
  layout = deck_craft.slide_layout(shapes, W, H, page_area)
352
1011
  findings += check_run_effects(number, slide)
1012
+ findings += check_run_ins(number, slide)
1013
+ findings += light_figures(number, slide, W * PT_PER_IN, H * PT_PER_IN)
353
1014
  findings += check_accents(slide, number, shapes, page_area, layout["display"])
354
1015
  findings += check_numeric_columns(number, shapes)
355
1016
  findings += check_concentric(number, shapes)
@@ -357,7 +1018,7 @@ def analyse(path: Path) -> dict:
357
1018
  continue
358
1019
  findings += check_measure(number, shapes, layout["title"])
359
1020
  findings += check_grouping(number, shapes, page_area)
360
- findings += check_bands(number, shapes, layout["area"], layout["boxes"], page_area)
1021
+ findings += check_bands(number, shapes, layout["area"], layout["boxes"], page_area, W, H)
361
1022
  return {"file": str(path), "pass": not any(f["severity"] == "HIGH" for f in findings), "findings": findings}
362
1023
 
363
1024