From fbbcd56351f398a88c8792bb7c345475b9c95178 Mon Sep 17 00:00:00 2001 From: Min-Gul Kim Date: Wed, 15 Jul 2026 08:23:27 +0900 Subject: [PATCH] fix: adversarial P1s from Codex review (v2.1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address gpt-5.6-sol@high BLOCK findings: - VS Code: spawn argv shell:false (no sendText injection) - animation: CT_Slide order, numeric slide sort, force required, appear path - refine: deferred inserts so multi-slide density patches stay stable - generative: preserve overflow; validate placements; wire LAYOUT_CMD - extract: barDir=col → column - Windows bootstrap: pin package, safe InstallRoot, SHA optional, exact exe - public benchmark: recipe smoke + honest synthetic-suite docs - check-plugin exact version equality; bump 2.1.1 351 tests OK; npm check green. --- .claude-plugin/plugin.json | 2 +- .grok/skills/officecli-pptx-designmd/SKILL.md | 4 +- CHANGELOG.md | 30 +++++ README.md | 4 +- docs/install.md | 4 +- docs/public-benchmark.md | 5 + docs/windows-installer.md | 14 +- editor/vscode/cli.js | 41 ++++-- editor/vscode/extension.js | 58 +++++---- package.json | 2 +- packaging/windows/Install-DesignmdPptx.ps1 | 58 +++++++-- packaging/windows/README.md | 2 +- packaging/windows/designmd-pptx.iss | 2 +- plugin.json | 2 +- python/designmd_pptx/__init__.py | 2 +- python/designmd_pptx/__main__.py | 6 +- python/designmd_pptx/animation.py | 95 ++++++++------ python/designmd_pptx/backend.py | 2 +- python/designmd_pptx/compile.py | 2 +- python/designmd_pptx/extract.py | 12 ++ python/designmd_pptx/generative.py | 53 +++++++- python/designmd_pptx/public_benchmark.py | 121 +++++++++++++++--- python/designmd_pptx/refine.py | 35 +++-- python/designmd_pptx/win_install.py | 8 +- python/tests/test_phase5_21_40_42.py | 47 ++++++- python/tests/test_refine.py | 32 +++++ python/tests/test_vscode_extension.py | 25 +++- python/tests/test_windows_installer.py | 4 + scripts/check-plugin.mjs | 13 +- skills/officecli-pptx-designmd/SKILL.md | 4 +- 30 files changed, 539 insertions(+), 150 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1b63ffd..0501a1c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "designmd-pptx", - "version": "2.1.0", + "version": "2.1.1", "description": "Compile awesome-design-md / Stitch DESIGN.md into officecli PPTX tokens, ordered decks, and staging-safe apply.", "author": { "name": "Min-Gul Kim", diff --git a/.grok/skills/officecli-pptx-designmd/SKILL.md b/.grok/skills/officecli-pptx-designmd/SKILL.md index 97b401c..141f543 100644 --- a/.grok/skills/officecli-pptx-designmd/SKILL.md +++ b/.grok/skills/officecli-pptx-designmd/SKILL.md @@ -1,9 +1,9 @@ --- name: officecli-pptx-designmd -description: "designmd-pptx v2.1: compile awesome-design-md / Stitch DESIGN.md into officecli PPTX tokens, ordered decks, staging-safe apply; compose briefs; constraint layout + generative freeform; refine/animate; extract/restyle/reconstruct; a11y + public benchmark; doctor --install; Windows one-file installer; VS Code extension; 60+ patterns; CJK text-fit; Gate 3 vision; render via agent-bridge. Trigger on DESIGN.md, getdesign.md, brand design for slides, deck outline, restyle deck, modernize slides, slide master, potx template, /designmd-pptx, /officecli-pptx-designmd." +description: "designmd-pptx v2.1.1: compile awesome-design-md / Stitch DESIGN.md into officecli PPTX tokens, ordered decks, staging-safe apply; compose briefs; constraint layout + generative freeform; refine/animate; extract/restyle/reconstruct; a11y + public benchmark; doctor --install; Windows one-file installer; VS Code extension; 60+ patterns; CJK text-fit; Gate 3 vision; render via agent-bridge. Trigger on DESIGN.md, getdesign.md, brand design for slides, deck outline, restyle deck, modernize slides, slide master, potx template, /designmd-pptx, /officecli-pptx-designmd." --- -# officecli-pptx-designmd (v2.1) +# officecli-pptx-designmd (v2.1.1) ## Locate the toolkit diff --git a/CHANGELOG.md b/CHANGELOG.md index 9573e92..a99fc4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to designmd-pptx are documented here. +## [2.1.1] — 2026-07-15 + +Adversarial review follow-up (Codex gpt-5.6-sol@high **BLOCK** findings). + +### Security +- **VS Code extension (#45):** run CLI via `child_process.spawn` + `shell:false` + argv array; never `terminal.sendText` of shell-joined user feedback. + +### Fixed +- **Animation (#40):** CT_Slide child order (`transition`/`timing` before + `extLst`); numeric slide sort; in-place/`-o` overwrite requires `--force`; + appear uses `animEffect` (no broken `p:set`); `pulse` emphasis removed as + unimplemented. +- **Refine (#19):** deferred continuation inserts so multi-slide density + patches no longer shift indices mid-pass. +- **Generative (#21):** overflow lists/prose preserved under `content.overflow`; + external placements validated on-canvas; `DESIGNMD_LAYOUT_CMD` wired into + freeform path. +- **Extract (#22):** `c:barChart` + `barDir=col` → `column` (not bar). +- **Windows installer (#35):** pin `designmd-pptx==2.1.1`; InstallRoot must + stay under LocalAppData product dir; uninstall requires product manifest; + OfficeCLI extract limited to `officecli.exe` + optional SHA-256. +- **Public benchmark (#42):** recipe-builder smoke per fixture; honest docs + (synthetic deck-spec suite, not rendered PPTX corpus); gate not hard-coded + pass without smoke. + +### Changed +- Version **2.1.1**; `npm run check` requires exact version equality across + plugin / package / `__version__`. + ## [2.1.0] — 2026-07-15 Phase 5 / **intelligence** release. Production core from v2.0.0 plus generative diff --git a/README.md b/README.md index deeb696..c5c082d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # designmd-pptx [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-2.1.0-brightgreen)](plugin.json) -[![Release](https://img.shields.io/badge/release-v2.1.0-blue)](https://github.com/kimmingul/designmd-pptx/releases/tag/v2.1.0) +[![Version](https://img.shields.io/badge/version-2.1.1-brightgreen)](plugin.json) +[![Release](https://img.shields.io/badge/release-v2.1.1-blue)](https://github.com/kimmingul/designmd-pptx/releases/tag/v2.1.1) **awesome-design-md / Stitch `DESIGN.md` → [OfficeCLI](https://github.com/officecli/officecli) PPTX** — packaged for **Claude Code, OpenAI Codex, and Grok Build** (**v2.1**). diff --git a/docs/install.md b/docs/install.md index e8c9e18..d3f9048 100644 --- a/docs/install.md +++ b/docs/install.md @@ -58,8 +58,8 @@ Primary **editor surface** (decision: [editor-integration-decision.md](editor-in ```bash cd editor/vscode -npx @vscode/vsce package --no-dependencies # → designmd-pptx-0.1.0.vsix -code --install-extension designmd-pptx-0.1.0.vsix +npx @vscode/vsce package --no-dependencies # → designmd-pptx-0.2.0.vsix +code --install-extension designmd-pptx-0.2.0.vsix # Cursor: Extensions → Install from VSIX… ``` diff --git a/docs/public-benchmark.md b/docs/public-benchmark.md index d2eea06..f01a8f8 100644 --- a/docs/public-benchmark.md +++ b/docs/public-benchmark.md @@ -1,5 +1,10 @@ # Public benchmark methodology (v1.0.0) +> **Honesty (v2.1.1):** This is a **synthetic deck-spec + recipe-builder smoke +> suite**, not a rendered-PPTX corpus. It does **not** run OfficeCLI apply, +> screenshots, or live Gate 3 vision. “PASS” means a11y auto-correct + recipe +> ops emission succeeded for each fixture. + ## Rights - **License:** CC0-1.0 diff --git a/docs/windows-installer.md b/docs/windows-installer.md index d4a5187..04ec7d9 100644 --- a/docs/windows-installer.md +++ b/docs/windows-installer.md @@ -1,8 +1,9 @@ -# Windows standalone installer (#35) +# Windows one-file bootstrap installer (#35) -Non-developer friendly install of **designmd-pptx** + a **pinned official -OfficeCLI** on Windows 10/11 — without requiring a pre-configured Python -dev environment. +Non-developer friendly **network bootstrap** of **designmd-pptx** + a **pinned +official OfficeCLI** on Windows 10/11. This is **not** an offline MSI that +bundles Python/OfficeCLI; it needs network access (winget/PyPI/GitHub) on first +install. ## One-file installer (primary) @@ -20,8 +21,9 @@ powershell -ExecutionPolicy Bypass -File Install-DesignmdPptx.ps1 -DryRun | `-DryRun` | Print actions only | | `-SkipOfficeCli` | Skip pinned OfficeCLI download | | `-SkipPath` | Do not modify user PATH | -| `-PackageSource X` | `designmd-pptx` (default) or local path/wheel | -| `-InstallRoot PATH` | Override `%LOCALAPPDATA%\designmd-pptx` | +| `-PackageSource X` | default `designmd-pptx==2.1.1` (pinned); or local path/wheel | +| `-OfficeCliSha256` | Optional expected SHA-256 of the officecli-dist tarball | +| `-InstallRoot PATH` | Must stay under `%LOCALAPPDATA%\designmd-pptx` | ### Layout diff --git a/editor/vscode/cli.js b/editor/vscode/cli.js index adb2d3d..15cfaea 100644 --- a/editor/vscode/cli.js +++ b/editor/vscode/cli.js @@ -1,14 +1,20 @@ -/* Pure CLI resolution helpers — no vscode import (unit-testable). */ +/* Pure CLI resolution helpers — no vscode import (unit-testable). + * + * Security (#45 adversarial fix): never build shell command strings from + * user input. Callers must spawn with argv + shell:false / Task API. + */ "use strict"; const path = require("path"); const fs = require("fs"); +/** + * @deprecated Prefer resolveCli().argv — kept only for display/debug labels. + * Does NOT make shell-safe strings for user-controlled input. + */ function quote(s) { - if (/[\s"]/g.test(String(s))) { - return `"${String(s).replace(/"/g, '\\"')}"`; - } - return String(s); + // Escape for display only; never use for shell execution of untrusted input. + return JSON.stringify(String(s)); } /** @@ -19,6 +25,7 @@ function quote(s) { * @param {string} [opts.pythonPathExtra] * @param {string[]} opts.args * @param {NodeJS.ProcessEnv} [opts.env] + * @returns {{ argv: string[], cwd: string, env: NodeJS.ProcessEnv, display: string }} */ function resolveCli(opts) { const root = opts.workspaceRoot || process.cwd(); @@ -37,15 +44,16 @@ function resolveCli(opts) { .filter(Boolean) .join(path.delimiter); } - let shellCmd; + /** @type {string[]} */ + let argv; if (cliPath) { - shellCmd = [quote(cliPath), ...opts.args.map(quote)].join(" "); + argv = [cliPath, ...opts.args]; } else { - shellCmd = [quote(python), "-m", "designmd_pptx", ...opts.args.map(quote)].join( - " ", - ); + argv = [python, "-m", "designmd_pptx", ...opts.args]; } - return { shellCmd, cwd: root, env }; + // Display form is JSON-quoted for human terminals only — never pass to a shell. + const display = argv.map((a) => JSON.stringify(String(a))).join(" "); + return { argv, cwd: root, env, display, shellCmd: display }; } /** @@ -89,4 +97,13 @@ function diagnosticsFromReport(data) { return diags; } -module.exports = { quote, resolveCli, diagnosticsFromReport }; +/** + * Detect shell metacharacters that would be dangerous if argv were joined into a shell. + * Used by unit tests; runtime never shells user args. + * @param {string} s + */ +function hasShellMeta(s) { + return /[$`;&|<>(){}!]/.test(String(s)); +} + +module.exports = { quote, resolveCli, diagnosticsFromReport, hasShellMeta }; diff --git a/editor/vscode/extension.js b/editor/vscode/extension.js index 6b585ac..4bdee05 100644 --- a/editor/vscode/extension.js +++ b/editor/vscode/extension.js @@ -7,6 +7,7 @@ const vscode = require("vscode"); const path = require("path"); const fs = require("fs"); +const { spawn } = require("child_process"); const { resolveCli: resolveCliPure, diagnosticsFromReport } = require("./cli"); const DIAG_COLLECTION = "designmd-pptx"; @@ -83,7 +84,7 @@ function workspaceOutput(rel) { /** * @param {string[]} args - * @returns {{ shellCmd: string, cwd: string, env: NodeJS.ProcessEnv }} + * @returns {{ argv: string[], cwd: string, env: NodeJS.ProcessEnv, display: string }} */ function resolveCli(args) { const root = workspaceRoot() || process.cwd(); @@ -98,35 +99,48 @@ function resolveCli(args) { }); } -function quote(s) { - if (/[\s"]/g.test(String(s))) { - return `"${String(s).replace(/"/g, '\\"')}"`; - } - return String(s); -} - /** + * Run designmd-pptx with argv (shell:false) — never interpolates user input + * into a shell string (adversarial #45 P1). * @param {string[]} args * @param {{ reveal?: boolean, title?: string }} [opts] + * @returns {Thenable} */ function runCli(args, opts = {}) { - const { shellCmd, cwd, env } = resolveCli(args); + const { argv, cwd, env, display } = resolveCli(args); const name = opts.title || `designmd-pptx ${args[0] || ""}`.trim(); - const terminal = - vscode.window.terminals.find((t) => t.name === "designmd-pptx") || - vscode.window.createTerminal({ name: "designmd-pptx", cwd, env }); + const out = vscode.window.createOutputChannel("designmd-pptx", { log: true }); if (opts.reveal !== false) { - terminal.show(true); + out.show(true); } - if (env.PYTHONPATH) { - const exp = - process.platform === "win32" - ? `$env:PYTHONPATH = '${env.PYTHONPATH.replace(/'/g, "''")}'` - : `export PYTHONPATH=${quote(env.PYTHONPATH)}`; - terminal.sendText(exp, true); - } - terminal.sendText(shellCmd, true); - return vscode.window.setStatusBarMessage(`$(sync~spin) ${name}`, 4000); + out.appendLine(`$ ${display}`); + const status = vscode.window.setStatusBarMessage(`$(sync~spin) ${name}`, 60000); + + return new Promise((resolve) => { + const [cmd, ...cmdArgs] = argv; + const child = spawn(cmd, cmdArgs, { + cwd, + env, + shell: false, + windowsHide: true, + }); + child.stdout?.on("data", (buf) => out.append(buf.toString())); + child.stderr?.on("data", (buf) => out.append(buf.toString())); + child.on("error", (err) => { + out.appendLine(`error: ${err.message}`); + status.dispose(); + vscode.window.showErrorMessage(`designmd-pptx failed to start: ${err.message}`); + resolve(); + }); + child.on("close", (code) => { + out.appendLine(`\n[exit ${code}]`); + status.dispose(); + if (code !== 0) { + vscode.window.showWarningMessage(`${name} exited ${code} — see Output → designmd-pptx`); + } + resolve(); + }); + }); } // ── Commands ──────────────────────────────────────────────────────────────── diff --git a/package.json b/package.json index c5320fd..7ade9f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "designmd-pptx", - "version": "2.1.0", + "version": "2.1.1", "description": "DESIGN.md → officecli PPTX compiler, packaged for Claude Code / Codex / Grok Build (v2.1)", "license": "MIT", "private": true, diff --git a/packaging/windows/Install-DesignmdPptx.ps1 b/packaging/windows/Install-DesignmdPptx.ps1 index a0f9f4f..0c73fe8 100644 --- a/packaging/windows/Install-DesignmdPptx.ps1 +++ b/packaging/windows/Install-DesignmdPptx.ps1 @@ -44,9 +44,11 @@ param( [switch]$DryRun, [switch]$SkipOfficeCli, [switch]$SkipPath, - [string]$PackageSource = "designmd-pptx", + # Pin package by default (adversarial #35) — override with -PackageSource for editable installs + [string]$PackageSource = "designmd-pptx==2.1.1", [string]$InstallRoot = "", [string]$OfficeCliPin = "", # empty → read from embedded default / env + [string]$OfficeCliSha256 = "", # optional expected SHA-256 of the tarball [string]$PythonMin = "3.10" ) @@ -54,10 +56,23 @@ $ErrorActionPreference = "Stop" $ProductName = "designmd-pptx" # Keep in sync with python/designmd_pptx/compatibility.json official.recommended $DefaultOfficeCliPin = "0.2.117" +$DefaultLocalRoot = Join-Path $env:LOCALAPPDATA $ProductName if (-not $InstallRoot) { - $InstallRoot = Join-Path $env:LOCALAPPDATA $ProductName + $InstallRoot = $DefaultLocalRoot } +# Guard: install/uninstall root must live under LocalAppData\designmd-pptx +# (or explicit subpath). Blocks recursive delete of arbitrary trees. +function Assert-SafeInstallRoot([string]$root) { + $full = [System.IO.Path]::GetFullPath($root) + $allowed = [System.IO.Path]::GetFullPath($DefaultLocalRoot) + if (-not ($full.Equals($allowed, [System.StringComparison]::OrdinalIgnoreCase) -or + $full.StartsWith($allowed + [IO.Path]::DirectorySeparatorChar, + [System.StringComparison]::OrdinalIgnoreCase))) { + throw "InstallRoot must be under $allowed (got $full). Refusing unsafe path." + } +} +Assert-SafeInstallRoot $InstallRoot $BinDir = Join-Path $InstallRoot "bin" $VenvDir = Join-Path $InstallRoot "venv" $Manifest = Join-Path $InstallRoot "install.manifest.json" @@ -214,19 +229,36 @@ function Install-OfficeCliPin { if (-not (Test-Path $tmp) -or ((Get-Item $tmp).Length -lt 64)) { throw "Download too small or missing: $tmp" } - # tar is available on Windows 10+ + $expectSha = if ($OfficeCliSha256) { $OfficeCliSha256 } else { $env:DESIGNMD_OFFICECLI_SHA256 } + if ($expectSha) { + $hash = (Get-FileHash -Algorithm SHA256 -Path $tmp).Hash.ToLowerInvariant() + if ($hash -ne $expectSha.ToLowerInvariant()) { + throw "OfficeCLI tarball SHA-256 mismatch: got $hash expected $expectSha" + } + Write-Ok "SHA-256 verified" + } else { + Write-Info "No DESIGNMD_OFFICECLI_SHA256 / -OfficeCliSha256 set — skipping hash verify (pin URL only)" + } + # tar is available on Windows 10+; extract only under our temp root (path safety) $extract = Join-Path $env:TEMP "designmd-officecli-extract-$pin" if (Test-Path $extract) { Remove-Item -Recurse -Force $extract } New-Item -ItemType Directory -Force $extract | Out-Null tar -xzf $tmp -C $extract + $extractFull = [System.IO.Path]::GetFullPath($extract) $exe = Get-ChildItem -Path $extract -Recurse -Filter "officecli.exe" -ErrorAction SilentlyContinue | + Where-Object { + $p = [System.IO.Path]::GetFullPath($_.FullName) + $p.StartsWith($extractFull, [System.StringComparison]::OrdinalIgnoreCase) + } | Select-Object -First 1 if (-not $exe) { - $exe = Get-ChildItem -Path $extract -Recurse -Filter "officecli*" -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notmatch '\.tar|\.gz|\.txt|\.md' } | - Select-Object -First 1 + throw "Archive has no officecli.exe under extract root (refusing ambiguous officecli* matches)" + } + # Reject zip-slip / path traversal outside extract root + $exeFull = [System.IO.Path]::GetFullPath($exe.FullName) + if (-not $exeFull.StartsWith($extractFull, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing officecli path outside extract root: $exeFull" } - if (-not $exe) { throw "Archive has no officecli binary" } $destOfficial = Join-Path $OfficialDir "officecli.exe" $destBin = Join-Path $BinDir "officecli.exe" Copy-Item -Force $exe.FullName $destOfficial @@ -357,6 +389,11 @@ function Remove-UserPathEntry { function Invoke-Uninstall { Write-Step "Uninstall $ProductName from $InstallRoot" + Assert-SafeInstallRoot $InstallRoot + # Require product marker unless dry-run of empty tree + if (-not $DryRun -and (Test-Path $InstallRoot) -and -not (Test-Path $Manifest)) { + throw "Refusing uninstall: $Manifest missing (not a designmd-pptx install root)" + } if ($DryRun) { Write-Info "dry-run: remove $InstallRoot and PATH entry" return @@ -366,7 +403,12 @@ function Invoke-Uninstall { try { $m = Get-Content $Manifest -Raw | ConvertFrom-Json $pathModified = [bool]$m.path_modified - } catch {} + if ($m.product -and $m.product -ne $ProductName) { + throw "Refusing uninstall: manifest product is $($m.product)" + } + } catch { + if ("$_" -match "Refusing") { throw } + } } if ($pathModified -or -not $SkipPath) { Remove-UserPathEntry diff --git a/packaging/windows/README.md b/packaging/windows/README.md index c6e3266..5c3e6f5 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -4,7 +4,7 @@ | Artifact | Role | |---|---| -| **`Install-DesignmdPptx.ps1`** | **One-file installer** (primary). Run on any Windows 10+ with PowerShell 5.1+. | +| **`Install-DesignmdPptx.ps1`** | **One-file network bootstrap** (primary). Not offline MSI. | | `Uninstall` via same script (`-Uninstall`) or copied `Uninstall-DesignmdPptx.ps1` | **Uninstall path** | | `designmd-pptx.iss` + `build-installer.ps1` | Optional **Setup.exe** (Inno Setup 6) GUI wrapper | | `python/designmd_pptx/win_install.py` | Cross-platform plan/manifest helpers + CLI | diff --git a/packaging/windows/designmd-pptx.iss b/packaging/windows/designmd-pptx.iss index 9d0a441..7483ca8 100644 --- a/packaging/windows/designmd-pptx.iss +++ b/packaging/windows/designmd-pptx.iss @@ -8,7 +8,7 @@ ; with -Uninstall. #define MyAppName "designmd-pptx" -#define MyAppVersion "2.1.0" +#define MyAppVersion "2.1.1" #define MyAppPublisher "designmd-pptx contributors" #define MyAppURL "https://github.com/kimmingul/designmd-pptx" diff --git a/plugin.json b/plugin.json index f8a92a2..c72893f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "designmd-pptx", - "version": "2.1.0", + "version": "2.1.1", "description": "DESIGN.md → officecli PPTX compiler for agentic AI: compose markdown briefs into decks, extract/restyle existing decks, brand slide masters, Gate 3 screenshot QA. Grok Build manifest of the multi-platform (Claude Code / Codex / Grok) plugin.", "author": { "name": "Min-Gul Kim", diff --git a/python/designmd_pptx/__init__.py b/python/designmd_pptx/__init__.py index c46ed54..aff84a0 100644 --- a/python/designmd_pptx/__init__.py +++ b/python/designmd_pptx/__init__.py @@ -1,3 +1,3 @@ """DESIGN.md (awesome-design-md / Stitch) → officecli PPTX slide tokens & recipes.""" -__version__ = "2.1.0" +__version__ = "2.1.1" diff --git a/python/designmd_pptx/__main__.py b/python/designmd_pptx/__main__.py index 14f69b1..8dc4b73 100644 --- a/python/designmd_pptx/__main__.py +++ b/python/designmd_pptx/__main__.py @@ -912,7 +912,7 @@ def cmd_animate(args: argparse.Namespace) -> int: out=out, animation=overrides, tokens=tokens, - force=bool(args.force) or (out.resolve() == Path(args.pptx).resolve()), + force=bool(args.force), ) mark = "ok" if report.ok else "FAIL" print(f"animate {mark}: slides={report.slides_touched} effects={report.effects_added} " @@ -1384,8 +1384,8 @@ def build_parser() -> argparse.ArgumentParser: choices=list(sorted({"none", "fade", "push", "wipe", "cut", "cover"})), help="Slide transition override") an.add_argument("--emphasis", default=None, - choices=["none", "pulse"], - help="Emphasis preset (default none)") + choices=["none"], + help="Emphasis preset (only 'none' is implemented)") an.add_argument("--stagger-ms", type=int, default=None, help="Stagger between shapes in ms") an.add_argument("--force", action="store_true", help="Overwrite destination") diff --git a/python/designmd_pptx/animation.py b/python/designmd_pptx/animation.py index cde3e91..aa2cb82 100644 --- a/python/designmd_pptx/animation.py +++ b/python/designmd_pptx/animation.py @@ -44,9 +44,10 @@ "fly_in": {"preset_id": 2, "preset_class": "entr", "dur_ms": 500, "filter": None}, } +# Emphasis is reserved; only "none" is implemented in the OOXML emitter. +# Unknown values fall back to none with a warning at extract_animation time. EMPHASIS_PRESETS: dict[str, dict[str, Any]] = { "none": {"preset_id": 0, "preset_class": None}, - "pulse": {"preset_id": 24, "preset_class": "emph", "dur_ms": 500}, # flashbulb-ish } # Slide transitions (child element local name under p:transition) @@ -145,7 +146,10 @@ def extract_animation(fm: dict[str, Any] | None) -> tuple[dict[str, Any], list[s emph = str(raw.get("emphasis") or cfg["emphasis"]).lower() if emph not in EMPHASIS_PRESETS: - warnings.append(f"animation.emphasis: unknown {emph!r} — using none") + warnings.append( + f"animation.emphasis: {emph!r} not implemented — using none " + "(only 'none' is emitted in v2.1.x)" + ) emph = "none" cfg["emphasis"] = emph @@ -350,20 +354,11 @@ def el(tag: str, **attrs: str) -> etree._Element: effect_kids = el("p:childTnLst") effect_ctn.append(effect_kids) - if ent.get("filter"): - anim = el("p:animEffect", transition="in", filter=str(ent["filter"])) - else: - # appear / fly_in without filter: use set visibility - anim = el("p:set") - to_el = el("p:to") - str_val = etree.Element(opc.qn("p:strVal")) - # use a: namespace for strVal in some producers; p:strVal is accepted - str_val = etree.Element("{http://schemas.openxmlformats.org/drawingml/2006/main}strVal") - str_val.set("val", "visible") - # Prefer p:strVal if present in schema; DrawingML strVal works in PPT - to_el.append(str_val) - anim.append(to_el) - + # Prefer filter-based entrances (fade/wipe). Appear uses animEffect + # with an empty filter rather than a fragile p:set tree — PowerPoint + # accepts animEffect for presetID=1 as well. + filt = ent.get("filter") or "fade" + anim = el("p:animEffect", transition="in", filter=str(filt)) effect_kids.append(anim) cbhvr = el("p:cBhvr") anim.append(cbhvr) @@ -373,14 +368,6 @@ def el(tag: str, **attrs: str) -> etree._Element: cbhvr.append(tgt) tgt.append(el("p:spTgt", spid=str(spid))) - if not ent.get("filter"): - # For set-based appear, need attrNameLst - attr = el("p:attrNameLst") - an = el("p:attrName") - an.text = "style.visibility" - attr.append(an) - cbhvr.append(attr) - # prev/next conditions for sequence prev = el("p:prevCondLst") pc = el("p:cond", evt="onPrev", delay="0") @@ -443,27 +430,43 @@ def inject_slide_animation( if parent is not None: parent.remove(old) + # CT_Slide child order (ECMA-376): cSld, clrMapOvr?, transition?, timing?, extLst? + tr_el = _build_transition(transition, transition_speed) timing = _build_timing( targets, entrance=entrance, stagger_ms=stagger_ms, on=on, ) + to_place: list[etree._Element] = [] + if tr_el is not None: + to_place.append(tr_el) + transitions = 1 if timing is not None: - root.append(timing) + to_place.append(timing) effects = len(targets) - tr_el = _build_transition(transition, transition_speed) - if tr_el is not None: - # transition should appear before timing in many writers; append is OK for PPT - # Insert after cSld if possible - csld = root.find(opc.qn("p:cSld")) - if csld is not None: - idx = list(root).index(csld) + 1 - root.insert(idx, tr_el) + if to_place: + children = list(root) + # Anchor: before extLst, else after clrMapOvr, else after cSld, else end + insert_at = next( + (i for i, c in enumerate(children) if c.tag == opc.qn("p:extLst")), + None, + ) + if insert_at is None: + for tag in (opc.qn("p:clrMapOvr"), opc.qn("p:cSld")): + for i, c in enumerate(children): + if c.tag == tag: + insert_at = i + 1 + break + if insert_at is not None: + break + if insert_at is None: + for el in to_place: + root.append(el) else: - root.insert(0, tr_el) - transitions = 1 + for offset, el in enumerate(to_place): + root.insert(insert_at + offset, el) return opc.serialize(root, declaration=decl), effects, transitions @@ -496,8 +499,18 @@ def animate_pptx( return AnimationReport(ok=True, notes=["animation disabled in config"]) dest = Path(out) if out else src - if dest.exists() and dest.resolve() != src.resolve() and not force: - return AnimationReport(ok=False, notes=[f"refusing to overwrite {dest} without --force"]) + same = dest.resolve() == src.resolve() + if dest.exists() and not force: + # Staging-safe contract: overwrite of any existing path (incl. in-place) needs --force + return AnimationReport( + ok=False, + notes=[f"refusing to overwrite {dest} without --force"], + ) + if same and not force: + return AnimationReport( + ok=False, + notes=["in-place animate requires --force"], + ) notes: list[str] = [] slides_touched = 0 @@ -511,9 +524,13 @@ def animate_pptx( except (OSError, zipfile.BadZipFile) as e: return AnimationReport(ok=False, notes=[f"corrupt pptx: {e}"]) + def _slide_key(n: str) -> tuple[int, str]: + m = re.search(r"slide(\d+)\.xml$", n) + return (int(m.group(1)), n) if m else (10**9, n) + slide_names = sorted( - n for n in parts - if re.fullmatch(r"ppt/slides/slide\d+\.xml", n) + (n for n in parts if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)), + key=_slide_key, ) if not slide_names: return AnimationReport(ok=False, notes=["no ppt/slides/slideN.xml parts"]) diff --git a/python/designmd_pptx/backend.py b/python/designmd_pptx/backend.py index d953057..e428c88 100644 --- a/python/designmd_pptx/backend.py +++ b/python/designmd_pptx/backend.py @@ -318,7 +318,7 @@ def _ensure_proc(self) -> subprocess.Popen: daemon=True, ).start() init = self._call("initialize", { - "clientInfo": {"name": "designmd-pptx", "version": "2.1.0"}, + "clientInfo": {"name": "designmd-pptx", "version": "2.1.1"}, }) self._caps = None self._server = init diff --git a/python/designmd_pptx/compile.py b/python/designmd_pptx/compile.py index 871b4ce..955fb4c 100644 --- a/python/designmd_pptx/compile.py +++ b/python/designmd_pptx/compile.py @@ -13,7 +13,7 @@ from .colors_parse import collect_css_vars from .validate import validate_content_overlay, validate_tokens_against_schema_file -COMPILER_VERSION = "2.1.0" +COMPILER_VERSION = "2.1.1" DEFAULT_PATTERNS = [ "cover", diff --git a/python/designmd_pptx/extract.py b/python/designmd_pptx/extract.py index e8eed83..131c47a 100644 --- a/python/designmd_pptx/extract.py +++ b/python/designmd_pptx/extract.py @@ -186,11 +186,23 @@ def _similar_row(shapes: list[dict[str, Any]]) -> list[dict[str, Any]]: def _chart_type(chart_root: ET.Element) -> str: + """Map OOXML chart plot type → officecli chart_type string. + + Note: vertical *column* charts and horizontal *bar* charts both use + ``c:barChart`` in ECMA-376; direction is ``c:barDir`` (``col`` | ``bar``). + """ plot = chart_root.find(".//c:plotArea", NS) if plot is None: return "column" for child in list(plot): local = _local(child.tag) + if local in ("barChart", "bar3DChart"): + # ECMA-376: barDir val="col" → column, val="bar" → bar (default bar) + bar_dir = child.find("c:barDir", NS) + direction = (bar_dir.get("val") if bar_dir is not None else None) or "bar" + if str(direction).lower() in ("col", "column"): + return "column" + return "bar" if local in _CHART_TYPE_MAP: return _CHART_TYPE_MAP[local] if local.endswith("Chart") and local not in ("ofPieChart",): diff --git a/python/designmd_pptx/generative.py b/python/designmd_pptx/generative.py index 3f00062..a1295f8 100644 --- a/python/designmd_pptx/generative.py +++ b/python/designmd_pptx/generative.py @@ -386,6 +386,26 @@ def validate_builder( return LayoutValidation(ok=False, overflow=str(e), placed_count=0) +def validate_placements(placements: list[dict[str, Any]]) -> tuple[bool, str]: + """Geometry sanity for freeform placements (cm, on-canvas, non-empty).""" + if not placements: + return False, "empty placements" + for i, p in enumerate(placements): + if not isinstance(p, dict): + return False, f"placements[{i}] not an object" + try: + x, y, w, h = float(p["x"]), float(p["y"]), float(p["w"]), float(p["h"]) + except (KeyError, TypeError, ValueError): + return False, f"placements[{i}] missing numeric x/y/w/h" + if w <= 0 or h <= 0: + return False, f"placements[{i}] non-positive size" + if x < -0.5 or y < -0.5 or x + w > L.CANVAS_W + 0.5 or y + h > L.CANVAS_H + 0.5: + return False, f"placements[{i}] outside canvas" + if not p.get("name"): + return False, f"placements[{i}] missing name" + return True, "ok" + + def freeform_to_ops( placements: list[dict[str, Any]], tokens: dict[str, Any], @@ -438,10 +458,25 @@ def recipe_freeform(tokens: dict, content: dict | None = None) -> list[dict]: ) if isinstance(profile, str): profile = parse_style_directive(profile) - # Prefer pre-validated placements when present + # External placements must pass geometry sanity before emission (adversarial #21) placements = content.get("placements") if isinstance(placements, list) and placements: + ok_pl, why = validate_placements(placements) + if not ok_pl: + raise L.LayoutOverflow( + f"freeform placements invalid: {why} — regenerate or fix content.placements" + ) return freeform_to_ops(placements, tokens) + # Optional external layout generator (DESIGNMD_LAYOUT_CMD) + ext = external_layout_tree(content) + if isinstance(ext, dict): + if isinstance(ext.get("placements"), list): + ok_pl, why = validate_placements(ext["placements"]) + if ok_pl: + return freeform_to_ops(ext["placements"], tokens) + if ext.get("preset"): + profile = dict(profile) + profile["freeform_preset"] = ext["preset"] tree = build_freeform_tree(content, profile, tokens=tokens) val = validate_tree(tree, density_hint=str(profile.get("density") or "comfortable")) if not val.ok: @@ -483,24 +518,32 @@ def _apply_style_to_slide( prefer_ff = profile.get("prefer_freeform_for") or frozenset() use_ff = force_freeform or recipe in prefer_ff or profile.get("force_relayout") - # Cap list density per style + # Cap list density per style — preserve overflow in structured fields + # (never silent drop; adversarial #21). max_items = int(profile.get("max_list_items") or 5) + overflow: dict[str, Any] = {} for key in ("bullets", "items", "steps", "stages", "cards", "entries"): raw = content.get(key) if isinstance(raw, list) and len(raw) > max_items: + overflow[key] = raw[max_items:] content[key] = raw[:max_items] content["notes"] = ( (content.get("notes") or "") - + f" [generative: trimmed {key} to {max_items} for {profile.get('id')} style]" + + f" [generative: overflow {len(overflow[key])} {key} → content.overflow]" ).strip() patch["trimmed"] = key + patch["overflow_count"] = len(overflow[key]) max_body = int(profile.get("max_body_chars") or 220) for bk in ("body", "blurb", "subtitle", "quote", "insight_body"): if isinstance(content.get(bk), str) and len(content[bk]) > max_body: - cut = content[bk][: max_body - 1].rsplit(" ", 1)[0] - content[bk] = (cut or content[bk][: max_body - 1]) + "…" + full = content[bk] + cut = full[: max_body - 1].rsplit(" ", 1)[0] + content[bk] = (cut or full[: max_body - 1]) + "…" + overflow[bk] = full patch["shortened"] = bk + if overflow: + content["overflow"] = overflow if use_ff: tree = build_freeform_tree(content, profile, tokens=tokens) diff --git a/python/designmd_pptx/public_benchmark.py b/python/designmd_pptx/public_benchmark.py index d917694..00fc5d0 100644 --- a/python/designmd_pptx/public_benchmark.py +++ b/python/designmd_pptx/public_benchmark.py @@ -65,9 +65,14 @@ class PublicSuiteMeta: decks_generated: int = 0 recipes_covered: list[str] = field(default_factory=list) themes: list[str] = field(default_factory=list) + recipe_build_ok: int = 0 + recipe_build_fail: int = 0 methodology: str = ( - "Synthetic deck-specs × design themes; before side degrades contrast/" - "alt; after runs a11y auto-correct; scored with benchmark_thresholds.json" + "Synthetic deck-spec a11y + recipe-builder smoke suite (CC0). " + "Does NOT download private decks, render PPTX, or run live Gate 3. " + "Each fixture: degraded before tokens/deck → a11y auto-correct after; " + "primary recipe is materialised via RECIPE_BUILDERS (ops emitted). " + "OfficeCLI apply/screenshot is out of band for this public harness." ) def to_dict(self) -> dict[str, Any]: @@ -252,12 +257,52 @@ def _degrade_for_before(deck: dict[str, Any], tokens: dict[str, Any]) -> tuple[d return before_deck, before_tokens +def _materialize_primary( + tokens: dict[str, Any], + deck: dict[str, Any], +) -> tuple[int, list[str]]: + """Invoke RECIPE_BUILDERS for each slide. Returns (layout_failures, notes).""" + from . import recipes as recipes_mod + + failures = 0 + notes: list[str] = [] + builders = recipes_mod.RECIPE_BUILDERS + for s in deck.get("slides") or []: + if not isinstance(s, dict): + failures += 1 + continue + recipe = str(s.get("recipe") or "") + content = s.get("content") if isinstance(s.get("content"), dict) else {} + builder = builders.get(recipe) + if builder is None: + failures += 1 + notes.append(f"unknown recipe {recipe!r}") + continue + try: + ops = builder(tokens, content) + if not isinstance(ops, list) or not ops: + failures += 1 + notes.append(f"{recipe}: empty ops") + elif not any(o.get("type") == "slide" for o in ops if isinstance(o, dict)): + failures += 1 + notes.append(f"{recipe}: no slide op") + except Exception as e: # noqa: BLE001 — record builder failure as layout metric + failures += 1 + notes.append(f"{recipe}: {type(e).__name__}: {e}") + return failures, notes + + def build_public_fixtures( n: int = MIN_PUBLIC_DECKS, *, design_path: str | Path | None = None, ) -> tuple[list[dict[str, Any]], PublicSuiteMeta]: - """Compile tokens once and build fixture dicts for run_fixture_suite.""" + """Compile tokens once and build fixture dicts for run_fixture_suite. + + Honesty note (adversarial #42): this is a **synthetic deck-spec + recipe + builder smoke** suite, not a rendered-PPTX corpus. Gate 3 is offline + structural (no contact sheet unless provided). + """ pkg = Path(__file__).parent design = Path(design_path) if design_path else pkg / "default.DESIGN.md" tokens = compile_design_md(design) @@ -265,6 +310,8 @@ def build_public_fixtures( fixtures: list[dict[str, Any]] = [] recipes_seen: set[str] = set() themes_seen: set[str] = set() + build_ok = 0 + build_fail = 0 for deck in decks: did = str(deck["id"]) @@ -274,22 +321,49 @@ def build_public_fixtures( before_deck, before_tokens = _degrade_for_before(deck, tokens) after_tokens, _ = a11y_mod.auto_correct_contrast(before_tokens) after_deck, _ = a11y_mod.ensure_notes_and_alt(before_deck) + + # Recipe materialisation smoke on the *after* deck + layout_fail, build_notes = _materialize_primary(after_tokens, after_deck) + if layout_fail: + build_fail += 1 + else: + build_ok += 1 + + # Offline gate: pass only when recipe smoke succeeded (not hard-coded) + after_gate = { + "pass": layout_fail == 0, + "provider": "recipe_smoke", + "issues": [ + {"severity": "error", "code": "recipe_build", "message": n} + for n in build_notes + ], + } fixtures.append({ "id": did, "before_deck": before_deck, "after_deck": after_deck, "before_tokens": before_tokens, "after_tokens": after_tokens, - "before_extract": {"loss_ledger": []}, - "after_extract": {"loss_ledger": []}, - "after_gate": {"pass": True, "issues": []}, + # No real extract — ledger empty is honest for synthetic fixtures + "before_extract": {"loss_ledger": [], "note": "synthetic: no pptx extract"}, + "after_extract": {"loss_ledger": [], "note": "synthetic: no pptx extract"}, + "after_gate": after_gate, + # Inject layout_failure via a synthetic after metric path: + # score_deck uses layout_failures(tokens, deck) — we also stash + # builder failures into after_deck meta for audit. + "_layout_builder_failures": layout_fail, }) + if layout_fail and isinstance(after_deck.get("meta"), dict): + after_deck["meta"]["layout_builder_failures"] = layout_fail + after_deck["meta"]["layout_builder_notes"] = build_notes[:8] meta_out = PublicSuiteMeta( decks_requested=n, decks_generated=len(fixtures), recipes_covered=sorted(r for r in recipes_seen if r), themes=sorted(themes_seen), + recipe_build_ok=build_ok, + recipe_build_fail=build_fail, ) return fixtures, meta_out @@ -300,27 +374,40 @@ def run_public_suite( design_path: str | Path | None = None, thresholds: dict[str, Any] | None = None, ) -> tuple[bench.SuiteReport, PublicSuiteMeta]: - """Run the ≥100 deck public benchmark. Returns (suite_report, meta).""" - if n < MIN_PUBLIC_DECKS: - # allow smaller smoke runs but annotate - pass + """Run the ≥100 deck public synthetic suite. Returns (suite_report, meta).""" fixtures, meta = build_public_fixtures(n, design_path=design_path) th = thresholds or bench.load_thresholds() - # Public suite may evaluate many decks; keep max_failed at 0 report = bench.run_fixture_suite(fixtures=fixtures, thresholds=th) + # Fold recipe-builder failures into suite status (layout_failure may be 0 + # if deck-spec is well-formed but builder raised). + extra_fail = 0 + for fx, result in zip(fixtures, report.results): + bf = int(fx.get("_layout_builder_failures") or 0) + if bf and result.status == "pass": + result.status = "fail" + result.threshold_breaches = list(result.threshold_breaches) + [ + f"recipe_builder_failures={bf}" + ] + extra_fail += 1 + if extra_fail: + report.decks_fail += extra_fail + report.decks_pass = max(0, report.decks_pass - extra_fail) + report.ok = False report.notes.append( - f"public benchmark v{meta.version} license={meta.license} " - f"decks={meta.decks_generated} recipes={len(meta.recipes_covered)}" + f"public synthetic suite v{meta.version} license={meta.license} " + f"decks={meta.decks_generated} recipes={len(meta.recipes_covered)} " + f"recipe_build_ok={meta.recipe_build_ok} fail={meta.recipe_build_fail}" ) report.notes.append(meta.methodology) - if meta.decks_generated < MIN_PUBLIC_DECKS: + report.notes.append( + "NOT a rendered-PPTX corpus: no OfficeCLI apply/screenshot in this harness" + ) + if meta.decks_generated < MIN_PUBLIC_DECKS and n >= MIN_PUBLIC_DECKS: report.notes.append( f"WARNING: generated {meta.decks_generated} < {MIN_PUBLIC_DECKS} " "public-deck target" ) - # fail suite if below publication bar when n requested ≥ bar - if n >= MIN_PUBLIC_DECKS: - report.ok = False + report.ok = False return report, meta diff --git a/python/designmd_pptx/refine.py b/python/designmd_pptx/refine.py index f182562..35c2c6f 100644 --- a/python/designmd_pptx/refine.py +++ b/python/designmd_pptx/refine.py @@ -148,15 +148,22 @@ def apply_patches( max_list_items: int = 4, max_body_chars: int = 220, ) -> tuple[dict[str, Any], list[dict[str, Any]]]: - """Return (new_deck, patch_log) after applying finding-driven mutations.""" + """Return (new_deck, patch_log) after applying finding-driven mutations. + + Continuations are collected and inserted *after* all index-based patches + so inserting a slide never shifts later targets mid-pass (adversarial #19). + """ out = copy.deepcopy(deck) slides = out.get("slides") or [] if not isinstance(slides, list): return out, [] log: list[dict[str, Any]] = [] n = len(slides) + # (after_index, cont_slide) — applied high→low after the main pass + pending_inserts: list[tuple[int, dict[str, Any]]] = [] + # Track which (slide_index, code_family) already handled to avoid double-patch + handled: set[tuple[int, str]] = set() - # Process high-severity first ordered = sorted( findings, key=lambda f: 0 if str(f.get("severity")) == "error" else 1, @@ -164,10 +171,18 @@ def apply_patches( for finding in ordered: code = str(finding.get("code") or "").lower() + family = ( + "density" if code in _DENSITY_CODES or code in ("overflow",) + else "contrast" if code in _CONTRAST_CODES + else "align" if code in _ALIGNMENT_CODES + else code + ) idxs = _slide_index(finding, n) for i in idxs: if i >= len(slides): continue + if (i, family) in handled: + continue slide = slides[i] if not isinstance(slide, dict): continue @@ -188,7 +203,6 @@ def apply_patches( (content.get("notes") or "") + f" [refine: held {len(tail)} overflow items for next slide]" ).strip() - # Insert continuation slide after current cont = copy.deepcopy(slide) cont["id"] = f"{slide.get('id', f's{i}')}-cont" cont_content = dict(cont.get("content") or {}) @@ -197,8 +211,8 @@ def apply_patches( if "(cont." not in title: cont_content["title"] = f"{title} (cont.)" cont["content"] = cont_content - slides.insert(i + 1, cont) - n = len(slides) + pending_inserts.append((i + 1, cont)) + handled.add((i, family)) log.append({ "action": "split_list", "slide": i + 1, @@ -209,7 +223,6 @@ def apply_patches( "code": code, }) continue - # Shorten long prose fields shortened = False for bk in _TEXT_BODY_KEYS: if bk in content and isinstance(content[bk], str): @@ -219,6 +232,7 @@ def apply_patches( content[bk] = new shortened = True if shortened: + handled.add((i, family)) log.append({ "action": "shorten_text", "slide": i + 1, @@ -226,7 +240,6 @@ def apply_patches( "code": code, }) continue - # Fallback: prefer denser multi-column recipe for pure bullets if recipe == "bullets" and key == "bullets" and len(content.get("bullets") or []) >= 3: cards = [ {"title": str(b)[:40], "body": ""} @@ -235,6 +248,7 @@ def apply_patches( slide["recipe"] = "feature_cards" content["cards"] = cards content.pop("bullets", None) + handled.add((i, family)) log.append({ "action": "recipe_swap", "slide": i + 1, @@ -245,11 +259,11 @@ def apply_patches( continue if code in _CONTRAST_CODES: - # Cannot fix brand tokens here — annotate notes for human/a11y pass note = content.get("notes") or "" hint = " [refine: run a11y --fix-contrast on tokens]" if hint.strip() not in note: content["notes"] = (note + hint).strip() + handled.add((i, family)) log.append({ "action": "annotate_contrast", "slide": i + 1, @@ -261,12 +275,17 @@ def apply_patches( hint = " [refine: prefer engine-solved recipe / reduce chrome]" if hint.strip() not in note: content["notes"] = (note + hint).strip() + handled.add((i, family)) log.append({ "action": "annotate_alignment", "slide": i + 1, "code": code, }) + # Apply inserts from back to front so indices stay valid + for after_idx, cont in sorted(pending_inserts, key=lambda t: t[0], reverse=True): + slides.insert(after_idx, cont) + out["slides"] = slides return out, log diff --git a/python/designmd_pptx/win_install.py b/python/designmd_pptx/win_install.py index 501ad94..5ce6a9f 100644 --- a/python/designmd_pptx/win_install.py +++ b/python/designmd_pptx/win_install.py @@ -168,14 +168,14 @@ def build_install_plan( f"{paths.venv_dir}/Scripts/pip install -U pip; " + ( f"pip install designmd-pptx=={PKG_VERSION}" - if package_source == "pip" + if package_source in ("pip", f"designmd-pptx=={PKG_VERSION}") else f"pip install {package_source}" ) ), downloads=( - f"designmd-pptx from PyPI (target {PKG_VERSION})" - if package_source == "pip" - else f"local path {package_source}" + f"designmd-pptx=={PKG_VERSION} from PyPI (pinned)" + if package_source in ("pip", f"designmd-pptx=={PKG_VERSION}") + else f"package source {package_source}" ), ), ] diff --git a/python/tests/test_phase5_21_40_42.py b/python/tests/test_phase5_21_40_42.py index 2bc9309..522f9a3 100644 --- a/python/tests/test_phase5_21_40_42.py +++ b/python/tests/test_phase5_21_40_42.py @@ -62,11 +62,27 @@ def test_generate_deck_swaps_recipes(self) -> None: # keynote maps bullets → feature_cards OR freeform when force new_recipe = report["deck"]["slides"][0]["recipe"] self.assertIn(new_recipe, ("feature_cards", "freeform")) - # list trimmed to max_list_items content = report["deck"]["slides"][0]["content"] key = "cards" if "cards" in content else "bullets" - if key in content: + if key in content and key == "bullets": self.assertLessEqual(len(content[key]), 4) + # Overflow preserved (not silent drop) + if len([f"P{i}" for i in range(8)]) > 4: + self.assertIn("overflow", content) + + def test_overflow_preserved_not_dropped(self) -> None: + deck = { + "slides": [{ + "id": "s1", + "recipe": "bullets", + "content": {"title": "T", "bullets": [f"P{i}" for i in range(10)]}, + }], + } + report = gen.generate_deck_layout(deck, profile_id="minimal") + content = report["deck"]["slides"][0]["content"] + ov = content.get("overflow") or {} + self.assertTrue(ov.get("bullets") or content.get("notes", "").find("overflow") >= 0 + or report["deck"]["slides"][0]["recipe"] == "freeform") def test_vision_density_forces_freeform(self) -> None: deck = { @@ -180,11 +196,36 @@ def test_inject_slide_adds_timing_and_transition(self) -> None: self.assertIn(b"timing", new) self.assertIn(b"transition", new) self.assertIn(b"animEffect", new) - # namespace-safe re-parse + # namespace-safe re-parse + CT_Slide order: transition/timing before extLst from designmd_pptx import opc root = opc.parse(new) self.assertIsNotNone(root.find(opc.qn("p:timing"))) self.assertIsNotNone(root.find(opc.qn("p:transition"))) + tags = [opc.qn(t).split("}")[-1] if False else ( + c.tag.split("}")[-1] if "}" in c.tag else c.tag + ) for c in list(root)] + # simplified local names + locals_ = [c.tag.split("}")[-1] for c in list(root)] + if "clrMapOvr" in locals_: + self.assertLess(locals_.index("clrMapOvr"), locals_.index("transition")) + self.assertLess(locals_.index("transition"), locals_.index("timing")) + if "extLst" in locals_: + self.assertLess(locals_.index("timing"), locals_.index("extLst")) + + def test_inplace_requires_force(self) -> None: + slide = self._minimal_slide_xml("CoverTitle") + with tempfile.TemporaryDirectory() as td: + src = Path(td) / "deck.pptx" + with zipfile.ZipFile(src, "w") as zf: + zf.writestr("[Content_Types].xml", "") + zf.writestr("ppt/slides/slide1.xml", slide) + report = anim.animate_pptx( + src, out=src, + animation={"enabled": True, "entrance": "fade", "transition": "fade"}, + force=False, + ) + self.assertFalse(report.ok) + self.assertTrue(any("force" in n.lower() for n in report.notes)) def test_animate_pptx_roundtrip(self) -> None: slide = self._minimal_slide_xml("CoverTitle") diff --git a/python/tests/test_refine.py b/python/tests/test_refine.py index 507b017..619efef 100644 --- a/python/tests/test_refine.py +++ b/python/tests/test_refine.py @@ -54,6 +54,38 @@ def test_split_long_bullets(self) -> None: self.assertEqual(len(out["slides"][1]["content"]["bullets"]), 6) self.assertIn("cont", out["slides"][1]["content"]["title"].lower()) + def test_split_does_not_corrupt_later_slides(self) -> None: + """Adversarial #19: insert must not shift indices mid-pass.""" + deck = { + "slides": [ + { + "id": "s1", + "recipe": "bullets", + "content": {"title": "A", "bullets": [f"a{i}" for i in range(8)]}, + }, + { + "id": "s2", + "recipe": "bullets", + "content": {"title": "B", "bullets": [f"b{i}" for i in range(8)]}, + }, + ], + } + findings = [{ + "code": "density", + "severity": "error", + "message": "crowded", + "slide": None, # all slides + }] + out, log = refine.apply_patches(deck, findings, max_list_items=4) + ids = [s["id"] for s in out["slides"]] + self.assertIn("s1", ids) + self.assertIn("s2", ids) + # Both originals split → 4 slides (s1, s1-cont, s2, s2-cont) order may vary + self.assertEqual(len(out["slides"]), 4) + s2 = next(s for s in out["slides"] if s["id"] == "s2") + self.assertEqual(len(s2["content"]["bullets"]), 4) + self.assertTrue(any(p["action"] == "split_list" for p in log)) + def test_shorten_body(self) -> None: long_body = "word " * 80 deck = { diff --git a/python/tests/test_vscode_extension.py b/python/tests/test_vscode_extension.py index 2be4d30..f848b99 100644 --- a/python/tests/test_vscode_extension.py +++ b/python/tests/test_vscode_extension.py @@ -35,27 +35,37 @@ def test_package_json_mvp_surface(self) -> None: self.assertTrue((EXT / "media" / "icon.svg").is_file()) def test_cli_helper_resolves_python_module(self) -> None: - # Node unit test for pure helper + # Node unit test for pure helper — argv, never shell-joined user input script = r""" const path = require('path'); -const { resolveCli, diagnosticsFromReport } = require('./cli.js'); +const { resolveCli, diagnosticsFromReport, hasShellMeta } = require('./cli.js'); const root = path.resolve('../..'); const r = resolveCli({ workspaceRoot: root, pythonPath: 'python3', args: ['doctor', '--install', '--dry-run'], }); -if (!r.shellCmd.includes('designmd_pptx')) throw new Error('cmd: ' + r.shellCmd); +if (!Array.isArray(r.argv)) throw new Error('argv missing'); +if (!r.argv.includes('designmd_pptx')) throw new Error('argv: ' + JSON.stringify(r.argv)); if (!r.env.PYTHONPATH || !r.env.PYTHONPATH.includes('python')) { throw new Error('PYTHONPATH: ' + r.env.PYTHONPATH); } +// Injection-sensitive feedback stays a single argv element (no shell eval) +const evil = resolveCli({ + workspaceRoot: root, + pythonPath: 'python3', + args: ['refine', 'deck.json', '--feedback', '$(id); rm -rf /'], +}); +const fb = evil.argv[evil.argv.indexOf('--feedback') + 1]; +if (fb !== '$(id); rm -rf /') throw new Error('feedback argv corrupted: ' + fb); +if (!hasShellMeta(fb)) throw new Error('meta detect'); const d = diagnosticsFromReport({ pass: false, findings: [{ code: 'density', severity: 'error', message: 'crowded', slide: 2 }], }); if (d.length < 2) throw new Error('diags ' + JSON.stringify(d)); if (d[0].line !== 1) throw new Error('line ' + d[0].line); -console.log('ok', r.shellCmd); +console.log('ok', r.argv.join(' ')); """ proc = subprocess.run( ["node", "-e", script], @@ -67,6 +77,13 @@ def test_cli_helper_resolves_python_module(self) -> None: self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) self.assertIn("ok", proc.stdout) + def test_extension_uses_spawn_not_sendtext_for_cli(self) -> None: + ext = (EXT / "extension.js").read_text(encoding="utf-8") + self.assertIn("spawn", ext) + self.assertIn("shell: false", ext) + # Must not send user feedback through a shell string + self.assertNotIn("terminal.sendText(shellCmd", ext) + def test_decision_doc_points_at_extension(self) -> None: doc = (ROOT / "docs" / "editor-integration-decision.md").read_text(encoding="utf-8") self.assertIn("VS Code", doc) diff --git a/python/tests/test_windows_installer.py b/python/tests/test_windows_installer.py index 3db10ff..4b0d8b8 100644 --- a/python/tests/test_windows_installer.py +++ b/python/tests/test_windows_installer.py @@ -100,6 +100,10 @@ def test_ps1_acceptance_markers(self) -> None: "Invoke-Uninstall", "Get-OfficeCliUrl", "0.2.117", + "designmd-pptx==2.1.1", # pinned package + "Assert-SafeInstallRoot", + "SHA256", + "officecli.exe", # exact binary name (no ambiguous officecli*) ): self.assertIn(needle, text, f"missing {needle!r}") diff --git a/scripts/check-plugin.mjs b/scripts/check-plugin.mjs index b3767c4..fdd5681 100644 --- a/scripts/check-plugin.mjs +++ b/scripts/check-plugin.mjs @@ -66,7 +66,9 @@ ok(sync.status === 0, `adapters in sync: ${(sync.stdout || sync.stderr || '').tr const pkg = path.join(root, 'python', 'designmd_pptx', '__init__.py'); ok(fs.existsSync(pkg), 'python package present'); const init = fs.readFileSync(pkg, 'utf8'); -ok(init.includes('2.1'), 'package version 2.1.x'); +const verMatch = init.match(/__version__\s*=\s*["']([\d.]+)["']/); +const pyVer = verMatch ? verMatch[1] : ''; +ok(pyVer === plugin.version, `package version ${pyVer} == plugin ${plugin.version}`); const req = path.join(root, 'python', 'requirements.txt'); ok(fs.existsSync(req), 'requirements.txt'); @@ -78,11 +80,16 @@ const py = spawnSync('python', ['-c', pyCode], { encoding: 'utf-8', shell: false, }); +const imported = (py.stdout || '').trim(); ok( - py.status === 0 && (py.stdout || '').includes('2.1'), - `python import: ${(py.stdout || py.stderr || '').trim()}` + py.status === 0 && imported === plugin.version, + `python import: ${imported} (plugin ${plugin.version})` ); +// Exact equality across package.json as well +const npj = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +ok(npj.version === plugin.version, `npm package.json ${npj.version} == plugin`); + if (failed) { console.error(`\n${failed} check(s) failed`); process.exit(1); diff --git a/skills/officecli-pptx-designmd/SKILL.md b/skills/officecli-pptx-designmd/SKILL.md index 97b401c..141f543 100644 --- a/skills/officecli-pptx-designmd/SKILL.md +++ b/skills/officecli-pptx-designmd/SKILL.md @@ -1,9 +1,9 @@ --- name: officecli-pptx-designmd -description: "designmd-pptx v2.1: compile awesome-design-md / Stitch DESIGN.md into officecli PPTX tokens, ordered decks, staging-safe apply; compose briefs; constraint layout + generative freeform; refine/animate; extract/restyle/reconstruct; a11y + public benchmark; doctor --install; Windows one-file installer; VS Code extension; 60+ patterns; CJK text-fit; Gate 3 vision; render via agent-bridge. Trigger on DESIGN.md, getdesign.md, brand design for slides, deck outline, restyle deck, modernize slides, slide master, potx template, /designmd-pptx, /officecli-pptx-designmd." +description: "designmd-pptx v2.1.1: compile awesome-design-md / Stitch DESIGN.md into officecli PPTX tokens, ordered decks, staging-safe apply; compose briefs; constraint layout + generative freeform; refine/animate; extract/restyle/reconstruct; a11y + public benchmark; doctor --install; Windows one-file installer; VS Code extension; 60+ patterns; CJK text-fit; Gate 3 vision; render via agent-bridge. Trigger on DESIGN.md, getdesign.md, brand design for slides, deck outline, restyle deck, modernize slides, slide master, potx template, /designmd-pptx, /officecli-pptx-designmd." --- -# officecli-pptx-designmd (v2.1) +# officecli-pptx-designmd (v2.1.1) ## Locate the toolkit