Skip to content

Commit 6767e9d

Browse files
committed
Visual identity: per-language typography + accent geometry; new deck-design subagent
The decks rendered fine geometrically but had the AI-generated look — default Calibri on every run, plain blank backgrounds, no accent shapes. python-pptx supports both: setting a real font family on every run, and adding decorative rectangles to every slide. We were doing neither. autopapertoppt/exporters/pptx.py * `_FONT_FAMILIES` table keyed by language → (latin, east_asian). Inter is the Latin face across all 14 supported languages; the east-asian slot fills in Microsoft JhengHei UI (zh-tw) / YaHei UI (zh-cn) / Yu Gothic UI (ja) / Malgun Gothic (ko) / Nirmala UI (hi). * `_apply_typography(prs, language)` post-build pass walks every slide -> shape -> paragraph -> run and writes both `<a:latin>` and `<a:ea typeface=...>` XML. PowerPoint consults a SEPARATE East-Asian font slot for CJK code points; leaving it unset would have made CJK characters render in the host's default (PMingLiU / SimSun / etc.) which doesn't match Inter. * `_decorate_with_accents(prs)` post-build pass: - Cover slide: a 0.4" × full-height navy band on the left (`accent_left`). - Every other slide: a 0.08" × full-width navy bar at y=0 (`accent_top`). Both shapes are sent to the back of z-order via spTree.insert(2) so text content sits above them. Both carry semantic shape names so pptx_edit.update_slide(...) and pptx_edit.delete_slide(...) can target them. scripts/_overflow_check.py Decorative rectangles have empty text_frames; the existing _estimate_wrapped_height_emu inflates an empty frame to ~1 line-height (~0.2") which false-flagged the 0.08" accent bar. Skip when text_frame.text is empty. .claude/agents/deck-design.md New subagent owning visual identity — typography rules per language, brand palette discipline, accent geometry expectations, master-slide contract, and the anti-patterns that make a deck obviously machine-generated (default Calibri, plain blank backgrounds, centred-only covers, all-text body slides). CLAUDE.md + .claude/agents/slide-deck-rules.md CLAUDE.md table gains a 10th row pointing at deck-design. slide-deck-rules.md adds a "scope split" note clarifying that the sibling deck-design subagent owns visual identity while this one stays focused on geometry / overflow / content caps. tests/test_exporters.py + tests/test_pptx_edit.py Three tests assumed `slide.shapes[0]` is the title. With accent rectangles now sitting at index 0, they crash on empty text_frames. Replaced with a `_slide_text(slide, name)` helper that finds the shape by its semantic name — which the project already pins as a contract elsewhere. Re-rendered the 4 zh-tw speculative-decoding decks: xia2024unlocking-zh-tw 20 slides, 156 shapes, overflow PASS spector2023accelerating 21 slides, 164 shapes, overflow PASS xu2024edgellm 22 slides, 172 shapes, overflow PASS svirschevski2024specexec 21 slides, 164 shapes, overflow PASS Cover title verified to carry latin='Inter' + east-asian 'Microsoft JhengHei UI'. Content slide 3 carries `accent_top` shape. 510/510 pytest pass; ruff clean.
1 parent b8d72f5 commit 6767e9d

7 files changed

Lines changed: 327 additions & 8 deletions

File tree

.claude/agents/deck-design.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
---
2+
name: deck-design
3+
description: Visual-design rules for the pptx exporter — typography (per-language font stack), brand palette, accent geometry, master-slide expectations, and the anti-patterns that make a deck obviously machine-generated (default Calibri, blank backgrounds, centered-only covers, text-only walls). Use BEFORE any change to `autopapertoppt/exporters/pptx.py`'s visual surface, when authoring a new template file under `assets/template/`, or when investigating a "this deck looks AI-made" complaint. Read-only audit + design reference.
4+
tools: Read, Grep, Glob, Bash
5+
---
6+
7+
You are the deck-design auditor for AutoPaperToPPT. The sibling
8+
`slide-deck-rules` subagent owns *geometry / overflow* (15-pt headers,
9+
7.05" footer guard, `_BULLETS_PER_CELL_MAX`, etc.); this agent owns
10+
*visual identity* — typography, colour, accent shapes, master-slide
11+
structure, anti-tells.
12+
13+
When a generated deck looks like every other LLM output (default Calibri,
14+
white background, plain centred title, text-only body slides, no visual
15+
breathing room), the geometry is probably fine but the visual identity
16+
is missing. That's what this agent guards.
17+
18+
## Visual identity contract
19+
20+
### Typography (per-language font stack)
21+
22+
Default Calibri / Arial is the single biggest "AI-generated" tell.
23+
The exporter MUST set a typeface on every run.
24+
25+
| Language | Primary font (Latin) | East-Asian (`<a:ea>`) | Fallback rationale |
26+
|---|---|---|---|
27+
| en, es, fr, de, pt, it, vi, id | Inter (or Calibri Light) || Inter ships free + on most modern Windows / Office installs; degrades gracefully |
28+
| zh-tw | Inter (Latin) | Microsoft JhengHei UI | Win TW default; cleaner than PMingLiU |
29+
| zh-cn | Inter (Latin) | Microsoft YaHei UI | Win CN default; cleaner than SimSun |
30+
| ja | Inter (Latin) | Yu Gothic UI | Win JP default; modern look |
31+
| ko | Inter (Latin) | Malgun Gothic | Win KR default |
32+
| ru | Inter (Latin) || Inter has full Cyrillic |
33+
| hi | Inter (Latin) | Nirmala UI | Win Devanagari default |
34+
35+
Implementation pattern (`autopapertoppt/exporters/pptx.py`):
36+
- Module-level `_FONT_FAMILIES: dict[str, tuple[str, str | None]]` keyed
37+
by language → `(latin_family, east_asian_family)`.
38+
- `_apply_typography(prs, language)` post-build pass walks every slide,
39+
every shape with a text frame, every run; sets `<a:latin typeface=...>`
40+
AND `<a:ea typeface=...>` on the run's XML. Both slots matter —
41+
setting only `run.font.name` (the Latin slot) leaves CJK chars
42+
rendered in PowerPoint's default East Asian font.
43+
44+
### Colour palette
45+
46+
Already pinned in `pptx.py`:
47+
48+
| Constant | RGB | Use |
49+
|---|---|---|
50+
| `_BRAND_DARK` | `#1F3A66` (deep navy) | Primary text + accent bar |
51+
| `_BRAND_ACCENT` | `#C0392B` (warm red) | KPI highlights, hover-style emphasis |
52+
| `_BRAND_GREY` | `#555555` | Metadata, secondary text |
53+
| `_BRAND_LIGHT` | `#AAAAAA` | Rule lines, dividers |
54+
55+
Do NOT introduce new brand colours casually — every additional colour
56+
fights for attention. Reuse the four above unless the user explicitly
57+
adds one.
58+
59+
### Accent geometry (the "this is a designed deck" tell)
60+
61+
Every content slide gets a thin top accent bar:
62+
- Position: `left=0, top=0, width=_SLIDE_WIDTH (13.333"), height=Inches(0.08)`
63+
- Fill: `_BRAND_DARK` solid
64+
- Name: `accent_top` (semantic name so `pptx_edit` can target it)
65+
66+
The cover slide gets a left vertical band:
67+
- Position: `left=0, top=0, width=Inches(0.4), height=_SLIDE_HEIGHT (7.5")`
68+
- Fill: `_BRAND_DARK` solid
69+
- Name: `accent_left`
70+
- Cover textboxes shift right by `Inches(0.4)` worth of margin to clear it.
71+
72+
Section-divider slides may use a larger top band (`height=Inches(0.6)`)
73+
with the section title overlaid in light text — but this is optional
74+
and only for runs > 4 papers.
75+
76+
### Master-slide expectations
77+
78+
A real template (`assets/template/thesis-style.pptx`, when added) would
79+
ship master + 4-6 layouts. As long as the exporter still uses
80+
`prs.slide_layouts[6]` (blank), the visual identity comes from the
81+
programmatic accent bar + typography pass. Either path is acceptable
82+
provided every slide ends up with:
83+
1. A consistent font family per language (no Calibri default).
84+
2. An accent geometry (top bar / cover band / section band) at fixed
85+
positions across slides.
86+
3. Page numbers in `_BRAND_GREY` (already set).
87+
4. The semantic shape names listed in `slide-deck-rules.md`.
88+
89+
## Anti-patterns (instant "AI-generated" tells)
90+
91+
- Plain `prs.slide_layouts[6]` (blank) with no programmatic accent. Every
92+
slide looks the same vacant white.
93+
- `run.font.name` left unset — PowerPoint falls back to Calibri 11pt.
94+
This is the single biggest tell.
95+
- Centred-only cover slide — typography style that screams "default
96+
PowerPoint template". A left-band + left-aligned title reads as
97+
designed.
98+
- New colours added per-slide. Brand discipline matters — four colours
99+
total, no exceptions.
100+
- Title slide includes the search query verbatim ("Paper Survey:
101+
speculative decoding LLM inference") as the title. That's a
102+
metadata string, not a deck title — wrap it in `_cover_title(...)`
103+
which lowercases + applies title-case + adds a period (or a
104+
language-appropriate suffix).
105+
- Body slides that are pure bullets. Mix at least 2 layouts:
106+
bullet list + KPI block + table + figure / diagram. The `figures=`
107+
field in `PaperSummary` is mandatory exactly because pure-text decks
108+
look generated. See [paper-summary-author](paper-summary-author.md).
109+
- Identical line-height across heading + body. Headings should have
110+
tighter line-height than body.
111+
112+
## How to audit a deck
113+
114+
1. Open `<deck>.pptx` in PowerPoint (or `python-pptx`'s reader).
115+
2. Check the FIRST run's `run.font.name` on the cover title. If `None`
116+
or `Calibri`, the typography pass didn't run.
117+
3. Check slide 2 (a content slide) for a shape named `accent_top` at
118+
`y=0`. If missing, the accent pass didn't run.
119+
4. Check the cover slide for `accent_left`. If missing, the left band
120+
is gone.
121+
5. Scan slides 3..N for visual variety: bullet density vs KPI vs table
122+
vs figure. If every slide is text-only, the `figures=` step was
123+
skipped.
124+
6. If a font family is set but PowerPoint still shows Calibri on
125+
CJK glyphs, the `<a:ea>` XML override isn't being written — only
126+
the Latin font slot was.
127+
128+
## Reporting format
129+
130+
```
131+
deck-design — <deck path>
132+
[1] Typography (latin + east-asian) .......... PASS / FAIL — <note>
133+
[2] Top accent bar on content slides ......... PASS / FAIL — <count missing>
134+
[3] Cover-slide left band .................... PASS / FAIL
135+
[4] Brand palette discipline (≤ 4 colours) ... PASS / FAIL
136+
[5] Visual variety (bullets / KPI / table /
137+
figure mix) .............................. PASS / FAIL — <ratio>
138+
[6] No "Paper Survey: <raw-query>" leak
139+
on cover ................................. PASS / FAIL
140+
141+
Verdict: PASS / PASS with notes / FAIL
142+
```

.claude/agents/slide-deck-rules.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ tools: Read, Grep, Glob
66

77
You are the slide-deck rules reference for AutoPaperToPPT. When invoked, return the relevant rule(s) for the change being made and flag any direct violations you can spot in the diff. The actual overflow inspection lives in the sibling `slide-overflow-check` subagent — don't re-implement it here.
88

9+
**Scope split** — this agent owns *geometry* and *content safety*
10+
(slide dimensions, footer guard, truncation caps, per-slide content
11+
caps, semantic shape names, i18n keys, rendering-tier dispatch). The
12+
sibling `deck-design` subagent owns *visual identity* (typography per
13+
language, brand palette, accent geometry, "looks AI-generated"
14+
anti-patterns). Both apply to any change to
15+
`autopapertoppt/exporters/pptx.py` — consult the appropriate one for
16+
the concern at hand.
17+
918
## Slide Deck Rules
1019

1120
The pptx exporter is the most visually-sensitive surface in the project. Several non-obvious rules keep its output safe for a thesis-defence audience.

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
> recent Aider, and several other tools auto-load `AGENTS.md`; keep them in
55
> sync when you change rules. Detailed rules now live in `.claude/agents/`
66
> as subagents (`code-quality-reviewer`, `compliance-auditor`,
7-
> `slide-deck-rules`, `env-vars`, `language-vocabulary-check`, plus the
8-
> task-running agents `dod-verify`, `paper-summary-author`,
7+
> `slide-deck-rules`, `deck-design`, `env-vars`, `language-vocabulary-check`,
8+
> plus the task-running agents `dod-verify`, `paper-summary-author`,
99
> `post-author-audit`, `slide-overflow-check`).
1010
1111
## Project Overview
@@ -114,6 +114,7 @@ window open during an IEEE / Scholar / paywalled-PDF step, the path is broken
114114
| Design patterns, SOLID, performance, async, unit tests, full linter rule set | `code-quality-reviewer` |
115115
| Core-vs-source-plugin boundary, network safety, browser-automation hard rule, path safety, suppression conventions, bandit skip config | `compliance-auditor` |
116116
| pptx exporter geometry, rendering tiers, truncation caps, semantic shape names, i18n, LLM-as-agent vs Python pipeline | `slide-deck-rules` |
117+
| pptx visual identity (typography per language, brand palette, accent geometry, master-slide expectations, "looks AI-generated" anti-patterns) | `deck-design` |
117118
| Env vars + Python / `.venv` toolchain reference | `env-vars` |
118119
| Definition-of-Done gate runner | `dod-verify` |
119120
| LLM-as-agent thesis-style authoring (PDF → rich PaperSummary) | `paper-summary-author` |

autopapertoppt/exporters/pptx.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535

3636
from pptx import Presentation
3737
from pptx.dml.color import RGBColor
38+
from pptx.enum.shapes import MSO_SHAPE
3839
from pptx.enum.text import MSO_AUTO_SIZE, PP_ALIGN
40+
from pptx.oxml.ns import qn
3941
from pptx.util import Emu, Inches, Pt
4042

4143
from autopapertoppt.core.constants import EXPORT_PPTX
@@ -107,6 +109,36 @@
107109
_BRAND_ACCENT = RGBColor(0xC0, 0x39, 0x2B)
108110
_BRAND_GREY = RGBColor(0x55, 0x55, 0x55)
109111
_BRAND_LIGHT = RGBColor(0xAA, 0xAA, 0xAA)
112+
113+
# Per-language typography. (latin_family, east_asian_family). The Latin
114+
# family also covers Cyrillic / Greek / Devanagari via Inter; the
115+
# east-asian slot is what PowerPoint consults for CJK code points, so
116+
# leaving it `None` would let PowerPoint pick a default that doesn't
117+
# match the Latin choice. See ``deck-design`` agent doc for the
118+
# full rationale. Inter degrades gracefully to Calibri on hosts without
119+
# Inter installed.
120+
_FONT_FAMILIES: dict[str, tuple[str, str | None]] = {
121+
"en": ("Inter", None),
122+
"es": ("Inter", None),
123+
"fr": ("Inter", None),
124+
"de": ("Inter", None),
125+
"pt": ("Inter", None),
126+
"it": ("Inter", None),
127+
"vi": ("Inter", None),
128+
"id": ("Inter", None),
129+
"ru": ("Inter", None),
130+
"hi": ("Inter", "Nirmala UI"),
131+
"zh-tw": ("Inter", "Microsoft JhengHei UI"),
132+
"zh-cn": ("Inter", "Microsoft YaHei UI"),
133+
"ja": ("Inter", "Yu Gothic UI"),
134+
"ko": ("Inter", "Malgun Gothic"),
135+
}
136+
_DEFAULT_FONT_FAMILY: tuple[str, str | None] = ("Inter", None)
137+
138+
# Accent geometry (set on every content slide by the typography /
139+
# accent pass so a stock blank layout still reads as a designed deck).
140+
_ACCENT_TOP_HEIGHT = Inches(0.08)
141+
_ACCENT_LEFT_WIDTH = Inches(0.4)
110142
_BRAND_RULE = RGBColor(0xCC, 0xCC, 0xCC)
111143
_RQ_BOX_FILL = RGBColor(0xF3, 0xF6, 0xFA)
112144
_RQ_BOX_BORDER = RGBColor(0x1F, 0x3A, 0x66)
@@ -266,6 +298,11 @@ def _build(
266298
)
267299
# Page numbers are stamped AFTER trim so they reflect the final total.
268300
_stamp_page_numbers(prs, ctx.language)
301+
# Visual identity passes — applied last so they affect every shape
302+
# placed by every builder (including page numbers). See the
303+
# ``deck-design`` subagent doc for rationale.
304+
_apply_typography(prs, ctx.language)
305+
_decorate_with_accents(prs)
269306
return prs
270307

271308

@@ -1606,3 +1643,105 @@ def _stamp_page_numbers(prs: Presentation, language: str) -> None:
16061643
font_pt=_FOOTER_PT, colour=_BRAND_LIGHT,
16071644
align=PP_ALIGN.RIGHT,
16081645
)
1646+
1647+
1648+
# ---------------------------------------------------------------------------
1649+
# Visual identity passes — typography + accent geometry
1650+
# ---------------------------------------------------------------------------
1651+
1652+
1653+
def _apply_typography(prs: Presentation, language: str) -> None:
1654+
"""Set Latin + East-Asian font on every run across every slide.
1655+
1656+
Default Calibri is the biggest "AI-generated deck" tell. We walk
1657+
every shape post-build and write both ``<a:latin>`` and ``<a:ea>``
1658+
typeface XML on every run — leaving the east-asian slot at the
1659+
PowerPoint default would make CJK chars render in a font that
1660+
doesn't match the Latin choice.
1661+
"""
1662+
latin, east_asian = _FONT_FAMILIES.get(language, _DEFAULT_FONT_FAMILY)
1663+
for slide in prs.slides:
1664+
for shape in slide.shapes:
1665+
if not shape.has_text_frame:
1666+
continue
1667+
for paragraph in shape.text_frame.paragraphs:
1668+
for run in paragraph.runs:
1669+
run.font.name = latin
1670+
if east_asian:
1671+
_set_east_asian_typeface(run, east_asian)
1672+
1673+
1674+
def _set_east_asian_typeface(run, family: str) -> None:
1675+
"""Write the ``<a:ea typeface=...>`` element on a run's rPr.
1676+
1677+
python-pptx's ``run.font.name`` setter only writes the Latin
1678+
typeface (``<a:latin>``). PowerPoint consults a SEPARATE
1679+
east-asian slot when laying out CJK code points; this helper
1680+
fills it.
1681+
"""
1682+
r_pr = run._r.get_or_add_rPr()
1683+
existing = r_pr.find(qn("a:ea"))
1684+
if existing is not None:
1685+
r_pr.remove(existing)
1686+
ea = r_pr.makeelement(qn("a:ea"), {"typeface": family}, nsmap=None)
1687+
r_pr.append(ea)
1688+
1689+
1690+
def _decorate_with_accents(prs: Presentation) -> None:
1691+
"""Place the accent shapes (cover left band, top bar on content slides).
1692+
1693+
Idempotent: if the shapes already exist from a previous build pass
1694+
(rare but possible in tests that re-run ``_build``), they're left in
1695+
place — a name match suppresses re-add. The shapes are sent to the
1696+
back of the slide's z-order so they sit BEHIND any text on the slide.
1697+
"""
1698+
for index, slide in enumerate(prs.slides):
1699+
if index == 0:
1700+
_add_cover_left_band(slide)
1701+
else:
1702+
_add_top_accent_bar(slide)
1703+
1704+
1705+
def _add_cover_left_band(slide) -> None:
1706+
if _has_named_shape(slide, "accent_left"):
1707+
return
1708+
shape = slide.shapes.add_shape(
1709+
MSO_SHAPE.RECTANGLE,
1710+
Emu(0), Emu(0),
1711+
_ACCENT_LEFT_WIDTH, _SLIDE_HEIGHT,
1712+
)
1713+
shape.name = "accent_left"
1714+
shape.line.fill.background()
1715+
shape.fill.solid()
1716+
shape.fill.fore_color.rgb = _BRAND_DARK
1717+
_send_shape_to_back(shape, slide)
1718+
1719+
1720+
def _add_top_accent_bar(slide) -> None:
1721+
if _has_named_shape(slide, "accent_top"):
1722+
return
1723+
shape = slide.shapes.add_shape(
1724+
MSO_SHAPE.RECTANGLE,
1725+
Emu(0), Emu(0),
1726+
_SLIDE_WIDTH, _ACCENT_TOP_HEIGHT,
1727+
)
1728+
shape.name = "accent_top"
1729+
shape.line.fill.background()
1730+
shape.fill.solid()
1731+
shape.fill.fore_color.rgb = _BRAND_DARK
1732+
_send_shape_to_back(shape, slide)
1733+
1734+
1735+
def _has_named_shape(slide, name: str) -> bool:
1736+
return any(shape.name == name for shape in slide.shapes)
1737+
1738+
1739+
def _send_shape_to_back(shape, slide) -> None:
1740+
"""Move ``shape`` to be the first child of ``spTree`` (= back of z-order)."""
1741+
sp_tree = slide.shapes._spTree
1742+
sp = shape._element
1743+
sp_tree.remove(sp)
1744+
# spTree's first two children are nvGrpSpPr + grpSpPr (group metadata);
1745+
# everything after that is a shape in z-order. Insert at index 2 so the
1746+
# band lands BEHIND every text shape but the metadata stays intact.
1747+
sp_tree.insert(2, sp)

scripts/_overflow_check.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ def _inspect(pptx_path: Path) -> list[tuple[int, str, str, int, int]]:
7272
for shape in slide.shapes:
7373
if not shape.has_text_frame:
7474
continue
75+
# Decorative shapes (`accent_top`, `accent_left`, etc.) have
76+
# an empty text_frame; estimating wrapped text on an empty
77+
# frame inflates to ~1 line-height which would false-flag
78+
# the 0.08" top accent bar. Skip when no actual text.
79+
if not (shape.text_frame.text or "").strip():
80+
continue
7581
name = shape.name or "?"
7682
top = shape.top or 0
7783
height = shape.height or 0

0 commit comments

Comments
 (0)