Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@ Entries before v0.5.0 were written retroactively as summaries.

### Fixed

- **Custom templates with non-16:9 slide sizes (4:3 etc.) now lay out correctly** —
the engine's px coordinate system followed the template width but assumed a
fixed height of 1080, so 4:3 decks left the bottom quarter of every slide
empty, reported false out-of-bounds warnings, mismatched placeholder
coordinates, mis-measured text overflow, and rendered cropped previews in the
Web UI. The canvas is now derived from the template's real dimensions
(1920 px wide, height following the aspect ratio — 4:3 becomes 1920×1440),
and `analyze_template` / `deck.json` carry the canvas size so slide
composition uses the full slide. Behaviour for 16:9 templates is unchanged.
Known limitation: architecture diagram boxes with an omitted `box.height`
can still under-estimate text height on non-16:9 templates — specify
`box.height` explicitly. (#208)
- **Live slide preview: the first few slides now animate** — animation was
suppressed for 3 seconds after the slides tab appeared, and because a new
deck switches to that tab as soon as slides arrive, the first slides were
always shown instantly. Suppression is now based on whether the slides
already existed when the view mounted, instead of a timer.
- **Live slide preview: the PNG fallback now actually appears** — the error
state was reset on every 1-second poll, so the fallback was unmounted before
it could be seen; a failure to find the render container also marked the
slide as permanently processed, leaving an empty black box. Slides with
nothing to draw now fall back to the rendered PNG, and the fallback image
retries expired signed URLs.

- **AWS: uploaded custom templates now apply to PPTX generation** — the remote
server's template resolution only searched builtin templates, so a deck
referencing an uploaded user template silently fell back to
Expand Down
28 changes: 28 additions & 0 deletions docs/en/custom-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,34 @@ Design your template in PowerPoint, Google Slides, or Keynote (export as .pptx):
- Ensure background-to-text color contrast ratio of at least 4.5:1
- Test your template by running `analyze_template` and reviewing the output

### Slide size (aspect ratio)

Any slide size works — 16:9, 4:3, 16:10, and other ratios are all supported.
The engine derives the drawing canvas from your template's actual dimensions.

The coordinate system is **1920 px wide, with height following the aspect ratio**:

| Template slide size | Canvas in slide JSON |
|---|---|
| 16:9 (13.33 × 7.5 in) | 1920 × 1080 px |
| 4:3 (10 × 7.5 in) | 1920 × 1440 px |
| 16:10 | 1920 × 1200 px |

`analyze_template` reports the canvas size as `slide_size`, and the agent records
it in `deck.json` as `slideSize` so that slide composition uses the full canvas.

Known limitations for non-16:9 templates:

- **Architecture diagram boxes** — when `box.height` is omitted, the engine
estimates it from the text, and that estimate is calibrated for 16:9. On other
aspect ratios it can be up to ~25% too small, causing text to overflow.
Specify `box.height` explicitly to avoid this.
- **Style demos** — the bundled style gallery HTML files use a fixed 16:9 canvas.
This does not affect generated slides (only the design tokens are consumed).
- **Deck list thumbnails** — the Web UI deck list crops thumbnails to a fixed
ratio so that card heights stay aligned in the grid. Slide previews
(workspace, carousel) follow the real aspect ratio.

---

## Analyzing a Template
Expand Down
8 changes: 8 additions & 0 deletions personas/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ style's `:root` (`--fs-*`, color vars) in `specs/art-direction.html`. The style
FROZEN for you — if a needed token genuinely doesn't exist, report it in your summary
rather than inventing an ad-hoc value.

### Canvas dimensions

The canvas is **width 1920px fixed, height variable** depending on the template.
Always read `deck.json` `slideSize` to determine the actual slide height (H).
- Content area: y = title bottom + margin to H−130
- For 16:9 (H=1080): y=173–950. For 4:3 (H=1440): y=173–1310
- Never assume H=1080 — derive from `slideSize`

## Consistency Review Mode

If the instruction is `"Consistency review."` (or asks for a consistency review), you
Expand Down
2 changes: 1 addition & 1 deletion personas/style.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Write incrementally via `run_style_python`:
- All colors via `var()` references, never hardcoded in elements
- Text style classes (`.t-title`, `.t-body`, etc.) reference CSS variables
- Inline style only for position/size (`left`, `top`, `width`, `height`)
- Coordinate system: 1920×1080 absolute positioning
- Coordinate system: 1920×1080 absolute positioning (style demos always use 16:9 fixed canvas)
- Font sizes: pt units only
- `body { zoom: 0.7; }` for display scaling
- 5–6 slides maximum (cover + design areas)
8 changes: 6 additions & 2 deletions personas/vibe.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,16 @@ Rules:
3. Call `apply_style(deck_id, style)` to set art direction
4. If the user specified a style or tone, honor that instead of inferring
5. Read `specs/art-direction.html` via `run_python` (`read_text("specs/art-direction.html")`),
extract the `:root` CSS variables, then update `deck.json` via `write_json`:
extract the `:root` CSS variables, then update `deck.json` via `write_json`.
Also record the template's `slideSize` — call `analyze_template(template)` to get
`slide_size`, or use the known default `{"width": 1920, "height": 1080}` for standard
16:9 templates:
```json
{
"template": "{template}.pptx",
"fonts": {"fullwidth": "{fullwidth font}", "halfwidth": "{halfwidth font}"},
"defaultTextColor": "{--color-text value}"
"defaultTextColor": "{--color-text value}",
"slideSize": {"width": 1920, "height": (from analyze_template or 1080 for 16:9)}
}
```

Expand Down
4 changes: 2 additions & 2 deletions sdpm/references/guides/arch-elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ Choose scale based on architecture complexity:
## Drawing Area

```
Slide: 1920 x 1080px
Recommended area: x=60–1860, y=200–900 (title_only layout)
Slide: 1920 x H px (H = slide height from slideSize; 1080 for 16:9, 1440 for 4:3)
Recommended area: x=60–1860, y=200–(H−180) (title_only layout)
```

---
Expand Down
9 changes: 9 additions & 0 deletions sdpm/references/guides/arch-layout-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ arch_diagram(spec="<logical-structure JSON string>",
x=100, y=180, width=1720, height=800, theme="dark")
```

Default target area (x=100, y=180, width=1720, height=800) assumes 16:9 canvas.
For non-16:9 templates, derive height from slideSize: e.g. `height = H - 180 - 130`
(H from `deck.json` `slideSize`; 4:3 H=1440 → height=1130).

> **Known constraint (non-16:9):** The layout engine's text-to-box sizing uses a
> fixed px-to-pt ratio calibrated for 16:9. On taller canvases (e.g. 4:3), boxes with
> long `description` text may overflow. **Workaround:** explicitly set `"height"` on
> boxes with multi-line descriptions instead of relying on auto-calculation.

**CLI** — same engine, for SKILL.md/script hosts:

```bash
Expand Down
2 changes: 2 additions & 0 deletions sdpm/references/guides/chart-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ These can be set either as top-level `style` object or individually:

## JSON: Vertical bar (comparison)

(Example coordinates assume 16:9 canvas H=1080. Adjust y/height for other ratios.)

```json
{
"slides": [
Expand Down
2 changes: 2 additions & 0 deletions sdpm/references/guides/chart-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ These can be set as a `style` object:

## JSON: Load test trend

(Example coordinates assume 16:9 canvas H=1080. Adjust y/height for other ratios.)

```json
{
"slides": [
Expand Down
2 changes: 2 additions & 0 deletions sdpm/references/guides/chart-pie.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ These can be set as a `style` object:

## JSON: Cost breakdown

(Example coordinates assume 16:9 canvas H=1080. Adjust y/height for other ratios.)

```json
{
"slides": [
Expand Down
3 changes: 2 additions & 1 deletion sdpm/references/guides/design-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ Let the distance between elements reflect their relationship — related items c
## Layout Balance

Balance elements vertically within the content area. Do not cluster at the top unless intentional (e.g. hero title).
Sample template (1920×1080): content area y=143–950.
Content area: y = title bottom + margin to H−130 (H = slide height from `slideSize`).
For 16:9 (H=1080): content area y=143–950.
Custom templates: refer to slide size and placeholder positions from `analyze-template`.


5 changes: 4 additions & 1 deletion sdpm/references/guides/grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ When `items` is specified, matched cells get an additional `"item"` key with cen

The starting point of grid is "which region to divide." This decision determines layout quality.

- **Full slide**: Use analyze-template to get the title bottom edge and calculate the content area
- **Full slide**: Use analyze-template to get the title bottom edge and calculate the content area.
Content area = title.y2 + margin to H−130 (H = slide height from `slideSize`).
- **Output from a parent grid**: First split the slide coarsely, then use the output coordinates as the next area
- **Inside a component**: Use a card or section's coordinates as the area and subdivide its contents
- **Partial region**: You don't have to use the full area — reserve space above for description text, below for a flow diagram, etc. Narrow the area to fit the content
Expand All @@ -107,6 +108,7 @@ The starting point of grid is "which region to divide." This decision determines
uv run python3 scripts/pptx_builder.py analyze-template template.pptx --layout "Title Only"
# → TITLE: {x:64, y:47, w:1803, h:95, y2:142, ...}
# → area_y = title.y2 + margin = 142 + 31 = 173
# → area_h = H - 130 - area_y (H from slideSize; 16:9 H=1080 → h=777)
```

### Step 2: Divide with grid
Expand All @@ -132,6 +134,7 @@ Use when left and right sides have different row counts, or regions have differe

These are samples showing how to use grid. They are not canonical layout patterns.
The combinations of columns/rows/gap/areas are open-ended — invent freely to match the content.
(All y/h values below assume 16:9 H=1080. For other ratios, recalculate from slideSize.)

### Funnel

Expand Down
5 changes: 4 additions & 1 deletion sdpm/references/workflows/create-new-1-art-direction.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,14 @@ uv run python3 scripts/pptx_builder.py analyze-template templates/{selected_temp
Update `deck.json` with the template name and fonts from the analyze output.
When `specs/art-direction.html` exists, read `:root` CSS variables and use `--color-text` as `defaultTextColor`.
If the style HTML specifies font-family, ask the user which to use — the style's fonts or the template's fonts.

Also record the template's slide size — check the `slide_size` field from the analyze output:
```json
{
"template": "{selected_template}.pptx",
"fonts": {"fullwidth": "(style or template)", "halfwidth": "(style or template)"},
"defaultTextColor": "(use --color-text from art-direction.html :root)"
"defaultTextColor": "(use --color-text from art-direction.html :root)",
"slideSize": {"width": 1920, "height": (from analyze_template slide_size)}
}
```

Expand Down
2 changes: 1 addition & 1 deletion sdpm/references/workflows/create-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ Use these as starting points. The style HTML doesn't need to match any specific
exactly, but staying in this range ensures the design translates well to actual slides.

**Critical rules — do NOT deviate:**
- Coordinate system: 1920×1080 absolute positioning (same as slides.json)
- Coordinate system: 1920×1080 absolute positioning (style demos use a fixed 16:9 canvas regardless of target template)
- Display scaling: `body { zoom: 0.7 }`. Do NOT use `transform: scale()` (breaks background sizing)
- Layout: `position: absolute` on all elements via `.el` class. Do NOT use flexbox or grid for slide layout (coordinates won't match slides.json)
- Font sizes: pt units only (same as slides.json). Do NOT use px, em, or rem
Expand Down
16 changes: 10 additions & 6 deletions sdpm/references/workflows/slide-json-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,21 +203,25 @@ Note: `notes` omitted for brevity. In actual slides, write them before `elements
## Positioning

- Coordinates and sizes are in pixels (px)
- Sample template: 1920×1080 basis, recommended drawing area x=58–1862, y=173–950
- Custom templates: use slide size and placeholder positions from `analyze-template`
- Canvas width is always 1920px. Height depends on the template's aspect ratio
(check `deck.json` `slideSize` or `analyze-template` `slide_size`)
- Recommended drawing area: x=58–1862, y = title bottom + margin to H−130
(H = slide height from slideSize; for 16:9 H=1080 → y=173–950)
- Bottom-most element: aim for `y + height ≥ 80% of slide height`

### Coordinate quick reference
### Coordinate quick reference (16:9 example, H=1080)

| % | x (horizontal) | y (vertical) |
|---|----------------|--------------|
| % | x (horizontal) | y (vertical, 16:9) |
|---|----------------|---------------------|
| 5 | 96 | 54 |
| 10 | 192 | 108 |
| 25 | 480 | 270 |
| 50 | 960 | 540 |
| 75 | 1440 | 810 |
| 100 | 1920 | 1080 |

For other aspect ratios, y values scale with H (e.g. 4:3 H=1440: 100% y=1440).

**Common sizes**: card width 400–600px, 2-column 900px each, 3-column 600px each, 4-column 450px each. Icon size is relative to context — see design-rules.

## Elements
Expand Down Expand Up @@ -302,7 +306,7 @@ Height includes the language label (22px). Code body height is `height - 22`.
- `autoWidth`: true → word_wrap disabled (width fits text)
- `line`: border color. Omit or `"none"` for no border
- `lineWidth`: border thickness (pt, default 1)
- `margin*`: px (same 1920×1080 basis as other coordinates)
- `margin*`: px (same 1920-wide canvas basis as other coordinates)
- `verticalAlign`: `top`, `middle`, `bottom` (default: top for textbox)
- **Line breaks**: `\n` creates a line break (internally split into paragraphs)

Expand Down
44 changes: 43 additions & 1 deletion sdpm/sdpm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,16 @@ def init(
deck_data["fonts"] = extract_fonts(template_src)
except Exception:
pass
# Write slideSize derived from template (new-deck only — R4)
try:
from pptx import Presentation as _Prs
from sdpm.engine import slide_size_px as _slide_size_px

_prs = _Prs(str(template_src))
w, h = _slide_size_px(int(_prs.slide_width), int(_prs.slide_height))
deck_data["slideSize"] = {"width": w, "height": h}
except Exception:
pass

deck_json = out_dir / "deck.json"
write_json(deck_json, deck_data, suffix="\n")
Expand Down Expand Up @@ -463,6 +473,36 @@ def _resolve_config(
dtc = "#FFFFFF" if is_dark else "#333333"
warnings.append(f"defaultTextColor auto-set to {dtc}")

# slideSize validation — compare deck.json cache with template reality
from pptx import Presentation as _Prs
from sdpm.engine import slide_size_px as _slide_size_px

_prs = _Prs(str(template_file))
actual_size = _slide_size_px(int(_prs.slide_width), int(_prs.slide_height))

deck_slide_size = data.get("slideSize")
if deck_slide_size:
cached = (deck_slide_size.get("width"), deck_slide_size.get("height"))
if cached != actual_size:
warnings.append(
f"slideSize mismatch: deck.json has {dict(deck_slide_size)}, "
f'template actual is {{"width": {actual_size[0]}, "height": {actual_size[1]}}}. '
f"Please update deck.json slideSize."
)

# Height boundary warning (moved from lint — requires template reality)
actual_height = actual_size[1]
for si, slide in enumerate(data.get("slides", []), 1):
for ei, elem in enumerate(slide.get("elements", []), 1):
ey = elem.get("y")
eh = elem.get("height")
if isinstance(ey, (int, float)) and isinstance(eh, (int, float)):
if ey + eh > actual_height:
warnings.append(
f"slide {si} element {ei}: y({ey}) + height({eh}) = {ey + eh} "
f"exceeds slide height {actual_height}."
)

# Lint
from sdpm.engine.schema.lint import lint as lint_slides

Expand Down Expand Up @@ -763,7 +803,9 @@ def _apply_grid_overlay(png_paths: list[str]) -> None:
draw = ImageDraw.Draw(overlay)
for pct in range(5, 100, 5):
x, y = int(w * pct / 100), int(h * pct / 100)
px_x, px_y = int(1920 * pct / 100), int(1080 * pct / 100)
px_x = int(1920 * pct / 100)
# Derive px_y from image aspect ratio (no Presentation needed)
px_y = round(1920 * h / w * pct / 100)
draw.line([(x, 0), (x, h)], fill=color, width=1)
draw.line([(0, y), (w, y)], fill=color, width=1)
if pct % 10 == 0:
Expand Down
33 changes: 33 additions & 0 deletions sdpm/sdpm/engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,36 @@
- diff: deck diffing (roundtrip based)
- analyzer: template analysis
"""

# ── Canvas derivation helpers ──
# Width is always 1920 px (design invariant D1). Height and EMU scale
# are derived from the template's physical slide dimensions.

_CANVAS_WIDTH_PX = 1920


def emu_per_px(slide_width_emu: int) -> float:
"""Derive EMU-per-px scale from the slide width in EMU.

The canvas is always 1920 px wide; this function returns the
EMU-per-pixel ratio for that basis.

Examples:
16:9 (12192000 EMU) → 6350.0
4:3 (9144000 EMU) → 4762.5
"""
return slide_width_emu / _CANVAS_WIDTH_PX


def slide_size_px(slide_width_emu: int, slide_height_emu: int) -> tuple[int, int]:
"""Derive canvas size in px from physical slide dimensions in EMU.

Width is always 1920 (design invariant). Height is proportional
to the aspect ratio.

Examples:
16:9 (12192000, 6858000) → (1920, 1080)
4:3 (9144000, 6858000) → (1920, 1440)
"""
scale = emu_per_px(slide_width_emu)
return (_CANVAS_WIDTH_PX, round(slide_height_emu / scale))
Loading
Loading