Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot committed 2026-09-12 00:02:22 +08:00
1 parent 221f0178b1
commit fd01ce868a
62 files changed
+1516 -269

No files matched your search

+6 -6
View File
@@ -33,8 +33,8 @@
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main",
"adapter": "codex-plugin",
"commit": "304fb3b4825c4132c273732b14d2d5e86b54f8e3",
"syncedAt": "2026-09-07T16:00:01Z"
"commit": "cb955b0d5becbef76d2c1f0096b6e1f238e1e7f7",
"syncedAt": "2026-09-11T16:00:01Z"
},
{
"id": "ui-ux-pro-max",
@@ -105,8 +105,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "09ad58f0d58decc9d30799ca83374ff2604ef16b",
"syncedAt": "2026-09-10T23:40:59Z"
"commit": "b3073f8fcf5d2ebeda621a424097dde9dd771cea",
"syncedAt": "2026-09-11T16:00:01Z"
},
{
"id": "grill-me",
@@ -141,8 +141,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
"commit": "ccef3db535aa163a883f6ac000bdceb16b13e5e5",
"syncedAt": "2026-09-10T23:40:59Z"
"commit": "d155ba9ebfffe4742efefda8d68c2e0e8e490924",
"syncedAt": "2026-09-11T16:00:01Z"
},
{
"id": "shuorenhua",
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-09-10T23:43:18Z"
"syncedAt": "2026-09-11T16:02:21Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "ccef3db535aa163a883f6ac000bdceb16b13e5e5",
"commit": "d155ba9ebfffe4742efefda8d68c2e0e8e490924",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-09-10T23:40:59Z"
"syncedAt": "2026-09-11T16:00:01Z"
}
@@ -1,6 +1,6 @@
{
"name": "oh-my-codex",
"version": "0.21.4",
"version": "0.21.5",
"description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。",
"author": {
"name": "Yeachan Heo",
@@ -2,8 +2,8 @@
"sourceId": "oh-my-codex",
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main",
"commit": "304fb3b4825c4132c273732b14d2d5e86b54f8e3",
"commit": "cb955b0d5becbef76d2c1f0096b6e1f238e1e7f7",
"adapter": "codex-plugin",
"sourcePath": "plugins/oh-my-codex",
"syncedAt": "2026-09-07T16:00:01Z"
"syncedAt": "2026-09-11T16:00:01Z"
}
@@ -15,8 +15,7 @@ Use when working code is bloated, noisy, repetitive, over-abstracted, or AI-gene
the user requests cleanup/refactor/deslop; or a follow-up left duplicate/dead code,
weak boundaries, missing tests, fallback-like paths, or wrappers. Inputs are the requested
feature/files and behavior to preserve. A file list scope is valid; keep the pass bounded
to it. In the Ralph workflow, run this skill on Ralph's changed files only, standard mode
unless explicitly requested otherwise.
to it. Limit the pass to the calling task's changed files unless broader cleanup was requested.
## Before editing
@@ -24,7 +23,7 @@ unless explicitly requested otherwise.
2. **Create a cleanup plan before code**: list scope and smells, include fallback findings/classifications/escalation, and order safest/highest-signal fixes first.
3. **Inventory fallback-like code** in scope: quick hacks, temporary workaround, temporary fallback, just bypass, just skip, fallback if it fails, swallowed errors, silent defaults, broad compatibility shims, and duplicate alternate execution paths.
4. Classify each fallback: **Masking fallback slop** hides evidence, bypasses the contract, suppresses validation, swallows failures, silently defaults, or adds untested paths; **Grounded compatibility/fail-safe fallback** is narrow at an external/version/fail-safe boundary, documents rationale, preserves failure evidence, and tests primary plus fallback.
5. Prefer root-cause repair, deletion, boundary repair, or explicit failure behavior. For broad/ambiguous/cross-layer/architectural findings, invoke `$ralplan` for consensus resolution; when already inside ralplan, ralph, team, or another OMX workflow, do not spawn a nested `$ralplan`—attach the finding to the active handoff.
5. Prefer root-cause repair, deletion, boundary repair, or explicit failure behavior. For broad/ambiguous/cross-layer/architectural findings, invoke `$ralplan` for consensus resolution; when already inside ralplan, ultragoal, team, or another OMX workflow, do not spawn a nested `$ralplan`—attach the finding to the active handoff.
## Smell taxonomy and passes
@@ -26,7 +26,7 @@ This skill is terminal and read-only by default. It gathers evidence and produce
- The answer is fully repo-local; use `explore` for codebase facts.
- The main question is whether to adopt, replace, upgrade, or compare dependencies; use `dependency-expert`.
- The user only needs implementation against already-grounded requirements; use `executor`, `$ralph`, or `$team` as appropriate.
- The user only needs implementation against already-grounded requirements; execute directly, or use `$team` when coordinated parallel work is warranted.
- The task can be answered from stable local project conventions without current external lookup.
## Specialist Routing
@@ -87,7 +87,7 @@ Combine the `code-reviewer` recommendation and architect status. Approval requir
Approval criteria: **APPROVE** only when `code-reviewer` returns APPROVE, architect status is `CLEAR`, and both independent lanes returned evidence. **REQUEST CHANGES** for a blocker, unresolved high/critical finding, or unavailable lane. **COMMENT** may record non-blocking findings.
Do not self-review as a fallback. If the `code-reviewer` or `architect` path is missing, unavailable, skipped, or fails, block approval until independent lane evidence exists. On the explicit Ralph path, findings may trigger automatic fix follow-up without another permission prompt; plain `code-review` itself remains read-only and does **not** promise auto-fix.
Do not self-review as a fallback. If the `code-reviewer` or `architect` path is missing, unavailable, skipped, or fails, block approval until independent lane evidence exists. On the supported `omx ralph` CLI compatibility path, findings may trigger automatic fix follow-up without another permission prompt; plain `code-review` itself remains read-only and does **not** promise auto-fix.
## Evidence/output contract
@@ -12,7 +12,7 @@ Deep Interview is an intent-first Socratic clarification loop before planning or
- The request is broad, ambiguous, or missing concrete acceptance criteria
- The user says "deep interview", "interview me", "ask me everything", "don't assume", or "ouroboros"
- The user wants to avoid misaligned implementation from underspecified requirements
- You need a requirements artifact before handing off to `ralplan`, `autopilot`, `ralph`, or `team`
- You need a requirements artifact before handing off to `ralplan`, `autopilot`, `ultragoal`, or `team`
</Use_When>
<Do_Not_Use_When>
@@ -149,7 +149,7 @@ If no flag is provided, use **Standard**.
Repeat until ambiguity `<= threshold`, the pressure pass is complete, the readiness gates are explicit, the user exits with warning, or max rounds are reached. This is a stop condition: below threshold, do not open a new ordinary interview branch.
### 2a) Generate next question
If the initial context is oversized and no prompt-safe summary has been recorded yet, the next question must be only a summary request. Do not score ambiguity, do not run readiness gates, and do not hand off to `$ultragoal`, `$ralplan`, `$autopilot`, `$ralph`, or `$team` until that summary answer is captured.
If the initial context is oversized and no prompt-safe summary has been recorded yet, the next question must be only a summary request. Do not score ambiguity, do not run readiness gates, and do not hand off to `$ultragoal`, `$ralplan`, `$autopilot`, or `$team` until that summary answer is captured.
Use:
- Original idea
@@ -429,13 +429,13 @@ Only set `execution_contract_required:true` when the selected downstream workflo
### Goal-mode follow-ups
Include these product-facing suggestions when they fit the clarified spec, without removing the existing `$ultragoal`, `$ralplan`, `$autopilot`, `$ralph`, and `$team` handoff options:
Include these product-facing suggestions when they fit the clarified spec, without removing the existing `$ultragoal`, `$ralplan`, `$autopilot`, and `$team` handoff options:
- **`$ultragoal`** — default goal-mode follow-up for implementation or general goal-oriented follow-up specs that should be converted into durable Codex/OMX goals with sequential completion tracking.
- **`$autoresearch-goal`** — use when the clarified context is a research project: a research question, reference/literature gathering, evaluator-backed analysis, or professor/critic-style deliverable.
- **`$autoresearch`** — use when the clarified context is a research project: a research question, reference/literature gathering, evaluator-backed analysis, or professor/critic-style deliverable.
- **`$performance-goal`** — use when the clarified context is an optimization or performance project with measurable speed, latency, throughput, memory, benchmark, or evaluator criteria.
Recommend `$ultragoal` as the default durable goal-mode follow-up because it supersedes Ralph for goal tracking. Preserve `$team` for coordinated parallel implementation and keep `$ralph` only as an explicit fallback for persistent single-owner execution/verification when the user specifically selects it.
Recommend `$ultragoal` as the default durable goal-mode follow-up because it supersedes Ralph for goal tracking. Preserve `$team` for coordinated parallel implementation.
### 1. **`$ultragoal` (Default durable execution follow-up)**
- **Input Artifact:** `.omx/specs/deep-interview-{slug}.md` (optionally accompanied by the transcript/context snapshot for traceability)
@@ -444,7 +444,7 @@ Recommend `$ultragoal` as the default durable goal-mode follow-up because it sup
- **Skipped / Already-Satisfied Stages:** Requirement interview, ambiguity clarification, doc/context preflight, and early intent-boundary elicitation
- **Expected Output:** `.omx/ultragoal/brief.md`, `.omx/ultragoal/goals.json`, `.omx/ultragoal/ledger.jsonl`, implementation evidence, verification evidence, and final cleanup/review-gate evidence
- **Best When:** The clarified spec is execution-ready or the user explicitly wants durable goal tracking as the next step
- **Next Recommended Step:** Run the Ultragoal completion loop; launch `$team` only inside an active Ultragoal story when parallel lanes are warranted, and use `$ralph` only as an explicit fallback when the user asks for that legacy persistence mode
- **Next Recommended Step:** Run the Ultragoal completion loop; launch `$team` only inside an active Ultragoal story when parallel lanes are warranted
### 2. **`$ralplan` (Recommended when architecture/test-shape review is still needed)**
- **Input Artifact:** `.omx/specs/deep-interview-{slug}.md` (optionally accompanied by the transcript/context snapshot for traceability)
@@ -453,7 +453,7 @@ Recommend `$ultragoal` as the default durable goal-mode follow-up because it sup
- **Skipped / Already-Satisfied Stages:** Requirements discovery, ambiguity clarification, and early intent-boundary elicitation
- **Expected Output:** Canonical planning artifacts under `.omx/plans/`, especially `prd-*.md` and `test-spec-*.md`
- **Best When:** Requirements are clear enough to stop interviewing, but architectural validation / consensus planning is still desirable
- **Next Recommended Step:** Use the approved planning artifacts with `$ultragoal` as the default durable goal-mode follow-up (optionally with `$team` for parallel lanes); choose `$autoresearch-goal` for research validation or `$performance-goal` for measurable optimization, and use `$ralph` only as an explicit fallback when a narrow single-owner persistence loop is requested
- **Next Recommended Step:** Use the approved planning artifacts with `$ultragoal` as the default durable goal-mode follow-up (optionally with `$team` for parallel lanes); choose `$autoresearch` for research validation or `$performance-goal` for measurable optimization
### 3. **`$autopilot`**
- **Input Artifact:** `.omx/specs/deep-interview-{slug}.md`
@@ -462,27 +462,18 @@ Recommend `$ultragoal` as the default durable goal-mode follow-up because it sup
- **Skipped / Already-Satisfied Stages:** Initial requirement discovery and ambiguity reduction
- **Expected Output:** Planning/execution progress, QA evidence, and validation artifacts produced by autopilot
- **Best When:** The clarified spec is already strong enough for direct planning + execution without an additional consensus gate
- **Next Recommended Step:** Continue through autopilot's execution/QA/validation flow; if coordination-heavy execution emerges, prefer `$team` under a leader-owned `$ultragoal` ledger, using `$ralph` only as an explicit fallback when a narrow single-owner persistence loop is requested
- **Next Recommended Step:** Continue through autopilot's execution/QA/validation flow; if coordination-heavy execution emerges, prefer `$team` under a leader-owned `$ultragoal` ledger
### 4. **`$ralph` (Explicit fallback only)**
- **Input Artifact:** `.omx/specs/deep-interview-{slug}.md`
- **Invocation:** `$ralph <spec-path>`
- **Consumer Behavior:** Use the spec's acceptance criteria and boundary constraints as the persistence target. Do not reopen requirements discovery unless the user explicitly asks to refine further.
- **Skipped / Already-Satisfied Stages:** Requirement interview, ambiguity clarification, and initial scope-definition work
- **Expected Output:** Iterative execution progress and verification evidence tracked against the clarified criteria
- **Best When:** The user explicitly asks for Ralph's persistent sequential completion pressure; otherwise use `$ultragoal` for durable goal tracking and completion checkpoints
- **Next Recommended Step:** If this explicit fallback is selected, continue Ralph's persistence loop; if work expands into coordination-heavy lanes, hand off to `$team` under `$ultragoal` checkpointing rather than promoting Ralph as the next default
### 5. **`$team`**
### 4. **`$team`**
- **Input Artifact:** `.omx/specs/deep-interview-{slug}.md`
- **Invocation:** `$team <spec-path>`
- **Consumer Behavior:** Treat the spec as shared execution context for coordinated parallel work. Preserve the clarified intent, non-goals, decision boundaries, and acceptance criteria as common lane constraints.
- **Skipped / Already-Satisfied Stages:** Requirement clarification and early ambiguity reduction
- **Expected Output:** Coordinated multi-agent execution against the shared spec, with evidence that can later feed Ultragoal checkpoints by default, or an explicit Ralph verification pass only when requested
- **Expected Output:** Coordinated multi-agent execution against the shared spec, with final verification evidence and checkpoints in an already-active Ultragoal ledger when applicable
- **Best When:** The task is large, multi-lane, or blocker-sensitive enough to justify coordinated parallel execution instead of a single persistent loop
- **Next Recommended Step:** Follow the team verification path when the coordinated execution phase finishes; checkpoint completion through `$ultragoal` by default, escalating to a separate Ralph loop only when the user explicitly asks for that persistent verification/fix owner
- **Next Recommended Step:** Follow the team verification path when the coordinated execution phase finishes; checkpoint an already-active `$ultragoal` ledger when applicable. Do not start another execution loop solely to verify.
### 6. **Refine further**
### 5. **Refine further**
- **Input Artifact:** Existing transcript, context snapshot, and current spec draft
- **Invocation:** Continue the interview loop
- **Consumer Behavior:** Re-enter questioning to resolve the highest-leverage remaining uncertainty
@@ -534,7 +525,7 @@ Recommend `$ultragoal` as the default durable goal-mode follow-up because it sup
- [ ] Fuzzy or conflicting terminology was challenged against repo language/current code behavior when applicable
- [ ] Scenario-based edge-case grilling was used when boundary ambiguity would materially affect implementation
- [ ] Durable docs/ADR/memory updates, if any, were explicitly opted into and public-safe
- [ ] Handoff options provided (`$ultragoal`, `$ralplan`, `$autopilot`, `$ralph`, `$team`) plus context-sensitive goal-mode suggestions (`$autoresearch-goal`, `$performance-goal`) when applicable
- [ ] Handoff options provided (`$ultragoal`, `$ralplan`, `$autopilot`, `$team`) plus context-sensitive goal-mode suggestions (`$autoresearch`, `$performance-goal`) when applicable
- [ ] No direct implementation performed in this mode
</Final_Checklist>
@@ -12,7 +12,7 @@ Shared operating, delegation, state, hook, team, cancellation, and verification
## Use when
- Product, UX, frontend, or design-system decisions need a repo-local source of truth.
- A feature needs a design brief before `$ralph`, a designer lane, or implementation.
- A feature needs a design brief before a designer lane or implementation.
- Existing UI, assets, screenshots, or constraints need an actionable design summary.
Do not use it for visual-reference implementation matching (use `$visual-ralph`), screenshot comparison alone, or backend/infrastructure work without user-facing design impact.
@@ -95,4 +95,4 @@ If the TUI statusline is not showing:
If `omx hud` shows "No active modes":
- This is expected when no workflows are running
- Start a workflow (ralph, autopilot, etc.) and check again
- Start an explicitly requested supported workflow (`$autopilot` or `$ultragoal`) and check again
@@ -14,7 +14,7 @@ Plan creates concise, actionable work plans. Auto-detects interview vs direct, s
</Use_When>
<Do_Not_Use_When>
- Want direct execution — use `ultragoal`/`team`/`ralph`
- Want direct execution — execute the scoped task directly; use `ultragoal` only for durable goals or `team` for coordinated parallel work
- Simple focused fix with obvious scope — skip planning
</Do_Not_Use_When>
@@ -92,7 +92,7 @@ Before any execution lane begins, ralplan must emit terminal planning state (com
Ralplan is not complete, skippable, or ready for execution merely because `.omx/plans/prd-*.md` and `.omx/plans/test-spec-*.md` exist. Those files are planning artifacts, not consensus evidence.
Before any Autopilot, Pipeline, Ultragoal, Team, Ralph, or implementation handoff, persist a durable handoff record that distinguishes:
Before any Autopilot, Ultragoal, Team, or implementation handoff, persist a durable handoff record that distinguishes:
- `planning_artifacts`: PRD/test-spec paths.
- `ralplan_architect_review`: the completed Architect review with an approving verdict.
@@ -106,13 +106,13 @@ Follow the Plan skill's full documentation for consensus mode details.
## Goal-Mode Follow-up Suggestions
When a bound `ralplan_execution_handoff` permits execution, include product-facing goal-mode suggestions alongside the existing Ralph and team options. Record the requested lane and persist the handoff without claiming host-issued authority.
When a bound `ralplan_execution_handoff` permits execution, include product-facing goal-mode suggestions alongside coordinated Team execution. Record the requested lane and persist the handoff without claiming host-issued authority.
- `$ultragoal` — **default goal-mode follow-up** for implementation or general goal-oriented follow-up plans that should become durable Codex/OMX goals with sequential completion tracking.
- `$autoresearch` — research-project follow-up when the plan centers on a question, literature/reference gathering, evaluator-backed research, or a professor/critic-style research deliverable. (`$autoresearch-goal` was retired to a sunset stub in OMX 0.21.)
- `$performance-goal` — optimization/performance follow-up when the plan centers on speed, latency, throughput, memory, benchmark, or other measurable performance work.
Keep `$team` as a first-class execution option and keep `$ralph` available only as an explicit fallback where appropriate: use Ultragoal as the default durable goal-mode follow-up, Team for coordinated parallel implementation, and Ralph only for intentionally selected persistent single-owner completion/verification pressure. For parallelizable durable-goal delivery, recommend `$ultragoal` + `$team` together: Ultragoal remains the leader-owned `.omx/ultragoal` ledger/Codex-goal wrapper while Team runs parallel lanes and returns checkpoint-ready evidence. Do not present Ralph as the recommended follow-up when durable goal tracking is needed; present Ultragoal as the superseding default, with Team for parallel delivery and Ralph only as an explicit fallback when its narrow persistence loop is specifically desired.
Use `$ultragoal` for durable goal tracking and `$team` for coordinated parallel implementation. When combined, Ultragoal owns the ledger and Team returns checkpoint-ready execution evidence.
Use the available-agent-types roster to produce explicit role/staffing allocation, reasoning-by-lane guidance, concrete launch hints (including `omx team` when parallel delivery is justified), and team verification responsibilities for any future receipt-authorized execution path.
## Pre-context Intake
@@ -130,80 +130,15 @@ Before consensus planning or execution handoff, ensure a grounded context snapsh
- likely codebase touchpoints
4. If ambiguity remains high, gather brownfield facts first. `omx explore` is deprecated; use normal repository inspection tools/subagents for simple read-only repository lookups and `omx sparkshell` only for explicit shell-native read-only evidence. Then run `$deep-interview --quick <task>` before continuing.
5. If the plan depends on official docs, version-aware framework guidance, best practices, or external dependency behavior, use `$best-practice-research` as the bounded evidence wrapper and auto-delegate `researcher` for the official/upstream lookup before finalizing the planning handoff so execution does not start from repo-local recall alone.
6. If a prior `$autoresearch` or `$autoresearch-goal` run exists, treat its approved artifact as evidence for the plan. Do not include Autoresearch as a final architecture or runtime component unless the user explicitly requested ongoing research automation; otherwise synthesize the evidence into the `$ralplan` ADR, risks, and verification steps.
6. If a prior `$autoresearch` run exists, treat its approved artifact as evidence for the plan. Read historical artifacts from `$autoresearch-goal` only as compatibility input. Do not include Autoresearch as a final architecture or runtime component unless the user explicitly requested ongoing research automation; otherwise synthesize the evidence into the `$ralplan` ADR, risks, and verification steps.
Do not hand off to execution modes until this intake is complete; if urgency forces progress, explicitly document the risk tradeoffs.
## Pre-Execution Gate
## Routing boundary
### Why the Gate Exists
Execution modes (ralph, autopilot, team, ultrawork) spin up heavy multi-agent orchestration. When launched on a vague request like "ralph improve the app", agents have no clear target — they waste cycles on scope discovery that should happen during planning, often delivering partial or misaligned work that requires rework.
The ralplan-first gate intercepts underspecified execution requests and redirects them through the ralplan consensus planning workflow. This ensures:
- **Explicit scope**: A PRD defines exactly what will be built
- **Test specification**: Acceptance criteria are testable before code is written
- **Consensus**: Planner, Architect, and Critic agree on the approach
- **No wasted execution**: Agents start with a clear, bounded task
### Good vs Bad Prompts
**Passes the gate** (specific enough for direct execution):
- `ralph fix the null check in src/hooks/bridge.ts:326`
- `autopilot implement issue #42`
- `team add validation to function processKeywordDetector`
- `ralph do:\n1. Add input validation\n2. Write tests\n3. Update README`
- `ultrawork add the user model in src/models/user.ts`
**Gated — redirected to ralplan** (needs scoping first):
- `ralph fix this`
- `autopilot build the app`
- `team improve performance`
- `ralph add authentication`
- `ultrawork make it better`
**Bypass the gate** (when you know what you want):
- `force: ralph refactor the auth module`
- `! autopilot optimize everything`
### When the Gate Does NOT Trigger
The gate auto-passes when it detects **any** concrete signal. You do not need all of them — one is enough:
| Signal Type | Example prompt | Why it passes |
|---|---|---|
| File path | `ralph fix src/hooks/bridge.ts` | References a specific file |
| Issue/PR number | `ralph implement #42` | Has a concrete work item |
| camelCase symbol | `ralph fix processKeywordDetector` | Names a specific function |
| PascalCase symbol | `ralph update UserModel` | Names a specific class |
| snake_case symbol | `team fix user_model` | Names a specific identifier |
| Test runner | `ralph npm test && fix failures` | Has an explicit test target |
| Numbered steps | `ralph do:\n1. Add X\n2. Test Y` | Structured deliverables |
| Acceptance criteria | `ralph add login - acceptance criteria: ...` | Explicit success definition |
| Error reference | `ralph fix TypeError in auth` | Specific error to address |
| Code block | `ralph add: \`\`\`ts ... \`\`\`` | Concrete code provided |
| Escape prefix | `force: ralph do it` or `! ralph do it` | Explicit user override |
### End-to-End Flow Example
1. User types: `ralph add user authentication`
2. Gate detects: execution keyword (`ralph`) + underspecified prompt (no files, functions, or test spec)
3. Gate redirects to **ralplan** with message explaining the redirect
4. Ralplan consensus runs:
- **Planner** creates initial plan (which files, what auth method, what tests)
- **Architect** reviews for soundness
- **Critic** validates quality and testability
5. Architect and Critic approval completes the planning lifecycle and persists `ralplan_consensus_gate.complete:true` as lifecycle evidence.
6. Execution begins when the session-bound, review-cycle-bound `ralplan_execution_handoff` authorizes the selected lane. An active supervised Autopilot run authorizes its defining Ultragoal next stage; standalone Ralplan records the user's selected lane.
### Troubleshooting
| Issue | Solution |
|-------|----------|
| Gate fires on a well-specified prompt | Add a file reference, function name, or issue number to anchor the request |
| Want to bypass the gate | Prefix with `force:` or `!` (e.g., `force: ralph fix it`) |
| Gate does not fire on a vague prompt | The gate only catches prompts with <=15 effective words and no concrete anchors; add more detail or use `$ralplan` explicitly |
| Redirected to ralplan but want to stop planning | Cancel or abandon through the workflow's administrative path. Advisory is cooperative and does not enforce host permissions; a real security gate would require an explicit host-issued, host-verified receipt. |
Ordinary scoped tasks stay in the direct execution lane described by `templates/AGENTS.md`.
Explicit workflow requests follow the keyword registry (`src/hooks/keyword-registry.ts`) and their owning skill contracts; this card does not define a second activation table.
Once Ralplan is active, preserve the planning/execution and consensus handoff requirements above. A conversational “just do it” is not a substitute for the required review evidence.
## Scenario Examples
@@ -1,13 +1,13 @@
---
name: visual-ralph
description: "Visual Ralph orchestration for frontend UI from generated references, static references, or live URL targets, using $ralph with built-in visual verdict and pixel-diff evidence until the implementation matches and leaves a reproducible design system."
description: "Visual Ralph orchestration for frontend UI from generated references, static references, or live URL targets, using $ultragoal with built-in visual verdict and pixel-diff evidence until the implementation matches and leaves a reproducible design system."
---
# Visual Ralph Skill
Use `$visual-ralph` for measured frontend implementation from an approved generated reference, static image, or live-URL baseline. The loop is:
`description / URL -> approved reference -> $ralph implementation -> Visual Ralph verdict + pixel diff -> reusable design system`.
`description / URL -> approved reference -> $ultragoal implementation -> Visual Ralph verdict + pixel diff -> reusable design system`.
For URL cloning, this skill owns the migrated `$web-clone` use case; preserve URL, viewport, fidelity, and interaction notes here. Do not invoke standalone `$web-clone`.
@@ -19,7 +19,7 @@ Shared operating, delegation, state, hook, team, cancellation, and verification
- A live URL or generated raster mockup needs measured implementation and pixel-level iteration.
- The result must leave reusable repo-native tokens/components, not only a matching screenshot.
Do not use it for a durable `DESIGN.md` brief (`$design`), non-visual backend work, comparison-only fixes that can go directly to `$ralph`, or deterministic SVG/code-native assets.
Do not use it for a durable `DESIGN.md` brief (`$design`), non-visual backend work, comparison-only fixes that can be executed directly, or deterministic SVG/code-native assets.
## Workflow
@@ -41,11 +41,11 @@ Copy the approved reference into `.omx/artifacts/visual-ralph/<slug>/reference.p
### 3. Approval gate
Stop after generation or URL capture and obtain approval of one reference image/state (or a targeted regeneration/capture adjustment). Before approval, do not implement or invoke `$ralph`. After approval, the image/baseline is the visual source of truth; major pivots require an explicit user request.
Stop after generation or URL capture and obtain approval of one reference image/state (or a targeted regeneration/capture adjustment). Before approval, do not implement or invoke `$ultragoal`. After approval, the image/baseline is the visual source of truth; major pivots require an explicit user request.
### 4. Hand off to `$ralph`
### 4. Hand off to `$ultragoal`
Pass the approved reference/baseline, URL and permission note when applicable, viewport/content state, interaction parity and exclusions, user description, detected frontend context, screenshot command/viewport, and the completion checklist. Ralph edits, runs, captures, and iterates after approval until matched or blocked.
Pass the approved reference/baseline, URL and permission note when applicable, viewport/content state, interaction parity and exclusions, user description, detected frontend context, screenshot command/viewport, and the completion checklist. The active Ultragoal execution lane edits, runs, captures, and iterates after approval until matched or blocked.
### 5. Verdict before every edit
@@ -66,7 +66,7 @@ Do not declare done until the approved reference/baseline and reproduction comma
## Handoff template
```text
$ralph "Implement the approved frontend reference.
$ultragoal "Implement the approved frontend reference.
Reference: <workspace reference or URL-derived artifact>
Source URL and permission/scope: <when applicable>
Viewport/content state: <viewport, route/state, seed/login assumptions>
+1 -1
View File
@@ -8,7 +8,7 @@
<p align="center">
<a href="https://trendshift.io/repositories/25760?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-25760" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/25760" alt="hugohe3%2Fppt-master | Trendshift" width="250" height="55"/></a>
<a href="https://www.star-history.com/hugohe3/ppt-master"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=hugohe3/ppt-master&type=trending&theme=dark" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=hugohe3/ppt-master&type=trending" width="198" height="55" /></picture></a>
<a href="https://www.star-history.com/hugohe3/ppt-master"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=hugohe3/ppt-master&type=rank&theme=dark" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=hugohe3/ppt-master&type=rank" width="186" height="55" /></picture></a>
</p>
English | [中文](./README_CN.md)
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "09ad58f0d58decc9d30799ca83374ff2604ef16b",
"commit": "b3073f8fcf5d2ebeda621a424097dde9dd771cea",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-09-10T23:40:59Z"
"syncedAt": "2026-09-11T16:00:01Z"
}
@@ -24,7 +24,7 @@ Custom canvases likewise use `0 0 W H` with positive integer pixels; a fractiona
## Platform Keep-clear
Canvas dimensions imply no title band, content topology, or recurring chrome; reserve space only for a real output obstruction. For `story`, keep meaning-bearing text, identity, and calls to action within `y=120..1740` by default because mobile story controls occupy the top and bottom; images, backgrounds, and texture may stay full bleed. An exact target-platform overlay guide or installed template overrides this advisory band.
Canvas dimensions imply no title band, content topology, or recurring chrome; reserve space only for a real output obstruction. For `story`, keep meaning-bearing text, identity, and calls to action within `y=120..1740` by default because mobile story controls occupy the top and bottom; images, backgrounds, and texture may stay full bleed. An exact target-platform overlay guide or installed template overrides this advisory band. Story starting geometry: 80 px margins, 920 px measure, title band ≈ 90 px; regions stack, side-by-side only for near-square halves, and the band below 1740 carries bleed, texture, or a cropped element, not dead space.
## Typography Scale Start
@@ -48,7 +48,10 @@ low = round(span * 0.025); start = round(span * 0.029); high = round(span * 0.03
| `wechat` | 900 | 23–30 | 26 |
| `moments` | 1080 | 27–36 | 31 |
| `xiaohongshu` | 1660 | 42–55 | 48 |
| `story` / `banner` | 1920 | 48–63 | 56 |
| `banner` | 1920 | 48–63 | 56 |
| `story` | 1920 | 48–63 | 56 |
| `a4` | 1754 | 44–58 | 51 |
`story` and `banner` share one span but not one measure: the same body size sits on a 1920 px line in `banner` and a 1080 px line in `story` (about half the characters per line), so portrait pages often settle at the low end of the band.
**Default — starting anchor, not a floor (may override when confirmed identity, source fidelity, or target viewing conditions require it)**: start from the table or formula, then resolve the complete role ramp and page density from the content and delivery context. The band only surfaces unusual values; falling outside it is not a validation failure. Apply the viewing-distance baseline in [`shared-standards-core.md`](./shared-standards-core.md) instead of silently shrinking a recurring role to make content fit.
@@ -66,13 +66,13 @@ The hash is a synchronization receipt, not proof of semantic equivalence; never
### Table schema — `ppt-master.semantic-table.v2`
Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding`, and per-side `borders` (each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md).
Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding`, and per-side `borders` keyed `left` / `right` / `top` / `bottom` / `diagonal_down` / `diagonal_up` (full words, unlike `align`; each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md).
**Hard rule — the table payload is complete**: every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. A payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry).
### Chart schemas
Supported `type` values: `column`, `bar`, `line`, `area` (each with `grouping`), `pie`, `doughnut`, `pieOfPie`, `barOfPie`, `radar`, `radarMarkers`, `radarFilled`, `scatter`, `bubble`, `combo`, `treemap`, `sunburst`, `histogram`, `pareto`, `boxWhisker`, `waterfall`, `funnel`, `stock`. Category charts take `categories` plus `series[].values`; XY charts take `series[].x` / `y` (bubble adds `size`); combo takes `plots[]` or typed `series[]`; ChartEx types take their own hierarchy or value fields. `data_labels` (column / bar / line / area plots only; other types show values through companion text), `axes`, `axis_titles`, `show_legend` / `legend_position`, companion text (`caption`, `source`, `note`), and `style.colors` are the chrome fields. 3D aliases and `surface` are unsupported; exploded pie, `map`, `heatmap`, `bullet`, and `gantt` fail fast. Every field, enum, and range: [`native-data.md`](../scripts/docs/native-data.md).
Supported `type` values: `column`, `bar`, `line`, `area` (each with `grouping`), `pie`, `doughnut`, `pieOfPie`, `barOfPie`, `radar`, `radarMarkers`, `radarFilled`, `scatter`, `bubble`, `combo`, `treemap`, `sunburst`, `histogram`, `pareto`, `boxWhisker`, `waterfall`, `funnel`, `stock`. Category charts take `categories` plus `series[].values`; XY charts take `series[].x` / `y` (bubble adds `size`); combo takes `plots[]` or typed `series[]`; ChartEx types take their own hierarchy or value fields. `data_labels` (column / bar / line / area plots only; other types show values through companion text), `axes` (per axis `minimum` / `maximum` / `major_unit`, never `min` / `max`), `axis_titles`, `show_legend` / `legend_position`, companion text (`caption`, `source`, `note`), and `style.colors` are the chrome fields. 3D aliases and `surface` are unsupported; exploded pie, `map`, `heatmap`, `bullet`, and `gantt` fail fast. Every field, enum, and range: [`native-data.md`](../scripts/docs/native-data.md).
**Chart chrome and color**: SVG-first metadata matches the fallback chrome and reads it literally — the dominant stroke of elements named `axis` becomes `style.axis_color` and of those named `grid` becomes `style.grid_color` (unlabeled lines fall back to the marker's dominant stroke), numbers keep their written form (a payload `73.0` renders as `73` unless `number_format` says otherwise), and marker text that is not a category, data label, axis label, or legend entry needs a companion entry; JSON-first owns its chrome. Generated payloads use uppercase `#RRGGBB`. Negative bars keep the series fill. The final checker tags SVG-first parity findings `blocks --native-charts-and-tables export`; that export refuses them, so clear them before exporting the native variant.
@@ -163,7 +163,7 @@ python3 skills/ppt-master/scripts/visualization_recall.py validate \
**Family selection**: user/template typography is authoritative. Delivery target: an explicit user/template target first, otherwise Windows Microsoft PowerPoint (owner: [`shared-standards-core.md`](./shared-standards-core.md) §4.1) — the authoring host's installed fonts never select a face; name concrete faces installed or approved on that target; at most four families; a brand/web face leads only after user-confirmed installation, otherwise export a safe face and keep it as a Design Spec reference (fonts are not embedded; CSS tails are preview aids, not PowerPoint fallbacks). Avoid near-equivalent splits (YaHei↔PingFang, SimSun↔Songti, Arial↔Helvetica↔Segoe UI, Times↔Times New Roman). Fonts in one deck form contrast (different family, weight, or proportion) or concord (one family throughout); across the direction set include both a concord and a contrast pairing unless the user or template fixes the stack, and never default to title = body without a reason.
**Reference — PPT-safe faces (recall, not a whitelist; one concrete named face per script — a stack carries at most one Latin face and one CJK face and never a fallback list of alternatives, because export takes the first named face of each script)**: CJK sans `Microsoft YaHei` / `SimHei`, CJK serif `SimSun` / `FangSong` / `KaiTi` (their macOS counterparts `PingFang SC` / `Heiti SC` / `Songti SC` are preview aliases, never the named face), Latin sans `Arial` / `Calibri` / `Segoe UI` / `Verdana` / `Trebuchet MS`, Latin serif `Times New Roman` / `Georgia` / `Cambria` / `Palatino` / `Garamond` (`Georgia` sets old-style figures whose digits sit at different heights — a hero-number or data role needs lining figures: `Times New Roman`, `Cambria`, or any listed sans or mono), mono `Consolas` / `Courier New`, display `Impact` / `Arial Black`. Let the locked style's character pick the axis and lead the title — `Microsoft YaHei` / `Arial` are the neutral members, never the automatic lead; a neutral sans title where the style asks for character is the failure to avoid. Non-pre-installed directions — retro/pixel Press Start 2P / VT323, rounded Nunito / Quicksand / OPPO Sans (safe substitute `Trebuchet MS` / `Verdana`), modern web Inter / HarmonyOS Sans / Source Han, calligraphic 隶书 / 华文行楷 / 华文新魏 (safe substitute `KaiTi` / `FangSong`, titles only), brand faces — need target installation or stay Design Spec references.
**Reference — PPT-safe faces (recall, not a whitelist; one concrete named face per script — a stack carries at most one Latin face and one CJK face and never a fallback list of alternatives, because export takes the first named face of each script)**: CJK sans `Microsoft YaHei` / `SimHei`, CJK serif `SimSun` / `FangSong` / `KaiTi` (their macOS counterparts `PingFang SC` / `Heiti SC` / `Songti SC` are preview aliases, never the named face), Traditional Chinese sans `Microsoft JhengHei`, serif `PMingLiU` / `DFKai-SB`, Japanese sans `Yu Gothic` / `Meiryo`, serif `Yu Mincho`, Korean sans `Malgun Gothic`, serif `Batang` (text on another locale's CJK face takes that locale's glyph forms), Arabic `Segoe UI` / `Tahoma` / `Times New Roman` (one face carries both the Latin and Arabic text), Latin sans `Arial` / `Calibri` / `Segoe UI` / `Verdana` / `Trebuchet MS`, Latin serif `Times New Roman` / `Georgia` / `Cambria` / `Palatino` / `Garamond` (`Georgia` sets old-style figures whose digits sit at different heights — a hero-number or data role needs lining figures: `Times New Roman`, `Cambria`, or any listed sans or mono), mono `Consolas` / `Courier New`, display `Impact` / `Arial Black`. Let the locked style's character pick the axis and lead the title — `Microsoft YaHei` / `Arial` are the neutral members, never the automatic lead; a neutral sans title where the style asks for character is the failure to avoid. Non-pre-installed directions — retro/pixel Press Start 2P / VT323, rounded Nunito / Quicksand / OPPO Sans (safe substitute `Trebuchet MS` / `Verdana`), modern web Inter / HarmonyOS Sans / Source Han, calligraphic 隶书 / 华文行楷 / 华文新魏 (safe substitute `KaiTi` / `FangSong`, titles only), brand faces — need target installation or stay Design Spec references.
**Role extension after confirmation**: while composing the roster, add a lowercase snake_case role with an exact stack only for a recurring role that materially needs a different family (`annotation`, `footer`, `footnote`, `data`, `emphasis`, `quote`, `code`), coherent with the confirmed heading/body system and locked style; one-off garnish stays omitted and Default names any added role in one compact `Role rationale` line in §IV.
@@ -177,7 +177,7 @@ Preset patterns are a separate PPT interface in [`native-data-interface.md`](./n
A filter is native-effect metadata, not a pixel-filter surface: one direct
`<defs><filter>` referenced as a direct `filter="url(#id)"` on a `<rect>`,
`<circle>`, `<image>`, `<path>`, `<text>`, or a helper-authored preset group,
`<circle>`, `<polygon>`, `<image>`, `<path>`, `<text>`, or a helper-authored preset group,
built from `feDropShadow` or the blur + flood + composite + merge graph below
with explicit `stdDeviation`, `dx`/`dy`, and `flood-opacity`. A meaningful
offset becomes one outer shadow; zero offset — even `feDropShadow` with
@@ -65,6 +65,7 @@ Conditional design guidance for presentations whose intended use is recorded, se
| Progressive reveal, continuing geometry, or timed emphasis materially aids explanation | Enable/load the appropriate animation capability |
| Quick generates a PPTX for recorded, self-running, or video delivery | **Hard rule**: enable Custom Animations before SVG authoring, use semantic groups and page-specific choreography, and complete the stage with a validated `animations.json` before base export; deck-wide `-a auto` and page transitions do not satisfy it; pages or groups may stay static — no coverage quota |
| Quick directly delivers a narrated video or MP4 | **Mandatory**: also enable Speaker Notes, Narration Audio, and video export; write the complete per-scene narration to `notes/total.md` before P01 as page-design input (agent-authored wording may be finalized after the roster, final/literal input stays verbatim); resolve narration-governed timing before audio, requiring timestamped page-local SRT for cue sync or subtitle delivery |
| Default generates a recorded or self-running deck and the workflow writes the narration itself | Enable Speaker Notes, Narration Audio, and Custom Animations at Stage 2; write the complete per-scene narration to `notes/total.md` after the approved roster/lock and before Step 5 as page-design input (agent-authored wording may be finalized during final-SVG validation, before audio) |
| The user explicitly requests static or page-transition-only playback | Keep object animation off; retain the remaining notes/audio/video outcomes |
Default never forces object animation or audio merely because a deck may later be recorded; the Quick requirement selects the capability, not motion coverage.
@@ -148,40 +148,64 @@ def _layout_text_sizes(pkg: "OoxmlPackage") -> list[dict]:
return _rank(sizes, 10)
def _sample_observed(pkg: "OoxmlPackage") -> dict:
def _theme_font_refs(fonts: dict) -> dict[str, str]:
"""Map `+mj-lt` / `+mn-ea` style theme references to their declared faces."""
refs: dict[str, str] = {}
for prefix, role in (("+mj", "title"), ("+mn", "body")):
pair = fonts.get(role) or {}
for suffix, slot in (("lt", "latin"), ("ea", "ea"), ("cs", "cs")):
face = pair.get(slot)
if face:
refs[f"{prefix}-{suffix}"] = face
return refs
def _sample_observed(pkg: "OoxmlPackage", font_refs: dict[str, str]) -> dict:
"""Aggregate run-level fonts, explicit point sizes, and explicit fill colors.
Theme extraction reports the *declared* identity; a hand-edited deck often
overrides it per shape / run. This is a frequency sample of run-level usage
(not a full style resolution — it misses schemeClr + master/layout
inheritance, and counts chart/gradient fills), enough for the workflow to
recommend theme vs observed. `sizes_pt` only counts runs that set an explicit
`sz`; runs inheriting the placeholder size are not seen here (use
`theme.sizes` for those), so a small sample is a hint, not the full picture.
recommend theme vs observed. Theme font references (`+mn-ea`) count as the
face they resolve to. `sizes_pt` only counts runs that set an explicit `sz`
and ranks by the characters those runs carry, so a size split across many
short runs does not outrank the size that sets most of the text; runs
inheriting the placeholder size are not seen here (use `theme.sizes` for
those), so a small sample is a hint, not the full picture.
"""
latin: dict[str, int] = {}
ea: dict[str, int] = {}
sizes: dict[float, int] = {}
size_runs: dict[float, int] = {}
size_chars: dict[float, int] = {}
colors: dict[str, int] = {}
for slide in pkg.iter_slides():
root = slide.part.xml
for tag, bucket in (("a:latin", latin), ("a:ea", ea)):
for elem in root.iterfind(f".//{tag}", NS):
face = (elem.attrib.get("typeface") or "").strip()
if face and not face.startswith("+"): # skip +mj-*/+mn-* theme refs
if face.startswith("+"):
face = font_refs.get(face, "")
if face:
bucket[face] = bucket.get(face, 0) + 1
for elem in root.iterfind(".//a:rPr", NS):
sz = (elem.attrib.get("sz") or "").strip()
for run in root.iterfind(".//a:r", NS):
rpr = run.find("a:rPr", NS)
sz = (rpr.attrib.get("sz") or "").strip() if rpr is not None else ""
if sz.isdigit():
pt = int(sz) / 100
sizes[pt] = sizes.get(pt, 0) + 1
size_runs[pt] = size_runs.get(pt, 0) + 1
size_chars[pt] = size_chars.get(pt, 0) + len(run.findtext("a:t", "", NS))
for elem in root.iterfind(".//a:srgbClr", NS):
val = (elem.attrib.get("val") or "").strip().upper()
if val:
colors[f"#{val}"] = colors.get(f"#{val}", 0) + 1
ranked_sizes = sorted(size_chars, key=lambda pt: (-size_chars[pt], -size_runs[pt], pt))
return {
"fonts": {"latin": _rank(latin, 5), "ea": _rank(ea, 5)},
"sizes_pt": _rank(sizes, 8),
"sizes_pt": [
{"value": pt, "count": size_runs[pt], "chars": size_chars[pt]}
for pt in ranked_sizes[:8]
],
"colors": _rank(colors, 8),
}
@@ -233,7 +257,7 @@ def extract_identity(pptx_path: Path) -> dict:
"slide_count": pkg.slide_count,
"canvas": canvas,
"theme": {"palette": palette, "fonts": fonts, "sizes": sizes},
"observed": _sample_observed(pkg),
"observed": _sample_observed(pkg, _theme_font_refs(fonts)),
"layout_sizes_pt": _layout_text_sizes(pkg),
}
@@ -173,6 +173,12 @@ def _summary_view(inventory: dict) -> dict:
"slide_index": slide.get("slide_index"),
"page_type": slide.get("page_type"),
"text_block_count": len(slide.get("text_blocks", [])),
# Blocks include empty and decorative frames; the non-space
# character count is the density signal.
"text_char_count": sum(
len("".join(str(block.get("text") or "").split()))
for block in slide.get("text_blocks", [])
),
"table_count": len(slide.get("tables", [])),
"chart_count": len(slide.get("charts", [])),
"diagram_count": len(slide.get("diagrams", [])),
@@ -33,6 +33,7 @@ from typing import Optional
from xml.etree import ElementTree as ET
from console_encoding import configure_utf8_stdio
from pptx_to_svg.preset_authoring import authored_preset_encoding
from svg_to_pptx.drawingml.utils import INHERITABLE_ATTRS
configure_utf8_stdio()
@@ -141,6 +142,11 @@ def _local_name(name: object) -> str:
return name.rsplit("}", 1)[-1] if isinstance(name, str) else ""
def _is_preset_atom(element: ET.Element) -> bool:
"""Return whether an element is a helper-owned authored preset group."""
return isinstance(element.tag, str) and authored_preset_encoding(element) is not None
def is_canonical_presentation_value(
value: str,
*,
@@ -339,7 +345,10 @@ def _promote_common_group_defaults(
stats: StyleCompactionStats,
) -> None:
"""Factor proven direct-child repetition into an existing SVG group."""
if _local_name(element.tag) in _DEFINITION_SUBTREES:
if (
_local_name(element.tag) in _DEFINITION_SUBTREES
or _is_preset_atom(element)
):
return
for child in element:
_promote_common_group_defaults(child, stats)
@@ -357,6 +366,10 @@ def _promote_common_group_defaults(
]
if len(children) < 2:
return
# Helper-owned preset atoms keep their paint local; the preset contract
# rejects paint that only arrives from an ancestor group.
if any(_is_preset_atom(child) for child in children):
return
element_styles = _style_declarations(element.get("style"))
if element_styles is None:
@@ -404,7 +417,10 @@ def _remove_redundant_inherited_styles(
inherited: dict[str, str],
stats: StyleCompactionStats,
) -> None:
if _local_name(element.tag) in _DEFINITION_SUBTREES:
if (
_local_name(element.tag) in _DEFINITION_SUBTREES
or _is_preset_atom(element)
):
return
declarations = _style_declarations(element.get("style"))
if declarations is None:
@@ -61,8 +61,10 @@ Useful options:
`--no-images`, and `--filter-images` map to the PDF image mode.
`--no-images` (or `--images none`) also applies to web pages (images stay
remote links, no `<stem>_files/`) and is a no-op on Markdown/text.
- A `.md` / `.markdown` / `.txt` URL whose body is not HTML is saved verbatim
under a `Source:` header, named by the URL's filename stem.
- A URL that serves a PDF / Office document (by Content-Type, body magic, or
suffix) is saved beside the Markdown and converted by that document's
backend; a `.md` / `.txt` URL whose body is not HTML is saved verbatim
under a `Source:` header, named by the URL stem.
- Unknown backend-specific flags are passed through to each selected converter.
- `-o/--output` selects one Markdown file for one input, or an output directory
for multiple inputs / directory inputs.
@@ -119,7 +121,7 @@ PyMuPDF is licensed under AGPL-3.0, with a commercial license available from Art
Hybrid converter: pure-Python for the common formats, pandoc fallback for the rest.
Native path (no external binary required):
- `.docx` — via `mammoth`; text-only tables are preserved as pipe Markdown, and OMML / Office Math equations (Word-native or MathType "Convert to Office Math") are rewritten to inline LaTeX. Classic MathType OLE objects carry no OMML and are kept only as their preview image.
- `.docx` — via `mammoth`; text-only tables (with footnotes) and chart data become pipe Markdown, and OMML / Office Math equations (Word-native or MathType "Convert to Office Math") are rewritten to inline LaTeX. Classic MathType OLE objects carry no OMML and are kept only as their preview image.
- `.html` / `.htm` — via `markdownify` + `beautifulsoup4`
- `.epub` — via `ebooklib` + `markdownify`
- `.ipynb` — via `nbconvert`
@@ -14,14 +14,14 @@ file no longer repeats. Section titles mirror the owning section of
## §2 Metadata placement and bounds
one child `<metadata type="application/json">` (attribute `data-pptx-json` is read-compatible, not canonical); the marker's `data-pptx-replace-with` selects the schema. Provide `x`, `y`, `width`, `height` in metadata or as `data-pptx-x/y/width/height` on the group; omitted bounds are inferred from the fallback geometry (then marker/ancestor `translate` / `scale` apply), complete explicit bounds are absolute slide coordinates. `x` / `y` are finite and inside the 32-bit DrawingML range; `width` / `height` additionally resolve to at least one EMU (tables: per resolved row and column). JSON-first markers require all four bounds in metadata. Classic charts accept root `plot_area` (`x`, `y`, `width`, `height`, a positive rectangle inside the frame → `c:manualLayout`; the frame grows past the plot on the side that carries tick labels until that strip is two axis-font ems tall, because PowerPoint drops the manual vertical layout when its labels do not fit and auto-fits the plot to the frame top instead); ChartEx rejects it. `svg_quality_checker.py` validates marker kind, JSON, bounds/fallback, table rows/columns, chart type and data shape, the authority contract, and (SVG-first) baseline freshness.
one child `<metadata type="application/json">` (attribute `data-pptx-json` is read-compatible, not canonical; the JSON is XML text, so `&` and `<` inside strings are written `&amp;` and `&lt;`); the marker's `data-pptx-replace-with` selects the schema. Provide `x`, `y`, `width`, `height` in metadata or as `data-pptx-x/y/width/height` on the group; omitted bounds are inferred from the fallback geometry (then marker/ancestor `translate` / `scale` apply), complete explicit bounds are absolute slide coordinates. `x` / `y` are finite and inside the 32-bit DrawingML range; `width` / `height` additionally resolve to at least one EMU (tables: per resolved row and column). JSON-first markers require all four bounds in metadata. Classic charts accept root `plot_area` (`x`, `y`, `width`, `height`, a positive rectangle inside the frame → `c:manualLayout`; the frame grows past the plot on the side that carries tick labels until that strip is two axis-font ems tall, because PowerPoint drops the manual vertical layout when its labels do not fit and auto-fits the plot to the frame top instead); ChartEx rejects it. `svg_quality_checker.py` validates marker kind, JSON, bounds/fallback, table rows/columns, chart type and data shape, the authority contract, and (SVG-first) baseline freshness.
## §2 Table schema — `ppt-master.semantic-table.v2`
Every payload carries that exact `schema`. Native tables are rectangular grids: `columns` for the optional header row, `rows` for body rows (shorter rows padded unless `strict_grid: true`; at most 1000 resolved rows and columns); `column_widths` / `row_heights` are finite non-negative relative weights matching the grid with one positive value (`row_heights` counts the header row when `columns` is present) (a row's top and bottom padding shrink to what its resolved height can hold beside the text line, since PowerPoint would otherwise grow the row past the drawn one); `header_rows` is an integer in range; `strict_grid`, `style.band_row`, and `bold` are JSON booleans. Exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` and kebab-case `cell_styles` selected by `cell_style` (precedence: cell defaults → named style → cell fields; object-valued `padding` merges, everything else replaces; `<style>` and `class` remain forbidden).
Cells accept `text`, `fill`, `fill_opacity`, `color`, `align`. (SVG-first parity compares a cell against the fallback `<text>` with its `<tspan>` lines concatenated, so a wrapped cell keeps a trailing space inside every non-final `<tspan>` or the joined words never match.) Cells also take (`l` / `ctr` / `r`; header cells export centred unless `align` is set, so SVG-first parity reports a left- or right-anchored fallback header whose payload leaves `align` unset), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding` and side-specific `padding_*`, `border_color`, `border_width`, `borders`, `lang`, `anchor_center`, `horizontal_overflow`. Multi-paragraph text replaces `text` with a non-empty `paragraphs` list of strings or objects — empty paragraph strings are preserved as blank lines — (`align` plus exactly one of `text` or non-empty `runs`; runs carry required `text` and optional `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one-typeface `font_family`, `lang`, `alt_lang`); unknown fields, wrong types, empty runs, multi-typeface families, and unsupported colors fail. Per-side borders use `borders.left|right|top|bottom|diagonal_down|diagonal_up` as `{ "style": "none" }` or `{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`, overriding a uniform `border_color` / `border_width` on style or cell. A missing `lang` derives `zh-CN` for CJK and `en-US` otherwise. `style.band_row: false` disables banding and materialized alternating fills. Typography mirrors the fallback: `style.font_family` and `style.font_size` from the drawn table text, `style.header_font_size` or per-cell `font_size` only where the fallback differs; with no explicit table font, Default uses the deck body family and anchor, Quick its active-context values.
Cells accept `text`, `fill`, `fill_opacity`, `color`, `align`; no cell-level `font_family` — the face is table-wide through `style.font_family`, so a column set in another face in the fallback falls back to the table face when replaced. (SVG-first parity compares a cell against the fallback `<text>` with its `<tspan>` lines concatenated, so a wrapped cell keeps a trailing space inside every non-final `<tspan>` or the joined words never match.) Cells also take (`l` / `ctr` / `r`; header cells export centred unless `align` is set, so SVG-first parity reports a left- or right-anchored fallback header whose payload leaves `align` unset), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding` and side-specific `padding_*`, `border_color`, `border_width`, `borders`, `lang`, `anchor_center`, `horizontal_overflow`. Multi-paragraph text replaces `text` with a non-empty `paragraphs` list of strings or objects — empty paragraph strings are preserved as blank lines — (`align` plus exactly one of `text` or non-empty `runs`; runs carry required `text` and optional `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one-typeface `font_family`, `lang`, `alt_lang`); unknown fields, wrong types, empty runs, multi-typeface families, and unsupported colors fail. Per-side borders use `borders.left|right|top|bottom|diagonal_down|diagonal_up` as `{ "style": "none" }` or `{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`, overriding a uniform `border_color` / `border_width` on style or cell. A missing `lang` follows the deck's `primary_language` when the cell's script matches it, otherwise the script default (`zh-CN`, `ja-JP`, `ko-KR`, `en-US`). `style.band_row: false` disables banding and materialized alternating fills. Typography mirrors the fallback: `style.font_family` and `style.font_size` from the drawn table text, `style.header_font_size` or per-cell `font_size` only where the fallback differs; with no explicit table font, Default uses the deck body family and anchor, Quick its active-context values.
`style` accepts `font_family`, `font_size`, `header_font_size`, `band_row`, `padding`, `valign`, `border_color`, `border_width`, `lang`, `table_style_id`, and the table-wide colours `header_fill`, `header_text`, `body_fill`, `body_text`, `band_fill`. SVG-first parity reads a header band drawn as one rect spanning the header rows as `style.header_fill` unless every header cell carries that same `fill` (and the dominant header text colour as `style.header_text` unless every header cell carries it), so a drawn band is projected at table level rather than repeated per cell. Fills are only what the payload resolves: a cell with no `fill` takes `style.header_fill` (header rows) or `style.band_fill` / `style.body_fill` (body rows), and when none of those is set it exports as `noFill` so the slide background shows through — the same as a fallback that draws no cell rect. Text colour never falls to nothing: a cell without `color` takes `defaults.run.color` (plain text is one run, and a `paragraphs` cell whose paragraphs are strings or `text` objects inherits the same way at cell level, so `defaults.run` `bold` / `color` / `font_size` reach both when the cell and `defaults.cell` leave them unset; `paragraphs[].runs` take the run defaults directly), then `style.body_text` / `style.header_text`, then `#1F2937` (`header_text` defaults to `#FFFFFF` only when `header_fill` is set). SVG-first parity reports a body text colour drawn in the fallback that none of those layers would carry, and a first-column colour that neither the cell nor `style.body_text` resolves to.
@@ -29,8 +29,10 @@ Cells accept `text`, `fill`, `fill_opacity`, `color`, `align`. (SVG-first parity
## §2 Chart schemas
Chart payloads carry no `schema` key: the marker's `data-pptx-replace-with` value selects the chart schema, and an invented key is rejected.
- **Category charts** — `column`, `bar`, `line`, `area`, `pie`, `doughnut`, `pieOfPie`, `barOfPie`, `radar`: `categories` plus `series[].values`. Column, bar, line, and area values may be `null` for a gap (the workbook cell stays blank, the cache omits the point, `dispBlanksAs` is `gap`; at least one value per series is a number) — a fallback that breaks one line into segments is two series of the same colour with `null` outside their segment. Pie-family charts take exactly one series with per-slice colors; `hole_size` is doughnut-only, integer `10..90`, default `75`; no rotation field. Column/bar may set `series[].point_colors` (camelCase `pointColors` is read-compatible; length = values). Bar categories run top-down in payload order — the exporter reverses the category axis and moves the value-axis crossing so the value axis keeps its declared position; `axes.category.reverse: false` restores PowerPoint's bottom-up order (column charts stay left-to-right unless `reverse: true`). Bar/column spacing: root `gap_width` (`0..500`, the empty slot share as a percentage of one bar; PowerPoint default `150`) and clustered `overlap` (`-100..100`, the share of one bar the next series covers, negative for a gap); SVG-first export reads both from the fallback bars inside `plot_area` when the payload omits them, JSON-first uses the defaults.
- **Line markers** — `line_style: "lineMarker"` draws a circle at every point in the series colour; `marker_size` (root or per series, SVG px diameter, `2..72` pt after `×0.75`) sizes it. `series[].point_colors` on a line series marks single points: a colour draws that point's marker in that colour, `null` leaves it bare (with `line_style: "line"` only the coloured points carry markers), so an emphasised endpoint is `[null, …, "#C8102E"]`. Area series draw no markers; a line with markers over a fill is a `combo`. `data_labels` (column, bar, line, and area plots only, combo plots of those types included; pie-family, radar, scatter/bubble, stock, and ChartEx charts reject it — show their values through companion `notes`) is `true` or an object with `show_value`, `position`, `number_format`, `font_size`, `font_family`, `bold`, `color`, per-point `colors`, and `points` (zero-based `idx` plus optional overrides); positions: clustered column/bar `outside_end` / `inside_end` / `inside_base` / `center`, stacked `inside_end` / `inside_base` / `center`, line `above` / `center` / `best_fit`, area none. Set `data_labels.number_format` to match the fallback (the parity gate reads `0`, `0.0`, `0.00`, `#,##0`, `#,##0.0`, their `%` forms — which scale the value by 100 — and a quoted literal prefix/suffix such as `"$"#,##0` or `0.0"%"`; other codes match only the literal value)'s rendered value labels.
- **Line markers** — `line_style: "lineMarker"` draws a circle at every point in the series colour; `marker_size` (root or per series, SVG px diameter, `2..72` pt after `×0.75`) sizes it. `series[].point_colors` on a line series marks single points: a colour draws that point's marker in that colour, `null` leaves it bare (with `line_style: "line"` only the coloured points carry markers), so an emphasised endpoint is `[null, …, "#C8102E"]`. Area series draw no markers; a line with markers over a fill is a `combo`. `data_labels` (column, bar, line, and area plots only, combo plots of those types included; pie-family, radar, scatter/bubble, stock, and ChartEx charts reject it — show their values through companion `notes`) is `true` or an object with `show_value`, `position`, `number_format`, `font_size`, `font_family`, `bold`, `color`, per-point `colors`, and `points` (zero-based `idx` plus optional overrides — with an explicit `show_*` flag every point keeps its label and `delete: true` hides one; without one only the listed points are labeled); positions: clustered column/bar `outside_end` / `inside_end` / `inside_base` / `center`, stacked `inside_end` / `inside_base` / `center`, line `above` / `center` / `best_fit`, area none; `series[].data_labels` takes the same object for one series, so two lines can label different points. Set `data_labels.number_format` to match the fallback (the parity gate reads `0`, `0.0`, `0.00`, `#,##0`, `#,##0.0`, their `%` forms — which scale the value by 100 — and a quoted literal prefix/suffix such as `"$"#,##0` or `0.0"%"`; other codes match only the literal value)'s rendered value labels.
- **Combo** — shared `categories` plus `plots[]` (`type: "column" | "line" | "area"`, own `series`, optional `axis: "secondary"`, optionally own `categories` / `category_numeric` when caches genuinely differ, and `series_indices` for imported identity — same-length unique non-negative integers forming one contiguous `0..N-1` range across plots) or typed `series[]` with per-series `type` / `axis` (adjacent compatible series share a plot). Area series may set `fill_opacity` (`0..1`; `fillOpacity` read-compatible); a line plot with `area_fill: true` exports as an area chart; line/area series may set `line_width` in SVG px (`lineWidth` read-compatible); column plots take `gap_width` / `overlap`, line plots `line_style` / `marker_size`. An area series is a fill without an outline; `line_width` adds a same-colour outline around the whole region (baseline included), so a fallback that strokes only the top edge — or draws markers on it — is an area plot under a line plot with the same values. Export layers areas below columns and lines.
- **Axes** — classic `axes` is a closed object of `category`, `value`, `secondary_category`, `secondary_value`, each with only `kind` (`text` / `date` / `value`), `position`, `visible`, `label_position` (`next_to` / `none` / `low` / `high`), `number_format`, `minimum`, `maximum`, `major_unit` (value axes), `reverse`, `major_gridlines`, `tick_marks` (`none` default / `out` / `in` / `cross`), tick-label `color` / `font_family` / `font_size` (SVG px) when one axis's labels differ from the chart-wide text style, and on value axes `cross_between` (`between` — points sit between ticks, the default written explicitly because renderers disagree when it is absent — or `mid_category` — the first point sits on the axis edge; SVG-first line/area export reads it from where the fallback line starts inside `plot_area`); single-plot `bar` takes `category` left/right and `value` bottom/top; pie-family rejects `axes`. For single-plot category charts, `kind: "date"` is supported only by `area`. Stock dates follow the separate Stock contract below. `position` is honoured through the axis crossing (`top` / `right` cross at the far category, and a reversed crossing axis swaps the ends). `scatter` / `bubble` use `x` and `y` roles only (`kind: "value"`; `x.position` bottom/top, `y.position` left/right) with the same fields. Logarithmic scales, minor units/gridlines, crossing values, display units, and tick skipping are unsupported.
- **XY** — `scatter` and `bubble` use `series[].x` + `series[].y` (`bubble` adds one `series[].size` / `sizes` per point), or `series[].points` as `[x, y]` / `[x, y, size]` tuples or `{x, y, size}` objects.
@@ -42,7 +42,7 @@ One resolved row contains these fields:
| Duration | Finite positive schedule duration; scalable native behavior trees preserve their internal timing ratios |
| Delay | Finite non-negative row offset; shape-trigger rows use it as `TriggerDelayTime` |
| Order | Positive integer sidecar order; ties retain stable SVG group order, then `effects[]` index; a group with no sidecar `order` follows the nearest listed group before it in SVG order (and precedes every listed group when none precedes it), so a headline above the numbered body enters first |
| Effect options | Effect-specific `direction`, `amount`, `color`, `font_name` (one installed PowerPoint face, required for Change Font; not a CSS list), `relative`, or `size` values from PowerPoint `EffectParameters` |
| Effect options | Effect-specific `direction`, `amount`, `color`, `font_name` (one installed PowerPoint face, required for Change Font; not a CSS list), `relative`, or `size` values from PowerPoint `EffectParameters`; an edge `direction` (`up` / `right` / `down` / `left`) names the side the effect starts from, PowerPoint's From Top / From Right / From Bottom / From Left |
| Timing options | Repeat count/span, auto-reverse, rewind, accelerate/decelerate, bounce-end ratio, and restart policy |
| Completion / cue | Optional dim/hide behavior and packaged `.m4a`/`.mp3`/`.wav` sound |
@@ -554,7 +554,7 @@ Filters are native-effect metadata, not a general pixel-filter surface.
| Concern | Contract |
|---|---|
| Definition/reference | Direct `<defs><filter id="...">` child with unique id; direct `filter="url(#id)"` attribute, never inline style |
| Public targets | `<rect>`, `<circle>`, `<image>`, `<path>`, `<text>`; one validated compact authored shape-preset `<g>`; an exact outer `<g filter>` whose sole visual child is one clipped `<image>` |
| Public targets | `<rect>`, `<circle>`, `<polygon>`, `<image>`, `<path>`, `<text>`; one validated compact authored shape-preset `<g>`; an exact outer `<g filter>` whose sole visual child is one clipped `<image>` |
| Required primitive | `feDropShadow` or `feGaussianBlur` |
| Generated glow form | Zero-offset `feDropShadow` with flood paint, or the complete blur + flood + composite + merge graph; never bare blur |
| Required parameters | Explicit `stdDeviation` on either effect primitive; explicit `dx`, `dy`, and `flood-opacity` on `feDropShadow`; explicit `flood-opacity` on `feFlood`; explicit `slope` on linear `feFuncA` |
@@ -506,8 +506,9 @@ PPTX import projections and mirror materialization call the same tree-level
implementation before publishing their authoring SVG. Standard workflows do
not rewrite completed SVG: they pass `--canonical-authoring` to
`svg_quality_checker.py`, which reports any remaining deterministic change as an
advisory warning (run `compact_svg_styles.py <svg_output> --inplace` on
authored project pages, re-run `stamp_native_fallbacks.py --write` on pages
advisory warning (run `compact_svg_styles.py <svg_output> --inplace` for
style findings or `compact_svg_coordinates.py <svg_output> --inplace
--keep-native-frames` for page-space metadata on authored project pages, re-run `stamp_native_fallbacks.py --write` on pages
that carry Chart/Table fallbacks because the rewrite changes their
fingerprinted subtree, then rerun the final gate to normalize, or keep the
explicit form). Structured template rosters keep their explicit form: per-slide
@@ -753,6 +754,8 @@ EMF/WMF images referenced by a page are preserved as external references, never
Convert project SVGs into PPTX. EMF/WMF images referenced from `svg_output/` are embedded as native `image/x-emf` / `image/x-wmf` media at full vector fidelity.
Each exported object is named after `data-pptx-shape-name`, else its SVG `id` (or `data-name`), else a positional `Group N` / `TextBox N`; forced-Morph `!!` names still win. The PowerPoint Selection and Animation panes therefore read like the source SVG.
Native formulas use the two markers owned by
[`native-formula.md`](../../references/native-formula.md). A standalone block
stores delimiter-free LaTeX in the JSON metadata of
@@ -1037,9 +1040,12 @@ class-average estimate, with the existing fixed advances for monospaced faces.
(headroom included), while the checker measures each real line glyph by
glyph: capital-heavy words, digits, and wide letters run wider than the Latin
rate, so the table also prints CAPS and DIGITS rates, and a zone should stay
about 5% below its bounds width. `--outline` adds the longest §IX planned
about 5% below its bounds width. The rates ignore `letter-spacing`, so a
tracked role or a display-size line is sized per string with `measure
--letter-spacing`. `--outline` adds the longest §IX planned
line per role — the planned wording only; a line rewritten while authoring
is re-estimated with the rates. The checker's overflow
is re-estimated with the rates. Quick projects have no Design Spec, so
the column stays empty there and the table says so. The checker's overflow
diagnostic prints that line's average px per character, which is not a
reusable rate. A lock role without its own
`<role>_family` resolves to `title_family` when the role name contains
@@ -407,7 +407,7 @@ def finalize_project(
safe_print("[OK] Done!")
print()
print("Next steps:")
print(f" python scripts/svg_to_pptx.py \"{project_dir}\"")
print(f" python3 scripts/svg_to_pptx.py \"{project_dir}\"")
return True
@@ -50,6 +50,7 @@ import concurrent.futures
import json
import os
import re
import stat
import sys
import tempfile
import threading
@@ -848,6 +849,12 @@ def load_manifest(path: str) -> dict:
def save_manifest(path: str, data: dict) -> None:
"""Atomically write manifest back to disk (tmp file + rename)."""
target = Path(path)
try:
mode = stat.S_IMODE(target.stat().st_mode)
except FileNotFoundError:
umask = os.umask(0)
os.umask(umask)
mode = 0o666 & ~umask
fd, tmp_path = tempfile.mkstemp(
prefix=target.stem + ".",
suffix=".tmp",
@@ -857,6 +864,8 @@ def save_manifest(path: str, data: dict) -> None:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
# mkstemp creates 0600; keep the manifest's own permissions.
os.chmod(tmp_path, mode)
os.replace(tmp_path, target)
except Exception:
try:
@@ -265,6 +265,29 @@ def normalize_language_tag(value: str) -> str:
return "-".join(normalized)
_OFFICE_CHINESE_REGIONS = {
"Hans": ("CN", frozenset({"CN", "SG"})),
"Hant": ("TW", frozenset({"TW", "HK", "MO"})),
}
def office_language_tag(value: str) -> str:
"""Return the region-form tag Office writes for one canonical tag.
PowerPoint's own files tag Chinese text ``zh-CN`` / ``zh-TW`` / ``zh-HK``
and match proofing languages by those culture names, so a script-qualified
Chinese tag (``zh-Hant-TW``, ``zh-Hans``) is written in that form. Every
other tag passes through unchanged.
"""
tag = normalize_language_tag(value)
parts = tag.split("-")
if parts[0] != "zh" or len(parts) < 2 or parts[1] not in _OFFICE_CHINESE_REGIONS:
return tag
default_region, regions = _OFFICE_CHINESE_REGIONS[parts[1]]
region = parts[2] if len(parts) > 2 and parts[2] in regions else default_region
return f"zh-{region}"
def language_base(value: str) -> str:
"""Return the lower-case primary language subtag."""
return normalize_language_tag(value).split("-", 1)[0]
@@ -308,8 +308,9 @@ def _project_path(project_path: Path, value: str | None, default: Path) -> Path:
def _project_input_path(project_path: Path, value: str) -> Path:
"""Resolve an input path: absolute, or existing as given, else project-relative."""
path = Path(value)
if path.is_absolute():
if path.is_absolute() or path.exists():
return path
return project_path / path
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
import zipfile
from pathlib import Path
from typing import Any
@@ -175,15 +176,26 @@ def _text_metrics(container: ET.Element, paragraph_count: int) -> dict[str, Any]
}
def _classify_page_type(index: int, total: int, text: str, slots: list[dict[str, Any]]) -> str:
def _has_keyword(text: str, keywords: tuple[str, ...]) -> bool:
"""Match Latin keywords as whole words and CJK keywords across line breaks."""
normalized = text.lower()
compact = re.sub(r"\s+", "", normalized) # vertical "目\n录" still reads 目录
return any(
re.search(rf"(?<![a-z]){re.escape(keyword)}(?![a-z])", normalized)
if keyword.isascii() else keyword in compact
for keyword in keywords
)
def _classify_page_type(index: int, total: int, text: str, slots: list[dict[str, Any]]) -> str:
if index == 1:
return "cover_candidate"
if index == total or any(keyword in normalized for keyword in THANKS_KEYWORDS):
if index == total or _has_keyword(text, THANKS_KEYWORDS):
return "ending_candidate"
if any(keyword in normalized for keyword in TOC_KEYWORDS):
if _has_keyword(text, TOC_KEYWORDS):
return "toc_candidate"
if any(keyword in normalized for keyword in CHAPTER_KEYWORDS):
# Body prose mentions "部分"/"part" too; only a short page reads as a divider.
if len(text) <= 120 and _has_keyword(text, CHAPTER_KEYWORDS):
return "chapter_candidate"
if len(slots) <= 2 and len(text) <= 80:
return "chapter_candidate"
@@ -27,6 +27,7 @@ import argparse
import filecmp
import json
import os
import stat
import re
import shutil
import subprocess
@@ -105,6 +106,25 @@ DEFERRED_CANVAS_MESSAGE = (
)
def _is_project_tree(source_path: Path) -> bool:
"""Return whether a file under projects/ sits inside an initialized project.
A sibling directory such as ``projects/<slug>_web_sources/`` (topic-research
output) is scratch material, not another project's tree.
"""
try:
relative = source_path.resolve().relative_to(PROJECTS_ROOT.resolve())
except ValueError:
return False
if not relative.parts:
return False
root = PROJECTS_ROOT.resolve() / relative.parts[0]
return any(
(root / marker).exists()
for marker in ("svg_output", "design_spec.md", "spec_lock.md", "README.md")
)
def _validate_image_manifest(
payload: object,
path: Path,
@@ -163,6 +183,12 @@ def _read_existing_image_manifest(path: Path) -> list[dict]:
def _write_json_atomic(path: Path, payload: object) -> None:
"""Write JSON through a same-directory temporary file and atomic rename."""
try:
mode = stat.S_IMODE(path.stat().st_mode)
except FileNotFoundError:
umask = os.umask(0)
os.umask(umask)
mode = 0o666 & ~umask
fd, temp_name = tempfile.mkstemp(
prefix=f"{path.stem}.",
suffix=".tmp",
@@ -172,6 +198,8 @@ def _write_json_atomic(path: Path, payload: object) -> None:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
# mkstemp creates 0600; keep the file's own (or the default) mode.
os.chmod(temp_name, mode)
os.replace(temp_name, path)
except Exception:
try:
@@ -281,7 +309,9 @@ class ProjectManager:
# full project dir name pasted back into init) is used as-is —
# re-appending would produce
# `name_ppt169_20260101_ppt169_20260102`.
if re.search(rf"_{re.escape(normalized_format)}_\d{{8}}$", project_name):
if re.search(r"_\d{8}$", project_name):
# `_<format>_<YYYYMMDD>` or a plain `_<YYYYMMDD>`: the caller
# pinned the directory name; --format still sets the canvas.
project_dir_name = project_name
else:
project_dir_name = f"{project_name}_{normalized_format}_{date_str}"
@@ -865,6 +895,7 @@ class ProjectManager:
inside_projects
and not is_within_path(source_path, project_dir.resolve())
and source_path.resolve().parent != PROJECTS_ROOT.resolve()
and _is_project_tree(source_path)
)
if copy:
effective_move = False
@@ -1125,7 +1156,12 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", required=True)
init = subparsers.add_parser("init", help="Create a project directory")
init.add_argument("project_name", help="Project name")
init.add_argument(
"project_name",
help="Project name; the directory becomes <name>_<YYYYMMDD>, or "
"<name>_<format>_<YYYYMMDD> with --format. A name already ending "
"in _<YYYYMMDD> is used as-is.",
)
init.add_argument(
"--format",
default=None,
@@ -193,9 +193,10 @@ def write_passthrough(
warnings=warnings,
)
for warning in warnings:
print(f"[INFO] {warning}")
_print_status(f"[INFO] {warning}")
_print_status(f"[OK] Saved Markdown to: {output}")
_print_status(f" Wrote conversion profile -> {profile}")
if not json_output:
print_output(output)
if json_output:
payload = build_result_payload(
@@ -334,6 +335,7 @@ def dispatch_single(
emit_result.unlink(missing_ok=True)
if output_path and output_path.is_file():
profile = ensure_profile(input_arg, output_path, route.script_name, "web")
if not args.json:
print_output(output_path)
if args.json:
print_json_result(input_arg, output_path, route.script_name, "web", profile)
@@ -377,6 +379,7 @@ def dispatch_single(
return 1
profile = ensure_profile(input_arg, output, route.script_name, conversion_type)
if not args.json:
print_output(output)
if args.json:
print_json_result(input_arg, output, route.script_name, conversion_type, profile)
@@ -182,6 +182,19 @@ def write_conversion_profile(
return profile_path
def record_source_url(markdown_path: str | Path, url: str) -> None:
"""Add the fetched URL to a profile written for a downloaded document."""
profile_path = profile_path_for(Path(markdown_path))
profile = _read_json(profile_path)
if not isinstance(profile, dict) or not isinstance(profile.get("source"), dict):
return
profile["source"]["url"] = url
profile_path.write_text(
json.dumps(profile, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def write_conversion_profile_best_effort(
*,
input_path: str,
@@ -3,7 +3,8 @@
Document to Markdown Converter (hybrid Python + Pandoc fallback)
Primary formats (pure Python, no external tools required):
.docx → mammoth (tables preserved; OMML equations rewritten to inline LaTeX)
.docx → mammoth (tables and embedded chart data as Markdown tables;
OMML equations rewritten to inline LaTeX)
.html → markdownify + BeautifulSoup
.epub → ebooklib + markdownify
.ipynb → nbconvert
@@ -82,6 +83,13 @@ DOCX_NS = {
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
}
EMU_PER_INCH = 914400
CHART_NS = "http://schemas.openxmlformats.org/drawingml/2006/chart"
# Word's built-in Title / Subtitle styles carry the cover heading; mammoth
# leaves them as plain paragraphs unless mapped.
DOCX_STYLE_MAP = """
p[style-name='Title'] => h1:fresh
p[style-name='Subtitle'] => h2:fresh
"""
MATH_NS = DOCX_NS["m"]
W_NS = DOCX_NS["w"]
XML_SPACE_ATTR = "{http://www.w3.org/XML/1998/namespace}space"
@@ -704,9 +712,27 @@ def _docx_paragraph_text(paragraph: ET.Element) -> str:
parts.append("\t")
elif local in {"br", "cr"}:
parts.append(" ")
elif local == "footnoteReference":
parts.append(f"[^{elem.get(f'{{{W_NS}}}id')}]")
return "".join(parts).strip()
def _docx_footnote_texts(docx: zipfile.ZipFile) -> dict[str, str]:
"""Return footnote id → plain text from ``word/footnotes.xml``."""
try:
root = ET.fromstring(docx.read("word/footnotes.xml"))
except (KeyError, ET.ParseError):
return {}
return {
note.get(f"{{{W_NS}}}id", ""): " ".join(
_docx_paragraph_text(paragraph)
for paragraph in note.findall("w:p", DOCX_NS)
).strip()
for note in root.findall("w:footnote", DOCX_NS)
if note.get(f"{{{W_NS}}}type") is None
}
def _docx_table_has_media(table: ET.Element) -> bool:
"""Return whether a table contains image-bearing nodes."""
return any(_local_name(elem) in {"drawing", "imagedata"} for elem in table.iter())
@@ -760,6 +786,7 @@ def _docx_inject_tables_markdown(
try:
with zipfile.ZipFile(input_file) as docx:
document_xml = docx.read("word/document.xml")
footnotes = _docx_footnote_texts(docx)
except (KeyError, zipfile.BadZipFile, OSError):
return None
try:
@@ -776,6 +803,12 @@ def _docx_inject_tables_markdown(
markdown = _docx_table_to_markdown(table)
if not markdown:
continue
# The table never reaches mammoth, so its footnotes would lose both
# the reference and the note text; keep them under the table.
for reference in table.iter(f"{{{W_NS}}}footnoteReference"):
note_id = reference.get(f"{{{W_NS}}}id", "")
if footnotes.get(note_id):
markdown += f"\n\n[^{note_id}]: {footnotes[note_id]}"
parent = parent_map.get(table)
if parent is None:
continue
@@ -803,6 +836,93 @@ def _docx_inject_tables_markdown(
return out_path, replacements
def _docx_chart_markdown(chart_xml: bytes, name: str) -> str:
"""Render one embedded Word chart's cached data as a Markdown table."""
import importlib.util
if importlib.util.find_spec("pptx") is None:
return f"> [Chart] {name} — data unavailable (python-pptx is not installed)"
from pptx.chart.chart import Chart
from pptx.oxml import parse_xml
from ppt_to_md import chart_to_markdown
return chart_to_markdown(Chart(parse_xml(chart_xml), None), name)
def _docx_inject_charts_markdown(
input_file: Path,
) -> tuple[Path, dict[str, str], list[str]] | None:
"""Place each embedded chart's cached data after its paragraph in a temp DOCX.
Mammoth drops ``c:chart`` drawings entirely, so the chart values — often
the only copy of a report's numbers — would vanish from the Markdown.
"""
try:
with zipfile.ZipFile(input_file) as docx:
document_xml = docx.read("word/document.xml")
rels_root = ET.fromstring(docx.read("word/_rels/document.xml.rels"))
chart_parts = {
name: docx.read(name)
for name in docx.namelist()
if name.startswith("word/charts/") and name.endswith(".xml")
}
except (KeyError, ET.ParseError, zipfile.BadZipFile, OSError):
return None
try:
root = ET.fromstring(document_xml)
except ET.ParseError:
return None
charts = list(root.iter(f"{{{CHART_NS}}}chart"))
if not charts:
return None
rels = {
rel.attrib.get("Id"): rel.attrib.get("Target", "")
for rel in rels_root.findall("rel:Relationship", DOCX_NS)
}
parent_map = {child: parent for parent in root.iter() for child in parent}
token_base = uuid.uuid4().hex
replacements: dict[str, str] = {}
warnings: list[str] = []
seen: set[str] = set()
for chart in charts:
rel_id = chart.get(f"{{{DOCX_NS['r']}}}id")
paragraph = parent_map.get(chart)
while paragraph is not None and _local_name(paragraph) != "p":
paragraph = parent_map.get(paragraph)
if not rel_id or rel_id in seen or paragraph is None:
continue
seen.add(rel_id)
name = f"Chart {len(replacements) + 1}"
part_name = posixpath.normpath(posixpath.join("word", rels.get(rel_id, "")))
try:
markdown = _docx_chart_markdown(chart_parts[part_name], name)
except Exception as exc: # a malformed cache must not sink the document
markdown = f"> [Chart] {name} — data unavailable ({exc.__class__.__name__})"
if "data unavailable" in markdown:
warnings.append(f"{name} ({part_name}): chart data unavailable")
parent = parent_map[paragraph]
token = f"MARKDOWNCHART{token_base}{len(replacements):04d}"
parent.insert(list(parent).index(paragraph) + 1, _make_text_paragraph(token))
replacements[token] = markdown
if not replacements:
return None
for prefix, uri in DOCX_NS.items():
if prefix != "rel":
ET.register_namespace(prefix, uri)
patched_xml = ET.tostring(root, encoding="utf-8", xml_declaration=True)
tmp = tempfile.NamedTemporaryFile(suffix=".docx", delete=False)
tmp.close()
out_path = Path(tmp.name)
with zipfile.ZipFile(input_file) as zin, \
zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = patched_xml if item.filename == "word/document.xml" else zin.read(item.filename)
zout.writestr(item, data)
return out_path, replacements, warnings
def _clean_mammoth_markdown(markdown: str) -> str:
"""Remove Mammoth escapes for punctuation that is safe as literal text."""
def _repl(match: re.Match[str]) -> str:
@@ -821,7 +941,9 @@ def _clean_mammoth_markdown(markdown: str) -> str:
# DOCX → Markdown (mammoth)
# ─────────────────────────────────────────────────────────────
def _convert_docx(input_file: Path, out_file: Path) -> str:
def _convert_docx(
input_file: Path, out_file: Path, warnings: list[str] | None = None,
) -> str:
try:
import mammoth
except ImportError:
@@ -887,13 +1009,27 @@ def _convert_docx(input_file: Path, out_file: Path) -> str:
if table_injection is not None:
table_file, table_replacements = table_injection
mammoth_source = table_file
chart_file = None
chart_replacements: dict[str, str] = {}
chart_injection = _docx_inject_charts_markdown(mammoth_source)
if chart_injection is not None:
chart_file, chart_replacements, chart_warnings = chart_injection
mammoth_source = chart_file
if warnings is not None:
warnings.extend(chart_warnings)
try:
with mammoth_source.open("rb") as f:
result = mammoth.convert_to_markdown(
f,
convert_image=mammoth.images.img_element(_save_image),
style_map=DOCX_STYLE_MAP,
)
finally:
if chart_file is not None:
try:
chart_file.unlink()
except OSError:
pass
if table_file is not None:
try:
table_file.unlink()
@@ -908,10 +1044,14 @@ def _convert_docx(input_file: Path, out_file: Path) -> str:
markdown = result.value
for token, table_markdown in table_replacements.items():
markdown = markdown.replace(token, table_markdown)
for token, chart_markdown in chart_replacements.items():
markdown = markdown.replace(token, chart_markdown)
for token, latex in math_replacements.items():
markdown = markdown.replace(token, latex)
markdown = _html_img_to_md(markdown)
markdown = _clean_mammoth_markdown(markdown)
# A logo set in a heading style is an image, not a heading.
markdown = re.sub(r"^#{1,6} (!\[[^\]]*\]\([^)]*\))[ \t]*$", r"\1", markdown, flags=re.M)
out_file.write_text(markdown, encoding="utf-8")
if manifest:
@@ -924,9 +1064,13 @@ def _convert_docx(input_file: Path, out_file: Path) -> str:
media_dir.rmdir()
media_dir = None # type: ignore[assignment]
if chart_replacements:
print(f" Charts: {len(chart_replacements)} rendered from their cached data")
for msg in result.messages:
if msg.type == "warning":
print(f" [warn] {msg.message}")
if warnings is not None:
warnings.append(msg.message)
_report_result(out_file, media_dir)
return markdown
@@ -1345,6 +1489,7 @@ def _convert_with_pandoc(input_file: Path, out_file: Path, suffix: str) -> str:
cmd.extend(["--extract-media", rel_media_dir])
result = subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
cwd=str(out_file.parent))
if result.returncode != 0:
print(f"[ERROR] Pandoc conversion failed:\n{result.stderr}")
@@ -1413,8 +1558,9 @@ def convert_to_markdown(input_path: str, output_path: str | None = None) -> str:
if suffix in NATIVE_FORMATS:
desc = _FORMAT_DESC[suffix]
print(f"[INFO] Converting {desc}: {input_file.name}")
warnings: list[str] = []
if suffix == ".docx":
markdown = _convert_docx(input_file, out_file)
markdown = _convert_docx(input_file, out_file, warnings)
elif suffix in (".html", ".htm"):
markdown = _convert_html(input_file, out_file)
elif suffix == ".epub":
@@ -1429,6 +1575,7 @@ def convert_to_markdown(input_path: str, output_path: str | None = None) -> str:
markdown_path=out_file,
converter="doc_to_md.py",
conversion_type=suffix.lstrip("."),
warnings=warnings,
)
if profile_path:
print(f" Wrote conversion profile -> {profile_path}")
@@ -13,6 +13,7 @@ import json
import os
import re
import sys
import unicodedata
from pathlib import Path
from collections import Counter
@@ -132,6 +133,10 @@ def get_heading_level(size: float, size_map: dict, text: str = "",
if len(text) > 80:
return 0
# Exclusion: a bullet-led line is a list item, whatever its glyph size
if text[:1] in _BULLET_GLYPHS:
return 0
# Exclusion: complete sentences ending with punctuation
sentence_endings = '.。!!??'
if text and text[-1] in sentence_endings:
@@ -184,12 +189,77 @@ def format_span_text(text: str, flags: int) -> str:
return text
# Bullet glyphs a PDF sets as their own span; ``·`` (U+00B7) is common in
# fact sheets, and a stray control character often follows the glyph.
_BULLET_GLYPHS = '•●○◦▪▸►·‧∙・'
def is_bullet_glyph_span(text: str) -> bool:
"""Return whether a span holds only a bullet glyph (plus spaces / control characters)."""
stripped = re.sub(r'[\s\x00-\x1f]+', '', text)
return len(stripped) == 1 and stripped in _BULLET_GLYPHS
# Below this many extracted text characters per page the PDF has no usable
# text layer; a scan renders as one image per page and nothing else.
SCANNED_PDF_TEXT_CHARS_PER_PAGE = 40
def scanned_pdf_warnings(markdown: str, page_count: int, image_count: int) -> list[str]:
"""Return a warning when the Markdown holds page images but almost no text.
``[Done] Success`` with an empty Markdown body is worse than a failure:
a downstream reader takes the file as the converted source and concludes
the document has no usable content. The check counts text outside image
references and page comments against the page count.
"""
if page_count <= 0:
return []
text_lines = [
line for line in markdown.splitlines()
if line.strip() and not line.lstrip().startswith(("![", "<!--"))
]
text_chars = sum(len(line.strip()) for line in text_lines)
if text_chars >= SCANNED_PDF_TEXT_CHARS_PER_PAGE * page_count:
return []
if image_count < max(1, page_count // 2):
return []
return [
f"scanned PDF: {text_chars} text characters over {page_count} pages, "
f"{image_count} page images; no text layer was extracted, so the "
"clauses exist only inside the images (OCR or a text-layer copy is needed)"
]
# Two alef forms before a lam ("اإلحصاءات" for "الإحصاءات") is a lam-alef
# ligature decomposed in the wrong order; well-formed Arabic practically
# never has it. A run of tatweel marks where glyphs were dropped.
_BROKEN_LAM_ALEF_RE = re.compile("[\u0627\u0623\u0625\u0622][\u0627\u0623\u0625\u0622]\u0644")
def arabic_text_layer_warnings(markdown: str) -> list[str]:
"""Warn when an Arabic text layer came out in visual order or lost glyphs."""
arabic = len(re.findall("[\u0600-\u06ff]", markdown))
if arabic < 200:
return []
broken = len(_BROKEN_LAM_ALEF_RE.findall(markdown))
tatweel = markdown.count("\u0640")
if broken < 5 and tatweel < arabic // 20:
return []
return [
f"Arabic text layer looks damaged: {broken} reversed lam-alef "
f"sequences and {tatweel} tatweel marks in {arabic} Arabic letters; "
"words, table cells, and numbers may be out of order or missing "
"letters, so check every figure against the PDF itself"
]
def detect_list_item(text: str) -> tuple:
"""Detect if the text is a list item. Returns (is_list, list_type, content)."""
text = text.strip()
ul_patterns = [
(r'^[•●○◦▪▸►]\s*', '-'),
(rf'^[{_BULLET_GLYPHS}][\s\x00-\x1f]*', '-'),
(r'^[-–—]\s+', '-'),
(r'^\*\s+', '-'),
]
@@ -199,8 +269,11 @@ def detect_list_item(text: str) -> tuple:
return (True, 'ul', marker + ' ' + text[match.end():])
# ``83.2%`` at the start of a line is a decimal, not item 83: after a
# dot the marker must not be followed by another digit.
ol_pattern = r'^(\d+)(?:[、)]|\.(?!\d))\s*'
# dot the marker must not be followed by another digit. ``1. 1 职业名称``
# (a clause number set with a space, as Chinese standards do) is a
# heading path, not item 1 with the text "1 职业名称": a short digit group
# right after the marker, followed by a space or CJK, keeps the line as is.
ol_pattern = r'^(\d+)(?:[、)]|\.(?!\d)(?!\s*\d{1,2}(?:\.\s*\d+)*[\s\u4e00-\u9fff]))\s*'
match = re.match(ol_pattern, text)
if match:
num = match.group(1)
@@ -284,6 +357,27 @@ def detect_headers_footers(doc: fitz.Document, threshold_ratio: float = 0.6) ->
return noise_texts
def _is_hangul(char: str) -> bool:
return "\uac00" <= char <= "\ud7a3" or "\u1100" <= char <= "\u11ff" or "\u3130" <= char <= "\u318f"
def join_wrapped_text(head: str, tail: str) -> str:
"""Join two wrapped PDF lines.
Chinese and Japanese wrap between characters, so a break between wide
characters takes no space; Korean spaces its words and wraps at them, so
a break touching Hangul keeps one.
"""
if (
head and tail
and unicodedata.east_asian_width(head[-1]) in {"W", "F"}
and unicodedata.east_asian_width(tail[0]) in {"W", "F"}
and not (_is_hangul(head[-1]) or _is_hangul(tail[0]))
):
return head + tail
return f"{head} {tail}"
def merge_adjacent_headings(elements: list) -> list:
"""
Merge adjacent same-level short headings.
@@ -332,7 +426,7 @@ def merge_adjacent_headings(elements: list) -> list:
break
# Merge
title_text += " " + next_text
title_text = join_wrapped_text(title_text, next_text)
j += 1
# Create merged element
@@ -1474,6 +1568,12 @@ def extract_pdf_to_markdown(
span_size = span["size"]
span_flags = span["flags"]
# A bullet glyph set in its own larger span must not
# promote the line to a heading; it is a list marker.
if is_bullet_glyph_span(span_text):
formatted_spans.append(span_text)
continue
line_size = max(line_size, span_size)
line_flags |= span_flags
span_count += 1
@@ -1503,6 +1603,8 @@ def extract_pdf_to_markdown(
heading_level = get_heading_level(line_size, size_map, line_text, line_flags)
is_list, list_type, list_content = detect_list_item(line_text)
if is_list and not list_content.split(' ', 1)[-1].strip():
continue
if heading_level > 0:
prefix = '#' * heading_level + ' '
@@ -1553,7 +1655,7 @@ def extract_pdf_to_markdown(
break
if not should_merge_lines({"content": merged_content, "is_heading": False, "is_list": False}, next_el):
break
merged_content += " " + next_el["content"]
merged_content = join_wrapped_text(merged_content, next_el["content"])
j += 1
merged_elements.append({
"type": 0,
@@ -1724,6 +1826,7 @@ def extract_pdf_to_markdown(
if prev_was_code:
flush_code_block()
page_count = len(doc)
doc.close()
markdown_content = merge_markdown_continuation_tables(markdown_content)
@@ -1740,12 +1843,17 @@ def extract_pdf_to_markdown(
json.dumps(image_manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
warnings = scanned_pdf_warnings(markdown_content, page_count, img_count)
warnings += arabic_text_layer_warnings(markdown_content)
for warning in warnings:
print(f"[WARN] {warning}")
profile_path = write_conversion_profile_best_effort(
input_path=pdf_path,
markdown_path=output_path,
converter="pdf_to_md.py",
conversion_type="pdf",
asset_dir=img_dir,
warnings=warnings,
)
print(f"[OK] Saved Markdown to: {output_path}")
if profile_path:
@@ -63,6 +63,7 @@ if not _HELP_REQUESTED:
from pptx.enum.action import PP_ACTION
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.oxml.ns import qn
from pptx.text.text import _Run
except ImportError:
print("[ERROR] python-pptx not installed. Run: pip install python-pptx", file=sys.stderr)
sys.exit(1)
@@ -302,7 +303,15 @@ def _paragraph_to_markdown(
parts.append(f"{lead}[{display}]({current_url}){trail}")
has_run_hyperlink = False
for run in paragraph.runs:
for child in paragraph._p:
tag = child.tag.rsplit("}", 1)[-1] if isinstance(child.tag, str) else ""
if tag == "br":
# A soft line break inside the paragraph; python-pptx's runs skip it.
current_text += "\n"
continue
if tag != "r":
continue
run = _Run(child, paragraph)
url = _run_url(run, shape)
if url:
has_run_hyperlink = True
@@ -37,6 +37,7 @@ import json
import os
import re
import socket
import subprocess
import sys
import time
from pathlib import Path
@@ -47,8 +48,17 @@ if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from _dispatcher import ( # noqa: E402
DOC_SUFFIXES,
EXCEL_SUFFIXES,
LEGACY_EXCEL_SUFFIXES,
PDF_SUFFIXES,
PRESENTATION_SUFFIXES,
build_conversion_command,
)
from _conversion_profile import ( # noqa: E402
profile_path_for,
record_source_url,
write_conversion_profile_best_effort,
)
@@ -272,15 +282,8 @@ CONFIG = {
}
def fetch_url(url: str) -> tuple[str, str]:
"""Fetch a web page with explicit headers and encoding detection.
Args:
url: Target URL.
Returns:
The response body as text and the final URL after redirects.
"""
def fetch_response(url: str):
"""Fetch a URL with explicit headers and return the raw HTTP response."""
headers = {
"User-Agent": CONFIG["user_agent"],
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
@@ -291,12 +294,17 @@ def fetch_url(url: str) -> tuple[str, str]:
response = _http_get(url, headers=headers,
timeout=CONFIG["timeout"], verify=not CONFIG["insecure"])
response.raise_for_status()
return _decode_response_text(response), response.url
return response
except Exception as e:
raise Exception(f"Failed to fetch {url}: {str(e)}")
def fetch_url(url: str) -> tuple[str, str]:
"""Fetch a web page as decoded text plus the final URL after redirects."""
response = fetch_response(url)
return _decode_response_text(response), response.url
def clean_title(title: str) -> str:
"""Remove common site suffixes from a title."""
if not title:
@@ -310,8 +318,8 @@ def sanitize_filename(name: str) -> str:
"""Sanitize a string for filesystem-safe filenames."""
# Replace whitespace with underscore first
clean = re.sub(r'\s+', '_', name)
# Remove all except Chinese, English, Numbers, Underscore
clean = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9_]', '', clean)
# Remove all except Chinese, English, Numbers, Underscore, Hyphen
clean = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9_-]', '', clean)
# Collapse repeating underscores
clean = re.sub(r'_+', '_', clean)
return clean[:80] # Truncate
@@ -780,6 +788,44 @@ def simple_html_to_markdown_traversal(
"""Convert HTML content to Markdown using BeautifulSoup traversal."""
lines = []
def fold(node: Tag) -> str:
"""Convert a cell or caption like body text, folded onto one line."""
text = ''.join(traverse(child) for child in node.children)
return re.sub(r'\s+', ' ', text).strip().replace('|', '\\|')
def span(cell: Tag, name: str) -> int:
try:
return min(max(int(cell.get(name) or 1), 1), 50)
except ValueError:
return 1
def row_cells(row: Tag, carried: dict[int, int] | None = None) -> list[str]:
"""Return one row's cells on the table grid.
A colspan pads empty cells to its right; ``carried`` maps a column to
the rows a rowspan above still covers, which take an empty cell here.
"""
carried = {} if carried is None else carried
cells: list[str] = []
def fill_carried() -> None:
while carried.get(len(cells), 0) > 0:
carried[len(cells)] -= 1
cells.append('')
for cell in row.find_all(['td', 'th'], recursive=False):
fill_carried()
rowspan = span(cell, 'rowspan')
for offset in range(span(cell, 'colspan')):
if rowspan > 1:
carried[len(cells)] = rowspan - 1
cells.append(fold(cell) if offset == 0 else '')
while any(count > 0 for column, count in carried.items() if column >= len(cells)):
if carried.get(len(cells), 0) > 0:
carried[len(cells)] -= 1
cells.append('')
return cells
def traverse(node: Tag | NavigableString) -> str:
if isinstance(node, Comment):
# ``Comment`` is a ``NavigableString`` subclass: without this
@@ -868,24 +914,29 @@ def simple_html_to_markdown_traversal(
if is_block:
inner_text = inner_text.strip()
# Post-processing for tables (simplified)
if node.name == 'tr':
# count tds
cells = [c.get_text(strip=True) for c in node.find_all(
['td', 'th'], recursive=False)]
return f"| {' | '.join(cells)} |\n"
return f"| {' | '.join(row_cells(node))} |\n"
if node.name == 'table':
# Try to add a separator line after first row if it looks like a header
rows = inner_text.strip().split('\n')
if rows:
cols_count = rows[0].count('|') - 1
if cols_count > 0:
# rough approx
sep = "| " + " | ".join(["---"] * int(cols_count/2)) + " |"
# Actually, the traverse of TR returns newline terminated strings.
# Let's just return what we gathered.
pass
carried: dict[int, int] = {}
rows = [
cells for cells in (
row_cells(tr, carried) for tr in node.find_all('tr')
if tr.find_parent('table') is node
) if cells
]
if not rows:
return f"\n\n{inner_text}\n\n"
width = max(len(cells) for cells in rows)
rows = [cells + [''] * (width - len(cells)) for cells in rows]
lines = [f"| {' | '.join(cells)} |" for cells in rows]
lines.insert(1, '| ' + ' | '.join(['---'] * width) + ' |')
caption = node.find('caption')
caption_text = (
fold(caption) if caption is not None and caption.find_parent('table') is node
else ''
)
lead = f"{caption_text}\n\n" if caption_text else ''
return f"\n\n{lead}" + '\n'.join(lines) + "\n\n"
return f"{prefix}{inner_text}{suffix}"
@@ -955,11 +1006,90 @@ def _save_plain_text_document(
return True, url, None, output_path
_DOCUMENT_CONTENT_TYPES = {
"application/pdf": ".pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/msword": ".doc",
"application/vnd.ms-excel": ".xls",
"application/epub+zip": ".epub",
}
_DOCUMENT_URL_SUFFIXES = frozenset(
PDF_SUFFIXES | EXCEL_SUFFIXES | LEGACY_EXCEL_SUFFIXES | PRESENTATION_SUFFIXES
| (DOC_SUFFIXES - {".html", ".htm"})
)
def remote_document_suffix(url: str, content_type: str, head: bytes) -> str | None:
"""Return the file suffix when a fetched URL is a document, not an HTML page.
A ``.pdf`` URL (or a suffix-less download that answers ``application/pdf``)
has no HTML body; the HTML extractor would fail on it. The body magic and
the Content-Type decide first, then the URL suffix, unless the server
explicitly answered with HTML (a viewer page at a document-looking URL).
"""
if head.startswith(b"%PDF-"):
return ".pdf"
ctype = content_type.split(";")[0].strip().lower()
if ctype in _DOCUMENT_CONTENT_TYPES:
return _DOCUMENT_CONTENT_TYPES[ctype]
if ctype.startswith("text/html") or ctype == "application/xhtml+xml":
return None
suffix = os.path.splitext(urlparse(url).path)[1].lower()
return suffix if suffix in _DOCUMENT_URL_SUFFIXES else None
def _convert_remote_document(
url: str, body: bytes, suffix: str, output_file: str | None,
download_images: bool = True,
) -> tuple[bool, str, str | None, str | None]:
"""Save a downloaded document beside its Markdown and run its own converter."""
stem = os.path.splitext(os.path.basename(urlparse(url).path))[0]
output_path = output_file or os.path.join(
CONFIG["output_dir"], f"{derive_base_name(stem, url)}.md",
)
output_dirname = os.path.dirname(output_path) or "."
os.makedirs(output_dirname, exist_ok=True)
base_name = os.path.splitext(os.path.basename(output_path))[0]
local_path = os.path.join(output_dirname, f"{base_name}{suffix}")
with open(local_path, "wb") as f:
f.write(body)
print(f" [OK] Document: {len(body)} bytes saved to {local_path}")
route = build_conversion_command(
local_path, output_path,
pdf_image_mode=None if download_images else "none",
)
print(f" [>>] {route.script_name} {local_path}")
sys.stdout.flush()
rc = subprocess.run(route.command).returncode
if rc != 0 or not os.path.isfile(output_path):
return False, url, f"{route.script_name} exited with {rc}", None
record_source_url(output_path, url)
return True, url, None, output_path
_META_REFRESH_RE = re.compile(
r"^\s*(\d+(?:\.\d+)?)\s*[;,]\s*url\s*=\s*['\"]?([^'\"]+)", re.IGNORECASE)
def _meta_refresh_target(soup: BeautifulSoup, base_url: str) -> str | None:
"""Return the http(s) target of an immediate `<meta http-equiv="refresh">`."""
meta = soup.find("meta", attrs={"http-equiv": re.compile(r"^refresh$", re.IGNORECASE)})
match = _META_REFRESH_RE.match(meta.get("content", "")) if meta else None
if not match or float(match.group(1)) > 5:
return None
target = urljoin(base_url, match.group(2).strip())
return target if urlparse(target).scheme in {"http", "https"} else None
def process_url(
url: str,
output_file: str | None = None,
*,
download_images: bool = True,
_refresh_hops: int = 0,
) -> tuple[bool, str, str | None, str | None]:
"""Fetch, convert, and save one web page as Markdown.
@@ -969,12 +1099,27 @@ def process_url(
"""
print(f"\n[Fetching] {url}")
try:
html, page_url = fetch_url(url)
response = fetch_response(url)
suffix = remote_document_suffix(
url, response.headers.get("Content-Type", ""), response.content[:8])
if suffix:
return _convert_remote_document(
url, response.content, suffix, output_file, download_images,
)
html, page_url = _decode_response_text(response), response.url
if is_plain_text_document(url, html):
return _save_plain_text_document(url, html, output_file)
soup = BeautifulSoup(html, 'html.parser')
base = soup.find('base', href=True)
base_url = urljoin(page_url, base['href']) if base else page_url
refresh_url = _meta_refresh_target(soup, base_url)
if refresh_url and refresh_url != page_url and _refresh_hops < 3:
print(f" [>>] Meta refresh: {refresh_url}")
ok, _, error, saved = process_url(
refresh_url, output_file,
download_images=download_images, _refresh_hops=_refresh_hops + 1,
)
return ok, url, error, saved
# Extract Metadata
metadata = extract_metadata(soup, url)
@@ -1013,6 +1158,12 @@ def process_url(
# Note: We pass the element to our traversal function
markdown_text = simple_html_to_markdown_traversal(content_div, base_url)
print(f" [OK] Content: {len(markdown_text)} chars")
warnings = []
if not markdown_text.strip():
warnings.append(
"no readable body text extracted; the page may render its "
"content with scripts or link to it elsewhere")
print(f" [WARN] {warnings[0]}")
# Construct content
final_output = []
@@ -1044,6 +1195,7 @@ def process_url(
converter="web_to_md.py",
conversion_type="web",
asset_dir=image_dir if image_count else None,
warnings=warnings,
)
print(f" [OK] Saved: {output_path}")
@@ -1133,10 +1285,17 @@ def main(argv: list[str] | None = None) -> int:
)
return 2
if args.output and len(targets) > 1:
print(
"web_to_md.py: error: -o/--output names one Markdown file and takes "
"one URL; for several URLs pass --dir <output directory> instead",
file=sys.stderr,
)
return 2
results = []
for i, url in enumerate(targets):
# Allow specific output file only if 1 URL
out = args.output if (len(targets) == 1 and args.output) else None
out = args.output or None
success, url, err, out_path = process_url(
url,
out,
@@ -303,10 +303,19 @@ def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
try:
files = _svg_files(args.input.resolve())
prepared = [
(path, *_prepare_file(path))
for path in files
]
except (NativeFallbackStampError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
# Pages stamp independently: one malformed page is reported without
# leaving every valid page unstamped.
prepared = []
errors: list[str] = []
for path in files:
try:
prepared.append((path, *_prepare_file(path)))
except (NativeFallbackStampError, OSError) as exc:
errors.append(str(exc))
try:
if args.write:
for path, payload, _svg_first, _json_first, changed in prepared:
if changed:
@@ -339,7 +348,9 @@ def main(argv: Optional[list[str]] = None) -> int:
)
if changed_files and not args.write:
print("Re-run with --write to apply these validated baseline updates.")
return 0
for error in errors:
print(f"Error: {error}", file=sys.stderr)
return 1 if errors else 0
if __name__ == "__main__":
@@ -1188,6 +1188,9 @@ class SVGQualityChecker:
self._pptx_structure_issues: List[Tuple[str, str]] = []
self._has_incomplete_page_roster = False
self._active_slide_count: int | None = None
# Early/page stages see part of the roster, so a slide jump's upper
# bound waits for the final gate.
self.partial_roster = False
self._prototype_by_output: Dict[Path, Path] = {}
self._active_prototype_path: Path | None = None
self._active_template_reuse_scope: str | None = None
@@ -2206,10 +2209,17 @@ class SVGQualityChecker:
for error in errors
)
return
def normalizer(error: str) -> str:
if "page-space metadata" in error:
return (
"`python3 scripts/compact_svg_coordinates.py <svg_output> "
"--inplace --keep-native-frames`"
)
return "`python3 scripts/compact_svg_styles.py <svg_output> --inplace`"
result['warnings'].extend(
f"Noncanonical compact authoring: {error} "
"(advisory; normalize with "
"`python3 scripts/compact_svg_styles.py <svg_output> --inplace`, "
f"(advisory; normalize with {normalizer(error)}, "
"re-stamp pages that carry Chart/Table fallbacks, and rerun the "
"final gate, or leave the explicit form)"
for error in errors
@@ -2779,7 +2789,7 @@ class SVGQualityChecker:
f'Invalid SVG hyperlink: {error}'
for error in _project_hyperlink_errors(
root,
slide_count=self._active_slide_count,
slide_count=None if self.partial_roster else self._active_slide_count,
)
)
@@ -9127,7 +9137,7 @@ class SVGQualityChecker:
"remain non-blocking"
)
print(f" 4. foreignObject: Use <text> + <tspan> for manual line breaks")
print(f" 5. Font issues: use PPT-safe exported typefaces (e.g. Microsoft YaHei / Arial / Consolas)")
print(f" 5. Font issues: use PPT-safe exported typefaces (e.g. Arial / Consolas, with the CJK face of the deck language)")
def _carrier_receipt_summary(self) -> Dict:
"""Aggregate factual per-page carrier receipts for compact review."""
@@ -251,6 +251,7 @@ def main() -> None:
print("=" * 80)
checker.check_directory(str(project))
else:
checker.partial_roster = stage in {"early", "page", "first-page"}
if roundtrip:
checker.check_roundtrip_workspace(target)
elif stage == "early":
@@ -94,6 +94,7 @@ from .styles import (
)
from .elements import (
complete_preset_adjustments,
shape_display_name,
empty_clip_path_reason,
convert_rect, convert_circle, convert_ellipse,
convert_line, convert_path,
@@ -831,6 +832,9 @@ def _convert_semantic_shape(
):
if carrier.get(name) is None and shape.get(name) is not None:
carrier.set(name, str(shape.get(name)))
if carrier.get('data-name') is None and shape.get('id'):
# The carrier path becomes the native object; keep the group's name.
carrier.set('data-name', str(shape.get('id')))
if shape.get('data-pptx-geometry-kind') == 'custom':
for name in (
@@ -1444,7 +1448,7 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
return ShapeResult(xml=f'''<p:grpSp>
<p:nvGrpSpPr>
<p:cNvPr id="{group_id}" name="Group {group_id}"/>
<p:cNvPr id="{group_id}" name="{_xml_escape(shape_display_name(elem, f'Group {group_id}'))}"/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
@@ -468,6 +468,20 @@ def project_image_errors(
return sorted(errors)
def shape_display_name(elem: ET.Element, fallback: str) -> str:
"""Name a PowerPoint object after its SVG identity when it has one.
``data-pptx-shape-name`` wins, then the SVG ``id`` (or ``data-name``), so
the Selection and Animation panes show ``p08-rail-edge`` rather than
``Freeform 9``; unnamed objects keep the positional fallback.
"""
for attribute in ('data-pptx-shape-name', 'id', 'data-name'):
value = (elem.get(attribute) or '').strip()
if value:
return value
return fallback
def _wrap_shape(
shape_id: int, name: str,
off_x: int, off_y: int,
@@ -550,7 +564,7 @@ def _wrap_geometry_object(
"""Wrap a semantic leaf as a shape or connector without guessing."""
if not effect_xml:
effect_xml = _element_effect_xml(elem, ctx)
name = elem.get('data-pptx-shape-name') or name
name = shape_display_name(elem, name)
shape_style_xml = _decode_shape_style(elem)
object_kind = elem.get('data-pptx-object')
if object_kind != 'connector':
@@ -1909,6 +1923,14 @@ def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
fill = build_fill_xml(elem, ctx, fill_op)
stroke = build_stroke_xml(elem, ctx, stroke_op)
effect = ''
filt_id = get_effective_filter_id(elem, ctx)
if filt_id and filt_id in ctx.defs:
effect = build_effect_xml(
ctx.defs[filt_id],
get_element_opacity(elem, ctx),
)
shape_id = _claim_element_shape_id(elem, ctx)
xfrm_attr = ''
off_x = px_to_emu(min_x)
@@ -1931,7 +1953,7 @@ def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
ctx,
shape_id, f'Polygon {shape_id}',
off_x, off_y, w_emu, h_emu,
geom, fill, stroke, xfrm_attr=xfrm_attr,
geom, fill, stroke, effect, xfrm_attr=xfrm_attr,
),
bounds_emu=bounds_emu,
)
@@ -2954,7 +2976,10 @@ def _build_run_properties_xml(
spc_attr = _letter_spacing_to_drawingml_spc(letter_spacing_px)
baseline_attr = f' baseline="{baseline_shift}"' if baseline_shift else ''
fonts = parse_font_family(ff) if ff else default_fonts
fonts = (
parse_font_family(ff, ctx.primary_language if ctx is not None else None)
if ff else default_fonts
)
run_fonts = (
{
'latin': fixed_font_family,
@@ -3174,7 +3199,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
else:
letter_spacing_px = 0.0
fonts = parse_font_family(font_family_str)
fonts = parse_font_family(font_family_str, ctx.primary_language)
parent_attrs: dict[str, Any] = {
'fill': fill_color,
@@ -3627,7 +3652,7 @@ def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
shape_xml = f'''<p:sp>
<p:nvSpPr>
<p:cNvPr id="{shape_id}" name="TextBox {shape_id}"/>
<p:cNvPr id="{shape_id}" name="{_xml_escape(shape_display_name(elem, f'TextBox {shape_id}'))}"/>
<p:cNvSpPr txBox="1"/><p:nvPr/>
</p:nvSpPr>
<p:spPr>
@@ -4978,7 +5003,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
return ShapeResult(xml=f'''<p:pic>
<p:nvPicPr>
<p:cNvPr id="{shape_id}" name="Image {shape_id}"/>
<p:cNvPr id="{shape_id}" name="{_xml_escape(shape_display_name(elem, f'Image {shape_id}'))}"/>
<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
@@ -5561,7 +5586,7 @@ def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | N
return ShapeResult(xml=f'''<p:pic>
<p:nvPicPr>
<p:cNvPr id="{shape_id}" name="Image {shape_id}"/>
<p:cNvPr id="{shape_id}" name="{_xml_escape(shape_display_name(elem, f'Image {shape_id}'))}"/>
<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
@@ -147,8 +147,8 @@ def infer_master_text_style_spec(
)
def _font_face(font_family: str) -> ThemeFontFace:
fonts = parse_font_family(font_family)
def _font_face(font_family: str, language: str | None = None) -> ThemeFontFace:
fonts = parse_font_family(font_family, language)
return ThemeFontFace(
latin=fonts["latin"],
ea=fonts["ea"],
@@ -177,7 +177,10 @@ def _typography_rows(lock_path: Path) -> dict[str, str]:
return rows
def load_theme_font_spec(project_path: Path) -> ThemeFontSpec | None:
def load_theme_font_spec(
project_path: Path,
language: str | None = None,
) -> ThemeFontSpec | None:
"""Load major/minor theme fonts from ``spec_lock.md`` typography rows."""
lock_path = project_path / "spec_lock.md"
if not lock_path.is_file():
@@ -189,8 +192,8 @@ def load_theme_font_spec(project_path: Path) -> ThemeFontSpec | None:
if not major_family or not minor_family:
return None
return ThemeFontSpec(
major=_font_face(major_family),
minor=_font_face(minor_family),
major=_font_face(major_family, language),
minor=_font_face(minor_family, language),
major_family=major_family,
minor_family=minor_family,
)
@@ -263,6 +263,7 @@ PROJECT_FILTER_PUBLIC_TARGETS = frozenset({
'circle',
'image',
'path',
'polygon',
'text',
})
_PROJECT_MARKER_NUMBER_TOKEN = (
@@ -2821,7 +2822,7 @@ def project_filter_errors(root: ET.Element) -> list[str]:
):
errors.add(
f'{label} cannot use filter; supported native targets are '
'rect, circle, image, path, text, a validated compact authored-'
'rect, circle, image, path, polygon, text, a validated compact authored-'
'preset shape, and an exact registered carrier group'
)
if tag == 'image' and elem.get('clip-path') is not None:
@@ -3190,7 +3191,41 @@ def get_effective_filter_id(elem: ET.Element, ctx: ConvertContext) -> str | None
# Font parsing
# ---------------------------------------------------------------------------
def parse_font_family(font_family_str: str) -> dict[str, str]:
# Windows EA faces for decks whose primary language is not Simplified Chinese:
# (sans, serif) by BCP-47 language/script prefix.
_EA_DEFAULTS_BY_LANGUAGE = (
(('ja',), ('Yu Gothic', 'Yu Mincho')),
(('ko',), ('Malgun Gothic', 'Batang')),
(('zh-hant', 'zh-tw', 'zh-hk', 'zh-mo'), ('Microsoft JhengHei', 'PMingLiU')),
)
# macOS Japanese faces map to Japanese Windows faces, not to Chinese ones.
_JA_FONT_FALLBACK_WIN = {
'Hiragino Sans': 'Yu Gothic',
'Hiragino Kaku Gothic ProN': 'Yu Gothic',
'Hiragino Kaku Gothic Pro': 'Yu Gothic',
'Hiragino Mincho ProN': 'Yu Mincho',
'Hiragino Mincho Pro': 'Yu Mincho',
}
def _language_is(language: str | None, prefix: str) -> bool:
"""Return whether a BCP-47 tag equals or starts with one subtag prefix."""
tag = (language or '').lower()
return tag == prefix or tag.startswith(prefix + '-')
def _ea_default(language: str | None, serif: bool) -> str:
"""Return the Windows EA fallback face for one deck language."""
for prefixes, faces in _EA_DEFAULTS_BY_LANGUAGE:
if any(_language_is(language, prefix) for prefix in prefixes):
return faces[1] if serif else faces[0]
return 'SimSun' if serif else 'Microsoft YaHei'
def parse_font_family(
font_family_str: str,
language: str | None = None,
) -> dict[str, str]:
"""Parse CSS font-family into latin/ea typeface names.
Prioritizes Windows-available fonts since PPTX is primarily opened on
@@ -3198,9 +3233,13 @@ def parse_font_family(font_family_str: str) -> dict[str, str]:
first named Latin face fills ``latin`` and the first named CJK face fills
``ea``; a CJK face also serves ``latin`` when no named Latin face exists,
and a generic family fills ``latin`` only when it precedes every named face.
``language`` (the deck's BCP-47 primary language) picks the EA fallback
when the stack names no CJK face, so Japanese text never lands on a
Chinese face.
"""
is_japanese = _language_is(language, 'ja')
if not font_family_str:
return {'latin': 'Segoe UI', 'ea': 'Microsoft YaHei'}
return {'latin': 'Segoe UI', 'ea': _ea_default(language, False)}
fonts = [f.strip().strip("'\"") for f in font_family_str.split(',')]
latin_font = None
@@ -3217,7 +3256,9 @@ def parse_font_family(font_family_str: str) -> dict[str, str]:
latin_font = GENERIC_FONT_MAP[font]
continue
win_font = FONT_FALLBACK_WIN.get(font, font)
win_font = (
_JA_FONT_FALLBACK_WIN.get(font) if is_japanese else None
) or FONT_FALLBACK_WIN.get(font, font)
if font in EA_FONTS:
ea_font = ea_font or win_font
else:
@@ -3231,7 +3272,7 @@ def parse_font_family(font_family_str: str) -> dict[str, str]:
# EA must always be a CJK-capable font
if not ea_font:
ea_font = 'SimSun' if final_latin in _SERIF_LATIN else 'Microsoft YaHei'
ea_font = _ea_default(language, final_latin in _SERIF_LATIN)
return {'latin': final_latin, 'ea': ea_font}
@@ -130,6 +130,17 @@ def _chart_data_labels(
raise RuntimeError(
"Native PPTX chart data_labels.points color must be a color"
)
if (
point_items
and not data_labels_show_unlisted(config)
and all(item.get("delete") is True for item in point_items)
):
raise RuntimeError(
"Native PPTX chart data_labels.points lists the labels to show; "
"every listed point is delete: true, so no label remains. List the "
"points to label, or set show_value: true so unlisted points keep "
"their labels and delete entries hide single points"
)
colors = _chart_list(
_first_present(
config.get("colors"),
@@ -169,6 +180,24 @@ def _chart_data_labels(
return config
def data_labels_show_unlisted(config: dict[str, Any]) -> bool:
"""Return whether points absent from ``points`` keep the series label.
An explicit truthy ``show_*`` flag makes ``points`` a list of overrides,
as in OOXML and the PPTX importer; without one ``points`` lists the only
labels shown.
"""
return any(
_chart_bool(_first_present(*(config.get(key) for key in aliases)), False)
for aliases in (
("show_value", "showValue", "value"),
("show_category", "showCategory", "category"),
("show_series", "showSeries", "series"),
("show_percent", "showPercent", "percent"),
)
)
def _data_label_point_items(
config: dict[str, Any],
chart_type: str,
@@ -347,10 +347,10 @@ def _major_gridlines_xml(color: str | None) -> str:
return f'<c:majorGridlines>{_chart_line_sp_pr_xml(color, width=6350)}</c:majorGridlines>'
def _font_face_xml(font_face: str | None) -> str:
def _font_face_xml(font_face: str | None, language: str | None = None) -> str:
if not font_face:
return ""
fonts = parse_font_family(font_face)
fonts = parse_font_family(font_face, language)
latin_font = _xml_escape(fonts["latin"])
ea_font = _xml_escape(fonts["ea"])
return (
@@ -378,7 +378,7 @@ def _chart_tx_pr_xml(
return (
f"<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr{rtl_attr}>"
f'<a:defRPr lang="{resolved_language}" sz="{font_size}"{bold_attr}>'
f'{fill_xml}{_font_face_xml(font_face)}</a:defRPr>'
f'{fill_xml}{_font_face_xml(font_face, language)}</a:defRPr>'
f'</a:pPr><a:endParaRPr lang="{resolved_language}"/></a:p></c:txPr>'
)
@@ -472,7 +472,7 @@ def _axis_title_xml(
"<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>"
f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
f'sz="{_chart_text_entry_font_size(item, font_size)}">'
f"{fill_xml}{_font_face_xml(_chart_text_entry_font_face(item, font_face))}"
f"{fill_xml}{_font_face_xml(_chart_text_entry_font_face(item, font_face), primary_language)}"
f"{run_rtl}</a:rPr>"
f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>"
"</c:rich></c:tx><c:layout/><c:overlay val=\"0\"/></c:title>"
@@ -1424,7 +1424,7 @@ def _text_box_xml(
lang = detect_text_lang(text, ctx.primary_language)
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
run_properties_xml = (
f'{fill_xml}{_font_face_xml(font_face)}'
f'{fill_xml}{_font_face_xml(font_face, ctx.primary_language)}'
f'{run_rtl}'
)
rtl_attr = (
@@ -23,6 +23,7 @@ from .chart_data import (
_data_label_position,
_data_label_point_items,
_data_labels_config,
data_labels_show_unlisted,
)
from .chart_style import (
_alpha_xml,
@@ -231,7 +232,7 @@ def _data_label_custom_text_xml(
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(line) else ""
paragraphs.append(
f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
f'sz="{font_size}"{bold_attr}>{fill_xml}{_font_face_xml(font_face)}'
f'sz="{font_size}"{bold_attr}>{fill_xml}{_font_face_xml(font_face, language)}'
f'{run_rtl}</a:rPr><a:t>{_xml_escape(line)}</a:t></a:r></a:p>'
)
return (
@@ -296,10 +297,12 @@ def _data_labels_xml(
)
if point_items:
selected_items = {int(item["idx"]): item for item in point_items}
show_unlisted = data_labels_show_unlisted(config)
point_label_xml = ""
for idx in range(point_count):
item = selected_items.get(idx)
if item is None:
if not show_unlisted:
point_label_xml += f'<c:dLbl><c:idx val="{idx}"/><c:delete val="1"/></c:dLbl>'
continue
if item.get("delete") is True:
@@ -355,7 +358,11 @@ def _data_labels_xml(
f"{_data_label_flags_xml({**config, **item})}"
"</c:dLbl>"
)
return f"<c:dLbls>{point_label_xml}{leader_lines_xml}</c:dLbls>"
series_label_xml = ""
if show_unlisted:
position_xml = f'<c:dLblPos val="{position}"/>' if position else ""
series_label_xml = f"{num_fmt_xml}{tx_pr_xml}{position_xml}{flags_xml}"
return f"<c:dLbls>{point_label_xml}{series_label_xml}{leader_lines_xml}</c:dLbls>"
label_colors = [
_clean_hex(item, "#404040")
@@ -620,7 +627,7 @@ def _chart_title_paragraph_xml(
run_rtl = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else ''
return (
f'<a:p><a:pPr{rtl_attr}/><a:r><a:rPr lang="{lang}" '
f'sz="{font_size}">{fill_xml}{_font_face_xml(font_face)}'
f'sz="{font_size}">{fill_xml}{_font_face_xml(font_face, primary_language)}'
f'{run_rtl}</a:rPr>'
f"<a:t>{_xml_escape(text)}</a:t></a:r></a:p>"
)
@@ -88,7 +88,7 @@ def _table_text_run(
f'<a:cs typeface="{escaped_face}"/>'
)
else:
font_xml = _font_face_xml(font_face)
font_xml = _font_face_xml(font_face, default_language)
outline_xml = ""
if outline is not None and outline.style == "solid":
assert outline.color is not None and outline.width is not None
@@ -71,7 +71,7 @@ from pptx_opc_validation import (
from pptx_workspace import WorkspaceResourceSpec
from pptx_ooxml.clone import clone_presentation_slides
from pptx_ooxml.package import prune_unreferenced_directory_parts
from language_tags import normalize_language_tag
from language_tags import office_language_tag
from hyperlink_contract import (
HYPERLINK_REL_TYPE,
trigger_shape_hyperlink_errors,
@@ -166,6 +166,26 @@ THEME_REL_TYPE = (
)
THEME_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.theme+xml"
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
# ST_SlideSizeType tokens for the standard screen ratios; every other canvas
# (portrait story, banner, print) is ``custom``. python-pptx's default
# template says ``screen4x3`` whatever cx/cy are set to afterwards.
_SLIDE_SIZE_TYPES = ((4, 3, "screen4x3"), (16, 9, "screen16x9"), (16, 10, "screen16x10"))
def _slide_size_type(width_emu: int, height_emu: int) -> str:
"""Return the ``p:sldSz type`` token matching a slide size."""
for ratio_w, ratio_h, token in _SLIDE_SIZE_TYPES:
if abs(width_emu * ratio_h - height_emu * ratio_w) <= max(width_emu, height_emu) // 200:
return token
return "custom"
def _set_slide_size_type(presentation, width_emu: int, height_emu: int) -> None:
"""Make the ``type`` token agree with the cx/cy the exporter just set."""
slide_size = presentation.element.find(f"{{{PML_NS}}}sldSz")
if slide_size is not None:
slide_size.set("type", _slide_size_type(width_emu, height_emu))
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
@@ -5120,14 +5140,19 @@ def _relax_output_permissions(output_path: Path) -> list[str]:
result = subprocess.run(
['icacls', str(output_path), '/grant', '*S-1-5-32-545:R'],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
warnings.append(f"icacls skipped for {output_path}: {exc}")
else:
if result.returncode != 0:
message = (result.stderr or result.stdout or '').strip()
# icacls writes in the console OEM code page, so decode lazily
# and leniently; text mode would decode under the interpreter's
# encoding (UTF-8 in UTF-8 mode) and raise inside the reader
# thread on non-ASCII bytes.
message = (result.stderr or result.stdout or b'').decode(
'oem', errors='replace',
).strip()
details = f": {message}" if message else ''
warnings.append(f"icacls failed for {output_path}{details}")
@@ -5979,6 +6004,25 @@ def _prerender_legacy_pngs(
return results
def _apply_template_text_language(extract_dir: Path, language: str) -> None:
"""Retag base-template en-US default text with the deck language.
Covers the presentation default text style (new text boxes) and master and
layout placeholders, so proofing follows the deck rather than en-US.
"""
ppt_dir = extract_dir / "ppt"
parts = [ppt_dir / "presentation.xml"]
parts += sorted((ppt_dir / "slideMasters").glob("slideMaster*.xml"))
parts += sorted((ppt_dir / "slideLayouts").glob("slideLayout*.xml"))
for part in parts:
if not part.is_file():
continue
xml = part.read_text(encoding="utf-8")
updated = xml.replace('lang="en-US"', f'lang="{language}"')
if updated != xml:
part.write_text(updated, encoding="utf-8")
def _presentation_format(width: float, height: float) -> str:
"""Map the slide aspect ratio to PowerPoint's PresentationFormat label.
Non-standard ratios (square, portrait, banner crops) report 'Custom'.
@@ -6528,7 +6572,7 @@ def create_pptx_with_native_svg(
)
text_flow = resolve_text_flow(text_flow, merge_paragraphs)
if primary_language is not None:
primary_language = normalize_language_tag(primary_language)
primary_language = office_language_tag(primary_language)
public_svg_files = list(svg_files)
passthrough_slides = set(roundtrip_passthrough_slides or set())
slide_patches = dict(roundtrip_slide_patches or {})
@@ -6852,6 +6896,7 @@ def create_pptx_with_native_svg(
prs = Presentation()
prs.slide_width = width_emu
prs.slide_height = height_emu
_set_slide_size_type(prs, width_emu, height_emu)
blank_layout = prs.slide_layouts[6]
for _ in svg_files:
@@ -8147,6 +8192,9 @@ def create_pptx_with_native_svg(
'PowerPoint will report the file as corrupt:\n' + details
)
if primary_language is not None and not roundtrip_export:
_apply_template_text_language(extract_dir, primary_language)
# Replace the python-pptx base-template metadata (stale "Steve Canny"
# author, 2013 dates, "generated using python-pptx", Slides=0) with
# accurate, tool-neutral document properties.
@@ -2490,7 +2490,7 @@ Recorded narration:
and not lockless_export
):
try:
theme_font_spec = load_theme_font_spec(project_path)
theme_font_spec = load_theme_font_spec(project_path, primary_language)
master_text_style_spec = load_master_text_style_spec(project_path)
theme_color_spec = load_theme_color_spec(project_path)
except (ThemeFontError, ThemeColorError) as exc:
@@ -0,0 +1,495 @@
#!/usr/bin/env python3
"""Regression tests for fixes reported by the September 2026 dogfood runs.
Document URLs route to their own converter, bullet glyphs never promote a PDF
line to a heading, clause numbers stay text, a scanned PDF warns, ``<polygon>``
carries a filter, the exported slide size type token follows the canvas, and
``init`` keeps a pinned directory name.
"""
from __future__ import annotations
import json
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
BACKEND_DIR = SCRIPTS_DIR / "source_to_md"
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
import pdf_to_md # noqa: E402
import doc_to_md # noqa: E402
import ppt_to_md # noqa: E402
import web_to_md # noqa: E402
from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes # noqa: E402
from svg_to_pptx.drawingml.utils import project_filter_errors # noqa: E402
from svg_to_pptx.pptx_package.builder import _slide_size_type # noqa: E402
from project_management.cli import _is_project_tree, PROJECTS_ROOT # noqa: E402
from narration_sync import _project_input_path # noqa: E402
from tts_backends import backend_edge # noqa: E402
from compact_svg_styles import compact_svg_style_tree # noqa: E402
from pptx_to_svg.preset_authoring import validate_authored_preset_tree # noqa: E402
from pptx_ooxml.analyzer import _classify_page_type # noqa: E402
from beautify_identity import _theme_font_refs # noqa: E402
from svg_to_pptx.native_objects.chart_data import _chart_data_labels # noqa: E402
from svg_to_pptx.native_objects.chart_xml import _data_labels_xml # noqa: E402
from svg_to_pptx.drawingml.utils import parse_font_family # noqa: E402
import text_measure # noqa: E402
from language_tags import office_language_tag # noqa: E402
from _conversion_profile import profile_path_for, record_source_url, write_conversion_profile # noqa: E402
PDF_URL = "https://www.example.gov/content/pkg/report/pdf/report.pdf"
class RemoteDocumentSuffixTests(unittest.TestCase):
def test_pdf_url_with_pdf_content_type(self) -> None:
self.assertEqual(
web_to_md.remote_document_suffix(PDF_URL, "application/pdf", b"%PDF-1.4"), ".pdf")
def test_suffixless_download_answering_pdf(self) -> None:
self.assertEqual(
web_to_md.remote_document_suffix(
"https://example.org/download?id=7", "application/pdf; charset=binary", b"%PDF-1.7"),
".pdf")
def test_body_magic_wins_over_wrong_content_type(self) -> None:
self.assertEqual(
web_to_md.remote_document_suffix(
"https://example.org/files/report", "application/octet-stream", b"%PDF-1.7"),
".pdf")
def test_docx_url_by_suffix(self) -> None:
self.assertEqual(
web_to_md.remote_document_suffix(
"https://example.org/a/brief.docx", "application/octet-stream", b"PK\x03\x04"),
".docx")
def test_html_viewer_at_document_url_stays_web(self) -> None:
self.assertIsNone(
web_to_md.remote_document_suffix(PDF_URL, "text/html; charset=utf-8", b"<!doctyp"))
def test_ordinary_page_is_not_a_document(self) -> None:
self.assertIsNone(
web_to_md.remote_document_suffix(
"https://example.org/article", "text/html", b"<html>"))
self.assertIsNone(
web_to_md.remote_document_suffix(
"https://example.org/page.html", "", b"<html>"))
class PdfBulletTests(unittest.TestCase):
def test_middot_bullet_is_a_list_item(self) -> None:
is_list, kind, content = pdf_to_md.detect_list_item("·\x01 Oaks turn red, brown, or russet;")
self.assertTrue(is_list)
self.assertEqual(kind, "ul")
self.assertEqual(content, "- Oaks turn red, brown, or russet;")
def test_bullet_led_line_is_never_a_heading(self) -> None:
size_map = {"body": 12.1, "h1": 13.6}
self.assertEqual(pdf_to_md.get_heading_level(13.6, size_map, "· Oaks turn red", 4), 0)
self.assertEqual(pdf_to_md.get_heading_level(13.6, size_map, "Autumn colours", 16), 1)
def test_bullet_glyph_span_detection(self) -> None:
self.assertTrue(pdf_to_md.is_bullet_glyph_span("· "))
self.assertTrue(pdf_to_md.is_bullet_glyph_span("•"))
self.assertTrue(pdf_to_md.is_bullet_glyph_span("·\x01"))
self.assertFalse(pdf_to_md.is_bullet_glyph_span("Oaks"))
self.assertFalse(pdf_to_md.is_bullet_glyph_span("· Oaks"))
class PdfClauseAndScanTests(unittest.TestCase):
def test_spaced_clause_number_is_not_a_list(self) -> None:
self.assertFalse(pdf_to_md.detect_list_item("1. 1 职业名称")[0])
self.assertFalse(pdf_to_md.detect_list_item("2. 1. 1 职业道德基本知识")[0])
def test_ordinary_ordered_items_still_match(self) -> None:
self.assertEqual(pdf_to_md.detect_list_item("1. 职业概况"), (True, "ol", "1. 职业概况"))
self.assertEqual(pdf_to_md.detect_list_item("3. Overview"), (True, "ol", "3. Overview"))
self.assertEqual(pdf_to_md.detect_list_item("1. 2024年营收"), (True, "ol", "1. 2024年营收"))
self.assertFalse(pdf_to_md.detect_list_item("83.2% of respondents")[0])
def test_scanned_pdf_warns(self) -> None:
markdown = "\n".join(
f"<!-- Page {i} -->\n![page {i}](scan_files/page_{i}.jpg)" for i in range(1, 14))
warnings = pdf_to_md.scanned_pdf_warnings(markdown, 13, 13)
self.assertEqual(len(warnings), 1)
self.assertIn("13 page images", warnings[0])
def test_text_pdf_does_not_warn(self) -> None:
markdown = "\n".join("第一章 总则 " * 20 for _ in range(13))
self.assertEqual(pdf_to_md.scanned_pdf_warnings(markdown, 13, 2), [])
class ImportSourcesProjectTreeTests(unittest.TestCase):
def test_research_web_sources_dir_is_not_a_project(self) -> None:
with tempfile.TemporaryDirectory(dir=PROJECTS_ROOT) as tmp:
root = Path(tmp)
scratch = root.with_name(root.name + "_web_sources")
scratch.mkdir()
try:
(scratch / "page.md").write_text("# page\n", encoding="utf-8")
self.assertFalse(_is_project_tree(scratch / "page.md"))
(root / "svg_output").mkdir()
(root / "sources").mkdir()
(root / "sources" / "a.md").write_text("# a\n", encoding="utf-8")
self.assertTrue(_is_project_tree(root / "sources" / "a.md"))
finally:
for child in scratch.iterdir():
child.unlink()
scratch.rmdir()
class NarrationRoundTests(unittest.TestCase):
def test_subtitle_split_keeps_a_written_number_whole(self) -> None:
text = "分别定点在东经八十度、一百一十点五度和一百四十度。"
# Per-character word boundaries, as MiniMax returns them for Chinese.
words = [
backend_edge._MappedWord(start=i * 10, end=i * 10 + 10, source_start=i, source_end=i + 1)
for i in range(len(text))
]
parts = backend_edge._hard_split_span(text, (0, len(text)), words, 14)
pieces = [text[a:b] for a, b in parts]
for piece in pieces:
self.assertFalse(
piece.startswith(("一十", "十点", "点五")) or piece.endswith(("一百", "一百一", "点")),
pieces,
)
self.assertEqual("".join(pieces), text.replace(" ", ""))
def test_project_input_path_accepts_a_cwd_relative_existing_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
project = root / "project"
(project / "exports").mkdir(parents=True)
pptx = project / "exports" / "deck.pptx"
pptx.write_bytes(b"PK")
self.assertEqual(_project_input_path(project, "exports/deck.pptx"), project / "exports" / "deck.pptx")
self.assertEqual(_project_input_path(project, str(pptx)), pptx)
def test_web_to_md_refuses_output_file_for_several_urls(self) -> None:
import io
from contextlib import redirect_stderr
buffer = io.StringIO()
with redirect_stderr(buffer):
rc = web_to_md.main(["https://example.org/a", "https://example.org/b", "-o", "out.md"])
self.assertEqual(rc, 2)
self.assertIn("--dir", buffer.getvalue())
class PolygonFilterTests(unittest.TestCase):
SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">'
'<defs><filter id="paperShadow"><feDropShadow dx="0" dy="6" stdDeviation="6" '
'flood-color="#000000" flood-opacity="0.25"/></filter></defs>'
'<polygon id="sheet" points="100,100 400,120 380,400 90,380" fill="#E8D9C4" '
'filter="url(#paperShadow)"/>'
'</svg>'
)
def test_polygon_is_a_public_filter_target(self) -> None:
errors = project_filter_errors(ET.fromstring(self.SVG))
self.assertEqual([e for e in errors if "cannot use filter" in e], [])
def test_polygon_exports_its_shadow(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
svg_path = root / "page.svg"
svg_path.write_text(self.SVG, encoding="utf-8")
xml, *_rest = convert_svg_to_slide_shapes(svg_path, resource_root=root)
self.assertIn("<a:outerShdw", xml)
self.assertIn('name="sheet"', xml) # named after its SVG id
class PresetPaintCompactionTests(unittest.TestCase):
SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">'
'<g id="rail-field" data-pptx-role="decoration" data-pptx-bounds="0 0 380 720">'
'<rect x="0" y="0" width="300" height="720" fill="#004B20"/>'
'<g id="p08-rail-edge" data-pptx-authoring="preset" data-pptx-object="shape" '
'data-pptx-prst="rtTriangle" data-pptx-frame="300 0 80 720" fill="#004B20" '
'stroke="none" transform="matrix(1 0 0 -1 0 720)">'
'<path d="M 300 720 L 300 0 L 380 720 Z"/></g>'
'</g></svg>'
)
def test_preset_keeps_local_paint(self) -> None:
root = ET.fromstring(self.SVG)
stats = compact_svg_style_tree(root)
self.assertEqual(stats.changed_declarations, 0)
self.assertEqual(validate_authored_preset_tree(root), [])
def test_parent_paint_is_not_stripped_from_preset(self) -> None:
root = ET.fromstring(self.SVG.replace(
'data-pptx-bounds="0 0 380 720">',
'data-pptx-bounds="0 0 380 720" fill="#004B20">',
))
compact_svg_style_tree(root)
preset = root.find('.//*[@id="p08-rail-edge"]')
self.assertEqual(preset.get("fill"), "#004B20")
self.assertEqual(validate_authored_preset_tree(root), [])
def test_comment_nodes_do_not_crash(self) -> None:
parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))
root = ET.fromstring(
self.SVG.replace('<rect ', '<!-- chart-plot-area --><rect ', 1),
parser=parser,
)
compact_svg_style_tree(root)
self.assertEqual(validate_authored_preset_tree(root), [])
class BeautifyIntakeTests(unittest.TestCase):
def test_prose_mentioning_part_stays_content(self) -> None:
text = "涉及的主要扶持措施" + "对新增部分的场地按实际租金给予补贴。" * 10
slots = [{}, {}]
self.assertEqual(_classify_page_type(8, 16, text, slots), "content_candidate")
self.assertEqual(
_classify_page_type(3, 16, "第一部分 政府采购基本概念 PART ONE", slots),
"chapter_candidate",
)
def test_theme_font_refs_resolve(self) -> None:
refs = _theme_font_refs({
"title": {"latin": "Verdana", "ea": "微软雅黑"},
"body": {"latin": "Verdana", "ea": "微软雅黑"},
})
self.assertEqual(refs["+mn-ea"], "微软雅黑")
self.assertEqual(refs["+mj-lt"], "Verdana")
class DataLabelPointTests(unittest.TestCase):
@staticmethod
def _xml(config: dict) -> str:
return _data_labels_xml(
config, chart_type="column", grouping="clustered", point_count=4,
font_size=1400, default_color="#000000", default_font_face=None,
)
def test_show_flag_makes_points_overrides(self) -> None:
xml = self._xml({"show_value": True, "points": [{"idx": 2, "delete": True}]})
self.assertEqual(xml.count('<c:delete val="1"/>'), 1)
self.assertIn('<c:showVal val="1"/><c:showCatName', xml.split("</c:dLbl>")[-1])
def test_points_without_flag_list_the_only_labels(self) -> None:
xml = self._xml({"points": [{"idx": 3}]})
self.assertEqual(xml.count('<c:delete val="1"/>'), 3)
def test_all_deleted_points_are_refused(self) -> None:
with self.assertRaisesRegex(RuntimeError, "no label remains"):
_chart_data_labels(
{"data_labels": {"points": [{"idx": 1, "delete": True}]}},
"column", "clustered", 4,
)
class JapaneseTypographyTests(unittest.TestCase):
def test_small_kana_and_long_vowel_never_open_a_line(self) -> None:
units = text_measure._protected_units("スーパーっゃ・")
self.assertTrue(all(unit[0] not in "ーっゃ・" for unit in units))
def test_wrapped_pdf_lines_join_without_space_between_cjk(self) -> None:
self.assertEqual(pdf_to_md.join_wrapped_text("約68億", "人ものお客様"), "約68億人ものお客様")
self.assertEqual(pdf_to_md.join_wrapped_text("新幹", "線"), "新幹線")
self.assertEqual(pdf_to_md.join_wrapped_text("the high", "speed"), "the high speed")
def test_ea_fallback_follows_deck_language(self) -> None:
self.assertEqual(parse_font_family("'Georgia', serif", "ja-JP")["ea"], "Yu Mincho")
self.assertEqual(parse_font_family("Arial", "ja")["ea"], "Yu Gothic")
self.assertEqual(parse_font_family("'Hiragino Sans'", "ja-JP")["latin"], "Yu Gothic")
self.assertEqual(parse_font_family("Arial", "zh-CN")["ea"], "Microsoft YaHei")
self.assertEqual(parse_font_family("Arial")["ea"], "Microsoft YaHei")
class TraditionalChineseIntakeTests(unittest.TestCase):
def test_office_language_tag_uses_region_form_for_chinese(self) -> None:
self.assertEqual(office_language_tag("zh-Hant-TW"), "zh-TW")
self.assertEqual(office_language_tag("zh-Hant-HK"), "zh-HK")
self.assertEqual(office_language_tag("zh-Hans"), "zh-CN")
self.assertEqual(office_language_tag("ja-JP"), "ja-JP")
def test_table_cells_keep_links(self) -> None:
html = (
'<table><tr><td>報告</td><td><ul><li><a href="/a.pdf">pdf</a></li>'
'<li><a href="/a.docx">docx</a></li></ul></td></tr></table>'
)
markdown = web_to_md.simple_html_to_markdown_traversal(
web_to_md.BeautifulSoup(html, "html.parser"), "https://example.gov.tw/x",
)
self.assertIn("[pdf](https://example.gov.tw/a.pdf)", markdown)
self.assertIn("[docx](https://example.gov.tw/a.docx)", markdown)
def test_downloaded_document_profile_records_url(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
markdown = Path(tmp) / "report.md"
markdown.write_text("# r\n", encoding="utf-8")
write_conversion_profile(
input_path=str(Path(tmp) / "report.pdf"), markdown_path=markdown,
converter="pdf_to_md.py", conversion_type="pdf",
)
record_source_url(markdown, "https://example.gov.tw/report.pdf")
profile = json.loads(profile_path_for(markdown).read_text(encoding="utf-8"))
self.assertEqual(profile["source"]["url"], "https://example.gov.tw/report.pdf")
class DocxIntakeTests(unittest.TestCase):
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
DOCUMENT = (
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
'xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><w:body>'
'<w:p><w:r><w:drawing><c:chart r:id="rId9"/></w:drawing></w:r></w:p>'
'<w:tbl><w:tr><w:tc><w:p><w:r><w:t>Bar queues</w:t></w:r>'
'<w:r><w:footnoteReference w:id="2"/></w:r></w:p></w:tc></w:tr></w:tbl>'
'</w:body></w:document>'
)
RELS = (
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId9" Type="chart" Target="charts/chart1.xml"/></Relationships>'
)
CHART = (
'<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">'
'<c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>'
'<c:ser><c:idx val="0"/><c:order val="0"/>'
'<c:cat><c:strRef><c:strCache><c:ptCount val="2"/>'
'<c:pt idx="0"><c:v>2022/23</c:v></c:pt><c:pt idx="1"><c:v>2023/24</c:v></c:pt>'
'</c:strCache></c:strRef></c:cat>'
'<c:val><c:numRef><c:numCache><c:ptCount val="2"/>'
'<c:pt idx="0"><c:v>702</c:v></c:pt><c:pt idx="1"><c:v>659</c:v></c:pt>'
'</c:numCache></c:numRef></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>'
)
FOOTNOTES = (
'<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:footnote w:type="separator" w:id="-1"><w:p/></w:footnote>'
'<w:footnote w:id="2"><w:p><w:r><w:t>Theatre closed until Winter 2026.</w:t></w:r></w:p>'
'</w:footnote></w:footnotes>'
)
def _docx(self, root: Path) -> Path:
path = root / "report.docx"
with zipfile.ZipFile(path, "w") as docx:
docx.writestr("word/document.xml", self.DOCUMENT)
docx.writestr("word/_rels/document.xml.rels", self.RELS)
docx.writestr("word/charts/chart1.xml", self.CHART)
docx.writestr("word/footnotes.xml", self.FOOTNOTES)
return path
def test_embedded_chart_cache_becomes_a_table(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
patched, replacements, warnings = doc_to_md._docx_inject_charts_markdown(
self._docx(Path(tmp)))
patched.unlink()
[markdown] = replacements.values()
self.assertIn("| 2023/24 | 659 |", markdown)
self.assertEqual(warnings, [])
def test_table_cell_footnote_survives(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
patched, replacements = doc_to_md._docx_inject_tables_markdown(self._docx(Path(tmp)))
patched.unlink()
[markdown] = replacements.values()
self.assertIn("Bar queues[^2]", markdown)
self.assertIn("[^2]: Theatre closed until Winter 2026.", markdown)
class BeautifyReadbackTests(unittest.TestCase):
def test_toc_and_chapter_keywords_match_whole_words(self) -> None:
toc = "吉林省自然资源厅 Department of Natural Resources\n目\n录\n出台背景\n政策依据"
self.assertEqual(_classify_page_type(2, 20, toc, [{}] * 8), "toc_candidate")
prose = "Quality assurance for the aquarium sector " * 4
self.assertEqual(_classify_page_type(5, 20, prose, [{}] * 4), "content_candidate")
def test_soft_line_break_survives_readback(self) -> None:
from pptx import Presentation
from pptx.util import Inches
deck = Presentation()
slide = deck.slides.add_slide(deck.slide_layouts[6])
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
box.text_frame.text = "Section one\vOverall duties" # \v writes <a:br/>
markdown = ppt_to_md.text_frame_to_markdown(box.text_frame, box)
self.assertIn("Section one\nOverall duties", markdown)
class KoreanIntakeTests(unittest.TestCase):
def test_wrap_breaks_korean_between_words(self) -> None:
lines, _widths, _oversized = text_measure.wrap_text(
"제주 해녀는 어촌계와 해녀회라는 공동체 규칙 아래서 바다밭을 가꾼다",
size=24, max_width=250, family="Malgun Gothic",
)
words = set("제주 해녀는 어촌계와 해녀회라는 공동체 규칙 아래서 바다밭을 가꾼다".split())
for line in lines:
self.assertTrue(set(line.split()) <= words, lines)
def test_wrap_keeps_arabic_and_cyrillic_words_whole(self) -> None:
for text in (
"القهوة العربية رمز للكرم والضيافة في شبه الجزيرة العربية",
"Кофе по-арабски является символом гостеприимства на Аравийском полуострове",
):
lines, _widths, _oversized = text_measure.wrap_text(
text, size=24, max_width=260, family="Arial", include_headroom=False)
self.assertEqual(" ".join(lines).split(), text.split(), lines)
def test_pdf_join_keeps_korean_word_space(self) -> None:
self.assertEqual(pdf_to_md.join_wrapped_text("감소하였으며", "이중"), "감소하였으며 이중")
self.assertEqual(pdf_to_md.join_wrapped_text("新幹", "線"), "新幹線")
def test_web_table_is_gfm_on_its_grid(self) -> None:
html = (
"<table><caption>해녀 현황</caption>"
'<tr><th rowspan="2">구분</th><th colspan="2">계</th></tr>'
"<tr><th>2025</th><th>2024</th></tr>"
"<tr><td>계</td><td>7,482</td><td>7,561</td></tr></table>"
)
markdown = web_to_md.simple_html_to_markdown_traversal(
web_to_md.BeautifulSoup(html, "html.parser"), "https://example.kr/")
self.assertIn("해녀 현황\n\n| 구분 | 계 | |\n| --- | --- | --- |", markdown)
self.assertIn("| | 2025 | 2024 |\n| 계 | 7,482 | 7,561 |", markdown)
class StampIndependenceTests(unittest.TestCase):
def test_bad_page_does_not_block_valid_pages(self) -> None:
import contextlib
import io
import stamp_native_fallbacks
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "01_ok.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>', encoding="utf-8")
(root / "02_bad.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg"><text>R&D</text></svg>', encoding="utf-8")
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = stamp_native_fallbacks.main([str(root)])
self.assertEqual(code, 1)
self.assertIn("01_ok.svg: unchanged", out.getvalue())
self.assertIn("02_bad.svg: invalid SVG XML", err.getvalue())
class ArabicPdfTests(unittest.TestCase):
def test_reversed_lam_alef_layer_warns(self) -> None:
broken = "نشرة اإلحصاءات الزراعية األعلى آالف " * 40
self.assertEqual(len(pdf_to_md.arabic_text_layer_warnings(broken)), 1)
def test_well_formed_arabic_does_not_warn(self) -> None:
clean = "القهوة العربية رمز للكرم والضيافة في شبه الجزيرة العربية " * 20
self.assertEqual(pdf_to_md.arabic_text_layer_warnings(clean), [])
class SlideSizeTypeTests(unittest.TestCase):
def test_standard_ratios_keep_their_token(self) -> None:
self.assertEqual(_slide_size_type(12192000, 6858000), "screen16x9")
self.assertEqual(_slide_size_type(9144000, 6858000), "screen4x3")
self.assertEqual(_slide_size_type(10287000, 18288000), "custom")
self.assertEqual(_slide_size_type(11811000, 16706000), "custom")
if __name__ == "__main__":
unittest.main()
@@ -372,7 +372,8 @@ class SourceCollisionTests(unittest.TestCase):
if encoding == "utf-8":
self.assertEqual(profile["warnings"], [])
else:
self.assertIn(encoding, result.stdout)
# --json keeps stdout machine-readable; the notice is on stderr.
self.assertIn(encoding, result.stderr)
self.assertIn(encoding, " ".join(profile["warnings"]))
def test_invalid_passthrough_fails_before_writing_outputs(self) -> None:
@@ -36,8 +36,14 @@ from svg_to_pptx.drawingml.elements import estimate_single_line_text_frame_width
from svg_to_pptx.drawingml.utils import split_project_text_clusters # noqa: E402
_CLOSING_PUNCTUATION = frozenset(',.;:!?)]}、,。;:!?)》」』】”’')
_CLOSING_PUNCTUATION = frozenset(',.;:!?)]}、,。;:!?)》」』】”’،؛؟')
_OPENING_PUNCTUATION = frozenset('([{(《「『【“‘')
# Japanese line-start prohibitions beyond punctuation: small kana, the long
# vowel mark, iteration marks, and the middle dot never open a line.
_NO_LINE_START = _CLOSING_PUNCTUATION | frozenset(
'ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ'
'ー々ゝゞヽヾ・'
)
_PREFERRED_BREAK_PUNCTUATION = frozenset(',。;:')
# A CJK clause break is preferred over the greedy break only while it keeps
# this share of the greedy line; below it the punctuation break would leave a
@@ -110,21 +116,40 @@ def measure_text(
)
def _is_latin_or_number_cluster(cluster: str) -> bool:
"""Return whether a rendered cluster belongs to a Latin/number token."""
# Scripts written without spaces between words; everything else that is a
# letter or digit (Latin, Cyrillic, Greek, Arabic, Hebrew, Devanagari, ...)
# forms words that only break at spaces.
_UNSPACED_SCRIPT_PREFIXES = ('THAI ', 'LAO ', 'KHMER ', 'MYANMAR ', 'TIBETAN ')
def _is_word_cluster(cluster: str) -> bool:
"""Return whether a rendered cluster belongs to a space-delimited word."""
bases = [
ch
for ch in cluster
if unicodedata.category(ch) not in {'Mn', 'Mc', 'Me'}
]
return bool(bases) and all(
ch.isdigit() or 'LATIN' in unicodedata.name(ch, '')
ch.isdigit()
or (
ch.isalpha()
and unicodedata.east_asian_width(ch) not in {'W', 'F'}
and not unicodedata.name(ch, '').startswith(_UNSPACED_SCRIPT_PREFIXES)
)
for ch in bases
)
def _is_hangul_cluster(cluster: str) -> bool:
"""Return whether a rendered cluster is a Hangul syllable or jamo."""
return any(
'\uac00' <= ch <= '\ud7a3' or '\u1100' <= ch <= '\u11ff' or '\u3130' <= ch <= '\u318f'
for ch in cluster
)
def _lexical_units(text: str) -> list[str]:
"""Split a paragraph while keeping Latin words and numbers atomic."""
"""Split a paragraph while keeping space-delimited words, numbers, and Korean words atomic."""
clusters = split_project_text_clusters(' '.join(text.split()))
units: list[str] = []
pending_space = False
@@ -137,10 +162,17 @@ def _lexical_units(text: str) -> list[str]:
continue
end = index + 1
if _is_latin_or_number_cluster(cluster):
word_end = index
while word_end < len(clusters) and not clusters[word_end].isspace():
word_end += 1
if any(_is_hangul_cluster(item) for item in clusters[index:word_end]):
# Korean breaks between space-separated words (eojeol), never
# inside one; an eojeol wider than the line is reported oversized.
end = word_end
elif _is_word_cluster(cluster):
while end < len(clusters):
next_cluster = clusters[end]
if _is_latin_or_number_cluster(next_cluster):
if _is_word_cluster(next_cluster):
end += 1
continue
connector = (
@@ -153,7 +185,7 @@ def _lexical_units(text: str) -> list[str]:
if (
connector
and end + 1 < len(clusters)
and _is_latin_or_number_cluster(clusters[end + 1])
and _is_word_cluster(clusters[end + 1])
):
end += 2
continue
@@ -175,7 +207,7 @@ def _protected_units(text: str) -> list[str]:
for unit in units:
content = unit.lstrip()
if protected and (
content[0] in _CLOSING_PUNCTUATION
content[0] in _NO_LINE_START
or protected[-1].rstrip()[-1] in _OPENING_PUNCTUATION
):
protected[-1] += unit
@@ -602,6 +634,12 @@ def _render_calibration_table(payload: dict[str, object], *, include_outline: bo
'while authoring (an expanded title, a longer label) is re-estimated '
'with the rates — the outline column does not cover it.'
)
lines.append(
'[NOTE] rates ignore letter-spacing and average a mixed-case sample: '
'a tracked role (a kicker with letter-spacing) or a display-size line '
'(a cover title) is sized per string with measure --letter-spacing, '
'not from the table.'
)
lines.append(
'[NOTE] measure and wrap take one --weight for the whole string: a line '
'with a bold run (an inline emphasis lead) is measured piecewise per '
@@ -658,13 +696,19 @@ def _run_calibrate(args: argparse.Namespace) -> int:
weights=weights,
)
payload['notes'] = _fallback_notes(roles, fallbacks)
if args.outline and not (project_path / 'design_spec.md').is_file():
payload['notes'].append(
'no design_spec.md in this project (Quick writes none), so the '
'--outline column has no §IX source and stays empty'
)
bold_roles = sorted(name for name, weight in weights.items() if weight == 'bold')
payload['notes'].append(
'rates for ' + ', '.join(bold_roles) + ' are measured at bold weight'
if bold_roles else
'every role is measured at normal weight; recalibrate a role '
'realized as bold deck-wide with --role NAME:FAMILY:SIZE:bold, '
'since bold runs about 7% wider'
'realized as bold deck-wide with --role NAME:FAMILY:SIZE:bold: '
'bold widens Latin and digits about 7%, while the bundled advance '
'table gives CJK one width for both weights'
)
output_path = project_path / 'validation' / 'text_calibration.json'
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -742,8 +786,9 @@ def build_parser() -> argparse.ArgumentParser:
type=_role_argument,
default=[],
metavar='NAME:FAMILY:SIZE[:bold]',
help='Typography role to calibrate when spec_lock.md is absent, e.g. '
'body:"Microsoft YaHei":20; repeatable.',
help='Typography role to calibrate, e.g. body:"Microsoft YaHei":20; '
'repeatable. Adds or overrides that role whether or not '
'spec_lock.md exists (FAMILY replaces the role\'s font stack).',
)
calibrate.add_argument('--json', action='store_true')
for command in (measure, wrap, box):
@@ -16,6 +16,10 @@ _TICKS_PER_MILLISECOND = 10_000
_SENTENCE_END = frozenset("。!?!?")
_CLAUSE_END = frozenset(",,;;::")
_CLOSING_PUNCTUATION = frozenset('”’」』)》)"\'')
# Characters that spell one number when adjacent: digits, CJK numerals and
# their connectors. A cue boundary between two of them splits a value such as
# 一百一十点五 or 2,750 across two subtitles.
_NUMERAL_CHARS = frozenset("0123456789零〇一二三四五六七八九十百千万亿两点.,%")
@dataclass(frozen=True)
@@ -196,6 +200,13 @@ def _sentence_spans(text: str) -> list[tuple[int, int]]:
return spans
def _inside_number(text: str, index: int) -> bool:
"""Return whether a cut at ``index`` would split one written-out number."""
if index <= 0 or index >= len(text):
return False
return text[index - 1] in _NUMERAL_CHARS and text[index] in _NUMERAL_CHARS
def _hard_split_span(
text: str,
span: tuple[int, int],
@@ -214,6 +225,15 @@ def _hard_split_span(
if start < word.source_end < end
and _display_length(text, start, word.source_end) <= max_chars
]
# Provider word boundaries in Chinese are often per character; prefer
# the cuts that keep a number whole and fall back only when no other
# cut fits the width.
whole_number_candidates = [
candidate for candidate in candidates
if not _inside_number(text, candidate[0])
]
if whole_number_candidates:
candidates = whole_number_candidates
if candidates:
split_at, _ = min(
candidates,
@@ -354,6 +354,7 @@ python3 ${SKILL_DIR}/scripts/finalize_svg.py <project_path>
| Decision | Flag |
|---|---|
| Explicit editable Chart/Table delivery decision | Append `--native-charts-and-tables` (markers, templates, semantic tables, and imported charts never activate it; formulas are always native) |
| Final checker reports oversized images | Append `--image-sizing display` |
| Preserved or produced `animations.json` | Keep the base command; the exporter reads the sidecar |
| Deck-wide motion setting | Append the resolved [`animations.md`](../references/animations.md) flags |
| Explicit Custom Animations disable | Keep the sidecar and append `-a none` |
@@ -16,7 +16,7 @@ description: Generate profile for 1:1, content-faithful re-layout of an existing
Existing `.pptx` + beautify intent ("把这份 PPT 美化一下" / "make this deck look better"), re-layout intent ("重新排版这份 PPT,内容别动"), or paste-back intent ("重排后我要把元素贴回原来的模板").
**Hard rule — content is frozen**: every source text string is preserved exactly (no add / remove / reword / reorder); freedom lives only in layout, hierarchy, spacing, and rhythm.
**Hard rule — content is frozen**: every source text string is preserved exactly (no add / remove / reword / reorder); freedom lives only in layout, hierarchy, spacing, and rhythm. Run-level emphasis (words the source colors or bolds) is hierarchy to keep: the same words stand out, restyled in the effective palette.
**Hard rule — not a patch, not a fill**: this regenerates a native deck through the selected runtime; it never edits the source in place, is not Edit Native PPTX, and never parses a third-party template for text-only substitution (the rejected #53 direction). It is the inverse of a `replication_mode: mirror` template ([`executor-structured.md`](../../references/executor-structured.md) §1.1), which keeps layout and edits text. When the authoritative input is a raster page roster whose visible layout must be preserved, activate the Quick-only [`image-to-pptx.md`](./image-to-pptx.md) instead; the two fidelity profiles never compose.
@@ -46,11 +46,11 @@ python3 ${SKILL_DIR}/scripts/project_manager.py import-sources <project_path> <s
`import-sources` already wrote the standard PPTX intake bundle under `analysis/` (for an older project, `pptx_intake.py <project_path>/sources/<source.pptx> -o <project_path>/analysis` once) and ran `ppt_to_md`, so the **frozen content contract** is `sources/<stem>.md` (one source slide per block, in order) and extracted pictures are in `images/` with per-slide binding in `images/image_manifest.json` (`occurrences[].slide_index`); never re-run `ppt_to_md`.
**Visual identity** — read `analysis/<stem>.identity.json`: `theme.palette.background` / `text` / `primary` / `accent1..6` and `theme.fonts.title` / `body` (`latin` / `ea` / `cs`, with `scripts` mapping `Hans` / `Hant` / `Jpan` / `Hang` supplemental faces — use the matching script when `ea` is empty) are what the deck declares; `theme.sizes.title` / `body` (pt) are the master placeholder defaults, `body` being the coarse level-1 value that commonly over-reads, with `theme.sizes.body_levels` as the full ramp for reference; `observed.colors` / `observed.fonts` / `observed.sizes_pt` are frequency-ranked samples of run-level overrides (not a complete style resolution — they miss `schemeClr` and inheritance and count chart/gradient fills); `layout_sizes_pt` is a reference fact only; `canvas.aspect` drove Step 3. A hand-edited deck can diverge from `theme`; Step 5 resolves which to use.
**Visual identity** — read `analysis/<stem>.identity.json`: `theme.palette.background` / `text` / `primary` / `accent1..6` and `theme.fonts.title` / `body` (`latin` / `ea` / `cs`, with `scripts` mapping `Hans` / `Hant` / `Jpan` / `Hang` supplemental faces — use the matching script when `ea` is empty) are what the deck declares; `theme.sizes.title` / `body` (pt) are the master placeholder defaults, `body` being the coarse level-1 value that commonly over-reads, with `theme.sizes.body_levels` as the full ramp for reference; `observed.colors` / `observed.fonts` / `observed.sizes_pt` are frequency-ranked samples of run-level overrides (`sizes_pt` by characters carried) (not a complete style resolution — they miss `schemeClr` and inheritance and count chart/gradient fills); `layout_sizes_pt` is a reference fact only; `canvas.aspect` drove Step 3. A hand-edited deck can diverge from `theme`; Step 5 resolves which to use.
**Hard rule — regenerate visuals, do not carry them over**: charts / tables / images are rebuilt from their data in the effective style, never spliced byte-for-byte; data values are frozen, only rendering is the deck's own; pictures are reused but re-laid-out. A user who wants an original element verbatim copies it across themselves.
**Optional source-SVG visual reference**: when the deck has complex vector decoration or a visual language colors/fonts cannot capture, build a read-only reference package for understanding style, not a carry-over path:
**Optional source-SVG visual reference**: when the deck has complex vector decoration, run-level emphasis (`sources/<stem>.md` and the inventory carry no run styles), or a visual language colors/fonts cannot capture, build a read-only reference package for understanding style, not a carry-over path:
```bash
python3 ${SKILL_DIR}/scripts/pptx_to_svg.py <project_path>/sources/<source.pptx> -o <project_path>/analysis/source_svg_import
@@ -65,7 +65,7 @@ Use the cleaned `svg-flat/slide_*.svg` pages and `svg-flat_vector_asset_inventor
python3 ${SKILL_DIR}/scripts/beautify_inventory.py <project_path>/analysis/<stem>.slide_library.json --images <project_path>/images/image_manifest.json -o <project_path>/analysis/beautify_inventory.json
```
Omit `--images` when no pictures were extracted. It joins `text_blocks`, `tables`, `charts`, `diagrams` (SmartArt nodes + hierarchy + source layout), and `images` (bound through `image_manifest` `occurrences[].slide_index`, with geometry and `usage_count`) per slide with frozen values inlined, and emits empty `ignored` and `needs_confirmation` arrays to fill with judgment: `ignored` — hidden slides/shapes, master-only text, image crop/opacity/rotation/mask; `needs_confirmation` — unreadable SmartArt, combo / dual-axis / waterfall charts, merged-cell or multi-header tables, density outliers (overcrowded or near-empty). SmartArt keeps its wording and relationships and is redrawn as ordinary editable shapes, never regenerated natively.
Omit `--images` when no pictures were extracted. It joins `text_blocks`, `tables`, `charts`, `diagrams` (SmartArt nodes + hierarchy + source layout), and `images` (bound through `image_manifest` `occurrences[].slide_index`, with geometry and `usage_count`) per slide with frozen values inlined, and emits empty `ignored` and `needs_confirmation` arrays to fill with judgment: `ignored` — hidden slides/shapes, master-only text, full-slide template-skin or fully covered pictures, image crop/opacity/rotation/mask; `needs_confirmation` — unreadable SmartArt, combo / dual-axis / waterfall charts, merged-cell or multi-header tables, density outliers (overcrowded or near-empty; `--summary` counts each page's `text_char_count`). SmartArt keeps its wording and relationships and is redrawn as ordinary editable shapes, never regenerated natively.
**Mandatory — bounded inventory reads**: the complete inventory is the validation ledger, not the authoring prompt. Read `beautify_inventory.py <inventory> --summary`, then `--page <N>`, adding `--with-geometry` only for structural ambiguity; never bulk-read either complete file during authoring.
@@ -116,7 +116,7 @@ Do not run the Default confirmation flow. Apply the same inventory interpretatio
"design_directions": {"selected": 0, "candidates": [
{"id": "source-replica", "name_en": "Source replica (recommended)", "mode": "custom", "mode_behavior_zh": "briefing 基底;逐页结构、顺序与文字 1:1 逐字不变。", "visual_style": "custom", "visual_style_behavior_zh": "复刻源 PPT 视觉身份与版式。", "icons": "…",
"color": {"palette": {"background": "#...", "secondary_bg": "#...", "primary": "#...", "accent": "#...", "secondary_accent": "#...", "body_text": "#..."}},
"typography": {"heading": {"primary": "…"}, "body": {"primary": "…"}, "body_size": "<dominant observed.sizes_pt × 4/3, as px>"},
"typography": {"heading": {"primary": "…"}, "body": {"primary": "…"}, "body_size": "<dominant observed.sizes_pt × 4/3 × canvas scale, as px>"},
"image_strategy": {"rendering": "custom", "behavior_zh": "…"}},
{"id": "alternative-a", "...": "same shape; body_size = canvas-appropriate baseline"},
{"id": "alternative-b", "...": "same shape"}
@@ -126,7 +126,7 @@ Do not run the Default confirmation flow. Apply the same inventory interpretatio
- **Recommend keep, allow override**: pre-fill the communication contract from the source's apparent audience and purpose (composite purposes in prose; examples are hints, never a `primary_job` selector) and canvas / mode / visual style / icons / image strategy with the source-faithful default (mode `briefing`, `image_usage` `provided`). The only true non-choices are frozen text and strict 1:1 page count; `content_divergence` is seeded verbatim with `locked: true` (the UI renders it read-only and restores it on every submit). A request to reshape wording or structure routes to the main pipeline.
- **The pre-selected default is the source replica** (`selected: 0`): the candidate that best replicates the source's `theme` / `observed` style. Author the other candidates with the same content-driven judgment the Strategist uses from scratch (color §e, typography §g) — palette and font pairing chosen for this deck's content, ≥3 meaningful candidates, no manufactured pairings to fill a quota; `primary` follows the source language, `english` only when that language is not English.
- **`body_size` is load-bearing and the replica follows the source's own size**: seed it from the dominant `observed.sizes_pt` value converted to px (`× 4/3` — a 20pt body becomes `26.67`; seeding bare `20` shrinks it ~25%, the pt-as-px trap); the confirm page writes that px to `result.json` and the chat branch retains it, with no second conversion and no `body_size_pt`. The most-frequent-size proxy counts titles, captions, and chart labels too, so cross-check it against the page's actual body blocks and prefer the size body paragraphs visibly render at. Seed chain: `observed` → `theme.sizes.body` (a level-1 default that over-reads; treat as an upper-ish guess) → the consumption-mode baseline (`text` 20 / `balanced` 24 / `presentation` 32 px); `body_levels` and `layout_sizes_pt` are reference context for judging a saner value, never auto-seeds. The canvas hint is a sanity range, not the seed — when the source size lands far outside it, surface that to the user rather than snapping. Alternatives may use the consumption-mode baseline.
- **`body_size` is load-bearing and the replica follows the source's own size**: seed it from the dominant `observed.sizes_pt` value converted to px (`× 4/3` — a 20pt body becomes `26.67`; seeding bare `20` shrinks it ~25%, the pt-as-px trap), then scaled by Step 3's canvas width ÷ `canvas.width_px` when they differ (a 960px-wide source on `ppt169` multiplies by 4/3 again); the confirm page writes that px to `result.json` and the chat branch retains it, with no second conversion and no `body_size_pt`. The most-frequent-size proxy counts titles, captions, and chart labels too, so cross-check it against the page's actual body blocks and prefer the size body paragraphs visibly render at. Seed chain: `observed` → `theme.sizes.body` (a level-1 default that over-reads; treat as an upper-ish guess) → the consumption-mode baseline (`text` 20 / `balanced` 24 / `presentation` 32 px); `body_levels` and `layout_sizes_pt` are reference context for judging a saner value, never auto-seeds. The canvas hint is a sanity range, not the seed — when the source size lands far outside it, surface that to the user rather than snapping. Alternatives may use the consumption-mode baseline.
Run Step 4's confirmation orchestration unchanged. In the UI branch read `confirm_ui/result.json` exactly once after the final wait and run `--shutdown` before Step 6; in the chat branch retain the visible final summary. Then enter Step 4 as Strategist with the plan pre-resolved under the two invariants — the content-faithful clause ([`strategist.md`](../../references/strategist.md) §d Layer 1) and page count = source slide count — writing the confirmed state completely into `design_spec.md` (mode, canvas, visual style, color + typography incl. `body_size`; skip both recommendation flows). §VII holds only `Page | Family | Template | Usage` rows for selected `chart` / `table` references projected into `spec_lock.md page_visualizations`; qualitative relationships stay in §IX; §VIII holds source pictures for re-layout.
@@ -144,7 +144,7 @@ Run Step 4's confirmation orchestration unchanged. In the UI branch read `confir
## 7. Validate Output
`python3 ${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py <project_path>/exports/<output.pptx>` — every source text string appears unaltered; chart categories / series / table cells match exactly; slide count equals the source; charts/tables are native SVG in the effective palette; text and shapes use the effective colors and fonts; paste-back elements retain palette and font declarations (alignment and font availability not guaranteed).
`python3 ${SKILL_DIR}/scripts/source_to_md/ppt_to_md.py <project_path>/exports/<output.pptx>` — every source text string appears unaltered (read-back orders text by position and splits at authored line breaks, so compare each page as whitespace-stripped containment, not sequence); chart categories / series / table cells match exactly; slide count equals the source; charts/tables are native SVG in the effective palette; text and shapes use the effective colors and fonts; paste-back elements retain palette and font declarations (alignment and font availability not guaranteed).
```markdown
## ✅ Beautify Complete
@@ -65,7 +65,7 @@ Unless an all-motion disable bypasses it, validate an existing sidecar first: `p
| Several atoms express one inseparable idea | Keep them together |
| Page chrome, structural layers, static framing | Preserve; use [`animations.md`](../../references/animations.md) §5 for target eligibility |
**Hard rule — visual equivalence**: regrouping changes object boundaries only — preserve every visible pixel, paint order, coordinate, transform, inherited paint, opacity, clip, filter, reference, and native metadata; keep rendering-bearing wrappers nested when flattening could change appearance. **Hard rule — structural boundary**: never split or merge across `data-pptx-layer`, `data-pptx-placeholder`, native chart/table carrier, native preset, or imported logical-object boundaries; ordinary direct-root groups follow [`shared-standards-core.md`](../../references/shared-standards-core.md) §4.3 (descriptive unique `id`, positive root-coordinate `data-pptx-bounds`, no bounds on nested groups).
**Hard rule — visual equivalence**: regrouping changes object boundaries only — preserve every visible pixel, paint order, coordinate, transform, inherited paint, opacity, clip, filter, reference, and native metadata; keep rendering-bearing wrappers nested when flattening could change appearance. **Hard rule — structural boundary**: never split or merge across `data-pptx-layer`, `data-pptx-placeholder`, native chart/table carrier, native preset, or imported logical-object boundaries; wrapping a whole preset with its label in one bounded parent group is not a merge across it; ordinary direct-root groups follow [`shared-standards-core.md`](../../references/shared-standards-core.md) §4.3 (descriptive unique `id`, positive root-coordinate `data-pptx-bounds`, no bounds on nested groups).
**Forbidden — group-list-first choreography**: choosing effects or order from `list-groups` before the audit; keeping a coarse wrapper because it has an `id`; splitting one idea into shapes or lines to raise the count; merging unrelated ideas to lower it; adding animation `data-*` attributes to SVG. There is no target group count.
@@ -35,7 +35,7 @@ python3 skills/ppt-master/scripts/notes_to_audio.py --list-voices --locale <loca
python3 skills/ppt-master/scripts/notes_to_audio.py --provider <elevenlabs|minimax|qwen|cosyvoice> --list-voices
```
From the flat list, pick **3–6 candidates**: cover both genders when the locale has them; for edge prefer the curated `COMMON_VOICES` set; for ElevenLabs prefer voices already in the user's account and never override a user-supplied `voice_id`; for MiniMax / Qwen / CosyVoice use a supplied cloned `voice_id` directly and never attempt cloning here; for CosyVoice subtitles use a timestamp-capable model/voice pair, and `--cosyvoice-audio-only` only when the user accepts no page-local SRT. Match the deck's tone — a Chinese consultant / financial deck leans a steady male (`zh-CN-YunjianNeural`) or clear female (`zh-CN-XiaoxiaoNeural`) voice; teaching / product decks a bright female or young male (`zh-CN-XiaoyiNeural` / `zh-CN-YunxiNeural`); launch / broadcast decks `zh-CN-YunyangNeural`; English consultant decks `en-US-GuyNeural` or `en-US-JennyNeural`; Japanese / Korean from `ja-JP-*` / `ko-KR-*` with gender + tone noted. Describe each candidate in one line in the user's chat language (gender · tone · best-fit scenario), with the exact name/ID to pass to `--voice-id` for cloud providers.
From the flat list, pick **3–6 candidates**: cover both genders when the locale has them; for edge prefer the curated `COMMON_VOICES` set; for ElevenLabs prefer voices already in the user's account and never override a user-supplied `voice_id`; for MiniMax / Qwen / CosyVoice `--list-voices` prints no catalog — use a supplied cloned `voice_id` directly (never attempt cloning here) or a system voice id from the provider's documentation; for CosyVoice subtitles use a timestamp-capable model/voice pair, and `--cosyvoice-audio-only` only when the user accepts no page-local SRT. Match the deck's tone — a Chinese consultant / financial deck leans a steady male (`zh-CN-YunjianNeural`) or clear female (`zh-CN-XiaoxiaoNeural`) voice; teaching / product decks a bright female or young male (`zh-CN-XiaoyiNeural` / `zh-CN-YunxiNeural`); launch / broadcast decks `zh-CN-YunyangNeural`; English consultant decks `en-US-GuyNeural` or `en-US-JennyNeural`; Japanese / Korean from `ja-JP-*` / `ko-KR-*` with gender + tone noted. Describe each candidate in one line in the user's chat language (gender · tone · best-fit scenario), with the exact name/ID to pass to `--voice-id` for cloud providers.
---
@@ -88,6 +88,7 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project_path> --recorded-narra
# 2C. Only when page-local SRT exists
python3 skills/ppt-master/scripts/narration_sync.py subtitles <project_path> --pptx <final_narrated_pptx> --force
# <final_narrated_pptx>: absolute, or an existing path as given, else relative to <project_path>
# 2D. Optional native video through installed Windows PowerPoint
python3 skills/ppt-master/scripts/powerpoint_video.py <final_narrated_pptx> -o <raw_powerpoint_video.mp4>