@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.
- package/CHANGELOG.md +12 -0
- package/README.md +14 -8
- package/README_ko-KR.md +13 -8
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +3 -2
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
- package/plugins/litclaude/bin/litclaude-hook.js +4 -4
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/lib/canonical-skill-resources.mjs +91 -31
- package/plugins/litclaude/skills/frontend-ui-ux/ATTRIBUTION.md +3 -6
- package/plugins/litclaude/skills/lit-docx/SKILL.md +50 -19
- package/plugins/litclaude/skills/lit-docx/references/components.md +207 -0
- package/plugins/litclaude/skills/lit-docx/references/direction-step.md +277 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/01-report-ko.md +166 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/02-brief-ko.md +105 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/03-manual-ko.md +164 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/04-proposal-ko.md +138 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/05-memo-en.md +55 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/06-journal-en.md +130 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/07-report-en.md +123 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/08-proposal-en.md +123 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/09-memo-ko.md +45 -0
- package/plugins/litclaude/skills/lit-docx/references/page-composition.md +221 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/brief.md +143 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/journal.md +142 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/manual.md +148 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/memo.md +137 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/proposal.md +140 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/report.md +156 -0
- package/plugins/litclaude/skills/lit-docx/scripts/convert_md_to_docx.py +274 -73
- package/plugins/litclaude/skills/lit-docx/scripts/docx_design.py +2449 -0
- package/plugins/litclaude/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/plugins/litclaude/skills/lit-docx/scripts/qa_docx.py +66 -9
- package/plugins/litclaude/skills/lit-docx/scripts/slop_lint.py +18 -6
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/report.yaml +26 -0
- package/plugins/litclaude/skills/lit-pptx/SKILL.md +105 -48
- package/plugins/litclaude/skills/lit-pptx/references/agents/analyst.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/agents/thesis.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/anti-slop-checklist.md +59 -30
- package/plugins/litclaude/skills/lit-pptx/references/authoring-guide.md +119 -42
- package/plugins/litclaude/skills/lit-pptx/references/consensus-protocol.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/data-display-cookbook.md +13 -6
- package/plugins/litclaude/skills/lit-pptx/references/density-and-fill.md +164 -0
- package/plugins/litclaude/skills/lit-pptx/references/design-system-azure.md +7 -4
- package/plugins/litclaude/skills/lit-pptx/references/direction-step.md +186 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/01-pitch-signal-ko.md +210 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/02-pitch-studio-ko.md +147 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/03-business-review-ledger-ko.md +199 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/04-business-review-night-en.md +203 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +179 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/06-lecture-chalk-ko.md +199 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/07-data-review-night-en.md +194 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/08-image-led-atlas-ko.md +175 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/09-briefing-gazette-ko.md +204 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/10-status-update-ledger-ko.md +179 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/11-portfolio-studio-en.md +164 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/12-research-tutorial-chalk-en.md +174 -0
- package/plugins/litclaude/skills/lit-pptx/references/interview-protocol.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/layout-families.md +198 -0
- package/plugins/litclaude/skills/lit-pptx/references/stages/autopilot.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/stages/interview.md +2 -2
- package/plugins/litclaude/skills/lit-pptx/references/stages/plan.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/title-treatments.md +109 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/atlas.md +139 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/chalk.md +148 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/gazette.md +149 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/ledger.md +149 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/night.md +147 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/paper.md +151 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/signal.md +150 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/studio.md +132 -0
- package/plugins/litclaude/skills/lit-pptx/scripts/compile-deck.js +64 -16
- package/plugins/litclaude/skills/lit-pptx/scripts/craft_extras.py +664 -3
- package/plugins/litclaude/skills/lit-pptx/scripts/deck_craft.py +48 -6
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/layout-resolver.js +362 -11
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +211 -65
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-pack.js +3559 -0
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/slide-ast.js +10 -6
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/template-registry.js +286 -1
- package/plugins/litclaude/skills/lit-pptx/scripts/qa_deck.py +39 -10
- package/plugins/litclaude/skills/lit-pptx/scripts/validate_pptx.py +22 -7
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +92 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +97 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +93 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +94 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/night/pack.yaml +93 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/paper/pack.yaml +98 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/signal/pack.yaml +94 -0
- 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
|
|