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:
1 parent
360779e38d
commit
79c20679c5
59 files changed
+4095
-343
No files matched your search
@@ -42,8 +42,8 @@
|
||||
"repo": "https://github.com/vercel-labs/skills.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "533c7df02f9b4d913fb8eba553aa99d86103d1d4",
|
||||
"syncedAt": "2026-10-27T07:55:04Z"
|
||||
"commit": "d8eca76d5dbe47df2d146d036e32534b74ece57a",
|
||||
"syncedAt": "2026-10-28T01:57:49Z"
|
||||
},
|
||||
{
|
||||
"id": "generate-agents-md",
|
||||
@@ -141,8 +141,8 @@
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "8f04138777c1b6996e709993362a7b0a12888ecf",
|
||||
"syncedAt": "2026-10-26T16:00:00Z"
|
||||
"commit": "96da9cc7f1aa10580a3741664e2a77c721206c00",
|
||||
"syncedAt": "2026-10-28T01:57:49Z"
|
||||
},
|
||||
{
|
||||
"id": "frontend-slides",
|
||||
@@ -177,8 +177,8 @@
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"adapter": "claude-skill",
|
||||
"commit": "78759bf62e9a1ef43e926ff9e50afa4cfe9a63cb",
|
||||
"syncedAt": "2026-10-24T16:00:00Z"
|
||||
"commit": "f3660bfcd20bc07c29260b7e330261a01a2b5a6d",
|
||||
"syncedAt": "2026-10-28T01:57:49Z"
|
||||
},
|
||||
{
|
||||
"id": "grill-me",
|
||||
@@ -258,8 +258,8 @@
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"adapter": "skill-collection",
|
||||
"commit": "5988244ce7247f5112f00beb8050860330d93ca0",
|
||||
"syncedAt": "2026-10-27T07:55:04Z"
|
||||
"commit": "7356b0bcb5f22371c15dcb413afe5fb28412f52d",
|
||||
"syncedAt": "2026-10-28T01:57:49Z"
|
||||
},
|
||||
{
|
||||
"id": "shuorenhua",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "find-skills",
|
||||
"repo": "https://github.com/vercel-labs/skills.git",
|
||||
"ref": "main",
|
||||
"commit": "533c7df02f9b4d913fb8eba553aa99d86103d1d4",
|
||||
"commit": "d8eca76d5dbe47df2d146d036e32534b74ece57a",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/find-skills",
|
||||
"syncedAt": "2026-10-27T07:55:04Z"
|
||||
"syncedAt": "2026-10-28T01:57:49Z"
|
||||
}
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-10-27T07:58:23Z"
|
||||
"syncedAt": "2026-10-28T02:01:13Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "5988244ce7247f5112f00beb8050860330d93ca0",
|
||||
"commit": "7356b0bcb5f22371c15dcb413afe5fb28412f52d",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-10-27T07:55:04Z"
|
||||
"syncedAt": "2026-10-28T01:57:49Z"
|
||||
}
|
||||
@@ -75,16 +75,32 @@ Before proposing an edit, record these items for each candidate. Mark an inappli
|
||||
|
||||
For lazy interaction features, duplicate packages, server-rendered display work or other attributed assets, read the matching section in [Fix patterns](references/fix-patterns.md) before proposing an edit.
|
||||
|
||||
**Done:** the paired pass and its target dispositions are recorded, or the narrow-scope skip reason or blocker is explicit. Every candidate has a completed checklist, exact source/importer, proposed edit and named behavior checks. Label heuristics as heuristics and qualify conclusions affected by evidence gaps; the report step completes **audit** mode.
|
||||
### Unexpected client dependencies
|
||||
|
||||
Size ranking can miss small server-owned modules in client code. Before scanning in either mode, read [Client dependencies](references/client-dependencies.md) for graph joins, generated Server Action identities and tracing. Stream the export to check every emitted client module, including async outputs.
|
||||
|
||||
Before editing, save a **Client-dependency scan** table in the report: one row per match and every client-imported Server Action, with **Source + client importer**, **Emitted identity**, **Routes**, **Compressed bytes** and **Disposition + evidence**. Reconcile source Action imports against emitted references, including generated identities; verify whether implementation contents survive. A filename search or byte ranking alone cannot complete this table.
|
||||
|
||||
Use three dispositions: **possible leak** requires server-only ownership evidence and surviving client contents; **suppressed** covers intentional client content and verified reference-only Action stubs; **unverified** names the missing evidence. Public form schemas, help text and editable prompts stay suppressed even under `server/` or `prompts` paths. Record coverage gaps (unsupported outputs, missing roots or mappings), explicitly stating when none were found.
|
||||
|
||||
**Done:** the community/min-cut pass accounts for every target or records its skip reason or blocker. Every size candidate has a completed checklist, exact source/importer, proposed edit and behavior checks. Every client-dependency match is a heuristic finding, suppressed with a reason or unverified with an evidence gap. Audit mode ends after the report.
|
||||
|
||||
## 5. Verify one change — fix mode only
|
||||
|
||||
Classify each proposed edit as a size optimization or boundary remediation before applying it. A request to reduce browser JavaScript authorizes size edits, including in public prompt modules and finding importers. Removing private client state/payloads, relocating policy or adding `server-only` needs separate explicit approval; leave those findings unresolved for the owner even if the UI would stay unchanged.
|
||||
|
||||
Make one small, cohesive change. Capture/export an after snapshot with a new name and compare the **same analysis scope, output class and metric** with the baseline. For whole-app analysis, account for changes across all routes, including shared dependencies. Run relevant behavior tests and type-check; use [`next-dev-loop`](https://github.com/vercel/next.js/tree/canary/skills/next-dev-loop) when verifying the edit in the running app, and install it first if unavailable (`npx skills add https://github.com/vercel/next.js/tree/canary/skills/next-dev-loop`).
|
||||
|
||||
If no relevant tests exist, create and run focused smoke tests. Test production imports or rendered UI, not copied implementations. Exercise interactive flows through a browser or executable component tests, including input handling, timing and deferred actions. For debounced search, check case-insensitive results and the existing delay; for editable lazy features, trigger loading and edit the result. Record commands/actions and observed results. Source inspection, type-checking and successful builds alone do not verify behavior.
|
||||
|
||||
**Done:** retain the change only when the scoped metric improves and the intended behavior passes its checks. Revert a change that fails either condition. If checks are blocked, report the unverified edit and blocker rather than accepting it. Record the accepted after snapshot as the next baseline before another edit.
|
||||
|
||||
## 6. Report and stop
|
||||
|
||||
Audit reports candidates; fix reports accepted edits and any reverted or unverified attempts. Include snapshot names, scoped attribution/deltas, exact source/importer, behavior-check results and blockers. For a graph cut, include its route/render conditions, roots/target and unknowns. Distinguish source facts, build evidence, heuristics and separately observed runtime facts.
|
||||
|
||||
**Done:** every scoped candidate or attempted edit is accounted for, with evidence or a stated gap.
|
||||
Add a **Possible server-only leaks** section with five labeled lines per heuristic finding: **Module**, **Routes**, **Chain**, **Heuristics** (including ownership evidence) and **Compressed bytes**. Explain missing values on their lines. Keep unresolved boundary concerns separate from accepted size edits, including those in the same file. State when no leaks were found.
|
||||
|
||||
Use the scan table to populate **Suppressed matches** and **Evidence gaps**. Keep public form content and verified Action stubs outside the leak section, along with naming concerns. Include the coverage statement even when no gaps were found.
|
||||
|
||||
**Done:** every scoped candidate, leak finding, client-imported Action and attempted edit is accounted for, with evidence or a stated gap.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Client dependencies
|
||||
|
||||
Use the main workflow's graph-evidence checklist and installed schema.
|
||||
|
||||
## Discover emitted modules
|
||||
|
||||
While streaming the export, retain:
|
||||
|
||||
- **Modules:** exact `ident`, source `path` and `dependencies.sync`/`dependencies.async`. Browser layers (`[app-client]`, or `[client]` for Pages) distinguish client variants from `[app-ssr]` and `[app-rsc]`; output membership establishes which variants ship.
|
||||
- **Outputs:** each route's client and server output records, with `modules`, `async_loaders` and `coverage`. Join members by exact `ident`; an async loader target is a loading edge, not a member of the importer's chunk.
|
||||
- **Roots:** route entries with `entry_kind: client_bootstrap`, plus each entry's `client_references[].module_ident`.
|
||||
- **Bytes:** `[client-fs]` `part` records keyed by `source_path`. Count each `filename` once per source path across routes.
|
||||
|
||||
Group emitted members by source `path`, retaining each variant's identity. Inspect sources shared by server and client first, then remaining client members. Output membership identifies affected routes; unsupported membership is a coverage gap.
|
||||
|
||||
## Validate candidates
|
||||
|
||||
For every match, establish server ownership from source, callers or project documentation and confirm the relevant contents survive in emitted client code. Apply this to custom code and dependencies alike. Examples and common false positives:
|
||||
|
||||
- **Databases:** ORM schemas/models, migrations, SQL, drivers (`pg`, `mysql2`, `mongodb`) and connection factories. Public enums, validation data and browser-configured clients can be intentional.
|
||||
- **Authentication:** secret-based signing/verification, password hashing, session stores, OAuth secrets and server auth APIs. Browser session UI, anon-key clients and public-key verification can be intentional.
|
||||
- **Authorization:** permission tables, ACLs, entitlements and gates. Public mappings may render controls while the server enforces access.
|
||||
- **SDKs:** cloud, admin, payment, mail and model SDKs. Inspect project importers and configuration; browser clients, presigned URLs and public upload flows can be intentional.
|
||||
- **Model instructions:** public, editable prompts and browser inference are ordinary client content eligible for size optimization. Wording, length, roles and tool definitions alone do not establish ownership.
|
||||
- **Names and shims:** `server`, `db`, `auth`, `agents`, `prompts`, `*.server.*` and Node polyfills warrant inspection. UI copy and browser libraries using `buffer`, `stream`, `events`, `util` or `process` can be legitimate.
|
||||
|
||||
Inspect project source; for `node_modules`, use package identities and project importer configuration. Exclude type-only imports and exports erased from client output. Server modules absent from client output are not findings.
|
||||
|
||||
For each inspected Server Action client reference, map its emitted identity (possibly generated or `data:`) to the source action and client importer. Record routes and attributed compressed bytes, and check whether private implementation contents survive. Report verified reference-only stubs as suppressed matches; incomplete mappings or checks as unverified matches with specific gaps. Stub bytes alone do not establish a leak.
|
||||
|
||||
## Trace findings
|
||||
|
||||
For affected routes, run BFS from client roots over synchronous and asynchronous dependencies, retaining parent pointers and edge kinds. Use `(ident, crossedAsync)` as the visited key to preserve synchronous paths separately. Prefer the shortest synchronous chain; otherwise use the shortest async chain and mark its loading edge and source trigger. Across routes, choose the shortest chain of the preferred kind. Record missing roots as evidence gaps.
|
||||
|
||||
Keep conditional loading distinct from synchronous reachability. Optimized-away barrels may be absent from the emitted chain; report the chain the graph shows and the source import path when different.
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "78759bf62e9a1ef43e926ff9e50afa4cfe9a63cb",
|
||||
"commit": "f3660bfcd20bc07c29260b7e330261a01a2b5a6d",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-10-24T16:00:00Z"
|
||||
"syncedAt": "2026-10-28T01:57:49Z"
|
||||
}
|
||||
@@ -113,6 +113,7 @@ def build_inventory(slide_library: dict, images_by_slide: dict[int, list[dict]])
|
||||
{
|
||||
"chart_id": c.get("chart_id"),
|
||||
"chart_type": c.get("chart_type"),
|
||||
"title": c.get("title", ""),
|
||||
"category_count": c.get("category_count"),
|
||||
"series_count": c.get("series_count"),
|
||||
"categories": c.get("categories", []), # frozen data
|
||||
@@ -240,6 +241,8 @@ def _frozen_strings(slide: dict) -> list[str]:
|
||||
for row in table.get("cells", []):
|
||||
strings.extend(cell for cell in row if cell and cell.strip())
|
||||
for chart in slide.get("charts", []):
|
||||
if chart.get("title"):
|
||||
strings.append(chart["title"])
|
||||
strings.extend(str(c) for c in chart.get("categories", []) if str(c).strip())
|
||||
strings.extend(str(series.get("name")) for series in chart.get("series", []) if series.get("name"))
|
||||
for diagram in slide.get("diagrams", []):
|
||||
@@ -259,6 +262,7 @@ def _exported_text_by_slide(pptx_path: Path) -> dict[int, str]:
|
||||
for row in table.get("rows", []):
|
||||
parts.extend(cell.get("text", "") for cell in row.get("cells", []))
|
||||
for chart in slide.get("charts", []):
|
||||
parts.append(chart.get("title", ""))
|
||||
parts.extend(str(c) for c in chart.get("categories", []))
|
||||
parts.extend(str(series.get("name", "")) for series in chart.get("series", []))
|
||||
for diagram in slide.get("diagrams", []):
|
||||
|
||||
+10
-3
@@ -101,11 +101,16 @@ def format_coordinate(value: str | float) -> str:
|
||||
return "0" if compact in {"", "-0"} else compact
|
||||
|
||||
|
||||
def _compact_coordinate_quad(value: str) -> str:
|
||||
def _compact_coordinate_quad(value: str, *, preserve_nonzero_extent: bool = False) -> str:
|
||||
tokens = _number_tokens(value)
|
||||
if tokens is None or len(tokens) != 4:
|
||||
return value
|
||||
return " ".join(format_coordinate(token) for token in tokens)
|
||||
compacted = [format_coordinate(token) for token in tokens]
|
||||
if preserve_nonzero_extent:
|
||||
for axis in (2, 3):
|
||||
if float(tokens[axis]) > 0 and compacted[axis] == "0":
|
||||
compacted[axis] = tokens[axis]
|
||||
return " ".join(compacted)
|
||||
|
||||
|
||||
def _compact_transform(value: str) -> str:
|
||||
@@ -147,7 +152,9 @@ def _compact_attribute_value(
|
||||
if name == "data-pptx-frame":
|
||||
return _compact_coordinate_quad(value) if compact_native_frames else value
|
||||
if name == "data-pptx-bounds":
|
||||
return _compact_coordinate_quad(value)
|
||||
return _compact_coordinate_quad(
|
||||
value, preserve_nonzero_extent=not compact_native_frames,
|
||||
)
|
||||
if name == "transform":
|
||||
return _compact_transform(value)
|
||||
return value
|
||||
|
||||
@@ -134,14 +134,28 @@ 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 (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.
|
||||
- `.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, including footnote and endnote math. Classic MathType OLE objects carry no OMML and are kept only as their preview image. Simple fields (`w:fldSimple`, e.g. caption numbers) keep their displayed result, and directly hidden runs (`w:vanish`) are omitted with a warning, matching what Word displays.
|
||||
- `.html` / `.htm` — via `markdownify` + `beautifulsoup4`
|
||||
- `.epub` — via `ebooklib` + `markdownify`
|
||||
- `.epub` — via `ebooklib` + `markdownify`; keep spine order, then append
|
||||
supplemental document items (not the EPUB 3 navigation document) with a
|
||||
warning. Resolve images relative to each
|
||||
chapter before basename fallbacks, so equal filenames in different chapter
|
||||
directories keep their own images. Non-rendered script/style/template content
|
||||
is omitted with a warning; interactive `hidden` / `display:none` text is kept.
|
||||
- `.ipynb` — via `nbconvert`; code-cell output images and Markdown attachments
|
||||
use `<output-stem>_files/` with relative Markdown references
|
||||
|
||||
Pandoc fallback (only if you need these):
|
||||
- `.doc`, `.odt`, `.rtf`, `.tex`/`.latex`, `.rst`, `.org`, `.typ`
|
||||
- `.odt` uses a temporary copy to expose table header rows, flatten text-box
|
||||
paragraphs while retaining inline links, and resolve conditional text from
|
||||
its stored `text:is-hidden` state. Diagnostics report these normalizations
|
||||
and excluded author-hidden fields; text-box geometry remains in the source.
|
||||
- `.rtf` normalizes flat field instruction/result containers in a temporary
|
||||
copy so Pandoc retains their displayed text and links. Author-hidden text is
|
||||
omitted with a warning. Both preparations leave source files unchanged.
|
||||
- `.doc` still requires a Pandoc build with a `doc` reader; a build without
|
||||
that reader reports an explicit conversion failure, not an empty success.
|
||||
- `.typ` evaluates under pandoc, so a real project file that imports its template,
|
||||
a package, or a custom function fails there; it then (or without pandoc) keeps the
|
||||
source text with `=` headings mapped to Markdown and raw blocks, markup, math, and
|
||||
@@ -233,6 +247,10 @@ python3 scripts/source_to_md/ppt_to_md.py template.ppsx -o notes/template.md
|
||||
|
||||
Behavior:
|
||||
- extracts slide text in reading order
|
||||
- excludes author-hidden shapes and groups (`cNvPr/@hidden`) with warnings; their text, pictures, tables, charts, and SmartArt descendants do not enter Markdown or the Beautify intake inventory
|
||||
- rewrites Office Math equations (`a14:m` OMML) as `$...$` inline or `$$...$$` display LaTeX, using the same converter as `.docx`
|
||||
- uses a chart's own visible title in its `> [Chart]` header, and a picture's alt text (`descr`) as its Markdown alt text
|
||||
- reads the `mc:Fallback` of other shape-tree `mc:AlternateContent` it cannot interpret (3D models, ink, zoom), so their preview pictures and text are kept; ChartEx stays reported as unsupported
|
||||
- converts PowerPoint tables to Markdown tables; cell-internal line breaks become `<br>` so they cannot break the pipe-table row structure
|
||||
- transcribes category charts as category × series tables and scatter/bubble charts as typed X/Y[/size] point tables
|
||||
- preserves every readable chart dimension or series and emits `[Chart data warning: <reason>]` for missing caches/count mismatches; `[Chart data unavailable: <reason>]` is reserved for charts with no readable points (and unsupported ChartEx), so XY data is never flattened into a misleading category table
|
||||
@@ -262,7 +280,7 @@ python3 scripts/pptx_intake.py deck.pptx -o projects/demo/analysis
|
||||
|
||||
Outputs (per source deck, prefixed by file stem):
|
||||
- `<stem>.identity.json` — canvas size/aspect, theme palette/fonts, observed colors/fonts
|
||||
- `<stem>.slide_library.json` — text slots, geometry, native tables, native chart display caches, and SmartArt nodes/connections
|
||||
- `<stem>.slide_library.json` — text slots, geometry, native tables, native chart titles/display caches, and SmartArt nodes/connections. Chart titles include rich text and linked-string caches; Beautify carries them into its frozen inventory and verifies their presence in the exported deck.
|
||||
- `source_profile.json` — the single multi-deck index: a compact Strategist-facing digest per deck (over identity, tables, charts, SmartArt, and page types) under `decks[]`, with prefixed artifact pointers
|
||||
|
||||
`project_manager.py import-sources` runs this automatically for PPTX/PPTM/PPSX/PPSM/POTX/POTM inputs and stores the bundle directly under `analysis/`. Multi-deck per project: each deck uses its archived stem, including any collision suffix, for `<stem>.*` artifacts and its `decks[]` entry in the shared `source_profile.json` index. Re-running intake for an existing archived stem replaces its entry. The beautify profile stays single-deck and reads one chosen deck's `<stem>.*` artifacts.
|
||||
|
||||
@@ -224,6 +224,11 @@ notes and an absent file keeps them. Deleting inherited source notes only on a
|
||||
copy is not supported in v1. The same output-stem rule applies to narration
|
||||
audio.
|
||||
|
||||
Imported notes Markdown excludes text in author-hidden notes shapes or groups
|
||||
and records a `hidden-notes-content` diagnostic. Unchanged round-trip notes
|
||||
still retain their original native XML, including its hidden state; editing a
|
||||
visible note does not promote the excluded text into the replacement notes.
|
||||
|
||||
When a round-trip recorded-narration export omits `--animation-config`, it reads the workspace `animations.json` the importer wrote; that sidecar is part of the workspace contract, so deleting it fails export rather than restoring source motion.
|
||||
|
||||
Narration audio is keyed by the output SVG stem. A copied output page uses its
|
||||
@@ -1345,6 +1350,11 @@ Positioned `x`/`y`/nonzero `dy` rows keep the existing split/preserve/reflow
|
||||
behavior. A row starter's `dx` is consumed by its resolved line position;
|
||||
later inline scalar `dx` stays with its run through flattening.
|
||||
|
||||
Split rows retain text names, roles, classes and visibility. The first row
|
||||
keeps the original text id; later rows use collision-free `-line-N` ids.
|
||||
Inline anchors and id-bearing tspans survive splitting. These preservation
|
||||
rules do not broaden the native Generate authoring gate.
|
||||
|
||||
Native export represents inline `dx` with a separate NBSP run before the
|
||||
affected text. Its `a:rPr@spc`, in hundredths of a point, is
|
||||
`round(75 * (dx_px - estimated_space_width_px))`. The space estimate uses the
|
||||
@@ -1392,6 +1402,10 @@ SVG paths. The exact case-sensitive file must exist under the workspace
|
||||
`icons/`; bare, aliased, template-source, and unsynced references fail. Use
|
||||
this only for manual checks outside `finalize_svg.py`.
|
||||
|
||||
The replacement group retains the placeholder's id, class, style, opacity,
|
||||
visibility, clipping/filter context, and `data-pptx-*` / `data-edit-*`
|
||||
attributes. Hidden icon placeholders therefore remain hidden after embedding.
|
||||
|
||||
## SVG Compatibility Contract
|
||||
|
||||
The always-on SVG authoring contract lives in
|
||||
|
||||
@@ -20,7 +20,7 @@ The launcher starts the server in the background, waits for `GET /api/health`, r
|
||||
- **Single instance per project**: `live_preview/lock.json` is the discovery source for project-local consumers (including `visual_review.py`). A second launch reuses the live instance unless a different explicit `--port N` was requested — that mismatch fails and needs `--shutdown` first. Stale locks (dead pid) are overwritten; legacy `<project_path>/.live_preview.lock` root locks are still detected when live.
|
||||
- **Stop conditions**: **Exit preview** in the browser (the only UI action that stops Flask), a chat request to stop, the idle timeout, or an external kill. None of them discards unapplied work; see **Draft**.
|
||||
- **Draft**: every staging change (annotation add/remove, direct edit, undo) rewrites `live_preview/draft.json` before it is acknowledged; the file is removed once nothing is left unapplied. The next start restores each page whose SVG still matches the digest recorded when staging began, and the UI reports the restored pages. A page whose SVG changed meanwhile is not replayed — its draft moves to `live_preview/draft_conflict_<timestamp>.json`, as does an unreadable draft. A failed draft write is reported in the UI; the change then lives in memory only.
|
||||
- **Transient ids**: each element gets a temporary `_edit_N` id while running; on save only annotated elements keep their id.
|
||||
- **Transient ids**: each element gets a temporary `_edit_N` id while running; on save annotated elements and local fragment targets keep their id. Existing gradient, clip, and `<use>` references remain valid even when their target ids match `_edit_N`.
|
||||
- **Browser preview**: the server inlines `<use data-icon>` placeholders and serves `images/*`; the on-disk SVG is unchanged by preview.
|
||||
- **Re-export is chat-driven**: applying changes updates `svg_output/` only; refreshing the PPTX (`finalize_svg.py` + `svg_to_pptx.py`) is a chat step — the editor never runs the export pipeline.
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ Produces one import workspace (default `<pptx_stem>_template_import/` beside the
|
||||
| `svg-flat/slide_NN.svg` | Optional (`--inheritance-mode both`) self-contained verification view of each complete page |
|
||||
| `authoring-svg/` (+ `authoring-svg-flat/` with `both`) | Canonical compact editable SVG projected from parsed evidence, with model-readable `authoring_summary.json` and tool-only `authoring_manifest.json` |
|
||||
|
||||
Shapes or groups marked hidden by the source author stay out of imported SVG
|
||||
views and emit `author-hidden-omitted` diagnostics, including their source part
|
||||
and shape identity. This does not remove objects from an Edit Native source
|
||||
package or alter unchanged-page passthrough.
|
||||
|
||||
Source slides hidden from the slide show (`p:sld/@show="0"`) are retained, but
|
||||
template preview export does not preserve their slide-hidden state. Template
|
||||
import warns on stderr with the affected slide index and source part. Use Edit
|
||||
Native roundtrip when source slide-show visibility must survive; the warning
|
||||
does not claim that template previews preserve it.
|
||||
|
||||
- `layered` (default) emits only the canonical layered view; `both` adds the flat verification tree; `flat` emits a projection-only self-contained `svg/` tree without master/layout/inheritance files. Imported-deck round-trip uses the separate `authoring-svg-flat/` contract of `pptx_to_svg.py --roundtrip`.
|
||||
- Placeholder metadata lives in the manifest; master/layout SVGs show lightweight dashed guides with labels only in `svg/`. Charts, SmartArt, diagrams, and OLE objects are typed placeholders in `svg/` and preview images with a badge in `svg-flat/`; tables are converted to real SVG. Missing media and external linked images fail the import; EMF/WMF convert to PNG previews when the local toolchain supports it, otherwise the import fails.
|
||||
- The import transaction publishes `authoring-svg/` already normalized and decoration-factored: large non-semantic decorative vector groups become one canonical asset under `<import_workspace>/icons/imported/` referenced as `<use data-icon="imported/..." data-pptx-asset-role="decoration"/>`; do not run a second readability or compaction pass. `--inheritance-mode both` reuses the layered inventory (`--reuse-inventory`) so only genuinely flat-only vectors create another asset under the `flat` prefix; `--clean-stale` removes obsolete `flat_*` duplicates. Every asset root, placeholder, and v2 inventory record declares the `decoration` role; any subtree with a semantic marker, text, table, chart, or relationship stays inline, and extraction and both consumers fail closed if that boundary is crossed. Eligible records may retain `data-pptx-source-ref` so re-inlining re-establishes their object mapping; referenced defs (`gradient` / `pattern` / `filter` / `clipPath` / `marker`) are copied into each asset and namespaced.
|
||||
|
||||
+200
-22
@@ -56,6 +56,7 @@ from native_payloads import (
|
||||
serialize_native_payload_store,
|
||||
)
|
||||
from pptx_shapes import (
|
||||
CONNECTOR_PRESET_TYPES,
|
||||
NATIVE_FALLBACK_SHA256_ATTR,
|
||||
svg_native_fallback_fingerprint,
|
||||
svg_preset_preview_fingerprint,
|
||||
@@ -68,6 +69,13 @@ from svg_authoring_view import (
|
||||
semantic_subtree_sha256,
|
||||
)
|
||||
from svg_finalize.flatten_tspan import flatten_text_with_tspans
|
||||
from svg_to_pptx.drawingml.context import ConvertContext
|
||||
from svg_to_pptx.drawingml.utils import (
|
||||
is_stroked_zero_axis_shape,
|
||||
parse_transform_matrix,
|
||||
svg_hidden_reason,
|
||||
transform_point,
|
||||
)
|
||||
from svg_to_pptx.pptx_package.template_structure import (
|
||||
SOURCE_THEMES_FILENAME,
|
||||
SOURCE_THEMES_SCHEMA,
|
||||
@@ -1455,7 +1463,7 @@ def _paint_value(element: ET.Element, name: str) -> str | None:
|
||||
return _parse_style(element.get("style")).get(name)
|
||||
|
||||
|
||||
def _visible_leaf(element: ET.Element) -> bool:
|
||||
def _visible_leaf(element: ET.Element, *, check_dimensions: bool = True) -> bool:
|
||||
if _hidden(element):
|
||||
return False
|
||||
tag = _local_name(element.tag)
|
||||
@@ -1463,8 +1471,8 @@ def _visible_leaf(element: ET.Element) -> bool:
|
||||
return False
|
||||
if tag in {"g", "svg", "a"}:
|
||||
return any(_visible_leaf(child) for child in element)
|
||||
if tag in {"rect", "image", "foreignObject"}:
|
||||
for dimension in ("width", "height"):
|
||||
if tag in {"rect", "image", "foreignObject"} and (check_dimensions or tag != "rect"):
|
||||
for dimension in ("width", "height") if check_dimensions else ():
|
||||
raw = element.get(dimension)
|
||||
if raw is not None:
|
||||
try:
|
||||
@@ -1476,7 +1484,7 @@ def _visible_leaf(element: ET.Element) -> bool:
|
||||
if tag in {"line", "polyline"}:
|
||||
stroke = _paint_value(element, "stroke")
|
||||
return stroke not in {None, "none", "transparent"}
|
||||
if tag in {"path", "polygon", "circle", "ellipse"}:
|
||||
if tag in {"rect", "path", "polygon", "circle", "ellipse"}:
|
||||
fill = _paint_value(element, "fill")
|
||||
stroke = _paint_value(element, "stroke")
|
||||
return not (
|
||||
@@ -1562,7 +1570,10 @@ def _flatten_fixed_group(group: ET.Element, *, context: str) -> list[ET.Element]
|
||||
atoms.append(item)
|
||||
if len(atoms) == 1:
|
||||
atom = atoms[0]
|
||||
for name in ("data-pptx-frame", "data-pptx-object", "data-pptx-prst"):
|
||||
for name in (
|
||||
"data-pptx-frame", "data-pptx-object", "data-pptx-prst",
|
||||
"data-pptx-geometry-kind",
|
||||
):
|
||||
if group.get(name) is not None and atom.get(name) is None:
|
||||
atom.set(name, group.get(name, ""))
|
||||
return atoms
|
||||
@@ -1588,6 +1599,7 @@ def _fixed_atoms(
|
||||
scope: str,
|
||||
key: str,
|
||||
placeholder_source_refs: set[str] | None = None,
|
||||
warnings: list[str] | None = None,
|
||||
) -> list[ET.Element]:
|
||||
atoms: list[ET.Element] = []
|
||||
serial = 0
|
||||
@@ -1600,6 +1612,13 @@ def _fixed_atoms(
|
||||
or child.get(SOURCE_REF_ATTRIBUTE) in placeholder_source_refs
|
||||
):
|
||||
continue
|
||||
if _invisible_degenerate_source(child):
|
||||
if warnings is not None:
|
||||
warnings.append(
|
||||
f"{scope.title()} {key} element {child.get('id') or '<' + tag + '>'!r}: "
|
||||
"omitted invisible content without a positive source frame"
|
||||
)
|
||||
continue
|
||||
if not _visible_leaf(child):
|
||||
continue
|
||||
item = copy.deepcopy(child)
|
||||
@@ -1642,6 +1661,128 @@ def _frame(element: ET.Element | None) -> tuple[float, float, float, float] | No
|
||||
return values
|
||||
|
||||
|
||||
def _invisible_degenerate_source(element: ET.Element) -> bool:
|
||||
"""Omit only unbounded source content whose paint is known to be invisible."""
|
||||
if _frame(element) is not None:
|
||||
return False
|
||||
preview = copy.deepcopy(element)
|
||||
# Resolve local style precedence before propagating inherited paint on this
|
||||
# disposable view. Source attributes and geometry remain unchanged.
|
||||
for node in preview.iter():
|
||||
node.attrib.update(_parse_style(node.get("style")))
|
||||
node.attrib.pop("style", None)
|
||||
if _hidden(preview) and _paint_value(preview, "visibility") not in {"hidden", "collapse"}:
|
||||
return True
|
||||
for parent in preview.iter():
|
||||
for child in parent:
|
||||
_merge_group_inheritance(parent, child)
|
||||
parents = {id(child): parent for parent in preview.iter() for child in parent}
|
||||
for leaf in preview.iter():
|
||||
if _local_name(leaf.tag) in _NON_VISUAL_TAGS | {"g", "svg", "a"}:
|
||||
continue
|
||||
if svg_hidden_reason(leaf, parents):
|
||||
continue
|
||||
ancestor = parents.get(id(leaf))
|
||||
while ancestor is not None and _local_name(ancestor.tag) not in _NON_VISUAL_TAGS:
|
||||
ancestor = parents.get(id(ancestor))
|
||||
if ancestor is not None:
|
||||
continue
|
||||
# Dimensions do not prove a source image or stroked shape is empty.
|
||||
# Keep uncertain content for the existing geometry validation.
|
||||
if _visible_leaf(leaf, check_dimensions=False):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _source_bounds(
|
||||
element: ET.Element,
|
||||
stroke_width: float = 1.0,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
"""Bound a known source frame or native group's children without editing it."""
|
||||
raw = (element.get("data-pptx-frame") or "").replace(",", " ").split()
|
||||
if len(raw) != 4:
|
||||
return None
|
||||
try:
|
||||
x, y, width, height = (float(value) for value in raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if (
|
||||
not all(math.isfinite(value) for value in (x, y, width, height))
|
||||
or width < 0 or height < 0
|
||||
or (width == 0 and height == 0)
|
||||
):
|
||||
return None
|
||||
|
||||
# Only the validation zone is padded; native frames and geometry stay exact.
|
||||
for child in element.iter():
|
||||
raw_stroke = _paint_value(child, "stroke-width")
|
||||
if raw_stroke is not None:
|
||||
try:
|
||||
value = float(raw_stroke)
|
||||
except ValueError:
|
||||
continue
|
||||
if math.isfinite(value):
|
||||
stroke_width = max(stroke_width, value)
|
||||
if width == 0:
|
||||
x -= stroke_width / 2
|
||||
width = stroke_width
|
||||
if height == 0:
|
||||
y -= stroke_width / 2
|
||||
height = stroke_width
|
||||
if element.get("data-pptx-object") == "group":
|
||||
child_bounds = [
|
||||
_source_bounds(child, stroke_width)
|
||||
for child in element if _visible_leaf(child)
|
||||
]
|
||||
if not child_bounds or any(bounds is None for bounds in child_bounds):
|
||||
return None
|
||||
x = min(bounds[0] for bounds in child_bounds)
|
||||
y = min(bounds[1] for bounds in child_bounds)
|
||||
width = max(bounds[0] + bounds[2] for bounds in child_bounds) - x
|
||||
height = max(bounds[1] + bounds[3] for bounds in child_bounds) - y
|
||||
matrix = parse_transform_matrix(element.get("transform", ""))
|
||||
corners = [
|
||||
transform_point(matrix, px, py)
|
||||
for px in (x, x + width)
|
||||
for py in (y, y + height)
|
||||
]
|
||||
left = min(point[0] for point in corners)
|
||||
top = min(point[1] for point in corners)
|
||||
return (
|
||||
left, top,
|
||||
max(point[0] for point in corners) - left,
|
||||
max(point[1] for point in corners) - top,
|
||||
)
|
||||
|
||||
|
||||
def _root_group_bounds(
|
||||
element: ET.Element,
|
||||
canvas_width: float,
|
||||
canvas_height: float,
|
||||
ctx: ConvertContext | None = None,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
bounds = _frame(element)
|
||||
if bounds is not None:
|
||||
return bounds
|
||||
if (
|
||||
element.get("data-pptx-object") not in {"connector", "group"}
|
||||
and element.get("data-pptx-prst") not in CONNECTOR_PRESET_TYPES
|
||||
and not is_stroked_zero_axis_shape(element, ctx)
|
||||
):
|
||||
return None
|
||||
bounds = _source_bounds(element)
|
||||
if bounds is None:
|
||||
return None
|
||||
# The page clips edge strokes; bounds describe their on-canvas module zone.
|
||||
x, y, width, height = bounds
|
||||
left, top = max(0.0, x), max(0.0, y)
|
||||
right = min(canvas_width, x + width)
|
||||
bottom = min(canvas_height, y + height)
|
||||
if right <= left or bottom <= top:
|
||||
return None
|
||||
return left, top, right - left, bottom - top
|
||||
|
||||
|
||||
def _element_by_ref(root: ET.Element, source_ref: str) -> ET.Element | None:
|
||||
matches = [
|
||||
element
|
||||
@@ -1799,7 +1940,10 @@ def _slot_plans(
|
||||
raw.get("shapeId"),
|
||||
context=f"layout {layout.get('key')} placeholder shapeId",
|
||||
)
|
||||
bounds = _frame(_element_by_ref(layout_root, f"layout:{shape_id}"))
|
||||
bounds = (
|
||||
_frame(_element_by_ref(layout_root, f"layout:{shape_id}"))
|
||||
or _native_geometry(raw)
|
||||
)
|
||||
if bounds is None:
|
||||
for slide in source_slides:
|
||||
matching = next(
|
||||
@@ -1834,7 +1978,6 @@ def _slot_plans(
|
||||
bounds = _frame(
|
||||
_placeholder_guide_by_semantic(master_root, _semantic_role(raw))
|
||||
)
|
||||
bounds = bounds or _native_geometry(raw)
|
||||
if bounds is None:
|
||||
raise MirrorMaterializationError(
|
||||
f"Layout {layout.get('key')!r} placeholder {shape_id!r} has no "
|
||||
@@ -1904,7 +2047,7 @@ def _copy_text_carrier(source: ET.Element | None) -> ET.Element | None:
|
||||
if source_frame is not None:
|
||||
carrier.set(
|
||||
"data-pptx-frame",
|
||||
" ".join(format_coordinate(value) for value in source_frame),
|
||||
_format_source_frame(source_frame),
|
||||
)
|
||||
carrier.set("data-pptx-carrier", "true")
|
||||
return carrier
|
||||
@@ -2159,6 +2302,9 @@ def _blank_text_carrier(
|
||||
carrier = _copy_text_carrier(layout_guide)
|
||||
if carrier is None:
|
||||
carrier = _copy_text_carrier(master_guide)
|
||||
if carrier is not None:
|
||||
# The Master supplies typography here; the resolved slot owns its frame.
|
||||
carrier.attrib.pop("data-pptx-frame", None)
|
||||
if carrier is None:
|
||||
x, y, _width, height = plan.bounds
|
||||
carrier = ET.Element(
|
||||
@@ -2174,24 +2320,24 @@ def _blank_text_carrier(
|
||||
carrier.text = None
|
||||
for child in list(carrier):
|
||||
carrier.remove(child)
|
||||
if carrier.get("data-pptx-frame") is None:
|
||||
if _frame(carrier) is None:
|
||||
carrier.set(
|
||||
"data-pptx-frame",
|
||||
" ".join(format_coordinate(value) for value in plan.bounds),
|
||||
_format_source_frame(plan.bounds),
|
||||
)
|
||||
carrier.set("data-pptx-carrier", "true")
|
||||
return carrier
|
||||
|
||||
|
||||
def _blank_image_carrier(plan: SlotPlan) -> ET.Element:
|
||||
x, y, width, height = plan.bounds
|
||||
x, y, width, height = _format_source_frame(plan.bounds).split()
|
||||
return ET.Element(
|
||||
f"{{{SVG_NS}}}image",
|
||||
{
|
||||
"x": format_coordinate(x),
|
||||
"y": format_coordinate(y),
|
||||
"width": format_coordinate(width),
|
||||
"height": format_coordinate(height),
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"href": TRANSPARENT_PIXEL_DATA_URI,
|
||||
"preserveAspectRatio": "none",
|
||||
"data-pptx-carrier": "true",
|
||||
@@ -2203,6 +2349,15 @@ def _format_number(value: float) -> str:
|
||||
return f"{value:.8f}".rstrip("0").rstrip(".") or "0"
|
||||
|
||||
|
||||
def _format_source_frame(values: tuple[float, float, float, float]) -> str:
|
||||
"""Keep a positive source extent when page-coordinate rounding would erase it."""
|
||||
compacted = [format_coordinate(value) for value in values]
|
||||
for axis in (2, 3):
|
||||
if values[axis] > 0 and compacted[axis] == "0":
|
||||
compacted[axis] = _format_number(values[axis])
|
||||
return " ".join(compacted)
|
||||
|
||||
|
||||
def _optional_float(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -2225,9 +2380,7 @@ def _slot_wrapper(
|
||||
{
|
||||
"id": plan.slot_id,
|
||||
"data-pptx-placeholder": plan.semantic_role,
|
||||
"data-pptx-bounds": " ".join(
|
||||
format_coordinate(item) for item in plan.bounds
|
||||
),
|
||||
"data-pptx-bounds": _format_source_frame(plan.bounds),
|
||||
},
|
||||
)
|
||||
if plan.idx is not None:
|
||||
@@ -2399,6 +2552,7 @@ def _compose_template(
|
||||
slide: dict[str, Any] | None,
|
||||
slide_root: ET.Element | None,
|
||||
slot_plans: list[SlotPlan],
|
||||
warnings: list[str] | None = None,
|
||||
) -> ET.Element:
|
||||
width = float(native["slideSize"]["width_px"])
|
||||
height = float(native["slideSize"]["height_px"])
|
||||
@@ -2450,8 +2604,14 @@ def _compose_template(
|
||||
root.append(defs)
|
||||
for style in styles:
|
||||
root.append(style)
|
||||
geometry_context = ConvertContext(defs={
|
||||
element.get("id"): element for element in root.iter() if element.get("id")
|
||||
})
|
||||
|
||||
master_atoms = _fixed_atoms(master_root, scope="master", key=str(master["key"]))
|
||||
omission_warnings: list[str] = []
|
||||
master_atoms = _fixed_atoms(
|
||||
master_root, scope="master", key=str(master["key"]), warnings=omission_warnings,
|
||||
)
|
||||
layout_placeholder_refs = {
|
||||
f"layout:{item['shapeId']}"
|
||||
for item in layout.get("placeholders", [])
|
||||
@@ -2462,6 +2622,7 @@ def _compose_template(
|
||||
scope="layout",
|
||||
key=str(layout["key"]),
|
||||
placeholder_source_refs=layout_placeholder_refs,
|
||||
warnings=omission_warnings,
|
||||
)
|
||||
master_backgrounds = [
|
||||
atom for atom in master_atoms if _is_full_canvas_rect(atom, width, height)
|
||||
@@ -2488,6 +2649,13 @@ def _compose_template(
|
||||
continue
|
||||
item = copy.deepcopy(child)
|
||||
_merge_group_inheritance(slide_root, item)
|
||||
if _invisible_degenerate_source(item):
|
||||
element_id = item.get("id") or f"slide-{slide['index']}-node-{index + 1}"
|
||||
omission_warnings.append(
|
||||
f"element {element_id!r}: "
|
||||
"omitted invisible content without a positive source frame"
|
||||
)
|
||||
continue
|
||||
if _is_full_canvas_rect(item, width, height):
|
||||
item.set("id", item.get("id") or f"slide-{slide['index']}-background")
|
||||
item.set("data-pptx-layer", "slide")
|
||||
@@ -2502,8 +2670,12 @@ def _compose_template(
|
||||
if len(atoms) == 1:
|
||||
atoms[0].set("id", item.get("id"))
|
||||
item = atoms[0]
|
||||
if _local_name(item.tag) == "g" and _visible_leaf(item):
|
||||
bounds = _frame(item)
|
||||
if _local_name(item.tag) == "g" and (
|
||||
_frame(item) is not None
|
||||
or _visible_leaf(item)
|
||||
or is_stroked_zero_axis_shape(item, geometry_context)
|
||||
):
|
||||
bounds = _root_group_bounds(item, width, height, geometry_context)
|
||||
if bounds is None:
|
||||
raise MirrorMaterializationError(
|
||||
f"Slide {slide['index']} root group "
|
||||
@@ -2511,7 +2683,7 @@ def _compose_template(
|
||||
)
|
||||
item.set(
|
||||
"data-pptx-bounds",
|
||||
" ".join(format_coordinate(value) for value in bounds),
|
||||
_format_source_frame(bounds),
|
||||
)
|
||||
slide_content.append(item)
|
||||
if len(slide_backgrounds) > 1:
|
||||
@@ -2578,6 +2750,9 @@ def _compose_template(
|
||||
_strip_source_refs(element)
|
||||
root.append(element)
|
||||
_strip_source_refs(root)
|
||||
if warnings is not None:
|
||||
context = f"Slide {slide['index']}" if slide else f"Layout {layout['key']}"
|
||||
warnings.extend(f"{context}: {warning}" for warning in omission_warnings)
|
||||
return root
|
||||
|
||||
|
||||
@@ -3119,6 +3294,7 @@ def materialize_mirror_template(
|
||||
)
|
||||
|
||||
materialized_roots: list[tuple[Path, ET.Element]] = []
|
||||
warnings: list[str] = []
|
||||
for slide in slides:
|
||||
index = int(slide["index"])
|
||||
layout = layouts[str(slide["layoutKey"])]
|
||||
@@ -3133,6 +3309,7 @@ def materialize_mirror_template(
|
||||
slide=slide,
|
||||
slide_root=prepared_slides[index],
|
||||
slot_plans=plans_by_layout[str(layout["key"])],
|
||||
warnings=warnings,
|
||||
)
|
||||
materialized_roots.append((filename, root))
|
||||
|
||||
@@ -3342,6 +3519,7 @@ def materialize_mirror_template(
|
||||
|
||||
return {
|
||||
"schema": "ppt-master.mirror-materialization-report.v2",
|
||||
"warnings": warnings,
|
||||
"import_workspace": str(import_workspace),
|
||||
"template_workspace": str(template_workspace),
|
||||
"source_slide_indexes": [int(item["index"]) for item in slides],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -28,6 +29,7 @@ from .ooxml import (
|
||||
_paragraph_texts,
|
||||
_parse_slide_refs,
|
||||
_read_xml,
|
||||
_remove_author_hidden_shapes,
|
||||
_shape_identity,
|
||||
_slide_relationships,
|
||||
_table_containers,
|
||||
@@ -253,6 +255,18 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
|
||||
slides: list[dict[str, Any]] = []
|
||||
for slide_ref in slide_refs:
|
||||
slide_root = _read_xml(zf, slide_ref.part_name)
|
||||
hidden_shapes = _remove_author_hidden_shapes(slide_root)
|
||||
hidden_ids = {
|
||||
node.get("id") for shape in hidden_shapes
|
||||
for node in shape.iter(f"{{{NS['p']}}}cNvPr")
|
||||
}
|
||||
for shape in hidden_shapes:
|
||||
shape_id, shape_name = _shape_identity(shape, 0)
|
||||
print(
|
||||
f"Warning: slide {slide_ref.index}: excluded author-hidden shape "
|
||||
f"{shape_name!r} (id={shape_id}) and its children from PPTX intake",
|
||||
file=sys.stderr,
|
||||
)
|
||||
slots: list[dict[str, Any]] = []
|
||||
for order, container in enumerate(_text_containers(slide_root), start=1):
|
||||
shape_id, shape_name = _shape_identity(container, order)
|
||||
@@ -281,7 +295,10 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
|
||||
|
||||
tables = _analyze_tables(slide_root, slide_ref.index)
|
||||
charts = _analyze_charts(zf, slide_root, slide_ref)
|
||||
diagrams = read_smartart_diagrams(zf, slide_ref.part_name, slide_ref.index)
|
||||
diagrams = [
|
||||
item for item in read_smartart_diagrams(zf, slide_ref.part_name, slide_ref.index)
|
||||
if str(item.get("shape_id")) not in hidden_ids
|
||||
]
|
||||
slide_text = "\n".join(
|
||||
[slot["text"] for slot in slots if slot["text"]]
|
||||
+ [
|
||||
|
||||
+13
-3
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .ooxml import NS
|
||||
from .ooxml import NS, _paragraph_texts
|
||||
|
||||
|
||||
def empty_chart_data() -> dict[str, Any]:
|
||||
@@ -19,6 +19,7 @@ def empty_chart_data() -> dict[str, Any]:
|
||||
"series_count": 0,
|
||||
"categories": [],
|
||||
"series": [],
|
||||
"title": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -74,10 +75,18 @@ def _series_name(series: ET.Element, fallback: str) -> str:
|
||||
|
||||
|
||||
def read_chart_data(chart_root: ET.Element) -> dict[str, Any]:
|
||||
"""Return a compact summary of chart type, categories, series, and values."""
|
||||
"""Return the chart title, type, categories, series, and values."""
|
||||
title_node = chart_root.find("c:chart/c:title/c:tx", NS)
|
||||
title = ""
|
||||
if title_node is not None:
|
||||
rich = title_node.find("c:rich", NS)
|
||||
title = (
|
||||
"\n".join(_paragraph_texts(rich))
|
||||
if rich is not None else "\n".join(map(str, _cache_values(title_node)))
|
||||
)
|
||||
chart_types = _chart_types_with_series(chart_root)
|
||||
if not chart_types:
|
||||
return empty_chart_data()
|
||||
return {**empty_chart_data(), "title": title}
|
||||
|
||||
first_series = chart_types[0].find("c:ser", NS)
|
||||
categories = _cache_values(first_series.find("c:cat", NS)) if first_series is not None else []
|
||||
@@ -106,4 +115,5 @@ def read_chart_data(chart_root: ET.Element) -> dict[str, Any]:
|
||||
"series_count": len(series_payload),
|
||||
"categories": categories,
|
||||
"series": series_payload,
|
||||
"title": title,
|
||||
}
|
||||
@@ -253,6 +253,24 @@ def _container_geometry(
|
||||
return {"x": _px(x), "y": _px(y), "width": _px(width), "height": _px(height)}
|
||||
|
||||
|
||||
def _remove_author_hidden_shapes(root: ET.Element) -> list[ET.Element]:
|
||||
"""Remove author-hidden shape subtrees from an in-memory extraction view."""
|
||||
removed: list[ET.Element] = []
|
||||
shape_tags = {f"{{{NS['p']}}}{name}" for name in ("sp", "pic", "cxnSp", "grpSp", "graphicFrame")}
|
||||
|
||||
def visit(parent: ET.Element) -> None:
|
||||
for child in list(parent):
|
||||
properties = child.find("./*/p:cNvPr", NS) if child.tag in shape_tags else None
|
||||
if properties is not None and properties.get("hidden", "").strip().lower() in {"1", "true", "on"}:
|
||||
parent.remove(child)
|
||||
removed.append(child)
|
||||
else:
|
||||
visit(child)
|
||||
|
||||
visit(root)
|
||||
return removed
|
||||
|
||||
|
||||
def _text_containers(slide_root: ET.Element) -> list[ET.Element]:
|
||||
# mc:Fallback repeats its Choice for other clients; it is not slide content.
|
||||
fallback_nodes = {
|
||||
|
||||
@@ -531,7 +531,7 @@ def convert_pptx_to_svg(
|
||||
image_name_map = result.resource_inventory.image_name_map()
|
||||
image_name_map.update(options.asset_name_map)
|
||||
options = replace(options, asset_name_map=image_name_map)
|
||||
result.speaker_notes = import_speaker_notes(pkg)
|
||||
result.speaker_notes = import_speaker_notes(pkg, result.diagnostics)
|
||||
result.canvas_px = pkg.slide_size_px
|
||||
|
||||
# Default theme summary is kept for compatibility; conversion itself
|
||||
|
||||
+32
-5
@@ -6,7 +6,8 @@ import hashlib
|
||||
from dataclasses import dataclass
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .emu_units import NS
|
||||
from .emu_units import NS, ooxml_bool
|
||||
from .import_diagnostics import ImportDiagnostic, append_diagnostic
|
||||
from .ooxml_loader import OoxmlPackage
|
||||
|
||||
|
||||
@@ -57,8 +58,9 @@ def _paragraph_text(paragraph: ET.Element) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _notes_markdown(root: ET.Element) -> str:
|
||||
def _notes_markdown(root: ET.Element, hidden_shapes: list[ET.Element]) -> str:
|
||||
blocks: list[str] = []
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
for shape in root.findall(".//p:sp", NS):
|
||||
placeholder = shape.find("p:nvSpPr/p:nvPr/p:ph", NS)
|
||||
placeholder_type = placeholder.get("type") if placeholder is not None else None
|
||||
@@ -73,11 +75,22 @@ def _notes_markdown(root: ET.Element) -> str:
|
||||
if (text := _paragraph_text(paragraph))
|
||||
]
|
||||
if paragraphs:
|
||||
blocks.append("\n".join(paragraphs))
|
||||
ancestor = shape
|
||||
while ancestor is not None:
|
||||
properties = ancestor.find("./*/p:cNvPr", NS)
|
||||
if properties is not None and ooxml_bool(properties.get("hidden")):
|
||||
hidden_shapes.append(shape)
|
||||
break
|
||||
ancestor = parents.get(ancestor)
|
||||
else:
|
||||
blocks.append("\n".join(paragraphs))
|
||||
return "\n\n".join(blocks).strip()
|
||||
|
||||
|
||||
def import_speaker_notes(pkg: OoxmlPackage) -> tuple[ImportedSpeakerNote, ...]:
|
||||
def import_speaker_notes(
|
||||
pkg: OoxmlPackage,
|
||||
diagnostics: list[ImportDiagnostic] | None = None,
|
||||
) -> tuple[ImportedSpeakerNote, ...]:
|
||||
"""Return every non-empty source speaker note in presentation order."""
|
||||
notes: list[ImportedSpeakerNote] = []
|
||||
for slide in pkg.iter_slides():
|
||||
@@ -99,7 +112,21 @@ def import_speaker_notes(pkg: OoxmlPackage) -> tuple[ImportedSpeakerNote, ...]:
|
||||
root = ET.fromstring(payload)
|
||||
except ET.ParseError:
|
||||
continue
|
||||
markdown = _notes_markdown(root)
|
||||
hidden_shapes: list[ET.Element] = []
|
||||
markdown = _notes_markdown(root, hidden_shapes)
|
||||
if diagnostics is not None:
|
||||
for shape in hidden_shapes:
|
||||
properties = shape.find("p:nvSpPr/p:cNvPr", NS)
|
||||
append_diagnostic(diagnostics, ImportDiagnostic(
|
||||
code="hidden-notes-content",
|
||||
message="Author-hidden speaker-note text was excluded from Markdown",
|
||||
fallback="retain hidden source XML on untouched round-trip notes",
|
||||
part_path=source_part,
|
||||
slide_index=slide.index,
|
||||
shape_id=properties.get("id", "") if properties is not None else "",
|
||||
shape_name=properties.get("name", "") if properties is not None else "",
|
||||
shape_kind="notes-shape",
|
||||
))
|
||||
if not markdown:
|
||||
continue
|
||||
notes.append(ImportedSpeakerNote(
|
||||
|
||||
+39
-9
@@ -104,7 +104,7 @@ def convert_blip_fill(
|
||||
"""
|
||||
blip = blip_fill_elem.find("a:blip", NS)
|
||||
if blip is None:
|
||||
return PictureResult()
|
||||
return _missing_picture_result("Picture fill has no a:blip source", strict)
|
||||
|
||||
relationship_ids = blip_embed_relationship_ids(blip)
|
||||
linked_rid = blip.attrib.get(f"{{{NS['r']}}}link")
|
||||
@@ -119,7 +119,7 @@ def convert_blip_fill(
|
||||
raise MediaResolutionError(
|
||||
"Linked image relationship cannot be resolved; embed the image in PowerPoint first"
|
||||
)
|
||||
return PictureResult()
|
||||
return _missing_picture_result("Picture source has no embedded image relationship", strict)
|
||||
|
||||
target: str | None = None
|
||||
img_bytes: bytes | None = None
|
||||
@@ -154,6 +154,12 @@ def convert_blip_fill(
|
||||
filename, img_bytes = _normalize_office_media(filename, img_bytes)
|
||||
tile_source_bytes = img_bytes
|
||||
diagnostics = list(_unsupported_blip_effect_diagnostics(blip))
|
||||
if xfrm.w <= 0 or xfrm.h <= 0:
|
||||
diagnostics.append(PictureDiagnostic(
|
||||
code="picture-degenerate-frame",
|
||||
message=f"Picture has a nonpositive source frame: {xfrm.w} x {xfrm.h}",
|
||||
fallback="retain the source image and exact frame; no positive frame is invented",
|
||||
))
|
||||
filename, img_bytes, effect_diagnostics = _apply_blip_image_effects(
|
||||
filename,
|
||||
img_bytes,
|
||||
@@ -169,7 +175,17 @@ def convert_blip_fill(
|
||||
|
||||
# srcRect: l/t/r/b in 1/100000ths (so 50000 = 50%).
|
||||
src_rect = blip_fill_elem.find("a:srcRect", NS)
|
||||
crop = _parse_src_rect(src_rect)
|
||||
try:
|
||||
crop = _parse_src_rect(src_rect)
|
||||
except ValueError as exc:
|
||||
if strict:
|
||||
raise
|
||||
diagnostics.append(PictureDiagnostic(
|
||||
code="picture-crop-omitted",
|
||||
message=str(exc),
|
||||
fallback="retain the complete source image; the invalid crop cannot be reproduced",
|
||||
))
|
||||
crop = None
|
||||
|
||||
# A tile larger than its frame is visually a crop, not a stretch. Preserve
|
||||
# that common PowerPoint background construction through the same nested
|
||||
@@ -230,7 +246,7 @@ def convert_picture(
|
||||
"""Translate <p:pic> to SVG <image> (or nested <svg>+<image> for cropping)."""
|
||||
blip_fill = pic_elem.find("p:blipFill", NS)
|
||||
if blip_fill is None:
|
||||
return PictureResult()
|
||||
return _missing_picture_result("Picture has no p:blipFill source", strict)
|
||||
|
||||
return convert_blip_fill(
|
||||
blip_fill, xfrm, slide_part, pkg,
|
||||
@@ -246,6 +262,17 @@ def convert_picture(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _missing_picture_result(message: str, strict: bool) -> PictureResult:
|
||||
"""Report an unrecoverable picture source before omitting its empty preview."""
|
||||
if strict:
|
||||
raise MediaResolutionError(message)
|
||||
return PictureResult(diagnostics=(PictureDiagnostic(
|
||||
code="picture-omitted",
|
||||
message=message,
|
||||
fallback="omit the unavailable picture preview; no replacement image is invented",
|
||||
),))
|
||||
|
||||
|
||||
def _blip_opacity_attr(
|
||||
blip: ET.Element,
|
||||
) -> tuple[str, tuple[PictureDiagnostic, ...]]:
|
||||
@@ -387,10 +414,10 @@ def _parse_src_rect(elem: ET.Element | None) -> tuple[float, float, float, float
|
||||
return None
|
||||
vb_x = l
|
||||
vb_y = t
|
||||
vb_w = max(0.0, 1.0 - l - r)
|
||||
vb_h = max(0.0, 1.0 - t - b)
|
||||
vb_w = 1.0 - l - r
|
||||
vb_h = 1.0 - t - b
|
||||
if vb_w <= 0 or vb_h <= 0:
|
||||
return None
|
||||
raise ValueError("Picture crop has a nonpositive source rectangle")
|
||||
return vb_x, vb_y, vb_w, vb_h
|
||||
|
||||
|
||||
@@ -631,9 +658,12 @@ def _pct_attr(elem: ET.Element, name: str) -> float:
|
||||
if val is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(val) / 100000.0
|
||||
value = float(val) / 100000.0
|
||||
except ValueError:
|
||||
return 0.0
|
||||
raise ValueError(f"Invalid picture crop {name}={val!r}") from None
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"Invalid picture crop {name}={val!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _build_href(filename: str, img_bytes: bytes, subdir: str, embed: bool) -> str:
|
||||
|
||||
+166
-13
@@ -63,7 +63,7 @@ from .effect_to_svg import (
|
||||
convert_effects,
|
||||
unsupported_target_effect_metadata,
|
||||
)
|
||||
from .emu_units import NS, Xfrm, fmt_num, format_canvas_px_from_emu
|
||||
from .emu_units import NS, Xfrm, fmt_num, format_canvas_px_from_emu, parse_xfrm
|
||||
from .fill_to_svg import FillResult, resolve_fill
|
||||
from .formula_import import (
|
||||
A14_NS,
|
||||
@@ -124,6 +124,7 @@ class AssemblyContext:
|
||||
palette: ColorPalette | None
|
||||
pkg: OoxmlPackage
|
||||
slide_part: PartRef
|
||||
theme: PartRef | None = None
|
||||
slide_number: int | None = None
|
||||
theme_fonts: dict[str, str] = field(default_factory=dict)
|
||||
media_subdir: str = "images"
|
||||
@@ -257,6 +258,7 @@ def assemble_slide(
|
||||
palette=palette,
|
||||
pkg=pkg,
|
||||
slide_part=slide.part,
|
||||
theme=pkg.resolve_theme(slide.master),
|
||||
slide_number=pkg.first_slide_number + slide.index - 1,
|
||||
theme_fonts=theme_fonts or {},
|
||||
media_subdir=media_subdir,
|
||||
@@ -375,6 +377,7 @@ def assemble_part_solo(
|
||||
palette=palette,
|
||||
pkg=pkg,
|
||||
slide_part=part,
|
||||
theme=pkg.resolve_theme(part if role == "master" else parent_master),
|
||||
theme_fonts=theme_fonts or {},
|
||||
media_subdir=media_subdir,
|
||||
embed_images=embed_images,
|
||||
@@ -459,6 +462,11 @@ def _convert_node(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
|
||||
ctx.current_node = node
|
||||
try:
|
||||
if node.hidden and not ctx.keep_hidden:
|
||||
ctx.diagnose(
|
||||
"author-hidden-omitted",
|
||||
"Source shape is marked hidden by its author",
|
||||
"exclude the hidden shape and its children from the SVG view",
|
||||
)
|
||||
return ""
|
||||
if node.kind == SHAPE:
|
||||
return _convert_shape(node, ctx, top_level=top_level)
|
||||
@@ -594,6 +602,32 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
|
||||
node.inherited_body_properties,
|
||||
)
|
||||
is_vertical = is_vertical_txbody(tx_body, node.xfrm)
|
||||
source_xfrm = sp_pr.find("a:xfrm", NS) if sp_pr is not None else None
|
||||
source_frame = (
|
||||
parse_xfrm(source_xfrm)
|
||||
if source_xfrm is not None and source_xfrm.find("a:ext", NS) is not None
|
||||
else node.xfrm
|
||||
)
|
||||
if (
|
||||
(source_frame.w <= 0 or source_frame.h <= 0)
|
||||
and tx_body is not None
|
||||
and any(element.text for element in tx_body.iter(f"{{{NS['a']}}}t"))
|
||||
and (
|
||||
node.placeholder is None
|
||||
or _shape_scope(ctx) == "slide"
|
||||
or (sp_pr is not None and sp_pr.find("a:xfrm", NS) is not None)
|
||||
)
|
||||
):
|
||||
# Report explicit collapsed frames even when placeholder inheritance
|
||||
# recovers their geometry. Standalone guides can await inheritance.
|
||||
ctx.diagnose(
|
||||
"degenerate-text-frame",
|
||||
"Text has a nonpositive source frame: "
|
||||
+ " ".join(fmt_num(value) for value in (
|
||||
source_frame.x, source_frame.y, source_frame.w, source_frame.h,
|
||||
)),
|
||||
"retain text using available placeholder geometry or source overflow",
|
||||
)
|
||||
block_formula = _block_formula_zone(tx_body)
|
||||
block_formula_failed = False
|
||||
if block_formula is not None and not is_vertical:
|
||||
@@ -727,9 +761,13 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
|
||||
)
|
||||
if not visible_text_svg:
|
||||
return shape_xml
|
||||
text_frame_attr = ""
|
||||
if text_result.frame is not None:
|
||||
text_frame = " ".join(fmt_num(value, 8) for value in text_result.frame)
|
||||
text_frame_attr = f' data-pptx-frame="{text_frame}"'
|
||||
text_group = (
|
||||
f'<g id="{ctx.group_id_prefix}shape-{node.spid or ctx.shape_seq[0]}-text"'
|
||||
f' data-name="{_xml_escape(node.name)} text">\n'
|
||||
f' data-name="{_xml_escape(node.name)} text"{text_frame_attr}>\n'
|
||||
f"{visible_text_svg}\n</g>"
|
||||
)
|
||||
return f"{shape_xml}\n{text_group}"
|
||||
@@ -743,6 +781,7 @@ def _convert_shape(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) ->
|
||||
if (
|
||||
export_tx_body is not None
|
||||
and geom is not None
|
||||
and geom_xml
|
||||
and not text_result.contains_inline_formula
|
||||
):
|
||||
inner_parts.append(
|
||||
@@ -1074,6 +1113,13 @@ def _render_block_formula(
|
||||
top_level: bool,
|
||||
) -> str:
|
||||
"""Emit one canonical block marker and a dependency-free SVG preview."""
|
||||
if node.xfrm.w <= 0 or node.xfrm.h <= 0:
|
||||
ctx.diagnose(
|
||||
"formula-degenerate-frame",
|
||||
"Source formula has a missing or nonpositive frame: "
|
||||
+ _object_metadata(node, ctx)["data-pptx-frame"],
|
||||
"retain the formula content and exact frame; no positive frame is invented",
|
||||
)
|
||||
x = fmt_num(node.xfrm.x)
|
||||
y = fmt_num(node.xfrm.y)
|
||||
width = fmt_num(node.xfrm.w)
|
||||
@@ -1202,9 +1248,13 @@ def _resolve_geometry(node: ShapeNode, sp_pr: ET.Element | None) -> GeomResult |
|
||||
|
||||
if geom is None:
|
||||
return None
|
||||
# An evaluated path can retain a collapsed axis without inventing a frame.
|
||||
# Mirror/native validation still decides whether that source is exportable.
|
||||
permits_degenerate_axis = (
|
||||
node.kind == CONNECTOR
|
||||
or prst in CONNECTOR_PRESET_TYPES
|
||||
or (node.xfrm.w >= 0 and node.xfrm.h >= 0
|
||||
and (node.xfrm.w > 0 or node.xfrm.h > 0))
|
||||
)
|
||||
if (
|
||||
not permits_degenerate_axis
|
||||
@@ -1221,8 +1271,17 @@ def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None,
|
||||
if geom is None:
|
||||
geom = _resolve_geometry(node, sp_pr)
|
||||
if geom is None:
|
||||
if sp_pr is not None and any(
|
||||
sp_pr.find(path, NS) is not None
|
||||
for path in ("a:prstGeom", "a:custGeom")
|
||||
):
|
||||
ctx.diagnose(
|
||||
"geometry-omitted",
|
||||
"Source geometry could not be recovered at frame "
|
||||
+ _object_metadata(node, ctx)["data-pptx-frame"],
|
||||
"retain any text/image content; no replacement geometry is invented",
|
||||
)
|
||||
return ""
|
||||
|
||||
# Resolve style defaults early so markers can adopt the theme stroke color
|
||||
# when <a:ln> doesn't carry an explicit solidFill.
|
||||
try:
|
||||
@@ -1257,7 +1316,7 @@ def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None,
|
||||
fill = FillResult.none_fill()
|
||||
try:
|
||||
stroke = resolve_stroke(
|
||||
sp_pr,
|
||||
_effective_shape_stroke_properties(node, ctx, sp_pr),
|
||||
ctx.palette,
|
||||
id_prefix="m",
|
||||
id_seq=ctx.marker_seq,
|
||||
@@ -1326,6 +1385,30 @@ def _build_geometry_xml(node: ShapeNode, sp_pr: ET.Element | None,
|
||||
# Skip emitting stroke="none" to keep markup tight.
|
||||
pass
|
||||
|
||||
if (
|
||||
(node.xfrm.w <= 0 or node.xfrm.h <= 0)
|
||||
and not (
|
||||
(node.kind == CONNECTOR or geom.attrs.get("data-pptx-prst") in CONNECTOR_PRESET_TYPES)
|
||||
and node.xfrm.w >= 0 and node.xfrm.h >= 0
|
||||
and (node.xfrm.w > 0 or node.xfrm.h > 0)
|
||||
)
|
||||
):
|
||||
# Restoring evaluated zero-axis paths must not resurrect empty,
|
||||
# explicitly unpainted text-box outlines as exportable source content.
|
||||
if (
|
||||
node.kind == SHAPE
|
||||
and attrs.get("fill", "none") == "none"
|
||||
and attrs.get("stroke", "none") == "none"
|
||||
and not any(key in attrs for key in ("filter", "marker-start", "marker-end"))
|
||||
):
|
||||
return ""
|
||||
ctx.diagnose(
|
||||
"geometry-degenerate-frame",
|
||||
"Source geometry has a missing or degenerate frame: "
|
||||
+ _object_metadata(node, ctx)["data-pptx-frame"],
|
||||
"retain evaluated geometry and the exact source frame",
|
||||
)
|
||||
|
||||
semantic_attrs = {
|
||||
**geom.attrs,
|
||||
**_object_metadata(node, ctx),
|
||||
@@ -1362,16 +1445,64 @@ def _resolve_shape_style_defaults(node: ShapeNode, ctx: AssemblyContext) -> dict
|
||||
if fill_color:
|
||||
defaults["fill"] = fill_color
|
||||
|
||||
ln_ref = style.find("a:lnRef", NS)
|
||||
if ln_ref is not None and ln_ref.attrib.get("idx", "").strip() != "0":
|
||||
line_color = _resolve_ref_color(ln_ref, ctx)
|
||||
if line_color:
|
||||
defaults["stroke"] = line_color
|
||||
defaults.setdefault("stroke-width", "1")
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
def _effective_shape_stroke_properties(
|
||||
node: ShapeNode,
|
||||
ctx: AssemblyContext,
|
||||
sp_pr: ET.Element | None,
|
||||
) -> ET.Element | None:
|
||||
"""Resolve the referenced theme line before applying local line overrides."""
|
||||
reference = node.xml.find("p:style/a:lnRef", NS)
|
||||
if reference is None or reference.get("idx") == "0":
|
||||
return sp_pr
|
||||
try:
|
||||
index = int(reference.get("idx", ""))
|
||||
except ValueError:
|
||||
raise ValueError("Invalid theme line style index") from None
|
||||
styles = (
|
||||
ctx.theme.xml.findall("a:themeElements/a:fmtScheme/a:lnStyleLst/a:ln", NS)
|
||||
if ctx.theme is not None else []
|
||||
)
|
||||
if not 1 <= index <= len(styles):
|
||||
ctx.diagnose(
|
||||
"theme-line-reference-omitted",
|
||||
f"Theme line style {index} cannot be resolved",
|
||||
"retain the local outline without inventing a theme stroke",
|
||||
)
|
||||
return sp_pr
|
||||
|
||||
line = copy.deepcopy(styles[index - 1])
|
||||
reference_color = find_color_elem(reference)
|
||||
if reference_color is not None:
|
||||
for owner in line.iter():
|
||||
for child in list(owner):
|
||||
if child.tag == f"{{{NS['a']}}}schemeClr" and child.get("val") == "phClr":
|
||||
color = copy.deepcopy(reference_color)
|
||||
color.extend(copy.deepcopy(list(child)))
|
||||
owner[list(owner).index(child)] = color
|
||||
|
||||
local_line = sp_pr.find("a:ln", NS) if sp_pr is not None else None
|
||||
if local_line is not None:
|
||||
line.attrib.update(local_line.attrib)
|
||||
exclusive_children = (
|
||||
{"noFill", "solidFill", "gradFill", "pattFill", "blipFill", "grpFill"},
|
||||
{"prstDash", "custDash"},
|
||||
{"round", "bevel", "miter"},
|
||||
)
|
||||
for local in local_line:
|
||||
name = local.tag.rsplit("}", 1)[-1]
|
||||
family = next((names for names in exclusive_children if name in names), {name})
|
||||
for inherited in list(line):
|
||||
if inherited.tag.rsplit("}", 1)[-1] in family:
|
||||
line.remove(inherited)
|
||||
line.append(copy.deepcopy(local))
|
||||
properties = ET.Element(f"{{{NS['p']}}}spPr")
|
||||
properties.append(line)
|
||||
return properties
|
||||
|
||||
|
||||
def _resolve_text_style_default(node: ShapeNode, ctx: AssemblyContext) -> str:
|
||||
"""Resolve p:style fontRef color used by runs without explicit fill."""
|
||||
style = node.xml.find("p:style", NS)
|
||||
@@ -1626,9 +1757,9 @@ def _convert_picture(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool)
|
||||
"replace only this picture with a visible placeholder",
|
||||
)
|
||||
return _fallback_node_svg(node, ctx, top_level=top_level)
|
||||
_diagnose_picture_result(ctx, result)
|
||||
if not result.svg:
|
||||
return ""
|
||||
_diagnose_picture_result(ctx, result)
|
||||
ctx.media.update(result.media)
|
||||
effect = convert_effects(
|
||||
sp_pr,
|
||||
@@ -1707,6 +1838,15 @@ def _convert_connector(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool
|
||||
|
||||
def _convert_group(node: ShapeNode, ctx: AssemblyContext, *, top_level: bool) -> str:
|
||||
"""Render group contents flat (children already remapped to slide space)."""
|
||||
if node.children and (
|
||||
(node.xfrm.w == 0 and node.xfrm.ch_w is not None and node.xfrm.ch_w > 0)
|
||||
or (node.xfrm.h == 0 and node.xfrm.ch_h is not None and node.xfrm.ch_h > 0)
|
||||
):
|
||||
ctx.diagnose(
|
||||
"group-collapsed-axis",
|
||||
"The source group maps a positive child-coordinate extent to a zero frame axis",
|
||||
"retain the source coordinate mapping; child geometry may collapse or be omitted",
|
||||
)
|
||||
parent_fill = ctx.group_fills[-1] if ctx.group_fills else None
|
||||
group_properties = node.xml.find("p:grpSpPr", NS)
|
||||
try:
|
||||
@@ -1779,6 +1919,13 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
|
||||
"""
|
||||
graphic_data = node.xml.find("a:graphic/a:graphicData", NS)
|
||||
uri = graphic_data.attrib.get("uri", "graphicFrame") if graphic_data is not None else "graphicFrame"
|
||||
if node.xfrm.w <= 0 or node.xfrm.h <= 0:
|
||||
ctx.diagnose(
|
||||
"graphic-degenerate-frame",
|
||||
"Source graphic has a missing or nonpositive frame: "
|
||||
+ _object_metadata(node, ctx)["data-pptx-frame"],
|
||||
"retain recoverable content and the exact frame; no positive frame is invented",
|
||||
)
|
||||
|
||||
if uri == "http://schemas.openxmlformats.org/drawingml/2006/table":
|
||||
rendered, replacement_attrs, payload_metadata = _render_graphic_table(
|
||||
@@ -1875,6 +2022,12 @@ def _convert_graphic_fallback(node: ShapeNode, ctx: AssemblyContext,
|
||||
extra_attrs=[f'{_SOURCE_PROXY_ATTRIBUTE}="native-restore"'],
|
||||
)
|
||||
|
||||
if ctx.render_graphic_previews:
|
||||
ctx.diagnose(
|
||||
"graphic-preview-unavailable",
|
||||
f"Source graphic {uri} has no recoverable visual preview",
|
||||
"retain native metadata when available and display the labelled source-frame placeholder",
|
||||
)
|
||||
label = uri.rsplit("/", 1)[-1]
|
||||
placeholder = (
|
||||
f'<rect x="{fmt_num(node.xfrm.x)}" y="{fmt_num(node.xfrm.y)}" '
|
||||
@@ -2240,9 +2393,9 @@ def _render_graphic_preview(node: ShapeNode, ctx: AssemblyContext) -> str:
|
||||
asset_name_map=ctx.asset_name_map,
|
||||
strict=ctx.strict,
|
||||
)
|
||||
_diagnose_picture_result(ctx, result)
|
||||
if not result.svg:
|
||||
return ""
|
||||
_diagnose_picture_result(ctx, result)
|
||||
ctx.media.update(result.media)
|
||||
return result.svg
|
||||
|
||||
|
||||
@@ -297,9 +297,19 @@ def convert_tbl(
|
||||
|
||||
col_widths_px = _column_widths_px(tbl)
|
||||
if not col_widths_px:
|
||||
message = "Source table has no column grid; cell geometry cannot be recovered"
|
||||
if strict:
|
||||
raise ValueError(message)
|
||||
if diagnostic_sink is not None:
|
||||
diagnostic_sink("table-geometry-omitted", message, "retain the source table fallback without inventing columns")
|
||||
return TableResult()
|
||||
rows = tbl.findall("a:tr", NS)
|
||||
if not rows:
|
||||
message = "Source table has no rows; cell geometry cannot be recovered"
|
||||
if strict:
|
||||
raise ValueError(message)
|
||||
if diagnostic_sink is not None:
|
||||
diagnostic_sink("table-geometry-omitted", message, "retain the source table fallback without inventing rows")
|
||||
return TableResult()
|
||||
table_style = _resolve_table_style(tbl, table_styles)
|
||||
row_heights_px = [_row_height_px(r) for r in rows]
|
||||
@@ -314,6 +324,16 @@ def convert_tbl(
|
||||
or sum(col_widths_px) <= 0
|
||||
or sum(row_heights_px) <= 0
|
||||
)
|
||||
if source_geometry_invalid:
|
||||
message = "Source table has inconsistent cell counts or nonpositive row/column dimensions"
|
||||
if strict:
|
||||
raise ValueError(message)
|
||||
if diagnostic_sink is not None:
|
||||
diagnostic_sink(
|
||||
"table-geometry-unsupported",
|
||||
message,
|
||||
"render recoverable cells at source coordinates; cells outside the source grid cannot be placed",
|
||||
)
|
||||
|
||||
# PowerPoint's tblGrid widths and tr heights together describe the
|
||||
# *intrinsic* table size. The graphicFrame xfrm width/height may differ;
|
||||
|
||||
+19
-4
@@ -109,11 +109,13 @@ class TextResult:
|
||||
|
||||
`svg` is one or more <text> elements, already absolutely positioned
|
||||
inside the slide coordinate system. `defs` holds text gradient fills.
|
||||
`frame` carries the source box used by separately positioned vertical text.
|
||||
"""
|
||||
|
||||
svg: str = ""
|
||||
defs: list[str] = field(default_factory=list)
|
||||
contains_inline_formula: bool = False
|
||||
frame: tuple[float, float, float, float] | None = None
|
||||
|
||||
|
||||
VERTICAL_TEXT_MODES = {"eaVert", "vert", "wordArtVert", "wordArtVertRtl"}
|
||||
@@ -329,6 +331,10 @@ def convert_vertical_txbody(
|
||||
|
||||
box_x, box_y, box_w, box_h = _rotated_bbox(xfrm)
|
||||
center_x = box_x + box_w / 2.0
|
||||
clip_vertical = (
|
||||
body_pr is not None
|
||||
and body_pr.attrib.get("vertOverflow") in {"clip", "ellipsis"}
|
||||
)
|
||||
|
||||
glyphs: list[tuple[str, TextRun]] = []
|
||||
for run in runs:
|
||||
@@ -349,7 +355,7 @@ def convert_vertical_txbody(
|
||||
first_baseline: float | None = None
|
||||
previous_baseline: float | None = None
|
||||
for (char, run), advance in zip(glyphs, advances):
|
||||
if cursor_y + advance > bottom_y:
|
||||
if clip_vertical and cursor_y + advance > bottom_y:
|
||||
break
|
||||
baseline_y = cursor_y + run.font_size_px * 0.85
|
||||
tspan_attrs = _run_tspan_attrs(run)
|
||||
@@ -377,6 +383,7 @@ def convert_vertical_txbody(
|
||||
svg=f"<text{attrs}>{''.join(spans)}</text>",
|
||||
defs=_collect_text_defs(paragraphs),
|
||||
contains_inline_formula=False,
|
||||
frame=(box_x, box_y, box_w, box_h),
|
||||
)
|
||||
|
||||
|
||||
@@ -434,6 +441,7 @@ def _convert_east_asian_vertical(
|
||||
right_x -= width
|
||||
|
||||
anchor = body_pr.attrib.get("anchor", "t")
|
||||
clip_vertical = body_pr.attrib.get("vertOverflow") in {"clip", "ellipsis"}
|
||||
bottom_y = inner_y + inner_h
|
||||
text_blocks: list[str] = []
|
||||
for column, center_x in zip(columns, column_centers):
|
||||
@@ -450,11 +458,14 @@ def _convert_east_asian_vertical(
|
||||
|
||||
for upright, text, run in column:
|
||||
available = bottom_y - cursor_y
|
||||
if available <= 0:
|
||||
if clip_vertical and available <= 0:
|
||||
break
|
||||
if upright:
|
||||
advance = run.font_size_px * 1.05
|
||||
visible_count = min(len(text), int(available // advance))
|
||||
visible_count = (
|
||||
min(len(text), int(available // advance))
|
||||
if clip_vertical else len(text)
|
||||
)
|
||||
visible = text[:visible_count]
|
||||
if not visible:
|
||||
break
|
||||
@@ -472,7 +483,10 @@ def _convert_east_asian_vertical(
|
||||
break
|
||||
continue
|
||||
|
||||
visible = _fit_sideways_vertical_text(text, run, available)
|
||||
visible = (
|
||||
_fit_sideways_vertical_text(text, run, available)
|
||||
if clip_vertical else text
|
||||
)
|
||||
if not visible:
|
||||
break
|
||||
text_blocks.append(
|
||||
@@ -491,6 +505,7 @@ def _convert_east_asian_vertical(
|
||||
svg="\n".join(text_blocks),
|
||||
defs=_collect_text_defs(paragraphs),
|
||||
contains_inline_formula=False,
|
||||
frame=(box_x, box_y, box_w, box_h),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Office Math (OMML) to LaTeX
|
||||
|
||||
Convert one OMML subtree to a LaTeX string for the source converters, so Word
|
||||
and PowerPoint equations reach the Markdown as `$...$` / `$$...$$`.
|
||||
|
||||
Usage:
|
||||
Imported by scripts/source_to_md/doc_to_md.py and ppt_to_md.py
|
||||
|
||||
Examples:
|
||||
_omml_to_latex(o_math_element)
|
||||
|
||||
Dependencies:
|
||||
None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
|
||||
|
||||
# OMML n-ary operator chars (m:nary/m:naryPr/m:chr) → LaTeX command.
|
||||
NARY_OPS = {
|
||||
"∑": r"\sum", "∏": r"\prod", "∐": r"\coprod",
|
||||
"∫": r"\int", "∬": r"\iint", "∭": r"\iiint", "∮": r"\oint",
|
||||
"⋃": r"\bigcup", "⋂": r"\bigcap", "⋁": r"\bigvee", "⋀": r"\bigwedge",
|
||||
}
|
||||
# OMML accent chars (m:acc/m:accPr/m:chr) → LaTeX command.
|
||||
ACCENT_CMDS = {
|
||||
"̂": r"\hat", "̃": r"\tilde", "̄": r"\bar", "→": r"\vec", "⃗": r"\vec",
|
||||
"̇": r"\dot", "̈": r"\ddot", "̌": r"\check", "́": r"\acute", "̀": r"\grave",
|
||||
}
|
||||
|
||||
|
||||
def _local_name(elem: ET.Element) -> str:
|
||||
"""Return an element's local name; lxml comments and PIs have none."""
|
||||
tag = elem.tag
|
||||
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
|
||||
|
||||
|
||||
def _m_child(elem: ET.Element, name: str) -> ET.Element | None:
|
||||
"""Return the first OMML child with the given local name."""
|
||||
for child in elem:
|
||||
if _local_name(child) == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def _m_pr_val(elem: ET.Element, prop: str) -> str | None:
|
||||
"""Return m:val of a property inside the element's *Pr block (e.g. chr)."""
|
||||
for child in elem:
|
||||
if not _local_name(child).endswith("Pr"):
|
||||
continue
|
||||
for sub in child:
|
||||
if _local_name(sub) == prop:
|
||||
return sub.get(f"{{{MATH_NS}}}val")
|
||||
return None
|
||||
|
||||
|
||||
def _brace(latex: str) -> str:
|
||||
"""Wrap multi-char LaTeX in braces so it binds as one super/subscript arg."""
|
||||
return latex if len(latex) <= 1 else "{" + latex + "}"
|
||||
|
||||
|
||||
def _omml_part(elem: ET.Element, name: str) -> str:
|
||||
"""Convert a named OMML child (e/num/den/sup/sub/...) to LaTeX."""
|
||||
child = _m_child(elem, name)
|
||||
return _omml_to_latex(child) if child is not None else ""
|
||||
|
||||
|
||||
def _omml_run(elem: ET.Element) -> str:
|
||||
"""Concatenate text from an OMML run, skipping property children."""
|
||||
return "".join(
|
||||
c.text or "" for c in elem if _local_name(c) == "t"
|
||||
)
|
||||
|
||||
|
||||
def _omml_children(elem: ET.Element) -> str:
|
||||
"""Convert all non-property children in order (default/passthrough rule)."""
|
||||
return "".join(
|
||||
_omml_to_latex(c) for c in elem if not _local_name(c).endswith("Pr")
|
||||
)
|
||||
|
||||
|
||||
def _omml_matrix(elem: ET.Element, *, environment: str) -> str:
|
||||
"""Convert a matrix (m:m) or equation array (m:eqArr) to a LaTeX env."""
|
||||
rows: list[str] = []
|
||||
for row in elem:
|
||||
if _local_name(row) not in ("mr", "e"):
|
||||
continue
|
||||
if _local_name(row) == "e": # eqArr stores rows as bare <m:e>
|
||||
rows.append(_omml_to_latex(row))
|
||||
continue
|
||||
cells = [_omml_to_latex(cell) for cell in row if _local_name(cell) == "e"]
|
||||
rows.append(" & ".join(cells))
|
||||
body = r" \\ ".join(rows)
|
||||
return rf"\begin{{{environment}}} {body} \end{{{environment}}}"
|
||||
|
||||
|
||||
def _omml_to_latex(elem: ET.Element) -> str:
|
||||
"""Recursively convert one OMML element subtree to a LaTeX string.
|
||||
|
||||
Unknown elements degrade to a concatenation of their children rather than
|
||||
being dropped, so rare constructs lose markup but never lose content.
|
||||
"""
|
||||
local = _local_name(elem)
|
||||
|
||||
if local == "t":
|
||||
return elem.text or ""
|
||||
if local == "r":
|
||||
return _omml_run(elem)
|
||||
if local in ("oMath", "oMathPara", "e", "num", "den", "sup", "sub",
|
||||
"deg", "fName", "lim", "box", "borderBox"):
|
||||
return _omml_children(elem)
|
||||
if local == "sSup":
|
||||
return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "sup"))
|
||||
if local == "sSub":
|
||||
return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "sub"))
|
||||
if local == "sSubSup":
|
||||
return (_brace(_omml_part(elem, "e"))
|
||||
+ "_" + _brace(_omml_part(elem, "sub"))
|
||||
+ "^" + _brace(_omml_part(elem, "sup")))
|
||||
if local == "sPre":
|
||||
return ("{}_" + _brace(_omml_part(elem, "sub"))
|
||||
+ "^" + _brace(_omml_part(elem, "sup"))
|
||||
+ _brace(_omml_part(elem, "e")))
|
||||
if local == "f":
|
||||
return r"\frac{" + _omml_part(elem, "num") + "}{" + _omml_part(elem, "den") + "}"
|
||||
if local == "rad":
|
||||
deg = _m_child(elem, "deg")
|
||||
body = _omml_part(elem, "e")
|
||||
deg_latex = _omml_to_latex(deg) if deg is not None and len(deg) else ""
|
||||
return rf"\sqrt[{deg_latex}]{{{body}}}" if deg_latex else rf"\sqrt{{{body}}}"
|
||||
if local == "d":
|
||||
beg = _m_pr_val(elem, "begChr")
|
||||
end = _m_pr_val(elem, "endChr")
|
||||
beg = "(" if beg is None else (beg or ".")
|
||||
end = ")" if end is None else (end or ".")
|
||||
separator = _m_pr_val(elem, "sepChr")
|
||||
separator = "|" if separator is None else separator
|
||||
separator = _latex_literal(separator)
|
||||
beg = {"{": r"\{", "}": r"\}"}.get(beg, beg)
|
||||
end = {"{": r"\{", "}": r"\}"}.get(end, end)
|
||||
inner = separator.join(_omml_to_latex(c) for c in elem if _local_name(c) == "e")
|
||||
return rf"\left{beg}{inner}\right{end}"
|
||||
if local == "nary":
|
||||
chr_ = _m_pr_val(elem, "chr") or "∫"
|
||||
op = NARY_OPS.get(chr_, chr_)
|
||||
sub, sup = _m_child(elem, "sub"), _m_child(elem, "sup")
|
||||
out = op
|
||||
if sub is not None and len(sub):
|
||||
out += "_" + _brace(_omml_to_latex(sub))
|
||||
if sup is not None and len(sup):
|
||||
out += "^" + _brace(_omml_to_latex(sup))
|
||||
return out + _brace(_omml_part(elem, "e"))
|
||||
if local == "func":
|
||||
return "\\" + _omml_part(elem, "fName").strip() + _brace(_omml_part(elem, "e"))
|
||||
if local == "limLow":
|
||||
return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "lim"))
|
||||
if local == "limUpp":
|
||||
return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "lim"))
|
||||
if local == "bar":
|
||||
cmd = r"\underline" if _m_pr_val(elem, "pos") == "bot" else r"\overline"
|
||||
return cmd + "{" + _omml_part(elem, "e") + "}"
|
||||
if local == "acc":
|
||||
cmd = ACCENT_CMDS.get(_m_pr_val(elem, "chr") or "̂", r"\hat")
|
||||
return cmd + "{" + _omml_part(elem, "e") + "}"
|
||||
if local == "groupChr":
|
||||
char = _m_pr_val(elem, "chr")
|
||||
char = "⏟" if char is None else char
|
||||
pos = _m_pr_val(elem, "pos") or "bot"
|
||||
body = _omml_part(elem, "e")
|
||||
if (char, pos) in {("⏞", "top"), ("⏟", "bot")}:
|
||||
cmd = r"\overbrace" if pos == "top" else r"\underbrace"
|
||||
return cmd + "{" + body + "}"
|
||||
cmd = r"\overset" if pos == "top" else r"\underset"
|
||||
return cmd + r"{\text{" + _latex_literal(char) + "}}{" + body + "}"
|
||||
if local == "m":
|
||||
return _omml_matrix(elem, environment="matrix")
|
||||
if local == "eqArr":
|
||||
return _omml_matrix(elem, environment="aligned")
|
||||
|
||||
return _omml_children(elem)
|
||||
|
||||
|
||||
def _latex_literal(text: str) -> str:
|
||||
"""Escape literal characters that would otherwise change LaTeX syntax."""
|
||||
escapes = {"\\": r"\backslash{}", "{": r"\{", "}": r"\}", "%": r"\%",
|
||||
"&": r"\&", "#": r"\#", "_": r"\_", "$": r"\$"}
|
||||
return "".join(escapes.get(char, char) for char in text)
|
||||
+239
-211
@@ -44,6 +44,7 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from _batch import run_path_batch # noqa: E402
|
||||
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
|
||||
from _omml_latex import _m_pr_val, _omml_to_latex # noqa: E402
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
@@ -97,17 +98,6 @@ MATH_NS = DOCX_NS["m"]
|
||||
W_NS = DOCX_NS["w"]
|
||||
XML_SPACE_ATTR = "{http://www.w3.org/XML/1998/namespace}space"
|
||||
|
||||
# OMML n-ary operator chars (m:nary/m:naryPr/m:chr) → LaTeX command.
|
||||
NARY_OPS = {
|
||||
"∑": r"\sum", "∏": r"\prod", "∐": r"\coprod",
|
||||
"∫": r"\int", "∬": r"\iint", "∭": r"\iiint", "∮": r"\oint",
|
||||
"⋃": r"\bigcup", "⋂": r"\bigcap", "⋁": r"\bigvee", "⋀": r"\bigwedge",
|
||||
}
|
||||
# OMML accent chars (m:acc/m:accPr/m:chr) → LaTeX command.
|
||||
ACCENT_CMDS = {
|
||||
"̂": r"\hat", "̃": r"\tilde", "̄": r"\bar", "→": r"\vec", "⃗": r"\vec",
|
||||
"̇": r"\dot", "̈": r"\ddot", "̌": r"\check", "́": r"\acute", "̀": r"\grave",
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -480,163 +470,13 @@ def _manifest_entry(
|
||||
# mammoth drops all math content, so Word-native equations and MathType
|
||||
# formulas saved as Office Math (OMML) vanish from the output. This pure-Python
|
||||
# converter rewrites each <m:oMath> into inline `$...$` LaTeX before mammoth
|
||||
# runs, so formulas survive into the Markdown in document order.
|
||||
# runs, so formulas survive into the Markdown in document order. The OMML →
|
||||
# LaTeX conversion itself is shared with ppt_to_md in _omml_latex.py.
|
||||
#
|
||||
# Scope: OMML only. Classic MathType OLE objects (Equation.DSMT4 / MTEF binary)
|
||||
# carry no OMML — they expose only a WMF/EMF preview image, which mammoth still
|
||||
# emits as a picture. Decoding MTEF is out of scope.
|
||||
|
||||
def _m_child(elem: ET.Element, name: str) -> ET.Element | None:
|
||||
"""Return the first OMML child with the given local name."""
|
||||
for child in elem:
|
||||
if _local_name(child) == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def _m_pr_val(elem: ET.Element, prop: str) -> str | None:
|
||||
"""Return m:val of a property inside the element's *Pr block (e.g. chr)."""
|
||||
for child in elem:
|
||||
if not _local_name(child).endswith("Pr"):
|
||||
continue
|
||||
for sub in child:
|
||||
if _local_name(sub) == prop:
|
||||
return sub.get(f"{{{MATH_NS}}}val")
|
||||
return None
|
||||
|
||||
|
||||
def _brace(latex: str) -> str:
|
||||
"""Wrap multi-char LaTeX in braces so it binds as one super/subscript arg."""
|
||||
return latex if len(latex) <= 1 else "{" + latex + "}"
|
||||
|
||||
|
||||
def _omml_part(elem: ET.Element, name: str) -> str:
|
||||
"""Convert a named OMML child (e/num/den/sup/sub/...) to LaTeX."""
|
||||
child = _m_child(elem, name)
|
||||
return _omml_to_latex(child) if child is not None else ""
|
||||
|
||||
|
||||
def _omml_run(elem: ET.Element) -> str:
|
||||
"""Concatenate text from an OMML run, skipping property children."""
|
||||
return "".join(
|
||||
c.text or "" for c in elem if _local_name(c) == "t"
|
||||
)
|
||||
|
||||
|
||||
def _omml_children(elem: ET.Element) -> str:
|
||||
"""Convert all non-property children in order (default/passthrough rule)."""
|
||||
return "".join(
|
||||
_omml_to_latex(c) for c in elem if not _local_name(c).endswith("Pr")
|
||||
)
|
||||
|
||||
|
||||
def _omml_matrix(elem: ET.Element, *, environment: str) -> str:
|
||||
"""Convert a matrix (m:m) or equation array (m:eqArr) to a LaTeX env."""
|
||||
rows: list[str] = []
|
||||
for row in elem:
|
||||
if _local_name(row) not in ("mr", "e"):
|
||||
continue
|
||||
if _local_name(row) == "e": # eqArr stores rows as bare <m:e>
|
||||
rows.append(_omml_to_latex(row))
|
||||
continue
|
||||
cells = [_omml_to_latex(cell) for cell in row if _local_name(cell) == "e"]
|
||||
rows.append(" & ".join(cells))
|
||||
body = r" \\ ".join(rows)
|
||||
return rf"\begin{{{environment}}} {body} \end{{{environment}}}"
|
||||
|
||||
|
||||
def _omml_to_latex(elem: ET.Element) -> str:
|
||||
"""Recursively convert one OMML element subtree to a LaTeX string.
|
||||
|
||||
Unknown elements degrade to a concatenation of their children rather than
|
||||
being dropped, so rare constructs lose markup but never lose content.
|
||||
"""
|
||||
local = _local_name(elem)
|
||||
|
||||
if local == "t":
|
||||
return elem.text or ""
|
||||
if local == "r":
|
||||
return _omml_run(elem)
|
||||
if local in ("oMath", "oMathPara", "e", "num", "den", "sup", "sub",
|
||||
"deg", "fName", "lim", "box", "borderBox"):
|
||||
return _omml_children(elem)
|
||||
if local == "sSup":
|
||||
return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "sup"))
|
||||
if local == "sSub":
|
||||
return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "sub"))
|
||||
if local == "sSubSup":
|
||||
return (_brace(_omml_part(elem, "e"))
|
||||
+ "_" + _brace(_omml_part(elem, "sub"))
|
||||
+ "^" + _brace(_omml_part(elem, "sup")))
|
||||
if local == "sPre":
|
||||
return ("{}_" + _brace(_omml_part(elem, "sub"))
|
||||
+ "^" + _brace(_omml_part(elem, "sup"))
|
||||
+ _brace(_omml_part(elem, "e")))
|
||||
if local == "f":
|
||||
return r"\frac{" + _omml_part(elem, "num") + "}{" + _omml_part(elem, "den") + "}"
|
||||
if local == "rad":
|
||||
deg = _m_child(elem, "deg")
|
||||
body = _omml_part(elem, "e")
|
||||
deg_latex = _omml_to_latex(deg) if deg is not None and len(deg) else ""
|
||||
return rf"\sqrt[{deg_latex}]{{{body}}}" if deg_latex else rf"\sqrt{{{body}}}"
|
||||
if local == "d":
|
||||
beg = _m_pr_val(elem, "begChr")
|
||||
end = _m_pr_val(elem, "endChr")
|
||||
beg = "(" if beg is None else (beg or ".")
|
||||
end = ")" if end is None else (end or ".")
|
||||
separator = _m_pr_val(elem, "sepChr")
|
||||
separator = "|" if separator is None else separator
|
||||
separator = _latex_literal(separator)
|
||||
beg = {"{": r"\{", "}": r"\}"}.get(beg, beg)
|
||||
end = {"{": r"\{", "}": r"\}"}.get(end, end)
|
||||
inner = separator.join(_omml_to_latex(c) for c in elem if _local_name(c) == "e")
|
||||
return rf"\left{beg}{inner}\right{end}"
|
||||
if local == "nary":
|
||||
chr_ = _m_pr_val(elem, "chr") or "∫"
|
||||
op = NARY_OPS.get(chr_, chr_)
|
||||
sub, sup = _m_child(elem, "sub"), _m_child(elem, "sup")
|
||||
out = op
|
||||
if sub is not None and len(sub):
|
||||
out += "_" + _brace(_omml_to_latex(sub))
|
||||
if sup is not None and len(sup):
|
||||
out += "^" + _brace(_omml_to_latex(sup))
|
||||
return out + _brace(_omml_part(elem, "e"))
|
||||
if local == "func":
|
||||
return "\\" + _omml_part(elem, "fName").strip() + _brace(_omml_part(elem, "e"))
|
||||
if local == "limLow":
|
||||
return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "lim"))
|
||||
if local == "limUpp":
|
||||
return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "lim"))
|
||||
if local == "bar":
|
||||
cmd = r"\underline" if _m_pr_val(elem, "pos") == "bot" else r"\overline"
|
||||
return cmd + "{" + _omml_part(elem, "e") + "}"
|
||||
if local == "acc":
|
||||
cmd = ACCENT_CMDS.get(_m_pr_val(elem, "chr") or "̂", r"\hat")
|
||||
return cmd + "{" + _omml_part(elem, "e") + "}"
|
||||
if local == "groupChr":
|
||||
char = _m_pr_val(elem, "chr")
|
||||
char = "⏟" if char is None else char
|
||||
pos = _m_pr_val(elem, "pos") or "bot"
|
||||
body = _omml_part(elem, "e")
|
||||
if (char, pos) in {("⏞", "top"), ("⏟", "bot")}:
|
||||
cmd = r"\overbrace" if pos == "top" else r"\underbrace"
|
||||
return cmd + "{" + body + "}"
|
||||
cmd = r"\overset" if pos == "top" else r"\underset"
|
||||
return cmd + r"{\text{" + _latex_literal(char) + "}}{" + body + "}"
|
||||
if local == "m":
|
||||
return _omml_matrix(elem, environment="matrix")
|
||||
if local == "eqArr":
|
||||
return _omml_matrix(elem, environment="aligned")
|
||||
|
||||
return _omml_children(elem)
|
||||
|
||||
|
||||
def _latex_literal(text: str) -> str:
|
||||
"""Escape literal characters that would otherwise change LaTeX syntax."""
|
||||
escapes = {"\\": r"\backslash{}", "{": r"\{", "}": r"\}", "%": r"\%",
|
||||
"&": r"\&", "#": r"\#", "_": r"\_", "$": r"\$"}
|
||||
return "".join(escapes.get(char, char) for char in text)
|
||||
|
||||
|
||||
def _make_text_run(text: str) -> ET.Element:
|
||||
"""Build a <w:r><w:t xml:space="preserve">text</w:t></w:r> element."""
|
||||
@@ -654,6 +494,82 @@ def _make_text_paragraph(text: str) -> ET.Element:
|
||||
return paragraph
|
||||
|
||||
|
||||
DOCX_TEXT_PARTS = ("word/document.xml", "word/footnotes.xml", "word/endnotes.xml")
|
||||
|
||||
|
||||
def _docx_rewrite_parts(input_file: Path, patched: dict[str, bytes]) -> Path:
|
||||
"""Copy a DOCX to a temp file with the given package parts replaced."""
|
||||
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():
|
||||
zout.writestr(item, patched.get(item.filename) or zin.read(item.filename))
|
||||
return out_path
|
||||
|
||||
|
||||
def _docx_read_text_parts(input_file: Path) -> dict[str, ET.Element]:
|
||||
"""Parse the body, footnote and endnote parts that mammoth renders."""
|
||||
roots: dict[str, ET.Element] = {}
|
||||
try:
|
||||
with zipfile.ZipFile(input_file) as docx:
|
||||
for name in DOCX_TEXT_PARTS:
|
||||
try:
|
||||
roots[name] = ET.fromstring(docx.read(name))
|
||||
except (KeyError, ET.ParseError):
|
||||
continue
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return {}
|
||||
return roots
|
||||
|
||||
|
||||
def _docx_serialize(root: ET.Element) -> bytes:
|
||||
for prefix, uri in DOCX_NS.items():
|
||||
if prefix != "rel":
|
||||
ET.register_namespace(prefix, uri)
|
||||
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _docx_show_visible_run_text(
|
||||
input_file: Path,
|
||||
warnings: list[str] | None = None,
|
||||
) -> Path | None:
|
||||
"""Make mammoth read text the way Word displays it, in a temp DOCX.
|
||||
|
||||
mammoth ignores ``w:fldSimple``, dropping its displayed result (caption
|
||||
numbers, document properties), yet keeps runs Word hides with ``w:vanish``.
|
||||
Unwrap simple fields to their result runs and drop directly hidden runs.
|
||||
"""
|
||||
patched: dict[str, bytes] = {}
|
||||
hidden_runs = 0
|
||||
for name, root in _docx_read_text_parts(input_file).items():
|
||||
changed = False
|
||||
for parent in list(root.iter()):
|
||||
for child in list(parent):
|
||||
local = _local_name(child)
|
||||
if local == "fldSimple":
|
||||
position = list(parent).index(child)
|
||||
parent.remove(child)
|
||||
for offset, run in enumerate(list(child)):
|
||||
parent.insert(position + offset, run)
|
||||
changed = True
|
||||
elif local == "r":
|
||||
vanish = child.find("w:rPr/w:vanish", DOCX_NS)
|
||||
if vanish is not None and vanish.get(f"{{{W_NS}}}val") not in {"0", "false", "off"}:
|
||||
parent.remove(child)
|
||||
hidden_runs += 1
|
||||
changed = True
|
||||
if changed:
|
||||
patched[name] = _docx_serialize(root)
|
||||
if hidden_runs:
|
||||
warning = f"{hidden_runs} hidden-text run(s) omitted; Word does not display them."
|
||||
print(f"[WARN] {warning}", file=sys.stderr)
|
||||
if warnings is not None:
|
||||
warnings.append(warning)
|
||||
return _docx_rewrite_parts(input_file, patched) if patched else None
|
||||
|
||||
|
||||
def _docx_inject_math_latex(
|
||||
input_file: Path,
|
||||
warnings: list[str] | None = None,
|
||||
@@ -661,20 +577,31 @@ def _docx_inject_math_latex(
|
||||
"""Replace OMML equations with alphanumeric placeholders in a temp DOCX.
|
||||
|
||||
Returns ``(temp_file, {placeholder: latex})`` or None when the document has
|
||||
no OMML math. Placeholders are plain ``[A-Za-z0-9]`` tokens so mammoth never
|
||||
markdown-escapes the LaTeX; the caller swaps each token for its `$...$` value
|
||||
after mammoth has produced the Markdown.
|
||||
no OMML math. Body, footnote and endnote math are all rewritten. Placeholders
|
||||
are plain ``[A-Za-z0-9]`` tokens so mammoth never markdown-escapes the LaTeX;
|
||||
the caller swaps each token for its `$...$` value after mammoth has produced
|
||||
the Markdown.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(input_file) as docx:
|
||||
document_xml = docx.read("word/document.xml")
|
||||
except (KeyError, zipfile.BadZipFile, OSError):
|
||||
return None
|
||||
try:
|
||||
root = ET.fromstring(document_xml)
|
||||
except ET.ParseError:
|
||||
token_base = uuid.uuid4().hex
|
||||
replacements: dict[str, str] = {}
|
||||
patched: dict[str, bytes] = {}
|
||||
for name, root in _docx_read_text_parts(input_file).items():
|
||||
before = len(replacements)
|
||||
_docx_replace_math(root, token_base, replacements, warnings)
|
||||
if len(replacements) > before:
|
||||
patched[name] = _docx_serialize(root)
|
||||
if not replacements:
|
||||
return None
|
||||
return _docx_rewrite_parts(input_file, patched), replacements
|
||||
|
||||
|
||||
def _docx_replace_math(
|
||||
root: ET.Element,
|
||||
token_base: str,
|
||||
replacements: dict[str, str],
|
||||
warnings: list[str] | None,
|
||||
) -> None:
|
||||
"""Swap each OMML zone in one part for a placeholder run."""
|
||||
parent_map = {child: parent for parent in root.iter() for child in parent}
|
||||
targets: list[tuple[ET.Element, bool]] = []
|
||||
for elem in root.iter():
|
||||
@@ -685,12 +612,7 @@ def _docx_inject_math_latex(
|
||||
parent = parent_map.get(elem)
|
||||
if parent is None or _local_name(parent) != "oMathPara":
|
||||
targets.append((elem, False))
|
||||
if not targets:
|
||||
return None
|
||||
|
||||
token_base = uuid.uuid4().hex
|
||||
replacements: dict[str, str] = {}
|
||||
for index, (elem, display) in enumerate(targets):
|
||||
for elem, display in targets:
|
||||
parent = parent_map.get(elem)
|
||||
if parent is None:
|
||||
continue
|
||||
@@ -708,27 +630,10 @@ def _docx_inject_math_latex(
|
||||
position = list(parent).index(elem)
|
||||
parent.remove(elem)
|
||||
if latex:
|
||||
token = f"MATHEQ{token_base}{index:04d}"
|
||||
token = f"MATHEQ{token_base}{len(replacements):04d}"
|
||||
delim = "$$" if display else "$"
|
||||
replacements[token] = f"{delim}{latex}{delim}"
|
||||
parent.insert(position, _make_text_run(token))
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -1088,14 +993,15 @@ def _convert_docx(
|
||||
|
||||
# Rewrite OMML equations to LaTeX placeholders before mammoth (which would
|
||||
# otherwise drop them); the placeholders are swapped back below.
|
||||
math_injection = _docx_inject_math_latex(input_file, warnings)
|
||||
visible_file = _docx_show_visible_run_text(input_file, warnings)
|
||||
math_injection = _docx_inject_math_latex(visible_file or input_file, warnings)
|
||||
if math_injection is not None:
|
||||
math_file, math_replacements = math_injection
|
||||
else:
|
||||
math_file, math_replacements = None, {}
|
||||
table_file = None
|
||||
table_replacements: dict[str, str] = {}
|
||||
mammoth_source = math_file or input_file
|
||||
mammoth_source = math_file or visible_file or input_file
|
||||
table_injection = _docx_inject_tables_markdown(mammoth_source, warnings)
|
||||
if table_injection is not None:
|
||||
table_file, table_replacements = table_injection
|
||||
@@ -1126,9 +1032,11 @@ def _convert_docx(
|
||||
table_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if math_file is not None:
|
||||
for temp_file in (math_file, visible_file):
|
||||
if temp_file is None:
|
||||
continue
|
||||
try:
|
||||
math_file.unlink()
|
||||
temp_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -1287,9 +1195,10 @@ def _convert_html(input_file: Path, out_file: Path) -> str:
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Strip non-content elements (head/style/script) so metadata doesn't leak into MD
|
||||
# Strip non-content elements (head/style/script/template) so metadata and
|
||||
# never-rendered markup don't leak into MD
|
||||
soup = BeautifulSoup(raw_html, "html.parser")
|
||||
for tag in soup(["head", "style", "script", "noscript"]):
|
||||
for tag in soup(["head", "style", "script", "noscript", "template"]):
|
||||
tag.decompose()
|
||||
html = str(soup)
|
||||
html = _process_html_images(html, input_file.parent, media_dir, rel_media_dir)
|
||||
@@ -1424,11 +1333,11 @@ def _epub_image_candidates(src: str, document_name: str) -> list[str]:
|
||||
).lstrip("/")
|
||||
|
||||
candidates = [
|
||||
relative_path,
|
||||
src,
|
||||
raw_path,
|
||||
decoded_path,
|
||||
normalized_path,
|
||||
relative_path,
|
||||
Path(decoded_path).name,
|
||||
Path(normalized_path).name,
|
||||
]
|
||||
@@ -1469,15 +1378,36 @@ def _convert_epub(input_file: Path, out_file: Path) -> str:
|
||||
img_map[item.file_name] = filename
|
||||
img_map[Path(item.file_name).name] = filename
|
||||
|
||||
# Iterate document items in spine order
|
||||
# Keep the reading order, then retain supplemental documents (e.g. linked
|
||||
# footnotes) that an EPUB reader can open outside the linear spine.
|
||||
html_parts: list[str] = []
|
||||
spine_ids = [sid for sid, _ in book.spine]
|
||||
id_to_item = {it.get_id(): it for it in book.get_items_of_type(ebooklib.ITEM_DOCUMENT)}
|
||||
for sid in spine_ids:
|
||||
# The EPUB 3 navigation document is the book's table of contents, not text.
|
||||
extra_ids = [
|
||||
sid for sid, item in id_to_item.items()
|
||||
if sid not in spine_ids and not isinstance(item, epub.EpubNav)
|
||||
]
|
||||
if extra_ids:
|
||||
print(
|
||||
f"[WARN] EPUB: retaining {len(extra_ids)} document(s) outside the reading spine "
|
||||
"after the main text.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for sid in spine_ids + extra_ids:
|
||||
item = id_to_item.get(sid)
|
||||
if item is None:
|
||||
continue
|
||||
soup = BeautifulSoup(item.get_content(), "html.parser")
|
||||
inert = soup.find_all(["script", "style", "template"])
|
||||
if inert:
|
||||
print(
|
||||
f"[WARN] EPUB {item.file_name}: omitted {len(inert)} non-rendered "
|
||||
"script/style/template element(s).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for element in inert:
|
||||
element.decompose()
|
||||
for img in soup.find_all("img"):
|
||||
src = img.get("src", "")
|
||||
if not src:
|
||||
@@ -1583,6 +1513,95 @@ def _check_pandoc() -> bool:
|
||||
return shutil.which("pandoc") is not None
|
||||
|
||||
|
||||
def _prepare_odt_for_pandoc(input_file: Path) -> Path:
|
||||
"""Expose text containers skipped by Pandoc's ODT reader in a temporary copy."""
|
||||
text_ns = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
table_ns = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
||||
draw_ns = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
|
||||
try:
|
||||
with zipfile.ZipFile(input_file) as source:
|
||||
root = ET.fromstring(source.read("content.xml"))
|
||||
headers = textboxes = conditional = hidden = 0
|
||||
for parent in list(root.iter()):
|
||||
for child in list(parent):
|
||||
if child.tag == f"{{{table_ns}}}table-header-rows":
|
||||
index = list(parent).index(child)
|
||||
parent.remove(child)
|
||||
for row in reversed(list(child)):
|
||||
parent.insert(index, row)
|
||||
headers += 1
|
||||
elif child.tag == f"{{{draw_ns}}}frame":
|
||||
textbox = child.find(f"{{{draw_ns}}}text-box")
|
||||
if textbox is None:
|
||||
continue
|
||||
# Keep inline runs, links and line boundaries; geometry
|
||||
# has no Markdown representation. Other frame children
|
||||
# (for example images) remain available to Pandoc.
|
||||
span = ET.Element(f"{{{text_ns}}}span")
|
||||
span.text = textbox.text
|
||||
span.tail = child.tail
|
||||
child.tail = None
|
||||
for index, paragraph in enumerate(list(textbox)):
|
||||
if index:
|
||||
ET.SubElement(span, f"{{{text_ns}}}line-break")
|
||||
if paragraph.tag in {f"{{{text_ns}}}p", f"{{{text_ns}}}h"}:
|
||||
paragraph.tag = f"{{{text_ns}}}span"
|
||||
span.append(paragraph)
|
||||
parent.insert(list(parent).index(child) + 1, span)
|
||||
child.remove(textbox)
|
||||
textboxes += 1
|
||||
elif child.tag == f"{{{text_ns}}}hidden-text":
|
||||
if child.get(f"{{{text_ns}}}is-hidden") in {"true", "1"}:
|
||||
child.text = None
|
||||
child[:] = []
|
||||
hidden += 1
|
||||
child.tag = f"{{{text_ns}}}span"
|
||||
conditional += 1
|
||||
if not (headers or textboxes or conditional):
|
||||
return input_file
|
||||
print(
|
||||
f"[WARN] ODT: exposed {headers} table header container(s), flattened "
|
||||
f"{textboxes} text box(es), and resolved {conditional} conditional-text "
|
||||
f"field(s) for Pandoc; {hidden} author-hidden field(s) omitted. "
|
||||
"Text-box geometry is retained only in the source ODT.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix=".odt", delete=False) as temporary:
|
||||
prepared = Path(temporary.name)
|
||||
with zipfile.ZipFile(prepared, "w") as destination:
|
||||
for item in source.infolist():
|
||||
content = (
|
||||
ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
||||
if item.filename == "content.xml" else source.read(item.filename)
|
||||
)
|
||||
destination.writestr(item, content)
|
||||
return prepared
|
||||
except (OSError, KeyError, zipfile.BadZipFile, ET.ParseError) as exc:
|
||||
print(f"[WARN] ODT text preparation skipped: {exc}; using original file.", file=sys.stderr)
|
||||
return input_file
|
||||
|
||||
|
||||
def _prepare_rtf_for_pandoc(input_file: Path) -> Path:
|
||||
"""Keep flat fields that Pandoc otherwise silently skips."""
|
||||
content = input_file.read_text(encoding="latin-1")
|
||||
if any(
|
||||
re.fullmatch(r"\\v(?:-?[1-9]\d*)? ?", token.group())
|
||||
for token in re.finditer(r"\\(?:[a-zA-Z]+-?\d* ?|[^a-zA-Z])", content)
|
||||
):
|
||||
print("[WARN] RTF: author-hidden text is omitted by Pandoc.", file=sys.stderr)
|
||||
normalized, count = re.subn(
|
||||
r"(?<!\\)(\{(?:\\\*)?\\(?:fldrslt|fldinst)\s+)((?:\\[{}\\]|[^{}])+)(\})",
|
||||
lambda match: match[1] + "{" + match[2] + "}" + match[3],
|
||||
content,
|
||||
)
|
||||
if not count:
|
||||
return input_file
|
||||
print(f"[WARN] RTF: normalized {count} flat field container(s) for Pandoc.", file=sys.stderr)
|
||||
with tempfile.NamedTemporaryFile(suffix=".rtf", delete=False) as temporary:
|
||||
temporary.write(normalized.encode("latin-1"))
|
||||
return Path(temporary.name)
|
||||
|
||||
|
||||
def _convert_with_pandoc(input_file: Path, out_file: Path, suffix: str) -> str:
|
||||
if not _check_pandoc():
|
||||
print(f"[ERROR] Format '{suffix}' requires pandoc. Install it:")
|
||||
@@ -1595,11 +1614,16 @@ def _convert_with_pandoc(input_file: Path, out_file: Path, suffix: str) -> str:
|
||||
rel_media_dir = f"{out_file.stem}_files"
|
||||
media_dir = out_file.parent / rel_media_dir
|
||||
|
||||
prepared = input_file
|
||||
if suffix == ".odt":
|
||||
prepared = _prepare_odt_for_pandoc(input_file)
|
||||
elif suffix == ".rtf":
|
||||
prepared = _prepare_rtf_for_pandoc(input_file)
|
||||
cmd = [
|
||||
"pandoc",
|
||||
"-f", input_format,
|
||||
"-t", "gfm",
|
||||
str(input_file.resolve()),
|
||||
str(prepared.resolve()),
|
||||
"-o", str(out_file.resolve()),
|
||||
"--wrap", "none",
|
||||
"--strip-comments",
|
||||
@@ -1607,9 +1631,13 @@ def _convert_with_pandoc(input_file: Path, out_file: Path, suffix: str) -> str:
|
||||
if suffix in PANDOC_MEDIA_FORMATS:
|
||||
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))
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace",
|
||||
cwd=str(out_file.parent))
|
||||
finally:
|
||||
if prepared != input_file:
|
||||
prepared.unlink()
|
||||
if result.returncode != 0:
|
||||
print(f"[ERROR] Pandoc conversion failed:\n{result.stderr}")
|
||||
return ""
|
||||
|
||||
+99
-6
@@ -45,10 +45,12 @@ if str(_SCRIPTS_DIR) not in sys.path:
|
||||
from console_encoding import configure_utf8_stdio # noqa: E402
|
||||
from _batch import run_path_batch # noqa: E402
|
||||
from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
|
||||
from _omml_latex import _local_name, _omml_to_latex # noqa: E402
|
||||
from pptx_ooxml.diagram_read import ( # noqa: E402
|
||||
read_smartart_diagrams,
|
||||
smartart_to_markdown,
|
||||
)
|
||||
from pptx_ooxml.ooxml import _remove_author_hidden_shapes, _shape_identity # noqa: E402
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
@@ -74,6 +76,7 @@ DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
PRESENTATIONML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
RELATIONSHIP_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
MARKUP_COMPATIBILITY_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
OFFICE_MATH_TAG = "{http://schemas.microsoft.com/office/drawing/2010/main}m"
|
||||
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
|
||||
OFFICE_VECTOR_EXTENSIONS = {"emf", "wmf"}
|
||||
IMAGE_EXT_BY_CONTENT_TYPE = {
|
||||
@@ -215,6 +218,37 @@ def _expose_math_choice_shapes(slide: object) -> None:
|
||||
parent.insert(index + offset, shape)
|
||||
|
||||
|
||||
def _expose_alternate_fallback_shapes(slide: object) -> None:
|
||||
"""Read the Fallback of shape-tree ``mc:AlternateContent`` this reader cannot use.
|
||||
|
||||
Office Math Choices are exposed by ``_expose_math_choice_shapes`` and ChartEx
|
||||
is reported by ``_unexposed_chartex_markdown``. Any other Choice (3D models,
|
||||
ink, zoom) is unreadable here, so its Fallback, which is what PowerPoint
|
||||
without that feature shows, keeps pictures and text from vanishing.
|
||||
"""
|
||||
alternate_tag = f"{{{MARKUP_COMPATIBILITY_NS}}}AlternateContent"
|
||||
fallback_tag = f"{{{MARKUP_COMPATIBILITY_NS}}}Fallback"
|
||||
shape_tags = {
|
||||
f"{{{PRESENTATIONML_NS}}}{name}"
|
||||
for name in ("sp", "grpSp", "pic", "graphicFrame", "cxnSp")
|
||||
}
|
||||
for wrapper in list(slide.element.iter(alternate_tag)):
|
||||
fallback = wrapper.find(fallback_tag)
|
||||
parent = wrapper.getparent()
|
||||
if fallback is None or parent is None or any(
|
||||
data.get("uri") == CHARTEX_URI
|
||||
for data in wrapper.iter(f"{{{DRAWINGML_NS}}}graphicData")
|
||||
):
|
||||
continue
|
||||
shapes = list(fallback)
|
||||
if not shapes or any(shape.tag not in shape_tags for shape in shapes):
|
||||
continue
|
||||
index = parent.index(wrapper)
|
||||
parent.remove(wrapper)
|
||||
for offset, shape in enumerate(shapes):
|
||||
parent.insert(index + offset, shape)
|
||||
|
||||
|
||||
def iter_leaf_shapes(shapes: object) -> list[LeafShape]:
|
||||
"""Return a flattened, reading-order list of shapes."""
|
||||
items: list[LeafShape] = []
|
||||
@@ -301,6 +335,16 @@ def _run_url(run: object, shape: object) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _office_math_markdown(math: object) -> str:
|
||||
"""Render one a14:m zone as ``$...$``, or ``$$...$$`` for display math."""
|
||||
zone = next((item for item in math if _local_name(item) in {"oMathPara", "oMath"}), None)
|
||||
latex = _omml_to_latex(zone).strip() if zone is not None else ""
|
||||
if not latex:
|
||||
return ""
|
||||
delimiter = "$$" if _local_name(zone) == "oMathPara" else "$"
|
||||
return f"{delimiter}{latex}{delimiter}"
|
||||
|
||||
|
||||
def _paragraph_to_markdown(
|
||||
paragraph: object,
|
||||
shape: object,
|
||||
@@ -338,6 +382,10 @@ def _paragraph_to_markdown(
|
||||
# A soft line break inside the paragraph; python-pptx's runs skip it.
|
||||
current_text += "\n"
|
||||
continue
|
||||
if child.tag == OFFICE_MATH_TAG:
|
||||
# python-pptx's runs also skip Office Math; keep it as LaTeX.
|
||||
current_text += _office_math_markdown(child)
|
||||
continue
|
||||
if tag not in {"r", "fld"}:
|
||||
continue
|
||||
run = _Run(child, paragraph)
|
||||
@@ -515,7 +563,9 @@ def text_frame_to_markdown(
|
||||
"""
|
||||
visible_paragraphs = [
|
||||
paragraph for paragraph in text_frame.paragraphs
|
||||
if normalize_text(paragraph.text) or _paragraph_has_hyperlink(paragraph)
|
||||
if normalize_text(paragraph.text)
|
||||
or _paragraph_has_hyperlink(paragraph)
|
||||
or paragraph._p.find(OFFICE_MATH_TAG) is not None
|
||||
]
|
||||
if not visible_paragraphs:
|
||||
return ""
|
||||
@@ -621,6 +671,24 @@ def _format_chart_value(value: object) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _chart_title_text(chart: object) -> str:
|
||||
"""Return the chart's own visible title text, if it has one."""
|
||||
try:
|
||||
if chart.has_title and chart.chart_title.has_text_frame:
|
||||
return chart.chart_title.text_frame.text
|
||||
except (ValueError, AttributeError, KeyError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _picture_alt_text(shape: object) -> str:
|
||||
"""Return the author's alt text (cNvPr/@descr) as a Markdown-safe label."""
|
||||
element = getattr(shape, "_element", None)
|
||||
names = element.xpath("./*/p:cNvPr") if element is not None else []
|
||||
description = normalize_text(names[0].get("descr") or "") if names else ""
|
||||
return _escape_md_link_text(description.replace("\n", " "))
|
||||
|
||||
|
||||
def _chart_header(chart: object, name: str) -> tuple[str, str]:
|
||||
"""Return the Markdown chart header and its best-effort type label."""
|
||||
try:
|
||||
@@ -628,7 +696,10 @@ def _chart_header(chart: object, name: str) -> tuple[str, str]:
|
||||
except (ValueError, AttributeError, KeyError):
|
||||
chart_type = ""
|
||||
raw_name = "" if name is None else str(name)
|
||||
chart_name = normalize_text(raw_name).replace("\n", " ") or "Chart"
|
||||
chart_name = (
|
||||
normalize_text(_chart_title_text(chart) or raw_name).replace("\n", " ")
|
||||
or "Chart"
|
||||
)
|
||||
header = f"> [Chart] {chart_name}" + (f" — {chart_type}" if chart_type else "")
|
||||
return header, chart_type
|
||||
|
||||
@@ -1253,8 +1324,24 @@ def convert_presentation_to_markdown(
|
||||
with zipfile.ZipFile(input_file) as package:
|
||||
for slide_index, slide in enumerate(presentation.slides, 1):
|
||||
slide_part = str(slide.part.partname).lstrip("/")
|
||||
hidden_shapes = _remove_author_hidden_shapes(slide._element)
|
||||
hidden_ids = {
|
||||
node.get("id") for shape in hidden_shapes
|
||||
for node in shape.iter(f"{{{PRESENTATIONML_NS}}}cNvPr")
|
||||
}
|
||||
for shape in hidden_shapes:
|
||||
shape_id, shape_name = _shape_identity(shape, 0)
|
||||
warning = (
|
||||
f"Slide {slide_index}: excluded author-hidden shape "
|
||||
f"{shape_name!r} (id={shape_id}) and its children"
|
||||
)
|
||||
conversion_warnings.append(warning)
|
||||
print(f"[WARN] ppt_to_md: {warning}", file=sys.stderr)
|
||||
try:
|
||||
diagrams = read_smartart_diagrams(package, slide_part, slide_index)
|
||||
diagrams = [
|
||||
item for item in read_smartart_diagrams(package, slide_part, slide_index)
|
||||
if str(item.get("shape_id")) not in hidden_ids
|
||||
]
|
||||
except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc:
|
||||
diagrams_by_slide[slide_index] = []
|
||||
diagram_scan_failures[slide_index] = str(exc)
|
||||
@@ -1308,6 +1395,7 @@ def convert_presentation_to_markdown(
|
||||
emitted_diagram_ids: set[str] = set()
|
||||
emitted_chart_relationship_ids: set[str] = set()
|
||||
_expose_math_choice_shapes(slide)
|
||||
_expose_alternate_fallback_shapes(slide)
|
||||
for item in iter_leaf_shapes(slide.shapes):
|
||||
shape = item.shape
|
||||
|
||||
@@ -1354,16 +1442,21 @@ def convert_presentation_to_markdown(
|
||||
)
|
||||
if saved_picture is None:
|
||||
if is_picture_shape:
|
||||
blocks.append(f"> [Image] {getattr(shape, 'name', 'Picture')}")
|
||||
blocks.append(
|
||||
f"> [Image] {_picture_alt_text(shape) or getattr(shape, 'name', 'Picture')}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
if saved_picture.is_new_asset:
|
||||
image_count = next_image_index
|
||||
image_manifest.append(saved_picture.manifest_entry)
|
||||
asset_dir_used = True
|
||||
alt_text = (
|
||||
_picture_alt_text(shape)
|
||||
or f"Slide {slide_index} Image {image_ref_count}"
|
||||
)
|
||||
image_markdown = (
|
||||
f"![Slide {slide_index} Image {image_ref_count}]"
|
||||
f"({asset_dir.name}/{saved_picture.filename})"
|
||||
f""
|
||||
)
|
||||
image_link = _shape_click_target(shape)
|
||||
if image_link is not None:
|
||||
|
||||
+1
-1
@@ -887,7 +887,7 @@ def find_main_content(soup: BeautifulSoup) -> Tag | None:
|
||||
for element in elements:
|
||||
if not any(element is existing for existing in candidates):
|
||||
candidates.append(element)
|
||||
for tag in soup(["script", "style", "nav", "noscript", "iframe"]):
|
||||
for tag in soup(["script", "style", "nav", "noscript", "template", "iframe"]):
|
||||
tag.decompose()
|
||||
for tag in soup(["header", "footer", "aside"]):
|
||||
if not any(tag is body or body in tag.parents for body in candidates):
|
||||
|
||||
@@ -65,18 +65,26 @@ from pptx_shapes import (
|
||||
NATIVE_FALLBACK_SHA256_ATTR,
|
||||
svg_native_fallback_fingerprint,
|
||||
)
|
||||
from pptx_gradients import (
|
||||
NATIVE_GRADIENT_ATTR,
|
||||
NATIVE_GRADIENT_PREVIEW_SHA256_ATTR,
|
||||
NATIVE_GRADIENT_SHA256_ATTR,
|
||||
)
|
||||
from svg_compatibility import normalize_single_child_group_filters
|
||||
from svg_authoring_contract import (
|
||||
canonical_authoring_errors,
|
||||
normalize_compact_authoring_tree,
|
||||
)
|
||||
from svg_to_pptx.drawingml.context import IDENTITY_MATRIX
|
||||
from svg_to_pptx.drawingml.context import ConvertContext, IDENTITY_MATRIX
|
||||
from svg_to_pptx.drawingml.utils import (
|
||||
INHERITABLE_ATTRS,
|
||||
is_stroked_zero_axis_shape,
|
||||
matrix_multiply,
|
||||
parse_inline_style,
|
||||
parse_opacity,
|
||||
parse_transform_matrix,
|
||||
project_definition_index,
|
||||
project_gradient_errors,
|
||||
)
|
||||
|
||||
configure_utf8_stdio()
|
||||
@@ -938,10 +946,44 @@ def _unwrap_preview(parent: ET.Element, wrapper: ET.Element) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _strip_import_attributes(element: ET.Element, stats: ProjectionStats) -> None:
|
||||
def _strip_import_attributes(
|
||||
element: ET.Element,
|
||||
stats: ProjectionStats,
|
||||
*,
|
||||
geometry_owner: ET.Element | None = None,
|
||||
ctx: ConvertContext | None = None,
|
||||
) -> None:
|
||||
gradient_attributes = {
|
||||
NATIVE_GRADIENT_ATTR,
|
||||
NATIVE_GRADIENT_SHA256_ATTR,
|
||||
NATIVE_GRADIENT_PREVIEW_SHA256_ATTR,
|
||||
}
|
||||
preserve_gradient = False
|
||||
if _local_name(element.tag) in {"linearGradient", "radialGradient"}:
|
||||
preview = copy.deepcopy(element)
|
||||
for name in gradient_attributes:
|
||||
preview.attrib.pop(name, None)
|
||||
# Source gradients can need a preview outside the authored interface
|
||||
# (for example userSpaceOnUse for a collapsed outline). Keep the native
|
||||
# payload needed to export those previews without weakening that interface.
|
||||
preserve_gradient = bool(project_gradient_errors(preview))
|
||||
for name in list(element.attrib):
|
||||
if name not in AUTHORING_OMITTED_SOURCE_ATTRIBUTES:
|
||||
continue
|
||||
if preserve_gradient and name in gradient_attributes:
|
||||
continue
|
||||
if (
|
||||
geometry_owner is not None
|
||||
and (
|
||||
geometry_owner.get("data-pptx-object") == "connector"
|
||||
or is_stroked_zero_axis_shape(geometry_owner, ctx)
|
||||
)
|
||||
and element.get("data-pptx-part") == "geometry"
|
||||
and name in {"data-pptx-custgeom", "data-pptx-geometry-sha256"}
|
||||
):
|
||||
# Collapsed stroked shapes and connectors need the source geometry
|
||||
# because the compact projection no longer rehydrates source refs.
|
||||
continue
|
||||
stats.source_attributes[name] += 1
|
||||
del element.attrib[name]
|
||||
|
||||
@@ -978,7 +1020,11 @@ def _compact_text_runs(root: ET.Element, stats: ProjectionStats) -> None:
|
||||
stats.single_tspans_collapsed += 1
|
||||
|
||||
|
||||
def _project_subtree(parent: ET.Element, stats: ProjectionStats) -> None:
|
||||
def _project_subtree(
|
||||
parent: ET.Element,
|
||||
stats: ProjectionStats,
|
||||
ctx: ConvertContext,
|
||||
) -> None:
|
||||
for child in list(parent):
|
||||
part = child.get("data-pptx-part")
|
||||
tag = _local_name(child.tag)
|
||||
@@ -996,8 +1042,8 @@ def _project_subtree(parent: ET.Element, stats: ProjectionStats) -> None:
|
||||
_remove_child(parent, child)
|
||||
continue
|
||||
|
||||
_project_subtree(child, stats)
|
||||
_strip_import_attributes(child, stats)
|
||||
_project_subtree(child, stats, ctx)
|
||||
_strip_import_attributes(child, stats, geometry_owner=parent, ctx=ctx)
|
||||
|
||||
if part == "geometry-preview":
|
||||
child.attrib.pop("data-pptx-part", None)
|
||||
@@ -1436,7 +1482,8 @@ def _render_projection(
|
||||
stats = ProjectionStats()
|
||||
_mark_semantic_objects(root, stats)
|
||||
_compact_semantic_shapes(root, stats)
|
||||
_project_subtree(root, stats)
|
||||
definitions, _duplicates = project_definition_index(root)
|
||||
_project_subtree(root, stats, ConvertContext(defs=definitions))
|
||||
_compact_text_runs(root, stats)
|
||||
stats.compatibility_normalizations += len(
|
||||
normalize_single_child_group_filters(root)
|
||||
|
||||
+21
-2
@@ -28,6 +28,21 @@ ET.register_namespace('xlink', XLINK_NS)
|
||||
|
||||
# Only ids this tool generates; a user id like "_edit_gradient" is not transient.
|
||||
_TEMP_ID_RE = re.compile(r'_edit_\d+')
|
||||
_LOCAL_URL_RE = re.compile(r'''url\(\s*(['"]?)#([^)'"\s]+)\1\s*\)''', re.IGNORECASE)
|
||||
|
||||
|
||||
def _referenced_ids(root: ET.Element) -> set[str]:
|
||||
"""Keep fragment targets even when their authored ids look transient."""
|
||||
referenced = set()
|
||||
for elem in root.iter():
|
||||
for name, value in elem.attrib.items():
|
||||
referenced.update(match.group(2) for match in _LOCAL_URL_RE.finditer(value))
|
||||
if name.rsplit('}', 1)[-1] == 'href' and value.startswith('#'):
|
||||
referenced.add(value[1:])
|
||||
if elem.tag.rsplit('}', 1)[-1] == 'style' and elem.text:
|
||||
referenced.update(match.group(2) for match in _LOCAL_URL_RE.finditer(elem.text))
|
||||
referenced.update(re.findall(r'#(_edit_\d+)\b', elem.text))
|
||||
return referenced
|
||||
|
||||
|
||||
def assign_temp_ids(root: ET.Element) -> None:
|
||||
@@ -36,16 +51,20 @@ def assign_temp_ids(root: ET.Element) -> None:
|
||||
Clears any leftover _edit_N ids from previous sessions first, to avoid
|
||||
shifted numbering when elements are added/removed between sessions.
|
||||
"""
|
||||
referenced = _referenced_ids(root)
|
||||
for elem in root.iter():
|
||||
eid = elem.get('id', '')
|
||||
if _TEMP_ID_RE.fullmatch(eid):
|
||||
if _TEMP_ID_RE.fullmatch(eid) and eid not in referenced:
|
||||
elem.attrib.pop('id', None)
|
||||
|
||||
retained_ids = {elem.get('id') for elem in root.iter() if elem.get('id')}
|
||||
counter = 0
|
||||
for elem in root.iter():
|
||||
if elem is root:
|
||||
continue
|
||||
if elem.get('id') is None:
|
||||
while f'_edit_{counter}' in retained_ids:
|
||||
counter += 1
|
||||
elem.set('id', f'_edit_{counter}')
|
||||
counter += 1
|
||||
|
||||
@@ -332,7 +351,7 @@ def strip_unused_temp_ids(root: ET.Element, keep_ids: set) -> None:
|
||||
Mirrors the cleanup in server.py's save-all so a direct edit never
|
||||
strips the id an unsaved/saved annotation depends on.
|
||||
"""
|
||||
protected = set(keep_ids)
|
||||
protected = set(keep_ids) | _referenced_ids(root)
|
||||
for elem in root.iter():
|
||||
if elem.get('data-edit-target') == 'true':
|
||||
eid = elem.get('id')
|
||||
|
||||
@@ -212,10 +212,8 @@ def _inline_icons(
|
||||
warnings.append({'icon': icon_name, 'reason': 'no renderable paths in icon'})
|
||||
continue
|
||||
replacement = generate_icon_group(attrs, elements, style, base_size)
|
||||
id_match = re.search(r'\bid="([^"]+)"', use_str)
|
||||
if id_match:
|
||||
if attrs.get('id'):
|
||||
preview_attrs = [
|
||||
f'id="{_xml_attr(id_match.group(1))}"',
|
||||
f'data-icon="{_xml_attr(icon_name)}"',
|
||||
]
|
||||
for key in ('x', 'y', 'width', 'height'):
|
||||
|
||||
+20
-2
@@ -396,6 +396,16 @@ def parse_use_element(use_match: str) -> dict[str, str | float]:
|
||||
if stroke_width_value is not None:
|
||||
attrs['stroke-width'] = _validate_icon_attr(stroke_width_value, 'stroke-width', _ICON_STROKE_WIDTH_RE)
|
||||
|
||||
# These belong to the authored object, rather than the icon's local
|
||||
# geometry. Keep them on the replacement group, including visibility.
|
||||
for match in re.finditer(
|
||||
r'''(?<!\S)(id|class|style|opacity|display|visibility|fill-opacity|stroke-opacity|'''
|
||||
r'''clip-path|filter|data-pptx-[\w.-]+|data-edit-[\w.-]+)\s*=\s*(["'])(.*?)\2''',
|
||||
use_match,
|
||||
re.DOTALL,
|
||||
):
|
||||
attrs[match.group(1)] = html.unescape(match.group(3))
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
@@ -464,13 +474,21 @@ def generate_icon_group(attrs: dict[str, str | float], elements: list[str], styl
|
||||
)
|
||||
|
||||
elements_str = '\n '.join(elements)
|
||||
context_attrs = ''.join(
|
||||
f' {key}="{_xml_attr(value)}"'
|
||||
for key, value in attrs.items()
|
||||
if key in {
|
||||
'id', 'class', 'style', 'opacity', 'display', 'visibility',
|
||||
'fill-opacity', 'stroke-opacity', 'clip-path', 'filter',
|
||||
} or key.startswith(('data-pptx-', 'data-edit-'))
|
||||
)
|
||||
|
||||
if style == 'preserve':
|
||||
if min_x or min_y:
|
||||
inner_transform = f'translate({_format_number(-min_x)}, {_format_number(-min_y)})'
|
||||
elements_str = f'<g transform="{_xml_attr(inner_transform)}">\n {elements_str}\n </g>'
|
||||
return f'''<!-- icon: {_xml_attr(icon_name)} -->
|
||||
<g transform="{_xml_attr(transform)}">
|
||||
<g transform="{_xml_attr(transform)}"{context_attrs}>
|
||||
{elements_str}
|
||||
</g>'''
|
||||
|
||||
@@ -484,7 +502,7 @@ def generate_icon_group(attrs: dict[str, str | float], elements: list[str], styl
|
||||
color_attrs = f'fill="{_xml_attr(color)}"'
|
||||
|
||||
return f'''<!-- icon: {_xml_attr(icon_name)} -->
|
||||
<g transform="{_xml_attr(transform)}" {color_attrs}>
|
||||
<g transform="{_xml_attr(transform)}" {color_attrs}{context_attrs}>
|
||||
{elements_str}
|
||||
</g>'''
|
||||
|
||||
|
||||
+19
-2
@@ -45,6 +45,8 @@ TEXT_STYLE_ATTRS = {
|
||||
"stroke-width",
|
||||
"stroke-opacity",
|
||||
"opacity",
|
||||
"display",
|
||||
"visibility",
|
||||
"paint-order",
|
||||
# transforms/filters
|
||||
"transform",
|
||||
@@ -52,6 +54,8 @@ TEXT_STYLE_ATTRS = {
|
||||
"filter",
|
||||
}
|
||||
|
||||
TEXT_IDENTITY_ATTRS = {"id", "class", "data-name", "data-pptx-shape-name", "data-pptx-role"}
|
||||
|
||||
|
||||
num_re = re.compile(r"^[\s,]*([+-]?(?:\d+\.?\d*|\d*\.\d+))")
|
||||
|
||||
@@ -169,7 +173,7 @@ def copy_text_attrs(
|
||||
# Copy style string first
|
||||
if "style" in src_el.attrib and "style" not in exclude:
|
||||
dst_el.set("style", src_el.attrib["style"])
|
||||
for k in TEXT_STYLE_ATTRS:
|
||||
for k in TEXT_STYLE_ATTRS | TEXT_IDENTITY_ATTRS:
|
||||
if k in exclude:
|
||||
continue
|
||||
v = src_el.get(k)
|
||||
@@ -586,6 +590,7 @@ def flatten_text_with_tspans(
|
||||
)
|
||||
raise ValueError(f"Unsupported nested positional <tspan>: {preview}{suffix}")
|
||||
parent_map = {c: p for p in root.iter() for c in p}
|
||||
used_ids = {el.get("id") for el in root.iter() if el.get("id")}
|
||||
changed = False
|
||||
|
||||
# Collect candidates first to avoid modifying while iterating
|
||||
@@ -646,7 +651,7 @@ def flatten_text_with_tspans(
|
||||
current_line_lead_text = text_el.text or None
|
||||
|
||||
for idx, child in enumerate(list(text_el)):
|
||||
if not _is_svg_tag(child, "tspan"):
|
||||
if not (_is_svg_tag(child, "tspan") or _is_svg_tag(child, "a")):
|
||||
continue
|
||||
|
||||
content = collect_text_content(child)
|
||||
@@ -684,6 +689,16 @@ def flatten_text_with_tspans(
|
||||
new_texts.append(ne)
|
||||
|
||||
if new_texts:
|
||||
# Split rows remain distinct objects, but the first row retains
|
||||
# the authored locator and later rows get collision-free ids.
|
||||
original_id = text_el.get("id")
|
||||
if original_id:
|
||||
for line_number, ne in enumerate(new_texts[1:], start=2):
|
||||
candidate = f"{original_id}-line-{line_number}"
|
||||
while candidate in used_ids:
|
||||
candidate += "-line"
|
||||
ne.set("id", candidate)
|
||||
used_ids.add(candidate)
|
||||
# Replace original <text> with the list of new <text> nodes
|
||||
try:
|
||||
idx = list(parent).index(text_el)
|
||||
@@ -794,6 +809,8 @@ def _create_text_element_from_line(
|
||||
if (
|
||||
not lead_text
|
||||
and len(tspans) == 1
|
||||
and _is_svg_tag(tspans[0], "tspan")
|
||||
and not any(key in tspans[0].attrib for key in TEXT_IDENTITY_ATTRS)
|
||||
and not _has_tspan_children(tspans[0])
|
||||
and not tspans[0].tail
|
||||
and tspans[0].get(INLINE_FORMULA_ATTR) is None
|
||||
|
||||
@@ -173,6 +173,7 @@ except ImportError:
|
||||
_parse_pattern_colors = None
|
||||
|
||||
try:
|
||||
from svg_to_pptx.drawingml.context import ConvertContext as _ConvertContext
|
||||
from svg_to_pptx.drawingml.elements import (
|
||||
drawingml_text_frame_width_emu as _drawingml_text_frame_width_emu,
|
||||
empty_clip_path_reason as _empty_clip_path_reason,
|
||||
@@ -182,6 +183,7 @@ try:
|
||||
validate_preset_geometry_metadata as _validate_preset_geometry_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
_ConvertContext = None
|
||||
_drawingml_text_frame_width_emu = None
|
||||
_empty_clip_path_reason = None
|
||||
_estimate_single_line_text_frame_width = None
|
||||
@@ -5543,11 +5545,14 @@ class SVGQualityChecker:
|
||||
return
|
||||
|
||||
issues = set()
|
||||
geometry_context = _ConvertContext(defs={
|
||||
elem.get('id'): elem for elem in root.iter() if elem.get('id')
|
||||
})
|
||||
for elem in marked:
|
||||
tag = _local_name(elem)
|
||||
elem_id = elem.get('id')
|
||||
label = f'<{tag} id="{elem_id}">' if elem_id else f'<{tag}>'
|
||||
for error in _validate_preset_geometry_metadata(elem):
|
||||
for error in _validate_preset_geometry_metadata(elem, geometry_context):
|
||||
issues.add(f'{label} has invalid PPTX shape metadata: {error}')
|
||||
if _validate_authored_preset_tree is None:
|
||||
if any(
|
||||
|
||||
+6
-1
@@ -891,7 +891,12 @@ def _convert_semantic_shape(
|
||||
# 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':
|
||||
# Imported carriers retain their hash-guarded source geometry. Only a
|
||||
# semantic custom shape without a source payload compiles from SVG alone.
|
||||
if (
|
||||
shape.get('data-pptx-geometry-kind') == 'custom'
|
||||
and carrier.get('data-pptx-custgeom') is None
|
||||
):
|
||||
for name in (
|
||||
'data-pptx-custgeom',
|
||||
'data-pptx-geometry-kind',
|
||||
|
||||
+33
-18
@@ -85,6 +85,7 @@ from .utils import (
|
||||
quantize_ooxml_alpha,
|
||||
project_definition_index,
|
||||
svg_hidden_reason,
|
||||
is_stroked_zero_axis_shape,
|
||||
matrix_multiply, parse_transform_matrix, parse_transform_operations,
|
||||
transform_point, _xml_escape,
|
||||
)
|
||||
@@ -807,6 +808,7 @@ _PPTX_GUIDE_NAME_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_.-]{0,63}')
|
||||
_PPTX_VAL_FORMULA_RE = re.compile(r'val[\t ]+([+-]?\d+)')
|
||||
def _parse_preset_geometry_metadata(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext | None = None,
|
||||
) -> tuple[str | None, list[tuple[str, str]], tuple[float, float, float, float] | None]:
|
||||
"""Parse and validate rendering-neutral preset geometry metadata."""
|
||||
status = (elem.get('data-pptx-geometry-status') or '').strip()
|
||||
@@ -948,10 +950,15 @@ def _parse_preset_geometry_metadata(
|
||||
elem.get('data-pptx-object') == 'connector'
|
||||
or prst in CONNECTOR_PRESET_TYPES
|
||||
)
|
||||
if is_connector:
|
||||
is_native_group = (
|
||||
elem.tag.rsplit('}', 1)[-1] == 'g'
|
||||
and elem.get('data-pptx-object') == 'group'
|
||||
)
|
||||
if is_connector or is_native_group or is_stroked_zero_axis_shape(elem, ctx):
|
||||
if frame[2] < 0 or frame[3] < 0 or (frame[2] == 0 and frame[3] == 0):
|
||||
kind = 'Connector' if is_connector else 'Group'
|
||||
raise ValueError(
|
||||
'Connector data-pptx-frame dimensions must be non-negative '
|
||||
f'{kind} data-pptx-frame dimensions must be non-negative '
|
||||
f'and not both zero, got {raw_frame!r}'
|
||||
)
|
||||
elif frame[2] <= 0 or frame[3] <= 0:
|
||||
@@ -968,11 +975,14 @@ def _parse_preset_geometry_metadata(
|
||||
return prst, guides, frame
|
||||
|
||||
|
||||
def validate_preset_geometry_metadata(elem: ET.Element) -> list[str]:
|
||||
def validate_preset_geometry_metadata(
|
||||
elem: ET.Element,
|
||||
ctx: ConvertContext | None = None,
|
||||
) -> list[str]:
|
||||
"""Return native shape metadata errors for authoring-time validation."""
|
||||
errors: list[str] = []
|
||||
try:
|
||||
_parse_preset_geometry_metadata(elem)
|
||||
_parse_preset_geometry_metadata(elem, ctx)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
if elem.get('data-pptx-custgeom') is not None:
|
||||
@@ -1035,9 +1045,9 @@ def complete_preset_adjustments(
|
||||
]
|
||||
|
||||
|
||||
def _build_preset_geom_from_meta(elem: ET.Element) -> str | None:
|
||||
def _build_preset_geom_from_meta(elem: ET.Element, ctx: ConvertContext) -> str | None:
|
||||
"""Build validated native DrawingML preset geometry from SVG metadata."""
|
||||
prst, guides, _frame = _parse_preset_geometry_metadata(elem)
|
||||
prst, guides, _frame = _parse_preset_geometry_metadata(elem, ctx)
|
||||
if prst is None:
|
||||
return None
|
||||
if not guides:
|
||||
@@ -1094,7 +1104,7 @@ def _shape_xfrm_from_preset_frame(
|
||||
transform: str | None,
|
||||
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||||
"""Use the preserved logical frame for native preset size when present."""
|
||||
prst, _guides, frame = _parse_preset_geometry_metadata(elem)
|
||||
prst, _guides, frame = _parse_preset_geometry_metadata(elem, ctx)
|
||||
if frame is None:
|
||||
raw_x, raw_y, raw_w, raw_h = fallback_raw_rect
|
||||
x, y, w, h = fallback_resolved_rect
|
||||
@@ -1107,6 +1117,7 @@ def _shape_xfrm_from_preset_frame(
|
||||
preserves_zero_axis = (
|
||||
elem.get('data-pptx-object') == 'connector'
|
||||
or prst in CONNECTOR_PRESET_TYPES
|
||||
or is_stroked_zero_axis_shape(elem, ctx)
|
||||
)
|
||||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _shape_xfrm_from_svg_rect(
|
||||
ctx,
|
||||
@@ -1143,7 +1154,7 @@ def _pathlike_preset_xfrm(
|
||||
height: float,
|
||||
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||||
"""Resolve a path-like preset xfrm from its logical frame or visual bounds."""
|
||||
_prst, _guides, frame = _parse_preset_geometry_metadata(elem)
|
||||
_prst, _guides, frame = _parse_preset_geometry_metadata(elem, ctx)
|
||||
if frame is None:
|
||||
if _uses_full_transform(ctx, transform):
|
||||
tag = elem.tag.rsplit('}', 1)[-1]
|
||||
@@ -1282,9 +1293,11 @@ def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
y = ctx_y(raw_y, ctx)
|
||||
w = ctx_w(raw_w, ctx)
|
||||
h = ctx_h(raw_h, ctx)
|
||||
preset_geom = _build_preset_geom_from_meta(elem)
|
||||
preset_geom = _build_preset_geom_from_meta(elem, ctx)
|
||||
|
||||
if w <= 0 or h <= 0:
|
||||
if (w <= 0 or h <= 0) and not (
|
||||
preset_geom is not None and is_stroked_zero_axis_shape(elem, ctx)
|
||||
):
|
||||
return None
|
||||
|
||||
# SVG spec: when only one of rx/ry is specified, the other inherits its
|
||||
@@ -1429,7 +1442,7 @@ def _build_arc_ring_path(
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
if width < 0.5 or height < 0.5:
|
||||
if width <= 0 or height <= 0:
|
||||
return '', 0, 0, 0, 0
|
||||
|
||||
w_emu = px_to_emu(width)
|
||||
@@ -1472,7 +1485,7 @@ def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
cx_ = svg_length_x(elem.get('cx'), ctx)
|
||||
cy_ = svg_length_y(elem.get('cy'), ctx)
|
||||
r = svg_length_size(elem.get('r'), ctx)
|
||||
preset_geom = _build_preset_geom_from_meta(elem)
|
||||
preset_geom = _build_preset_geom_from_meta(elem, ctx)
|
||||
|
||||
if r <= 0:
|
||||
return None
|
||||
@@ -1636,7 +1649,7 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
heads (headEnd / tailEnd) correctly. Plain lines (no markers) continue to
|
||||
use custom geometry which is sufficient and avoids flipH/flipV complexity.
|
||||
"""
|
||||
preset_geom = _build_preset_geom_from_meta(elem)
|
||||
preset_geom = _build_preset_geom_from_meta(elem, ctx)
|
||||
transform = elem.get('transform')
|
||||
raw_x1 = svg_length_x(elem.get('x1'), ctx)
|
||||
raw_y1 = svg_length_y(elem.get('y1'), ctx)
|
||||
@@ -1797,7 +1810,7 @@ def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
|
||||
def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
"""Convert SVG <path> to DrawingML custom geometry shape."""
|
||||
preset_geom = _build_preset_geom_from_meta(elem)
|
||||
preset_geom = _build_preset_geom_from_meta(elem, ctx)
|
||||
preserved_custom_geom = _build_preserved_custom_geom(elem)
|
||||
native_geom = preset_geom or preserved_custom_geom
|
||||
d = elem.get('d', '')
|
||||
@@ -1885,7 +1898,7 @@ def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
|
||||
def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
"""Convert SVG <polygon> to DrawingML custom geometry shape."""
|
||||
preset_geom = _build_preset_geom_from_meta(elem)
|
||||
preset_geom = _build_preset_geom_from_meta(elem, ctx)
|
||||
points = parse_svg_points(elem.get('points', ''), min_points=3)
|
||||
|
||||
commands = [PathCommand('M', [points[0][0], points[0][1]])]
|
||||
@@ -1962,7 +1975,7 @@ def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
|
||||
|
||||
def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
"""Convert SVG <polyline> to DrawingML custom geometry shape."""
|
||||
preset_geom = _build_preset_geom_from_meta(elem)
|
||||
preset_geom = _build_preset_geom_from_meta(elem, ctx)
|
||||
points = parse_svg_points(elem.get('points', ''), min_points=2)
|
||||
|
||||
commands = [PathCommand('M', [points[0][0], points[0][1]])]
|
||||
@@ -5109,7 +5122,7 @@ def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
|
||||
def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||||
"""Convert SVG <ellipse> to DrawingML ellipse shape."""
|
||||
preset_geom = _build_preset_geom_from_meta(elem)
|
||||
preset_geom = _build_preset_geom_from_meta(elem, ctx)
|
||||
raw_cx = svg_length_x(elem.get('cx'), ctx)
|
||||
raw_cy = svg_length_y(elem.get('cy'), ctx)
|
||||
rx_attr = elem.get('rx')
|
||||
@@ -5125,7 +5138,9 @@ def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None
|
||||
rx = raw_rx * ctx.scale_x
|
||||
ry = raw_ry * ctx.scale_y
|
||||
|
||||
if rx <= 0 or ry <= 0:
|
||||
if (rx <= 0 or ry <= 0) and not (
|
||||
preset_geom is not None and is_stroked_zero_axis_shape(elem, ctx)
|
||||
):
|
||||
return None
|
||||
|
||||
x = cx_ - rx
|
||||
|
||||
+76
@@ -1512,6 +1512,82 @@ def svg_hidden_reason(
|
||||
return None
|
||||
|
||||
|
||||
def is_stroked_zero_axis_shape(
|
||||
element: ET.Element,
|
||||
ctx: ConvertContext | None = None,
|
||||
) -> bool:
|
||||
"""Recognize a native shape whose single collapsed frame axis still has an outline."""
|
||||
geometry_tags = {'rect', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline'}
|
||||
if (
|
||||
element.tag.rsplit('}', 1)[-1] not in geometry_tags | {'g'}
|
||||
or element.get('data-pptx-object') != 'shape'
|
||||
or not (
|
||||
element.get('data-pptx-prst')
|
||||
or element.get('data-pptx-geometry-kind') == 'custom'
|
||||
)
|
||||
):
|
||||
return False
|
||||
try:
|
||||
frame = tuple(float(value) for value in re.split(
|
||||
r'[\s,]+', (element.get('data-pptx-frame') or '').strip(),
|
||||
))
|
||||
except ValueError:
|
||||
return False
|
||||
if (
|
||||
len(frame) != 4 or not all(math.isfinite(value) for value in frame)
|
||||
or not ((frame[2] == 0 and frame[3] > 0) or (frame[3] == 0 and frame[2] > 0))
|
||||
):
|
||||
return False
|
||||
parents = {id(child): parent for parent in element.iter() for child in parent}
|
||||
for leaf in element.iter():
|
||||
if leaf.tag.rsplit('}', 1)[-1] not in geometry_tags or svg_hidden_reason(leaf, parents):
|
||||
continue
|
||||
lineage = [leaf]
|
||||
while id(lineage[-1]) in parents:
|
||||
lineage.append(parents[id(lineage[-1])])
|
||||
if any(node.tag.rsplit('}', 1)[-1] in {'defs', 'clipPath', 'mask', 'metadata'} for node in lineage):
|
||||
continue
|
||||
inherited = dict(ctx.inherited_styles) if ctx is not None else {}
|
||||
opacity = ctx.opacity_multiplier if ctx is not None else 1.0
|
||||
try:
|
||||
for node in reversed(lineage):
|
||||
inherited.update(_extract_inheritable_styles(node))
|
||||
local = parse_inline_style(node.get('style'))
|
||||
opacity *= parse_project_opacity(local.get('opacity', node.get('opacity', '1')))
|
||||
kind, value, alpha = parse_project_paint(inherited.get('stroke', 'none'), 'stroke')
|
||||
width = parse_project_geometry_length(inherited.get('stroke-width', '1'), 'stroke-width')
|
||||
opacity *= parse_project_opacity(inherited.get('stroke-opacity', '1')) * alpha
|
||||
except ValueError:
|
||||
continue
|
||||
if kind == 'color' and width > 0 and opacity > 0:
|
||||
return True
|
||||
if kind == 'reference' and ctx is not None and width > 0 and opacity > 0:
|
||||
gradient = ctx.defs.get(value)
|
||||
if gradient is None:
|
||||
continue
|
||||
try:
|
||||
# Only imported DrawingML gradients qualify for this source
|
||||
# frame exception; authored gradient contracts stay unchanged.
|
||||
if preserved_native_gradient_xml(gradient) is None:
|
||||
continue
|
||||
for stop in gradient:
|
||||
if stop.tag.rsplit('}', 1)[-1] != 'stop':
|
||||
continue
|
||||
style = parse_inline_style(stop.get('style'))
|
||||
stop_kind, _color, stop_alpha = parse_project_paint(
|
||||
style.get('stop-color', stop.get('stop-color', '#000000')),
|
||||
'stop-color',
|
||||
)
|
||||
stop_alpha *= parse_project_opacity(
|
||||
style.get('stop-opacity', stop.get('stop-opacity', '1')),
|
||||
)
|
||||
if stop_kind == 'color' and stop_alpha > 0:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def iter_project_geometry_lengths(
|
||||
root: ET.Element,
|
||||
) -> Iterator[tuple[ET.Element, str, str, str]]:
|
||||
|
||||
+13
-5
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
@@ -201,7 +202,7 @@ def resolve_first_rel(
|
||||
return None
|
||||
|
||||
|
||||
def parse_xfrm_record(sp: ET.Element) -> dict[str, int] | None:
|
||||
def parse_xfrm_record(sp: ET.Element) -> dict[str, float] | None:
|
||||
xfrm = sp.find("p:spPr/a:xfrm", NS)
|
||||
if xfrm is None:
|
||||
return None
|
||||
@@ -217,10 +218,10 @@ def parse_xfrm_record(sp: ET.Element) -> dict[str, int] | None:
|
||||
except ValueError:
|
||||
return None
|
||||
return {
|
||||
"x": emu_to_pixels(x),
|
||||
"y": emu_to_pixels(y),
|
||||
"width": emu_to_pixels(w),
|
||||
"height": emu_to_pixels(h),
|
||||
"x": x / EMU_PER_INCH * 96,
|
||||
"y": y / EMU_PER_INCH * 96,
|
||||
"width": w / EMU_PER_INCH * 96,
|
||||
"height": h / EMU_PER_INCH * 96,
|
||||
}
|
||||
|
||||
|
||||
@@ -574,6 +575,13 @@ def build_manifest(
|
||||
|
||||
for index, slide_path in enumerate(slide_parts, 1):
|
||||
slide_root = load_xml_from_zip(zf, slide_path)
|
||||
if slide_root is not None and slide_root.get("show", "").strip().lower() in {"0", "false", "off"}:
|
||||
print(
|
||||
f"Warning: source slide {index} ({slide_path}) is hidden from the slide show; "
|
||||
"template preview export does not preserve this slide-hidden state. "
|
||||
"The slide is retained; use Edit Native roundtrip to preserve its source visibility.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
slide_rels = parse_relationships(zf, slide_path)
|
||||
|
||||
layout_path = None
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep visible chart titles across PPTX intake and Beautify verification.
|
||||
|
||||
Usage:
|
||||
python3 -m unittest tests.test_beautify_chart_title_survival
|
||||
|
||||
Dependencies:
|
||||
python-pptx and the script runtime dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.chart.data import ChartData
|
||||
from pptx.enum.chart import XL_CHART_TYPE
|
||||
from pptx.util import Inches
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import beautify_inventory # noqa: E402
|
||||
import pptx_intake # noqa: E402
|
||||
|
||||
NS = {
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
|
||||
}
|
||||
|
||||
|
||||
class BeautifyChartTitleSurvivalTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.source = self.root / "source.pptx"
|
||||
presentation = Presentation()
|
||||
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||
data = ChartData()
|
||||
data.categories = ["CATEGORY_MARKER"]
|
||||
data.add_series("SERIES_MARKER", [271])
|
||||
chart = slide.shapes.add_chart(
|
||||
XL_CHART_TYPE.COLUMN_CLUSTERED,
|
||||
Inches(1), Inches(1), Inches(8), Inches(5), data,
|
||||
).chart
|
||||
chart.has_title = True
|
||||
chart.chart_title.text_frame.text = "TITLE_MARKER\n第二行 & <exact>"
|
||||
presentation.save(self.source)
|
||||
|
||||
def _intake(self) -> tuple[Path, dict]:
|
||||
analysis = self.root / "analysis"
|
||||
inventory = analysis / "inventory.json"
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(pptx_intake.main([str(self.source), "-o", str(analysis)]), 0)
|
||||
self.assertEqual(beautify_inventory.main([
|
||||
str(analysis / "source.slide_library.json"), "-o", str(inventory),
|
||||
]), 0)
|
||||
return inventory, json.loads(inventory.read_text(encoding="utf-8"))
|
||||
|
||||
def _mutate_chart(self, mutation) -> None:
|
||||
with zipfile.ZipFile(self.source) as archive:
|
||||
entries = {name: archive.read(name) for name in archive.namelist()}
|
||||
chart = ET.fromstring(entries["ppt/charts/chart1.xml"])
|
||||
mutation(chart)
|
||||
entries["ppt/charts/chart1.xml"] = ET.tostring(chart, encoding="utf-8")
|
||||
with zipfile.ZipFile(self.source, "w") as archive:
|
||||
for name, payload in entries.items():
|
||||
archive.writestr(name, payload)
|
||||
|
||||
def test_rich_title_survives_intake_and_missing_title_fails_verification(self) -> None:
|
||||
inventory, payload = self._intake()
|
||||
title = "TITLE_MARKER\n第二行 & <exact>"
|
||||
self.assertEqual(payload["slides"][0]["charts"][0]["title"], title)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(beautify_inventory.main([
|
||||
str(inventory), "--verify", str(self.source),
|
||||
]), 0)
|
||||
presentation = Presentation(self.source)
|
||||
presentation.slides[0].shapes[0].chart.has_title = False
|
||||
missing_title = self.root / "missing-title.pptx"
|
||||
presentation.save(missing_title)
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(beautify_inventory.main([
|
||||
str(inventory), "--verify", str(missing_title),
|
||||
]), 1)
|
||||
self.assertIn("TITLE_MARKER", output.getvalue())
|
||||
|
||||
def test_linked_title_cache_survives_intake(self) -> None:
|
||||
def linked_title(root):
|
||||
tx = root.find("c:chart/c:title/c:tx", NS)
|
||||
tx.clear()
|
||||
ref = ET.SubElement(tx, f"{{{NS['c']}}}strRef")
|
||||
ET.SubElement(ref, f"{{{NS['c']}}}f").text = "Sheet1!$A$1"
|
||||
cache = ET.SubElement(ref, f"{{{NS['c']}}}strCache")
|
||||
ET.SubElement(cache, f"{{{NS['c']}}}ptCount", {"val": "1"})
|
||||
point = ET.SubElement(cache, f"{{{NS['c']}}}pt", {"idx": "0"})
|
||||
ET.SubElement(point, f"{{{NS['c']}}}v").text = "LINKED_TITLE_MARKER"
|
||||
|
||||
self._mutate_chart(linked_title)
|
||||
_path, payload = self._intake()
|
||||
self.assertEqual(payload["slides"][0]["charts"][0]["title"], "LINKED_TITLE_MARKER")
|
||||
|
||||
def test_title_survives_chart_without_series(self) -> None:
|
||||
def remove_series(root):
|
||||
for plot in root.findall("c:chart/c:plotArea/*", NS):
|
||||
for series in plot.findall("c:ser", NS):
|
||||
plot.remove(series)
|
||||
|
||||
self._mutate_chart(remove_series)
|
||||
_path, payload = self._intake()
|
||||
chart = payload["slides"][0]["charts"][0]
|
||||
self.assertIn("TITLE_MARKER", chart["title"])
|
||||
self.assertEqual(chart["series"], [])
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exclude author-hidden objects from both Beautify content-source paths.
|
||||
|
||||
Usage:
|
||||
python3 -m unittest tests.test_beautify_hidden_content
|
||||
|
||||
Dependencies:
|
||||
python-pptx, Pillow, and the script runtime dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from PIL import Image
|
||||
from pptx import Presentation
|
||||
from pptx.chart.data import ChartData
|
||||
from pptx.enum.chart import XL_CHART_TYPE
|
||||
from pptx.util import Inches
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
for directory in (SCRIPTS, SCRIPTS / "source_to_md"):
|
||||
if str(directory) not in sys.path:
|
||||
sys.path.insert(0, str(directory))
|
||||
|
||||
import beautify_inventory # noqa: E402
|
||||
import ppt_to_md # noqa: E402
|
||||
import pptx_intake # noqa: E402
|
||||
|
||||
NS = {
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"dgm": "http://schemas.openxmlformats.org/drawingml/2006/diagram",
|
||||
}
|
||||
|
||||
|
||||
class BeautifyHiddenContentTests(unittest.TestCase):
|
||||
def test_hidden_subtrees_are_absent_from_markdown_inventory_and_images(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
presentation = Presentation()
|
||||
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||
|
||||
def textbox(shapes, text):
|
||||
shape = shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
|
||||
shape.text = text
|
||||
return shape
|
||||
|
||||
def hide(shape, value="1"):
|
||||
shape.element.xpath("./*/p:cNvPr")[0].set("hidden", value)
|
||||
|
||||
visible = textbox(slide.shapes, "VISIBLE_FALSE_MARKER")
|
||||
hide(visible, "false")
|
||||
hide(textbox(slide.shapes, "SECRET_TEXT_MARKER"))
|
||||
group = slide.shapes.add_group_shape()
|
||||
textbox(group.shapes, "SECRET_GROUP_MARKER")
|
||||
hide(group, "true")
|
||||
table = slide.shapes.add_table(1, 1, Inches(1), Inches(2), Inches(4), Inches(1))
|
||||
table.table.cell(0, 0).text = "SECRET_TABLE_MARKER"
|
||||
hide(table)
|
||||
data = ChartData()
|
||||
data.categories = ["SECRET_CATEGORY_MARKER"]
|
||||
data.add_series("SECRET_SERIES_MARKER", [271])
|
||||
chart = slide.shapes.add_chart(
|
||||
XL_CHART_TYPE.COLUMN_CLUSTERED,
|
||||
Inches(1), Inches(1), Inches(8), Inches(4), data,
|
||||
)
|
||||
chart.chart.has_title = True
|
||||
chart.chart.chart_title.text_frame.text = "SECRET_TITLE_MARKER"
|
||||
hide(chart)
|
||||
image = root / "picture.png"
|
||||
Image.new("RGB", (30, 30), "red").save(image)
|
||||
slide.shapes.add_picture(str(image), Inches(1), Inches(5))
|
||||
hidden_picture = slide.shapes.add_picture(str(image), Inches(2), Inches(5))
|
||||
hidden_picture.element.xpath("./*/p:cNvPr")[0].set("descr", "SECRET_IMAGE_MARKER")
|
||||
hide(hidden_picture, "on")
|
||||
source = root / "source.pptx"
|
||||
presentation.save(source)
|
||||
|
||||
with zipfile.ZipFile(source) as archive:
|
||||
entries = {name: archive.read(name) for name in archive.namelist()}
|
||||
slide_xml = ET.fromstring(entries["ppt/slides/slide1.xml"])
|
||||
group_xml = slide_xml.find(".//p:grpSp", NS)
|
||||
group_xml.append(ET.fromstring(
|
||||
f'<p:graphicFrame xmlns:p="{NS["p"]}" xmlns:a="{NS["a"]}" '
|
||||
f'xmlns:r="{NS["r"]}" xmlns:dgm="{NS["dgm"]}">'
|
||||
'<p:nvGraphicFramePr><p:cNvPr id="90" name="Hidden diagram"/>'
|
||||
'<p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>'
|
||||
'<p:xfrm><a:off x="0" y="0"/><a:ext cx="1000000" cy="1000000"/></p:xfrm>'
|
||||
f'<a:graphic><a:graphicData uri="{NS["dgm"]}">'
|
||||
'<dgm:relIds r:dm="rIdHiddenDiagram"/></a:graphicData></a:graphic>'
|
||||
'</p:graphicFrame>'
|
||||
))
|
||||
entries["ppt/slides/slide1.xml"] = ET.tostring(slide_xml)
|
||||
rel_name = "ppt/slides/_rels/slide1.xml.rels"
|
||||
rels = ET.fromstring(entries[rel_name])
|
||||
rel_namespace = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
ET.SubElement(rels, f"{{{rel_namespace}}}Relationship", {
|
||||
"Id": "rIdHiddenDiagram", "Type": NS["r"] + "/diagramData",
|
||||
"Target": "../diagrams/data1.xml",
|
||||
})
|
||||
entries[rel_name] = ET.tostring(rels)
|
||||
entries["ppt/diagrams/data1.xml"] = (
|
||||
f'<dgm:dataModel xmlns:dgm="{NS["dgm"]}" xmlns:a="{NS["a"]}">'
|
||||
'<dgm:ptLst><dgm:pt modelId="1" type="node"><dgm:prSet/>'
|
||||
'<dgm:t><a:bodyPr/><a:lstStyle/><a:p><a:r>'
|
||||
'<a:t>SECRET_DIAGRAM_MARKER</a:t></a:r></a:p></dgm:t>'
|
||||
'</dgm:pt></dgm:ptLst><dgm:cxnLst/></dgm:dataModel>'
|
||||
).encode()
|
||||
content_types = ET.fromstring(entries["[Content_Types].xml"])
|
||||
ET.SubElement(content_types, "{http://schemas.openxmlformats.org/package/2006/content-types}Override", {
|
||||
"PartName": "/ppt/diagrams/data1.xml",
|
||||
"ContentType": "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml",
|
||||
})
|
||||
entries["[Content_Types].xml"] = ET.tostring(content_types)
|
||||
with zipfile.ZipFile(source, "w") as archive:
|
||||
for name, payload in entries.items():
|
||||
archive.writestr(name, payload)
|
||||
original = source.read_bytes()
|
||||
warnings = io.StringIO()
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(warnings):
|
||||
self.assertEqual(ppt_to_md.main([str(source), "-o", str(root / "source.md")]), 0)
|
||||
self.assertEqual(pptx_intake.main([str(source), "-o", str(root / "analysis")]), 0)
|
||||
self.assertEqual(beautify_inventory.main([
|
||||
str(root / "analysis/source.slide_library.json"),
|
||||
"--images", str(root / "source_files/image_manifest.json"),
|
||||
"-o", str(root / "inventory.json"),
|
||||
]), 0)
|
||||
markdown = (root / "source.md").read_text(encoding="utf-8")
|
||||
inventory_text = (root / "inventory.json").read_text(encoding="utf-8")
|
||||
for text in (markdown, inventory_text):
|
||||
self.assertIn("VISIBLE_FALSE_MARKER", text)
|
||||
self.assertNotIn("SECRET_", text)
|
||||
inventory = json.loads(inventory_text)
|
||||
self.assertEqual(len(inventory["slides"][0]["images"]), 1)
|
||||
self.assertEqual(inventory["slides"][0]["tables"], [])
|
||||
self.assertEqual(inventory["slides"][0]["charts"], [])
|
||||
self.assertEqual(inventory["slides"][0]["diagrams"], [])
|
||||
self.assertIn("excluded author-hidden", warnings.getvalue())
|
||||
self.assertEqual(source.read_bytes(), original)
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify icon replacement retains authored identity and visibility."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
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))
|
||||
|
||||
|
||||
class IconContentSurvivalTests(unittest.TestCase):
|
||||
def test_finalize_keeps_hidden_icons_hidden_and_preserves_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
project = Path(temporary)
|
||||
(project / 'svg_output').mkdir()
|
||||
icon_dir = project / 'icons' / 'imported'
|
||||
icon_dir.mkdir(parents=True)
|
||||
(icon_dir / 'marker.svg').write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
|
||||
'<path d="M2 2H22V22H2Z"/></svg>', encoding='utf-8',
|
||||
)
|
||||
(project / 'svg_output' / '01.svg').write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">'
|
||||
'<use id="VISIBLE_MARK" data-icon="imported/marker" '
|
||||
'data-pptx-role="decoration" data-edit-annotation="A & B" '
|
||||
'x="20" y="20" width="40" height="40" fill="#123456"/>'
|
||||
'<use id="DISPLAY_MARK" data-icon="imported/marker" '
|
||||
'x="100" y="20" width="40" height="40" display="none"/>'
|
||||
'<use id="STYLE_MARK" data-icon="imported/marker" '
|
||||
'x="200" y="20" width="40" height="40" style="display:none"/>'
|
||||
'<use id="OPACITY_MARK" data-icon="imported/marker" '
|
||||
'x="300" y="20" width="40" height="40" opacity="0"/>'
|
||||
'</svg>', encoding='utf-8',
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS_DIR / 'finalize_svg.py'), str(project)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
root = ET.parse(project / 'svg_final' / '01.svg').getroot()
|
||||
elements = {elem.get('id'): elem for elem in root.iter() if elem.get('id')}
|
||||
self.assertEqual(elements['VISIBLE_MARK'].get('data-pptx-role'), 'decoration')
|
||||
self.assertEqual(elements['VISIBLE_MARK'].get('data-edit-annotation'), 'A & B')
|
||||
self.assertEqual(elements['DISPLAY_MARK'].get('display'), 'none')
|
||||
self.assertEqual(elements['STYLE_MARK'].get('style'), 'display:none')
|
||||
self.assertEqual(elements['OPACITY_MARK'].get('opacity'), '0')
|
||||
|
||||
from svg_editor.server import create_app
|
||||
|
||||
client = create_app(str(project), idle_timeout=0).test_client()
|
||||
preview = ET.fromstring(client.get('/api/slide/01.svg').get_json()['content'])
|
||||
icon = next(elem for elem in preview.iter() if elem.get('id') == 'VISIBLE_MARK')
|
||||
self.assertEqual(icon.get('data-icon'), 'imported/marker')
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for source gradients and small native arc preservation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
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))
|
||||
|
||||
from pptx_gradients import native_gradient_metadata, preserved_native_gradient_xml
|
||||
from svg_authoring_view import project_svg_batch
|
||||
from svg_quality.checker import SVGQualityChecker
|
||||
from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes, SvgNativeConversionError
|
||||
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
NS = {"a": DML_NS, "p": PML_NS}
|
||||
|
||||
|
||||
class ExportDegenerateSweepTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.source = self.root / "source.svg"
|
||||
|
||||
def _gradient_shape(self, *, vertical: bool = False, transparent: bool = False) -> ET.Element:
|
||||
root = ET.fromstring(
|
||||
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720" lang="en-US" '
|
||||
'data-pptx-page-role="content"><defs/></svg>'
|
||||
)
|
||||
opacity = "0" if transparent else "1"
|
||||
gradient = ET.fromstring(
|
||||
f'<linearGradient xmlns="{SVG_NS}" id="stroke" x1="100" y1="100" '
|
||||
'x2="400" y2="100" gradientUnits="userSpaceOnUse">'
|
||||
f'<stop offset="0" stop-color="#3060C0" stop-opacity="{opacity}"/>'
|
||||
f'<stop offset="1" stop-color="#D7263D" stop-opacity="{opacity}"/>'
|
||||
'</linearGradient>'
|
||||
)
|
||||
native = ET.fromstring(
|
||||
f'<a:gradFill xmlns:a="{DML_NS}"><a:gsLst>'
|
||||
f'<a:gs pos="0"><a:srgbClr val="3060C0"><a:alpha val="{0 if transparent else 100000}"/>'
|
||||
'</a:srgbClr></a:gs><a:gs pos="100000"><a:srgbClr val="D7263D">'
|
||||
f'<a:alpha val="{0 if transparent else 100000}"/></a:srgbClr></a:gs>'
|
||||
'</a:gsLst><a:lin ang="0" scaled="1"/></a:gradFill>'
|
||||
)
|
||||
gradient.attrib.update(native_gradient_metadata(native, gradient))
|
||||
root[0].append(gradient)
|
||||
frame = "100 100 0 300" if vertical else "100 100 300 0"
|
||||
path = "M 100 100 L 100 400" if vertical else "M 100 100 L 400 100"
|
||||
group = ET.SubElement(root, f"{{{SVG_NS}}}g", {
|
||||
"id": "source-shape", "data-pptx-object": "shape", "data-pptx-frame": frame,
|
||||
"data-pptx-geometry-kind": "custom", "data-pptx-semantic-object": "shape",
|
||||
"data-pptx-bounds": "98 98 4 304" if vertical else "98 98 304 4",
|
||||
})
|
||||
geometry = (
|
||||
f'<a:custGeom xmlns:a="{DML_NS}"><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>'
|
||||
'<a:rect l="l" t="t" r="r" b="b"/><a:pathLst><a:path w="300" h="100" fill="none">'
|
||||
'<a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="300" y="100"/>'
|
||||
'</a:lnTo></a:path></a:pathLst></a:custGeom>'
|
||||
)
|
||||
ET.SubElement(group, f"{{{SVG_NS}}}path", {
|
||||
"d": path, "data-pptx-part": "geometry", "stroke": "url(#stroke)",
|
||||
"stroke-width": "4", "fill": "none",
|
||||
"data-pptx-custgeom": base64.b64encode(geometry.encode()).decode(),
|
||||
"data-pptx-geometry-sha256": hashlib.sha256(path.encode()).hexdigest(),
|
||||
})
|
||||
self.source.write_bytes(ET.tostring(root))
|
||||
return root
|
||||
|
||||
def test_imported_gradient_zero_axis_survives_projection_and_export(self) -> None:
|
||||
for vertical in (False, True):
|
||||
with self.subTest(vertical=vertical):
|
||||
root = self._gradient_shape(vertical=vertical)
|
||||
output = self.root / ("vertical" if vertical else "horizontal")
|
||||
projected = output / self.source.name
|
||||
project_svg_batch(
|
||||
[(self.source, projected)], self.root, output,
|
||||
force=False, projection_kind="layered",
|
||||
)
|
||||
tree = ET.parse(projected).getroot()
|
||||
gradient = tree.find(f"{{{SVG_NS}}}defs/{{{SVG_NS}}}linearGradient")
|
||||
self.assertIsNotNone(preserved_native_gradient_xml(gradient))
|
||||
path = tree.find(f".//{{{SVG_NS}}}path")
|
||||
self.assertIn("data-pptx-custgeom", path.attrib)
|
||||
checked = SVGQualityChecker(quick_generate=True).check_file(str(projected))
|
||||
self.assertEqual(checked["errors"], [])
|
||||
xml, *_ = convert_svg_to_slide_shapes(projected, resource_root=self.root)
|
||||
native = ET.fromstring(xml)
|
||||
extent = native.find(".//p:sp/p:spPr/a:xfrm/a:ext", NS)
|
||||
self.assertEqual(extent.attrib, {
|
||||
"cx": "0" if vertical else "2857500",
|
||||
"cy": "2857500" if vertical else "0",
|
||||
})
|
||||
exported_gradient = native.find(".//a:ln/a:gradFill", NS)
|
||||
expected = ET.fromstring(preserved_native_gradient_xml(root[0][0]))
|
||||
self.assertEqual(ET.tostring(exported_gradient), ET.tostring(expected))
|
||||
custom = native.find(".//a:custGeom", NS)
|
||||
self.assertEqual(len(custom.findall("a:pathLst/a:path", NS)), 1)
|
||||
|
||||
def test_transparent_native_gradient_does_not_admit_zero_axis_shape(self) -> None:
|
||||
self._gradient_shape(transparent=True)
|
||||
with self.assertRaisesRegex(SvgNativeConversionError, "must be positive"):
|
||||
convert_svg_to_slide_shapes(self.source, resource_root=self.root)
|
||||
|
||||
def test_authored_gradient_does_not_gain_source_frame_exception(self) -> None:
|
||||
root = self._gradient_shape()
|
||||
gradient = root[0][0]
|
||||
for name in list(gradient.attrib):
|
||||
if name.startswith("data-pptx-gradient"):
|
||||
del gradient.attrib[name]
|
||||
self.source.write_bytes(ET.tostring(root))
|
||||
with self.assertRaisesRegex(SvgNativeConversionError, "gradientUnits"):
|
||||
convert_svg_to_slide_shapes(self.source, resource_root=self.root)
|
||||
gradient.attrib.update({"gradientUnits": "objectBoundingBox", "x1": "0", "y1": "0", "x2": "1", "y2": "0"})
|
||||
self.source.write_bytes(ET.tostring(root))
|
||||
with self.assertRaisesRegex(SvgNativeConversionError, "zero intrinsic height"):
|
||||
convert_svg_to_slide_shapes(self.source, resource_root=self.root)
|
||||
|
||||
def test_small_thick_circle_arc_keeps_representable_geometry(self) -> None:
|
||||
self.source.write_text(
|
||||
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720" lang="en-US" data-pptx-page-role="content">'
|
||||
'<g id="arc" data-pptx-bounds="530 250 220 220"><circle cx="640" cy="360" r="100" '
|
||||
'fill="none" stroke="#3060C0" stroke-width="20" stroke-dasharray="0.1 628.218530718"/>'
|
||||
'</g></svg>', encoding="utf-8",
|
||||
)
|
||||
checked = SVGQualityChecker(quick_generate=True).check_file(str(self.source))
|
||||
self.assertEqual(checked["errors"], [])
|
||||
xml, *_ = convert_svg_to_slide_shapes(self.source, resource_root=self.root)
|
||||
native = ET.fromstring(xml)
|
||||
self.assertEqual(len(native.findall(".//p:sp", NS)), 1)
|
||||
extent = native.find(".//p:sp/p:spPr/a:xfrm/a:ext", NS)
|
||||
self.assertGreater(int(extent.get("cx")), 0)
|
||||
self.assertLess(int(extent.get("cx")), 4763)
|
||||
self.assertGreater(int(extent.get("cy")), 0)
|
||||
self.assertIsNotNone(native.find(".//a:custGeom/a:pathLst/a:path", NS))
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify text visibility, links, and identity through finalization's public CLI."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
|
||||
|
||||
class FinalizeContentSurvivalTests(unittest.TestCase):
|
||||
def _finalize(self, content: str) -> ET.Element:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
project = Path(temporary.name)
|
||||
(project / 'svg_output').mkdir()
|
||||
(project / 'svg_output' / '01.svg').write_text(
|
||||
f'<svg xmlns="{SVG_NS}" viewBox="0 0 1280 720">{content}</svg>',
|
||||
encoding='utf-8',
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS_DIR / 'finalize_svg.py'), str(project)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
return ET.parse(project / 'svg_final' / '01.svg').getroot()
|
||||
|
||||
def test_split_rows_retain_identity_without_duplicate_ids(self) -> None:
|
||||
root = self._finalize(
|
||||
'<text id="name" data-pptx-shape-name="SELECTION_MARK" '
|
||||
'data-pptx-role="decoration" x="10" y="20" font-size="24">'
|
||||
'<tspan id="RUN_ID_MARK" x="10" dy="20">ONE_MARK</tspan>'
|
||||
'<tspan x="10" dy="30">TWO_MARK</tspan></text>'
|
||||
'<rect id="name-line-2" width="20" height="20"/>',
|
||||
)
|
||||
ids = [elem.get('id') for elem in root.iter() if elem.get('id')]
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
self.assertIn('name', ids)
|
||||
self.assertIn('RUN_ID_MARK', ids)
|
||||
rows = root.findall(f'{{{SVG_NS}}}text')
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0].get('data-pptx-shape-name'), 'SELECTION_MARK')
|
||||
self.assertEqual(rows[1].get('data-pptx-role'), 'decoration')
|
||||
self.assertEqual(''.join(root.itertext()), 'ONE_MARKTWO_MARK')
|
||||
|
||||
def test_direct_inline_anchor_and_tail_survive_split(self) -> None:
|
||||
root = self._finalize(
|
||||
'<text x="10" y="20" font-size="24">LEAD_MARK'
|
||||
'<a href="https://example.com/LINK_MARK">ANCHOR_MARK</a>TAIL_MARK'
|
||||
'<tspan x="10" dy="30">NEXT_MARK</tspan></text>',
|
||||
)
|
||||
self.assertEqual(''.join(root.itertext()), 'LEAD_MARKANCHOR_MARKTAIL_MARKNEXT_MARK')
|
||||
anchor = root.find(f'.//{{{SVG_NS}}}a')
|
||||
self.assertIsNotNone(anchor)
|
||||
self.assertEqual(anchor.get('href'), 'https://example.com/LINK_MARK')
|
||||
|
||||
def test_parent_and_row_visibility_survive_split(self) -> None:
|
||||
root = self._finalize(
|
||||
'<text display="none" x="10" y="20" font-size="24">'
|
||||
'<tspan x="10" dy="20">HIDDEN_PARENT_MARK</tspan></text>'
|
||||
'<text x="10" y="80" font-size="24">'
|
||||
'<tspan visibility="hidden" x="10" dy="20">HIDDEN_ROW_MARK</tspan>'
|
||||
'<tspan x="10" dy="30">VISIBLE_MARK</tspan></text>',
|
||||
)
|
||||
rows = {''.join(elem.itertext()): elem for elem in root.findall(f'{{{SVG_NS}}}text')}
|
||||
self.assertEqual(rows['HIDDEN_PARENT_MARK'].get('display'), 'none')
|
||||
self.assertEqual(rows['HIDDEN_ROW_MARK'].get('visibility'), 'hidden')
|
||||
self.assertIsNone(rows['VISIBLE_MARK'].get('visibility'))
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
"""Regress theme line inheritance and diagnosed degenerate source objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx_to_svg.converter import convert_pptx_to_svg
|
||||
|
||||
|
||||
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
SVG = "http://www.w3.org/2000/svg"
|
||||
NS = {"a": A, "p": P, "s": SVG}
|
||||
PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aG1cAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
def _frame(width: int = 300, height: int = 100, *, graphic: bool = False) -> str:
|
||||
prefix = "p" if graphic else "a"
|
||||
return (
|
||||
f'<{prefix}:xfrm><a:off x="952500" y="952500"/>'
|
||||
f'<a:ext cx="{width * 9525}" cy="{height * 9525}"/></{prefix}:xfrm>'
|
||||
)
|
||||
|
||||
|
||||
def _shape(height: int, local_line: str = "") -> str:
|
||||
return (
|
||||
'<p:sp><p:nvSpPr><p:cNvPr id="2" name="Source"/>'
|
||||
'<p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>'
|
||||
+ _frame(height=height)
|
||||
+ '<a:prstGeom prst="rect"/><a:noFill/>' + local_line
|
||||
+ '</p:spPr><p:style><a:lnRef idx="1"><a:srgbClr val="123456"/>'
|
||||
'</a:lnRef></p:style></p:sp>'
|
||||
)
|
||||
|
||||
|
||||
def _picture(fill: str, height: int = 100) -> str:
|
||||
return (
|
||||
'<p:pic><p:nvPicPr><p:cNvPr id="2" name="Picture"/>'
|
||||
'<p:cNvPicPr/><p:nvPr/></p:nvPicPr>' + fill
|
||||
+ '<p:spPr>' + _frame(height=height)
|
||||
+ '<a:prstGeom prst="rect"/></p:spPr></p:pic>'
|
||||
)
|
||||
|
||||
|
||||
def _graphic(content: str, *, height: int = 100, kind: str = "table") -> str:
|
||||
uri = (
|
||||
"http://schemas.openxmlformats.org/presentationml/2006/ole"
|
||||
if kind == "ole" else f"http://schemas.openxmlformats.org/drawingml/2006/{kind}"
|
||||
)
|
||||
return (
|
||||
'<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="2" name="Graphic"/>'
|
||||
'<p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>'
|
||||
+ _frame(height=height, graphic=True)
|
||||
+ f'<a:graphic><a:graphicData uri="{uri}">{content}'
|
||||
'</a:graphicData></a:graphic></p:graphicFrame>'
|
||||
)
|
||||
|
||||
|
||||
def _table(*, grid: str = '<a:gridCol w="2857500"/>', extra_cell: bool = False) -> str:
|
||||
cell = (
|
||||
'<a:tc><a:txBody><a:bodyPr/><a:lstStyle/><a:p>'
|
||||
'<a:r><a:rPr sz="1200"/><a:t>Kept</a:t></a:r>'
|
||||
'</a:p></a:txBody><a:tcPr/></a:tc>'
|
||||
)
|
||||
cells = cell + (cell.replace("Kept", "Outside grid") if extra_cell else "")
|
||||
return f'<a:tbl><a:tblPr/><a:tblGrid>{grid}</a:tblGrid><a:tr h="952500">{cells}</a:tr></a:tbl>'
|
||||
|
||||
|
||||
class ImportDegenerateSweepTests(unittest.TestCase):
|
||||
def _import(
|
||||
self, content: str, *, theme_line: str | None = None, layout_content: str = "",
|
||||
) -> tuple[ET.Element, dict]:
|
||||
def relationships(items: list[tuple[str, str, str]]) -> str:
|
||||
return (
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
+ ''.join(f'<Relationship Id="{key}" Type="{R}/{kind}" Target="{target}"/>'
|
||||
for key, kind, target in items)
|
||||
+ '</Relationships>'
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "source.pptx"
|
||||
with zipfile.ZipFile(source, "w") as package:
|
||||
package.writestr("ppt/presentation.xml", (
|
||||
f'<p:presentation xmlns:p="{P}" xmlns:r="{R}"><p:sldIdLst>'
|
||||
'<p:sldId id="256" r:id="rId1"/></p:sldIdLst>'
|
||||
'<p:sldSz cx="12192000" cy="6858000"/></p:presentation>'
|
||||
))
|
||||
package.writestr("ppt/_rels/presentation.xml.rels", relationships([
|
||||
("rId1", "slide", "slides/slide1.xml"),
|
||||
]))
|
||||
package.writestr("ppt/slides/slide1.xml", (
|
||||
f'<p:sld xmlns:p="{P}" xmlns:a="{A}" xmlns:r="{R}">'
|
||||
f'<p:cSld><p:spTree>{content}</p:spTree></p:cSld></p:sld>'
|
||||
))
|
||||
package.writestr("ppt/slides/_rels/slide1.xml.rels", relationships([
|
||||
("rIdLayout", "slideLayout", "../slideLayouts/slideLayout1.xml"),
|
||||
("rIdImage", "image", "../media/source.png"),
|
||||
]))
|
||||
package.writestr("ppt/media/source.png", PNG)
|
||||
package.writestr("ppt/slideLayouts/slideLayout1.xml", (
|
||||
f'<p:sldLayout xmlns:p="{P}" xmlns:a="{A}"><p:cSld><p:spTree>'
|
||||
f'{layout_content}</p:spTree></p:cSld></p:sldLayout>'
|
||||
))
|
||||
package.writestr("ppt/slideLayouts/_rels/slideLayout1.xml.rels", relationships([
|
||||
("rIdMaster", "slideMaster", "../slideMasters/slideMaster1.xml"),
|
||||
]))
|
||||
package.writestr("ppt/slideMasters/slideMaster1.xml", (
|
||||
f'<p:sldMaster xmlns:p="{P}"><p:cSld><p:spTree/></p:cSld>'
|
||||
'<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" '
|
||||
'accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" '
|
||||
'accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:sldMaster>'
|
||||
))
|
||||
if theme_line is not None:
|
||||
package.writestr("ppt/slideMasters/_rels/slideMaster1.xml.rels", relationships([
|
||||
("rIdTheme", "theme", "../theme/theme1.xml"),
|
||||
]))
|
||||
colors = ''.join(
|
||||
f'<a:{name}><a:srgbClr val="123456"/></a:{name}>'
|
||||
for name in ('dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2',
|
||||
'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink')
|
||||
)
|
||||
package.writestr("ppt/theme/theme1.xml", (
|
||||
f'<a:theme xmlns:a="{A}"><a:themeElements><a:clrScheme name="Test">'
|
||||
f'{colors}</a:clrScheme><a:fmtScheme><a:lnStyleLst>{theme_line}'
|
||||
'</a:lnStyleLst></a:fmtScheme></a:themeElements></a:theme>'
|
||||
))
|
||||
result = convert_pptx_to_svg(source, root / "output")
|
||||
report = json.loads((root / "output/validation/conversion-report.json").read_text())
|
||||
return ET.fromstring(result.slides[0].svg), report
|
||||
|
||||
def _warning(self, report: dict, code: str) -> dict:
|
||||
found = [item for item in report["diagnostics"] if item["code"] == code]
|
||||
self.assertEqual(len(found), 1, report)
|
||||
self.assertEqual(found[0]["shape_id"], "2")
|
||||
self.assertEqual(found[0]["part_path"], "ppt/slides/slide1.xml")
|
||||
return found[0]
|
||||
|
||||
def test_theme_line_paint_and_width_are_resolved_for_positive_and_zero_axes(self) -> None:
|
||||
for height in (100, 0):
|
||||
with self.subTest(height=height):
|
||||
svg, _ = self._import(_shape(height), theme_line=(
|
||||
'<a:ln w="76200"><a:solidFill><a:schemeClr val="phClr"/>'
|
||||
'</a:solidFill><a:prstDash val="dash"/></a:ln>'
|
||||
))
|
||||
path = svg.find('.//s:path', NS)
|
||||
self.assertEqual(path.get("stroke"), "#123456")
|
||||
self.assertEqual(path.get("stroke-width"), "8")
|
||||
self.assertEqual(path.get("stroke-dasharray"), "4 4")
|
||||
self.assertEqual(path.get("data-pptx-frame"), f"100 100 300 {height}")
|
||||
|
||||
def test_theme_no_fill_does_not_invent_an_outline(self) -> None:
|
||||
for height in (100, 0):
|
||||
with self.subTest(height=height):
|
||||
svg, report = self._import(_shape(height), theme_line='<a:ln w="76200"><a:noFill/></a:ln>')
|
||||
path = svg.find('.//s:path', NS)
|
||||
if height:
|
||||
self.assertEqual(path.get("stroke"), "none")
|
||||
else:
|
||||
self.assertIsNone(path)
|
||||
self.assertEqual(report["diagnostics"], [])
|
||||
|
||||
def test_local_paint_overrides_theme_transparency_without_inheriting_its_alpha(self) -> None:
|
||||
local = '<a:ln w="19050"><a:solidFill><a:srgbClr val="ABCDEF"/></a:solidFill></a:ln>'
|
||||
svg, _ = self._import(_shape(100, local), theme_line=(
|
||||
'<a:ln w="76200"><a:solidFill><a:schemeClr val="phClr"><a:alpha val="0"/>'
|
||||
'</a:schemeClr></a:solidFill></a:ln>'
|
||||
))
|
||||
path = svg.find('.//s:path', NS)
|
||||
self.assertEqual(path.get("stroke"), "#ABCDEF")
|
||||
self.assertEqual(path.get("stroke-width"), "2")
|
||||
self.assertIsNone(path.get("stroke-opacity"))
|
||||
|
||||
def test_theme_gradient_stops_keep_placeholder_color(self) -> None:
|
||||
svg, _ = self._import(_shape(0), theme_line=(
|
||||
'<a:ln w="76200"><a:gradFill><a:gsLst>'
|
||||
'<a:gs pos="0"><a:schemeClr val="phClr"/></a:gs>'
|
||||
'<a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs>'
|
||||
'</a:gsLst><a:lin ang="0" scaled="1"/></a:gradFill></a:ln>'
|
||||
))
|
||||
path = svg.find('.//s:path', NS)
|
||||
self.assertTrue(path.get("stroke").startswith("url(#"))
|
||||
gradient = svg.find('.//s:linearGradient', NS)
|
||||
self.assertEqual(gradient.get("gradientUnits"), "userSpaceOnUse")
|
||||
self.assertEqual([stop.get("stop-color") for stop in gradient], ["#123456", "#FFFFFF"])
|
||||
|
||||
def test_inherited_cap_and_local_override_keep_source_line_ends(self) -> None:
|
||||
theme = '<a:ln w="9525" cap="flat"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>'
|
||||
for cap in (None, "flat", "rnd"):
|
||||
with self.subTest(cap=cap):
|
||||
attributes = f' cap="{cap}"' if cap else ''
|
||||
local = (
|
||||
f'<a:ln w="19050"{attributes}><a:solidFill>'
|
||||
'<a:srgbClr val="ABCDEF"/></a:solidFill></a:ln>'
|
||||
)
|
||||
svg, _ = self._import(_shape(100, local), theme_line=theme)
|
||||
self.assertEqual(svg.find('.//s:path', NS).get("stroke-linecap"),
|
||||
{None: "butt", "flat": "butt", "rnd": "round"}[cap])
|
||||
|
||||
def test_missing_picture_sources_report_omission(self) -> None:
|
||||
for fill in ('', '<p:blipFill/>', '<p:blipFill><a:blip/></p:blipFill>'):
|
||||
with self.subTest(fill=fill):
|
||||
svg, report = self._import(_picture(fill))
|
||||
self.assertIsNone(svg.find('.//s:image', NS))
|
||||
self._warning(report, "picture-omitted")
|
||||
|
||||
def test_nonpositive_picture_keeps_source_frame_and_reports_it(self) -> None:
|
||||
svg, report = self._import(_picture(
|
||||
'<p:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>',
|
||||
height=0,
|
||||
))
|
||||
image = svg.find('.//s:image', NS)
|
||||
self.assertEqual(image.get("height"), "0")
|
||||
self.assertEqual(image.get("width"), "300")
|
||||
self._warning(report, "picture-degenerate-frame")
|
||||
|
||||
def test_invalid_picture_crop_reports_the_existing_full_image_fallback(self) -> None:
|
||||
for crop in ("100000", "150000", "nan", "invalid"):
|
||||
with self.subTest(crop=crop):
|
||||
svg, report = self._import(_picture(
|
||||
'<p:blipFill><a:blip r:embed="rIdImage"/>'
|
||||
f'<a:srcRect l="{crop}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>',
|
||||
))
|
||||
self.assertIsNotNone(svg.find('.//s:image', NS))
|
||||
self._warning(report, "picture-crop-omitted")
|
||||
|
||||
def test_unrecoverable_table_grid_and_truncated_cells_are_diagnosed(self) -> None:
|
||||
for table, code in (
|
||||
(_table(grid=""), "table-geometry-omitted"),
|
||||
(_table(extra_cell=True), "table-geometry-unsupported"),
|
||||
(_table(grid='<a:gridCol w="0"/>'), "table-geometry-unsupported"),
|
||||
('<a:tbl><a:tblGrid><a:gridCol w="2857500"/></a:tblGrid></a:tbl>', "table-geometry-omitted"),
|
||||
):
|
||||
with self.subTest(table=table):
|
||||
_, report = self._import(_graphic(table))
|
||||
self._warning(report, code)
|
||||
|
||||
def test_nonpositive_graphic_frames_report_without_inventing_dimensions(self) -> None:
|
||||
for kind, content in (("table", _table()), ("chart", ""), ("ole", "")):
|
||||
with self.subTest(kind=kind):
|
||||
svg, report = self._import(_graphic(content, height=0, kind=kind))
|
||||
self._warning(report, "graphic-degenerate-frame")
|
||||
group = svg.find('s:g', NS)
|
||||
self.assertEqual(group.get("data-pptx-frame"), "100 100 300 0")
|
||||
|
||||
def test_unresolved_theme_line_does_not_invent_a_stroke(self) -> None:
|
||||
svg, report = self._import(_shape(100))
|
||||
self._warning(report, "theme-line-reference-omitted")
|
||||
self.assertIsNone(svg.find('.//s:path', NS).get("stroke"))
|
||||
|
||||
def test_missing_graphic_preview_is_reported(self) -> None:
|
||||
_, report = self._import(_graphic("", kind="ole"))
|
||||
self._warning(report, "graphic-preview-unavailable")
|
||||
|
||||
def test_nonpositive_formula_frame_preserves_content_and_reports_it(self) -> None:
|
||||
math = (
|
||||
'<a14:m xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" '
|
||||
'xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">'
|
||||
'<m:oMathPara><m:oMath><m:r><a:rPr sz="1800">'
|
||||
'<a:solidFill><a:srgbClr val="123456"/></a:solidFill></a:rPr>'
|
||||
'<m:t>x</m:t></m:r></m:oMath></m:oMathPara></a14:m>'
|
||||
)
|
||||
shape = (
|
||||
'<p:sp><p:nvSpPr><p:cNvPr id="2" name="Formula"/><p:cNvSpPr/><p:nvPr/>'
|
||||
'</p:nvSpPr><p:spPr>' + _frame(height=0)
|
||||
+ '<a:prstGeom prst="rect"/><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr>'
|
||||
'<p:txBody><a:bodyPr/><a:lstStyle/><a:p>' + math + '</a:p></p:txBody></p:sp>'
|
||||
)
|
||||
svg, report = self._import(shape)
|
||||
self._warning(report, "formula-degenerate-frame")
|
||||
group = svg.find('s:g', NS)
|
||||
self.assertEqual(group.get("data-pptx-frame"), "100 100 300 0")
|
||||
self.assertEqual(group.get("data-pptx-replace-with"), "formula")
|
||||
self.assertIn("x", ''.join(group.itertext()))
|
||||
|
||||
def test_placeholder_inherits_omitted_and_explicit_zero_extents_with_diagnostic(self) -> None:
|
||||
shape = (
|
||||
'<p:sp><p:nvSpPr><p:cNvPr id="2" name="Placeholder"/><p:cNvSpPr/>'
|
||||
'<p:nvPr><p:ph type="body" idx="42"/></p:nvPr></p:nvSpPr><p:spPr>'
|
||||
+ _frame() + '<a:prstGeom prst="rect"/><a:noFill/></p:spPr>'
|
||||
'<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Text</a:t></a:r></a:p>'
|
||||
'</p:txBody></p:sp>'
|
||||
)
|
||||
for explicit in (False, True):
|
||||
with self.subTest(explicit=explicit):
|
||||
extent = '<a:ext cx="2857500" cy="952500"/>'
|
||||
source = shape.replace(extent, '<a:ext cx="0" cy="0"/>' if explicit else '')
|
||||
svg, report = self._import(source, layout_content=shape)
|
||||
group = svg.find('s:g', NS)
|
||||
self.assertEqual(group.get("data-pptx-frame"), "100 100 300 100")
|
||||
self.assertTrue(svg.findall('.//s:text', NS))
|
||||
if explicit:
|
||||
warning = self._warning(report, "degenerate-text-frame")
|
||||
self.assertIn("100 100 0 0", warning["message"])
|
||||
else:
|
||||
self.assertFalse(any(item["code"] == "degenerate-text-frame"
|
||||
for item in report["diagnostics"]))
|
||||
+207
@@ -3,6 +3,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
@@ -23,6 +25,7 @@ from pptx_shapes.formula import OOXML_COORDINATE_MAX # noqa: E402
|
||||
from animation_config import main as animation_config_main # noqa: E402
|
||||
from pptx_gradients import native_gradient_metadata, preserved_native_gradient_xml # noqa: E402
|
||||
from svg_finalize.flatten_tspan import flatten_text_with_tspans # noqa: E402
|
||||
from svg_authoring_view import project_svg_batch # noqa: E402
|
||||
from svg_quality.checker import SVGQualityChecker # noqa: E402
|
||||
from svg_to_pptx.animation_config import ( # noqa: E402
|
||||
build_group_listing,
|
||||
@@ -35,6 +38,7 @@ from svg_to_pptx.drawingml.converter import ( # noqa: E402
|
||||
convert_svg_to_slide_shapes,
|
||||
)
|
||||
from svg_to_pptx.drawingml.styles import build_gradient_fill # noqa: E402
|
||||
from svg_to_pptx.drawingml.elements import validate_preset_geometry_metadata # noqa: E402
|
||||
from svg_to_pptx.pptx_package.discovery import find_notes_files # noqa: E402
|
||||
|
||||
|
||||
@@ -69,6 +73,209 @@ class NativeExportGuardTests(unittest.TestCase):
|
||||
def _check(self) -> dict:
|
||||
return SVGQualityChecker(quick_generate=True).check_file(str(self.svg_path))
|
||||
|
||||
def _custom_connector(self, frame: str, path: str, *, transform: str = '') -> str:
|
||||
custom = (
|
||||
f'<a:custGeom xmlns:a="{NS["a"]}"><a:avLst/>'
|
||||
'<a:gdLst><a:gd name="middle" fmla="*/ w 1 2"/></a:gdLst>'
|
||||
'<a:ahLst/><a:cxnLst><a:cxn ang="0"><a:pos x="r" y="t"/>'
|
||||
'</a:cxn></a:cxnLst><a:rect l="l" t="t" r="r" b="b"/>'
|
||||
'<a:pathLst><a:path w="300" h="100" fill="none">'
|
||||
'<a:moveTo><a:pt x="0" y="0"/></a:moveTo>'
|
||||
'<a:lnTo><a:pt x="300" y="100"/></a:lnTo></a:path>'
|
||||
'<a:path w="300" h="100" fill="none">'
|
||||
'<a:moveTo><a:pt x="75" y="25"/></a:moveTo>'
|
||||
'<a:lnTo><a:pt x="225" y="75"/></a:lnTo></a:path>'
|
||||
'</a:pathLst></a:custGeom>'
|
||||
)
|
||||
encoded = base64.b64encode(custom.encode('utf-8')).decode('ascii')
|
||||
digest = hashlib.sha256(path.encode('utf-8')).hexdigest()
|
||||
self._svg(
|
||||
'<g id="custom" data-pptx-semantic-object="shape" '
|
||||
'data-pptx-object="connector" data-pptx-geometry-kind="custom" '
|
||||
f'data-pptx-frame="{frame}" {transform}>'
|
||||
f'<path d="{path}" data-pptx-part="geometry" '
|
||||
f'data-pptx-custgeom="{encoded}" data-pptx-geometry-sha256="{digest}" '
|
||||
'fill="none" stroke="#3060C0" stroke-width="2"/></g>'
|
||||
)
|
||||
return custom
|
||||
|
||||
def test_semantic_custom_connector_preserves_source_geometry(self) -> None:
|
||||
for frame, path, transform in (
|
||||
('120 180 300 0', 'M 120 180 L 420 180 M 195 180 L 345 180', ''),
|
||||
('120 180 0 100', 'M 120 180 L 120 280 M 120 205 L 120 255', ''),
|
||||
('120 180 300 100', 'M 120 180 L 420 280 M 195 205 L 345 255', ''),
|
||||
('120 180 300 0', 'M 120 180 L 420 180 M 195 180 L 345 180',
|
||||
'transform="rotate(45 270 180)"'),
|
||||
('120 180 300 0', 'M 120 180 L 420 180 M 195 180 L 345 180',
|
||||
'transform="translate(540 0) scale(-1 1)"'),
|
||||
):
|
||||
with self.subTest(frame=frame, transform=transform):
|
||||
custom = self._custom_connector(frame, path, transform=transform)
|
||||
xml, *_ = convert_svg_to_slide_shapes(self.svg_path, resource_root=self.root)
|
||||
self.assertIn(custom, xml)
|
||||
slide = ET.fromstring(xml)
|
||||
self.assertEqual(len(slide.findall('.//p:cxnSp', NS)), 1)
|
||||
ext = slide.find('.//p:cxnSp/p:spPr/a:xfrm/a:ext', NS)
|
||||
self.assertEqual(int(ext.get('cx')), int(float(frame.split()[2]) * 9525))
|
||||
self.assertEqual(int(ext.get('cy')), int(float(frame.split()[3]) * 9525))
|
||||
|
||||
def test_semantic_custom_connector_recompiles_an_edited_path(self) -> None:
|
||||
path = 'M 120 180 L 420 280 M 195 205 L 345 255'
|
||||
custom = self._custom_connector('120 180 300 100', path)
|
||||
self.svg_path.write_text(
|
||||
self.svg_path.read_text(encoding='utf-8').replace(path, 'M 120 180 L 300 240'),
|
||||
encoding='utf-8',
|
||||
)
|
||||
xml, *_ = convert_svg_to_slide_shapes(self.svg_path, resource_root=self.root)
|
||||
self.assertNotIn(custom, xml)
|
||||
slide = ET.fromstring(xml)
|
||||
self.assertEqual(len(slide.findall('.//p:cxnSp', NS)), 1)
|
||||
self.assertEqual(len(slide.findall('.//a:custGeom/a:pathLst/a:path', NS)), 1)
|
||||
|
||||
def test_custom_connector_projection_keeps_source_geometry(self) -> None:
|
||||
custom = self._custom_connector('120 180 300 0', 'M 120 180 L 420 180')
|
||||
authoring_dir = self.root / 'authoring'
|
||||
projected = authoring_dir / self.svg_path.name
|
||||
project_svg_batch(
|
||||
[(self.svg_path, projected)], self.root, authoring_dir,
|
||||
force=False, projection_kind='layered',
|
||||
)
|
||||
xml, *_ = convert_svg_to_slide_shapes(projected, resource_root=self.root)
|
||||
self.assertIn(custom, xml)
|
||||
self.assertEqual(len(ET.fromstring(xml).findall('.//p:cxnSp', NS)), 1)
|
||||
|
||||
def test_semantic_custom_connector_without_source_geometry_still_fails(self) -> None:
|
||||
self._svg(
|
||||
'<g data-pptx-semantic-object="shape" data-pptx-object="connector" '
|
||||
'data-pptx-geometry-kind="custom" data-pptx-frame="120 180 300 100">'
|
||||
'<path d="M 120 180 L 420 280" data-pptx-part="geometry" '
|
||||
'stroke="#3060C0" fill="none"/></g>'
|
||||
)
|
||||
with self.assertRaisesRegex(SvgNativeConversionError, 'requires preset or preserved'):
|
||||
self._export()
|
||||
|
||||
def test_stroked_zero_axis_presets_keep_the_native_frame(self) -> None:
|
||||
for preset in ('rect', 'ellipse'):
|
||||
for width, height in ((300, 0), (0, 100)):
|
||||
for transform in ('', 'transform="rotate(45 120 180)"'):
|
||||
with self.subTest(preset=preset, frame=(width, height), transform=transform):
|
||||
self._svg(
|
||||
'<g data-pptx-semantic-object="shape" data-pptx-object="shape" '
|
||||
f'data-pptx-prst="{preset}" data-pptx-frame="120 180 {width} {height}" '
|
||||
f'{transform}><path data-pptx-part="geometry" '
|
||||
f'd="M 120 180 L {120 + width} {180 + height}" '
|
||||
'stroke="#3060C0" stroke-width="2" fill="none"/></g>'
|
||||
)
|
||||
root = ET.parse(self.svg_path).getroot()
|
||||
self.assertEqual(validate_preset_geometry_metadata(root[0]), [])
|
||||
slide = self._export()
|
||||
geometry = slide.find('.//p:sp/p:spPr/a:prstGeom', NS)
|
||||
self.assertEqual(geometry.get('prst'), preset)
|
||||
ext = slide.find('.//p:sp/p:spPr/a:xfrm/a:ext', NS)
|
||||
self.assertEqual(ext.attrib, {'cx': str(width * 9525), 'cy': str(height * 9525)})
|
||||
|
||||
def test_stroked_zero_axis_custom_shape_projection_keeps_source_geometry(self) -> None:
|
||||
for width, height in ((300, 0), (0, 100)):
|
||||
with self.subTest(frame=(width, height)):
|
||||
custom = self._custom_connector(
|
||||
f'120 180 {width} {height}', f'M 120 180 L {120 + width} {180 + height}',
|
||||
)
|
||||
self.svg_path.write_text(
|
||||
self.svg_path.read_text(encoding='utf-8').replace(
|
||||
'data-pptx-object="connector"', 'data-pptx-object="shape"',
|
||||
), encoding='utf-8',
|
||||
)
|
||||
authoring_dir = self.root / f'authoring-{width}-{height}'
|
||||
projected = authoring_dir / self.svg_path.name
|
||||
project_svg_batch(
|
||||
[(self.svg_path, projected)], self.root, authoring_dir,
|
||||
force=False, projection_kind='layered',
|
||||
)
|
||||
xml, *_ = convert_svg_to_slide_shapes(projected, resource_root=self.root)
|
||||
self.assertIn(custom, xml)
|
||||
slide = ET.fromstring(xml)
|
||||
ext = slide.find('.//p:sp/p:spPr/a:xfrm/a:ext', NS)
|
||||
self.assertEqual(ext.attrib, {'cx': str(width * 9525), 'cy': str(height * 9525)})
|
||||
|
||||
def test_zero_axis_native_rect_and_ellipse_primitives_are_not_omitted(self) -> None:
|
||||
for preset in ('rect', 'ellipse'):
|
||||
for width, height in ((300, 0), (0, 100)):
|
||||
with self.subTest(preset=preset, frame=(width, height)):
|
||||
geometry = (
|
||||
f'x="120" y="180" width="{width}" height="{height}"'
|
||||
if preset == 'rect' else
|
||||
f'cx="{120 + width / 2}" cy="{180 + height / 2}" '
|
||||
f'rx="{width / 2}" ry="{height / 2}"'
|
||||
)
|
||||
self._svg(
|
||||
f'<{preset} data-pptx-object="shape" data-pptx-prst="{preset}" '
|
||||
f'data-pptx-frame="120 180 {width} {height}" {geometry} '
|
||||
'stroke="#3060C0" fill="none"/>'
|
||||
)
|
||||
ext = self._export().find('.//p:sp/p:spPr/a:xfrm/a:ext', NS)
|
||||
self.assertIsNotNone(ext)
|
||||
self.assertEqual(ext.attrib, {'cx': str(width * 9525), 'cy': str(height * 9525)})
|
||||
|
||||
def test_stroked_zero_axis_shape_uses_inherited_outline_during_export(self) -> None:
|
||||
self._svg(
|
||||
'<g data-pptx-semantic-object="shape" data-pptx-object="shape" '
|
||||
'data-pptx-prst="rect" data-pptx-frame="120 180 300 0" '
|
||||
'stroke="#3060C0" stroke-width="2">'
|
||||
'<path data-pptx-part="geometry" d="M 120 180 L 420 180" fill="none"/></g>'
|
||||
)
|
||||
group = ET.parse(self.svg_path).getroot()[0]
|
||||
self.assertEqual(validate_preset_geometry_metadata(group), [])
|
||||
ext = self._export().find('.//p:sp/p:spPr/a:xfrm/a:ext', NS)
|
||||
self.assertEqual(ext.attrib, {'cx': str(300 * 9525), 'cy': '0'})
|
||||
|
||||
def test_zero_axis_native_shape_requires_a_visible_stroke(self) -> None:
|
||||
for paint in (
|
||||
'', 'stroke="none"', 'stroke="transparent"',
|
||||
'stroke="#3060C0" stroke-width="0"',
|
||||
'stroke="#3060C0" stroke-opacity="0"',
|
||||
'stroke="#3060C0" opacity="0"',
|
||||
'stroke="#3060C000"',
|
||||
):
|
||||
with self.subTest(paint=paint):
|
||||
self._svg(
|
||||
'<g data-pptx-semantic-object="shape" data-pptx-object="shape" '
|
||||
'data-pptx-prst="rect" data-pptx-frame="120 180 300 0">'
|
||||
'<path data-pptx-part="geometry" d="M 120 180 L 420 180" '
|
||||
f'{paint} fill="none"/></g>'
|
||||
)
|
||||
group = ET.parse(self.svg_path).getroot()[0]
|
||||
self.assertTrue(validate_preset_geometry_metadata(group))
|
||||
with self.assertRaisesRegex(SvgNativeConversionError, 'must be positive'):
|
||||
self._export()
|
||||
|
||||
def test_zero_axis_shape_exception_keeps_invalid_frames_and_text_strict(self) -> None:
|
||||
for frame, tag in (
|
||||
('120 180 0 0', 'path'), ('120 180 -1 100', 'path'),
|
||||
('120 180 300 -1', 'path'), ('120 180 300 0', 'text'),
|
||||
):
|
||||
with self.subTest(frame=frame, tag=tag):
|
||||
element = ET.fromstring(
|
||||
f'<{tag} data-pptx-object="shape" data-pptx-prst="rect" '
|
||||
f'data-pptx-frame="{frame}" stroke="#3060C0"/>'
|
||||
)
|
||||
self.assertTrue(validate_preset_geometry_metadata(element))
|
||||
|
||||
def test_zero_axis_shape_reference_stroke_does_not_prove_visible_outline(self) -> None:
|
||||
for definitions in (
|
||||
'',
|
||||
'<defs><linearGradient id="g"><stop offset="0" stop-color="#3060C0" '
|
||||
'stop-opacity="0"/><stop offset="1" stop-color="#000000" '
|
||||
'stop-opacity="0"/></linearGradient></defs>',
|
||||
):
|
||||
with self.subTest(definitions=bool(definitions)):
|
||||
group = ET.fromstring(
|
||||
'<g data-pptx-object="shape" data-pptx-prst="rect" '
|
||||
'data-pptx-frame="120 180 300 0">'
|
||||
f'{definitions}<path d="M 120 180 L 420 180" '
|
||||
'stroke="url(#g)" stroke-width="2" fill="none"/></g>'
|
||||
)
|
||||
self.assertTrue(validate_preset_geometry_metadata(group))
|
||||
|
||||
@staticmethod
|
||||
def _shape_colors(slide: ET.Element) -> list[str]:
|
||||
return [
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Geometry Import Loss Regressions
|
||||
|
||||
Keep evaluated zero-axis paths and report geometry that cannot be recovered.
|
||||
|
||||
Usage:
|
||||
python3 -m unittest tests.test_pptx_geometry_import_losses
|
||||
|
||||
Examples:
|
||||
Run the module from the scripts directory.
|
||||
|
||||
Dependencies:
|
||||
Standard library and the local PPT Master importer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
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))
|
||||
|
||||
from pptx_to_svg.converter import convert_pptx_to_svg # noqa: E402
|
||||
|
||||
A = 'http://schemas.openxmlformats.org/drawingml/2006/main'
|
||||
P = 'http://schemas.openxmlformats.org/presentationml/2006/main'
|
||||
SVG = 'http://www.w3.org/2000/svg'
|
||||
NS = {'s': SVG, 'a': A}
|
||||
|
||||
|
||||
def _frame(width: int, height: int, *, extent: bool = True) -> str:
|
||||
ext = f'<a:ext cx="{width * 9525}" cy="{height * 9525}"/>' if extent else ''
|
||||
return f'<a:xfrm><a:off x="1143000" y="1714500"/>{ext}</a:xfrm>'
|
||||
|
||||
|
||||
def _shape(geometry: str, frame: str, *, connector: bool = False, hidden: bool = False) -> str:
|
||||
tag, nv_tag = ('cxnSp', 'CxnSp') if connector else ('sp', 'Sp')
|
||||
return (
|
||||
f'<p:{tag}><p:nv{nv_tag}Pr><p:cNvPr id="2" name="Source" hidden="{int(hidden)}"/>'
|
||||
f'<p:cNv{nv_tag}Pr/><p:nvPr/></p:nv{nv_tag}Pr><p:spPr>{frame}{geometry}'
|
||||
'<a:noFill/><a:ln w="19050"><a:solidFill><a:srgbClr val="123456"/>'
|
||||
f'</a:solidFill></a:ln></p:spPr></p:{tag}>'
|
||||
)
|
||||
|
||||
|
||||
class GeometryImportLossTests(unittest.TestCase):
|
||||
def _import(self, content: str) -> tuple[ET.Element, dict]:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / 'source.pptx'
|
||||
with zipfile.ZipFile(source, 'w') as archive:
|
||||
archive.writestr('ppt/presentation.xml', (
|
||||
f'<p:presentation xmlns:p="{P}" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
'<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst>'
|
||||
'<p:sldSz cx="12192000" cy="6858000"/></p:presentation>'
|
||||
))
|
||||
archive.writestr('ppt/_rels/presentation.xml.rels', (
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
|
||||
'Target="slides/slide1.xml"/></Relationships>'
|
||||
))
|
||||
archive.writestr('ppt/slides/slide1.xml', (
|
||||
f'<p:sld xmlns:p="{P}" xmlns:a="{A}"><p:cSld><p:spTree>'
|
||||
f'{content}</p:spTree></p:cSld></p:sld>'
|
||||
))
|
||||
result = convert_pptx_to_svg(source, root / 'output')
|
||||
report = json.loads((root / 'output/validation/conversion-report.json').read_text(encoding='utf-8'))
|
||||
return ET.fromstring(result.slides[0].svg), report
|
||||
|
||||
def _assert_warning(self, report: dict, code: str, shape_id: str = '2') -> None:
|
||||
matching = [item for item in report['diagnostics'] if item['code'] == code and item['shape_id'] == shape_id]
|
||||
# Layered and flat projections must not duplicate the same warning.
|
||||
self.assertEqual(len(matching), 1, report)
|
||||
self.assertEqual(matching[0]['part_path'], 'ppt/slides/slide1.xml')
|
||||
self.assertEqual(matching[0]['slide_index'], 1)
|
||||
self.assertEqual(matching[0]['severity'], 'warning')
|
||||
self.assertEqual(report['summary']['warnings'], len(report['diagnostics']))
|
||||
|
||||
def test_zero_axis_custom_paths_keep_source_payload_and_frame(self) -> None:
|
||||
for width, height, count in ((300, 0, 1), (0, 100, 1), (300, 0, 2)):
|
||||
with self.subTest(width=width, height=height, count=count):
|
||||
path = ('<a:path w="300" h="100" fill="none"><a:moveTo><a:pt x="0" y="0"/>'
|
||||
'</a:moveTo><a:lnTo><a:pt x="300" y="100"/></a:lnTo></a:path>')
|
||||
geometry = f'<a:custGeom><a:pathLst>{path * count}</a:pathLst></a:custGeom>'
|
||||
svg, report = self._import(_shape(geometry, _frame(width, height)))
|
||||
leaf = svg.find('.//s:path', NS)
|
||||
self.assertIsNotNone(leaf)
|
||||
self.assertEqual(leaf.get('data-pptx-frame'), f'120 180 {width} {height}')
|
||||
self.assertEqual(leaf.get('d').count('M '), count)
|
||||
payload = ET.fromstring(base64.b64decode(leaf.get('data-pptx-custgeom')))
|
||||
expected = ET.fromstring(f'<a:custGeom xmlns:a="{A}"><a:pathLst>{path * count}</a:pathLst></a:custGeom>')
|
||||
self.assertEqual(ET.tostring(payload), ET.tostring(expected))
|
||||
self._assert_warning(report, 'geometry-degenerate-frame')
|
||||
|
||||
def test_zero_axis_ordinary_presets_keep_evaluated_outline(self) -> None:
|
||||
for preset in ('rect', 'ellipse'):
|
||||
for width, height in ((300, 0), (0, 100)):
|
||||
with self.subTest(preset=preset, width=width, height=height):
|
||||
svg, report = self._import(_shape(f'<a:prstGeom prst="{preset}"/>', _frame(width, height)))
|
||||
path = svg.find('.//s:path', NS)
|
||||
self.assertIsNotNone(path)
|
||||
self.assertTrue(path.get('d'))
|
||||
self.assertEqual(path.get('stroke'), '#123456')
|
||||
self._assert_warning(report, 'geometry-degenerate-frame')
|
||||
|
||||
def test_unrecoverable_frames_report_omission_without_guessing_geometry(self) -> None:
|
||||
for connector in (False, True):
|
||||
preset = 'straightConnector1' if connector else 'rect'
|
||||
for frame in ('', _frame(0, 0), _frame(0, 0, extent=False)):
|
||||
with self.subTest(connector=connector, frame=frame):
|
||||
svg, report = self._import(_shape(f'<a:prstGeom prst="{preset}"/>', frame, connector=connector))
|
||||
self.assertIsNone(svg.find('.//s:path', NS))
|
||||
self._assert_warning(report, 'geometry-omitted')
|
||||
|
||||
def test_collapsed_group_reports_source_mapping_and_each_omitted_child(self) -> None:
|
||||
child = _shape('<a:prstGeom prst="straightConnector1"/>', _frame(300, 0), connector=True)
|
||||
group = (
|
||||
'<p:grpSp><p:nvGrpSpPr><p:cNvPr id="3" name="Collapsed"/>'
|
||||
'<p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm>'
|
||||
'<a:off x="1143000" y="1714500"/><a:ext cx="0" cy="952500"/>'
|
||||
'<a:chOff x="1143000" y="1714500"/><a:chExt cx="2857500" cy="952500"/>'
|
||||
f'</a:xfrm></p:grpSpPr>{child}</p:grpSp>'
|
||||
)
|
||||
svg, report = self._import(group)
|
||||
self.assertIsNone(svg.find('.//s:path', NS))
|
||||
self._assert_warning(report, 'group-collapsed-axis', '3')
|
||||
self._assert_warning(report, 'geometry-omitted')
|
||||
|
||||
def test_hidden_and_positive_controls_do_not_report_geometry_loss(self) -> None:
|
||||
for frame, hidden in ((_frame(300, 100), False), (_frame(0, 0), True)):
|
||||
with self.subTest(hidden=hidden):
|
||||
_, report = self._import(_shape('<a:prstGeom prst="rect"/>', frame, hidden=hidden))
|
||||
expected = ['author-hidden-omitted'] if hidden else []
|
||||
self.assertEqual([item['code'] for item in report['diagnostics']], expected)
|
||||
|
||||
def test_existing_zero_axis_connector_does_not_report_geometry_loss(self) -> None:
|
||||
svg, report = self._import(_shape(
|
||||
'<a:prstGeom prst="straightConnector1"/>', _frame(300, 0), connector=True,
|
||||
))
|
||||
self.assertIsNotNone(svg.find('.//s:path', NS))
|
||||
self.assertEqual(report['diagnostics'], [])
|
||||
|
||||
def test_unpainted_zero_axis_outline_is_not_resurrected(self) -> None:
|
||||
shape = _shape('<a:prstGeom prst="rect"/>', _frame(300, 0)).replace(
|
||||
'<a:solidFill><a:srgbClr val="123456"/></a:solidFill>', '<a:noFill/>',
|
||||
).replace(
|
||||
'</p:sp>', '<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody></p:sp>',
|
||||
)
|
||||
svg, report = self._import(shape)
|
||||
self.assertIsNone(svg.find('.//s:path', NS))
|
||||
self.assertIsNone(svg.find('.//s:g', NS))
|
||||
self.assertEqual(report['diagnostics'], [])
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report intentional hidden-shape exclusions without changing source objects.
|
||||
|
||||
Usage:
|
||||
python3 -m unittest tests.test_pptx_hidden_shape_diagnostics
|
||||
|
||||
Dependencies:
|
||||
python-pptx and the script runtime dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
|
||||
from pptx_to_svg.converter import ConvertOptions, convert_pptx_to_svg
|
||||
|
||||
|
||||
class HiddenShapeDiagnosticTests(unittest.TestCase):
|
||||
def test_hidden_shape_and_group_are_excluded_with_diagnostics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
presentation = Presentation()
|
||||
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||
visible = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
|
||||
visible.text = "VISIBLE_MARKER"
|
||||
hidden = slide.shapes.add_textbox(Inches(1), Inches(2), Inches(4), Inches(1))
|
||||
hidden.text = "HIDDEN_SHAPE_MARKER"
|
||||
hidden.element.xpath(".//p:cNvPr")[0].set("hidden", "1")
|
||||
group = slide.shapes.add_group_shape()
|
||||
child = group.shapes.add_textbox(Inches(1), Inches(3), Inches(4), Inches(1))
|
||||
child.text = "HIDDEN_GROUP_MARKER"
|
||||
group.element.xpath("./p:nvGrpSpPr/p:cNvPr")[0].set("hidden", "true")
|
||||
source = root / "source.pptx"
|
||||
presentation.save(source)
|
||||
before = source.read_bytes()
|
||||
for keep_hidden in (False, True):
|
||||
destination = root / str(keep_hidden)
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
result = convert_pptx_to_svg(
|
||||
source, destination,
|
||||
ConvertOptions(inheritance_mode="both", keep_hidden=keep_hidden),
|
||||
)
|
||||
text = (destination / "svg-flat/slide_01.svg").read_text(encoding="utf-8")
|
||||
self.assertIn("VISIBLE_MARKER", text)
|
||||
warnings = [d for d in result.diagnostics if d.code == "author-hidden-omitted"]
|
||||
if keep_hidden:
|
||||
self.assertIn("HIDDEN_SHAPE_MARKER", text)
|
||||
self.assertIn("HIDDEN_GROUP_MARKER", text)
|
||||
self.assertEqual(warnings, [])
|
||||
else:
|
||||
self.assertNotIn("HIDDEN_SHAPE_MARKER", text)
|
||||
self.assertNotIn("HIDDEN_GROUP_MARKER", text)
|
||||
self.assertEqual({d.shape_id for d in warnings}, {str(hidden.shape_id), str(group.shape_id)})
|
||||
self.assertTrue(all(d.part_path == "ppt/slides/slide1.xml" for d in warnings))
|
||||
self.assertEqual(source.read_bytes(), before)
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep recoverable degenerate source text editable without changing passthrough."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from authoring_roundtrip import materialize_flat_authoring_roundtrip
|
||||
from pptx_to_svg.converter import ConvertOptions, convert_pptx_to_svg
|
||||
from svg_to_pptx.drawingml.converter import SvgNativeConversionError
|
||||
from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg
|
||||
from svg_to_pptx.pptx_package.cli import main as export_main
|
||||
|
||||
SVG = "http://www.w3.org/2000/svg"
|
||||
NS = {
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
}
|
||||
|
||||
|
||||
class RoundtripDegenerateTextFrameTests(unittest.TestCase):
|
||||
def _workspace(
|
||||
self, root: Path, extent: tuple[int, int], *, stroked: bool = False,
|
||||
) -> tuple[Path, Path]:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
pages = []
|
||||
for number, text in enumerate(("Text", "Control"), start=1):
|
||||
page = root / f"slide_{number:02d}.svg"
|
||||
page.write_text(
|
||||
f'<svg xmlns="{SVG}" viewBox="0 0 1280 720" lang="en-US" font-family="Arial">'
|
||||
f'<text x="100" y="140" font-size="24" fill="#123456">{text}</text></svg>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
pages.append(page)
|
||||
source = root / "source.pptx"
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertTrue(create_pptx_with_native_svg(
|
||||
pages, source, resource_root=root, pptx_structure="flat",
|
||||
use_compat_mode=False, transition=None, workers=1, verbose=False,
|
||||
))
|
||||
with zipfile.ZipFile(source) as package:
|
||||
files = {name: package.read(name) for name in package.namelist()}
|
||||
slide = ET.fromstring(files["ppt/slides/slide1.xml"])
|
||||
frame = slide.find(".//p:sp/p:spPr/a:xfrm/a:ext", NS)
|
||||
frame.set("cx", str(extent[0]))
|
||||
frame.set("cy", str(extent[1]))
|
||||
if stroked:
|
||||
line = slide.find(".//p:sp/p:spPr/a:ln", NS)
|
||||
line.clear()
|
||||
line.set("w", "38100")
|
||||
fill = ET.SubElement(line, f"{{{NS['a']}}}solidFill")
|
||||
ET.SubElement(fill, f"{{{NS['a']}}}srgbClr", {"val": "123456"})
|
||||
files["ppt/slides/slide1.xml"] = ET.tostring(slide, encoding="utf-8")
|
||||
with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as package:
|
||||
for name, payload in files.items():
|
||||
package.writestr(name, payload)
|
||||
workspace = root / "workspace"
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
convert_pptx_to_svg(
|
||||
source, workspace, ConvertOptions(roundtrip=True, inheritance_mode="both"),
|
||||
)
|
||||
return source, workspace
|
||||
|
||||
def _edit(self, workspace: Path, frame: str | None = None) -> None:
|
||||
page = workspace / "authoring-svg-flat/slide_01.svg"
|
||||
root = ET.parse(page).getroot()
|
||||
source = next(node for node in root.iter() if node.get("data-pptx-source-ref", "").startswith("slide:"))
|
||||
text = next(node for node in source.iter() if node.text == "Text")
|
||||
text.text = "Edited"
|
||||
if frame is not None:
|
||||
source.set("data-pptx-frame", frame)
|
||||
page.write_bytes(ET.tostring(root))
|
||||
|
||||
def test_edited_nonpositive_source_text_uses_computed_frame(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
for index, extent in enumerate(((0, 0), (2857500, 0), (0, 952500))):
|
||||
with self.subTest(extent=extent):
|
||||
_, workspace = self._workspace(Path(temporary) / str(index), extent)
|
||||
self._edit(workspace)
|
||||
result = materialize_flat_authoring_roundtrip(
|
||||
workspace, workspace / "authoring-svg-flat", workspace / "materialized",
|
||||
)
|
||||
self.assertEqual(result.report["totals"]["edited_refs"], 1)
|
||||
output = workspace / "exports/result.pptx"
|
||||
log = io.StringIO()
|
||||
with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log):
|
||||
status = export_main([str(workspace), "--roundtrip", "-o", str(output)])
|
||||
self.assertEqual(status, 0, log.getvalue())
|
||||
with zipfile.ZipFile(output) as exported:
|
||||
slide = ET.fromstring(exported.read("ppt/slides/slide1.xml"))
|
||||
self.assertIn("Edited", [text.text for text in slide.findall(".//a:t", NS)])
|
||||
frame = slide.find(".//p:sp/p:spPr/a:xfrm/a:ext", NS)
|
||||
self.assertGreater(int(frame.get("cx")), 0)
|
||||
self.assertGreater(int(frame.get("cy")), 0)
|
||||
|
||||
def test_explicit_positive_repair_allows_edited_source_text(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
_, workspace = self._workspace(Path(temporary), (0, 0))
|
||||
self._edit(workspace, "100 100 300 100")
|
||||
result = materialize_flat_authoring_roundtrip(
|
||||
workspace, workspace / "authoring-svg-flat", workspace / "materialized",
|
||||
)
|
||||
self.assertEqual(result.report["totals"]["edited_refs"], 1)
|
||||
self.assertIn("Edited", (workspace / "materialized/slide_01.svg").read_text())
|
||||
|
||||
def test_unchanged_degenerate_source_pages_remain_byte_identical(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, workspace = self._workspace(Path(temporary), (0, 0))
|
||||
output = workspace / "exports/result.pptx"
|
||||
log = io.StringIO()
|
||||
with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log):
|
||||
result = export_main([str(workspace), "--roundtrip", "-o", str(output)])
|
||||
self.assertEqual(result, 0, log.getvalue())
|
||||
self.assertIn("passthrough=2", log.getvalue())
|
||||
with zipfile.ZipFile(source) as original, zipfile.ZipFile(output) as exported:
|
||||
for page in ("ppt/slides/slide1.xml", "ppt/slides/slide2.xml"):
|
||||
self.assertEqual(original.read(page), exported.read(page))
|
||||
|
||||
def test_stroked_zero_axis_shape_uses_existing_text_frame_validation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
_, workspace = self._workspace(Path(temporary), (2857500, 0), stroked=True)
|
||||
self._edit(workspace)
|
||||
materialize_flat_authoring_roundtrip(
|
||||
workspace, workspace / "authoring-svg-flat", workspace / "materialized",
|
||||
)
|
||||
materialized = ET.parse(workspace / "materialized/slide_01.svg").getroot()
|
||||
carrier = next(node for node in materialized.iter() if any(
|
||||
child.get("data-pptx-part") == "geometry" for child in node
|
||||
))
|
||||
self.assertEqual(carrier.get("data-pptx-frame").split()[2:], ["300", "0"])
|
||||
output = workspace / "exports/result.pptx"
|
||||
log = io.StringIO()
|
||||
with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log):
|
||||
with self.assertRaisesRegex(SvgNativeConversionError, "data-pptx-frame width and height must be positive"):
|
||||
export_main([str(workspace), "--roundtrip", "-o", str(output)])
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PPT Master - Hidden Speaker Notes Regression Tests
|
||||
|
||||
Keep author-hidden source notes out of editable Markdown and note overlays.
|
||||
|
||||
Usage:
|
||||
python3 -m unittest tests.test_roundtrip_hidden_notes
|
||||
|
||||
Examples:
|
||||
Run the module from the scripts directory.
|
||||
|
||||
Dependencies:
|
||||
python-pptx and the local PPTX import/export pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pptx import Presentation
|
||||
|
||||
from pptx_to_svg.converter import ConvertOptions, convert_pptx_to_svg
|
||||
from svg_to_pptx.pptx_package.cli import main as export_main
|
||||
|
||||
|
||||
NS = {
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
}
|
||||
NOTES_PART = "ppt/notesSlides/notesSlide1.xml"
|
||||
|
||||
|
||||
class RoundtripHiddenNotesTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.source = self.root / "source.pptx"
|
||||
self.workspace = self.root / "workspace"
|
||||
|
||||
def _import(self, *, grouped: bool = False, visible: bool = True) -> None:
|
||||
deck = Presentation()
|
||||
slide = deck.slides.add_slide(deck.slide_layouts[6])
|
||||
slide.notes_slide.notes_text_frame.text = "VISIBLE_NOTES"
|
||||
deck.save(self.source)
|
||||
with zipfile.ZipFile(self.source) as archive:
|
||||
parts = {name: archive.read(name) for name in archive.namelist()}
|
||||
root = ET.fromstring(parts[NOTES_PART])
|
||||
tree = root.find("p:cSld/p:spTree", NS)
|
||||
body = next(shape for shape in tree if shape.find(
|
||||
'p:nvSpPr/p:nvPr/p:ph[@type="body"]', NS,
|
||||
) is not None)
|
||||
hidden = copy.deepcopy(body)
|
||||
properties = hidden.find("p:nvSpPr/p:cNvPr", NS)
|
||||
properties.set("id", "90")
|
||||
properties.set("name", "Hidden note")
|
||||
hidden.find(".//a:t", NS).text = "HIDDEN_NOTES"
|
||||
if grouped:
|
||||
group = ET.fromstring(
|
||||
f'<p:grpSp xmlns:p="{NS["p"]}"><p:nvGrpSpPr>'
|
||||
'<p:cNvPr id="91" name="Hidden group" hidden="true"/>'
|
||||
'<p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:grpSp>'
|
||||
)
|
||||
group.append(hidden)
|
||||
tree.append(group)
|
||||
else:
|
||||
properties.set("hidden", "1")
|
||||
tree.append(hidden)
|
||||
if not visible:
|
||||
tree.remove(body)
|
||||
parts[NOTES_PART] = ET.tostring(root)
|
||||
with zipfile.ZipFile(self.source, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, payload in parts.items():
|
||||
archive.writestr(name, payload)
|
||||
self.source_parts = parts
|
||||
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
||||
self.result = convert_pptx_to_svg(
|
||||
self.source, self.workspace, ConvertOptions(inheritance_mode="both", roundtrip=True),
|
||||
)
|
||||
warnings = [item for item in self.result.diagnostics if item.code == "hidden-notes-content"]
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertEqual(warnings[0].part_path, NOTES_PART)
|
||||
self.assertEqual(warnings[0].shape_id, "90")
|
||||
|
||||
def _export(self) -> dict[str, bytes]:
|
||||
output = self.root / "output.pptx"
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
result = export_main([str(self.workspace), "--roundtrip", "-o", str(output)])
|
||||
self.assertEqual(result, 0, stdout.getvalue() + stderr.getvalue())
|
||||
self.assertIn("rebuilt=0", stdout.getvalue())
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
return {name: archive.read(name) for name in archive.namelist()}
|
||||
|
||||
def test_hidden_shape_does_not_leak_when_visible_notes_are_edited(self) -> None:
|
||||
self._import()
|
||||
note = self.workspace / "notes/slide_01.md"
|
||||
self.assertEqual(note.read_text(encoding="utf-8").strip(), "VISIBLE_NOTES")
|
||||
note.write_text(note.read_text(encoding="utf-8") + "\nNOTE_EDIT\n", encoding="utf-8")
|
||||
parts = self._export()
|
||||
self.assertIn(b"VISIBLE_NOTES", parts[NOTES_PART])
|
||||
self.assertIn(b"NOTE_EDIT", parts[NOTES_PART])
|
||||
self.assertNotIn(b"HIDDEN_NOTES", parts[NOTES_PART])
|
||||
|
||||
def test_hidden_group_notes_stay_hidden_and_untouched_native_notes_are_exact(self) -> None:
|
||||
self._import(grouped=True)
|
||||
note = self.workspace / "notes/slide_01.md"
|
||||
self.assertNotIn("HIDDEN_NOTES", note.read_text(encoding="utf-8"))
|
||||
parts = self._export()
|
||||
self.assertEqual(parts[NOTES_PART], self.source_parts[NOTES_PART])
|
||||
self.assertEqual(parts["ppt/slides/slide1.xml"], self.source_parts["ppt/slides/slide1.xml"])
|
||||
|
||||
def test_all_hidden_notes_warn_without_creating_markdown(self) -> None:
|
||||
self._import(visible=False)
|
||||
self.assertFalse((self.workspace / "notes/slide_01.md").exists())
|
||||
self.assertEqual(self.result.speaker_notes, ())
|
||||
parts = self._export()
|
||||
self.assertEqual(parts[NOTES_PART], self.source_parts[NOTES_PART])
|
||||
+63
@@ -42,6 +42,22 @@ class HtmlCharsetTests(unittest.TestCase):
|
||||
self.assertIn("中文收入", markdown)
|
||||
self.assertNotIn("\ufffd", markdown)
|
||||
|
||||
def test_template_markup_never_reaches_markdown(self) -> None:
|
||||
sys.path.insert(0, str(Path(doc_to_md.__file__).parent))
|
||||
import web_to_md
|
||||
|
||||
html = ('<html><body><article><p>Visible body</p>'
|
||||
'<template><p>TEMPLATE_ONLY</p></template></article></body></html>')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = Path(tmp) / "page.html"
|
||||
source.write_text(html, encoding="utf-8")
|
||||
with redirect_stdout(io.StringIO()):
|
||||
markdown = doc_to_md.convert_to_markdown(str(source))
|
||||
self.assertIn("Visible body", markdown)
|
||||
self.assertNotIn("TEMPLATE_ONLY", markdown)
|
||||
main = web_to_md.find_main_content(web_to_md.BeautifulSoup(html, "html.parser"))
|
||||
self.assertIsNotNone(main)
|
||||
self.assertNotIn("TEMPLATE_ONLY", main.get_text())
|
||||
|
||||
class NotebookContentLossTests(unittest.TestCase):
|
||||
def test_attachments_do_not_overwrite_named_outputs_or_each_other(self) -> None:
|
||||
@@ -238,6 +254,53 @@ class DocxContentLossTests(unittest.TestCase):
|
||||
self.assertIn("| [Evidence](https://example.invalid/evidence) |", md)
|
||||
self.assertIn("| --- |", md)
|
||||
|
||||
def test_simple_field_keeps_its_displayed_result(self) -> None:
|
||||
doc = Document()
|
||||
paragraph = doc.add_paragraph('Figure ')
|
||||
paragraph._p.append(parse_xml('<w:fldSimple ' + nsdecls('w') + ' w:instr=" SEQ Figure \\* ARABIC ">'
|
||||
'<w:r><w:t>7</w:t></w:r></w:fldSimple>'))
|
||||
paragraph.add_run(': Revenue by region')
|
||||
md, _ = self._convert(doc)
|
||||
self.assertIn('Figure 7: Revenue by region', md)
|
||||
|
||||
def test_hidden_text_is_omitted_with_a_warning(self) -> None:
|
||||
doc = Document()
|
||||
paragraph = doc.add_paragraph('Shown ')
|
||||
paragraph._p.append(parse_xml('<w:r ' + nsdecls('w') + '><w:rPr><w:vanish/></w:rPr>'
|
||||
'<w:t>HIDDEN_ANSWER</w:t></w:r>'))
|
||||
paragraph._p.append(parse_xml('<w:r ' + nsdecls('w') + '><w:rPr><w:vanish w:val="0"/></w:rPr>'
|
||||
'<w:t>after</w:t></w:r>'))
|
||||
md, profile = self._convert(doc)
|
||||
self.assertIn('Shown after', md)
|
||||
self.assertNotIn('HIDDEN_ANSWER', md)
|
||||
self.assertTrue(any('hidden-text' in w for w in profile['warnings']))
|
||||
|
||||
def test_footnote_math_is_rewritten_as_latex(self) -> None:
|
||||
doc = Document()
|
||||
doc.add_paragraph('Claim')._p.append(
|
||||
parse_xml('<w:r ' + nsdecls('w') + '><w:footnoteReference w:id="1"/></w:r>'))
|
||||
|
||||
def add_footnote(buf, target):
|
||||
with zipfile.ZipFile(buf) as zin, zipfile.ZipFile(target, 'w') as zout:
|
||||
for item in zin.infolist():
|
||||
data = zin.read(item.filename)
|
||||
if item.filename == '[Content_Types].xml':
|
||||
data = data.replace(b'</Types>', b'<Override PartName="/word/footnotes.xml" ContentType='
|
||||
b'"application/vnd.openxmlformats-officedocument.wordprocessingml.'
|
||||
b'footnotes+xml"/></Types>')
|
||||
if item.filename == 'word/_rels/document.xml.rels':
|
||||
data = data.replace(b'</Relationships>', b'<Relationship Id="rIdFn" Type="http://schemas.'
|
||||
b'openxmlformats.org/officeDocument/2006/relationships/footnotes" '
|
||||
b'Target="footnotes.xml"/></Relationships>')
|
||||
zout.writestr(item, data)
|
||||
zout.writestr('word/footnotes.xml', '<w:footnotes ' + nsdecls('w', 'm') + '>'
|
||||
'<w:footnote w:id="1"><w:p><w:r><w:t xml:space="preserve">Since </w:t></w:r>'
|
||||
'<m:oMath><m:sSup><m:e><m:r><m:t>e</m:t></m:r></m:e><m:sup><m:r><m:t>x</m:t>'
|
||||
'</m:r></m:sup></m:sSup></m:oMath></w:p></w:footnote></w:footnotes>')
|
||||
|
||||
md, _ = self._convert(doc, add_footnote)
|
||||
self.assertIn('Since $e^x$', md)
|
||||
|
||||
def _math_document(self, xml):
|
||||
doc = Document()
|
||||
doc.add_paragraph()._p.append(parse_xml('<m:oMath ' + nsdecls('m') + '>' + xml + '</m:oMath>'))
|
||||
|
||||
+95
@@ -184,3 +184,98 @@ class PptxContentLossTests(unittest.TestCase):
|
||||
choice.append(sp)
|
||||
md = self._convert(prs)
|
||||
self.assertIn('ratio holds', md)
|
||||
|
||||
def test_office_math_survives_as_latex(self) -> None:
|
||||
a14 = 'http://schemas.microsoft.com/office/drawing/2010/main'
|
||||
m = 'http://schemas.openxmlformats.org/officeDocument/2006/math'
|
||||
mc = 'http://schemas.openxmlformats.org/markup-compatibility/2006'
|
||||
|
||||
def math(display: bool, body: str) -> etree._Element:
|
||||
zone = f'<m:oMathPara><m:oMath>{body}</m:oMath></m:oMathPara>' if display \
|
||||
else f'<m:oMath>{body}</m:oMath>'
|
||||
return etree.fromstring(f'<a14:m xmlns:a14="{a14}" xmlns:m="{m}">{zone}</a14:m>')
|
||||
|
||||
prs, shape = self._presentation()
|
||||
inline = shape.text_frame.paragraphs[0]
|
||||
inline.text = 'ratio '
|
||||
inline._p.append(math(False, '<m:f><m:num><m:r><m:t>a</m:t></m:r></m:num>'
|
||||
'<m:den><m:r><m:t>b</m:t></m:r></m:den></m:f>'))
|
||||
inline.add_run().text = ' holds'
|
||||
display = shape.text_frame.add_paragraph()
|
||||
display._p.append(math(True, '<m:sSup><m:e><m:r><m:t>x</m:t></m:r></m:e>'
|
||||
'<m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup>'))
|
||||
sp = shape._element
|
||||
wrapper = etree.Element(f'{{{mc}}}AlternateContent', nsmap={'mc': mc, 'a14': a14})
|
||||
choice = etree.SubElement(wrapper, f'{{{mc}}}Choice', Requires='a14')
|
||||
etree.SubElement(wrapper, f'{{{mc}}}Fallback')
|
||||
sp.getparent().replace(sp, wrapper)
|
||||
choice.append(sp)
|
||||
md = self._convert(prs)
|
||||
self.assertIn(r'ratio $\frac{a}{b}$ holds', md)
|
||||
self.assertIn('$$x^2$$', md)
|
||||
|
||||
def _wrap_in_alternate_content(self, element, requires: str, choice_child) -> None:
|
||||
mc = 'http://schemas.openxmlformats.org/markup-compatibility/2006'
|
||||
wrapper = etree.Element(f'{{{mc}}}AlternateContent', nsmap={'mc': mc})
|
||||
choice = etree.SubElement(wrapper, f'{{{mc}}}Choice', Requires=requires)
|
||||
choice.append(choice_child)
|
||||
fallback = etree.SubElement(wrapper, f'{{{mc}}}Fallback')
|
||||
element.getparent().replace(element, wrapper)
|
||||
fallback.append(element)
|
||||
|
||||
def test_unreadable_alternate_content_keeps_its_fallback_picture(self) -> None:
|
||||
from PIL import Image
|
||||
|
||||
prs, shape = self._presentation()
|
||||
shape.text = 'caption'
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
image = Path(tmp) / 'model.png'
|
||||
Image.new('RGB', (40, 40), 'red').save(image)
|
||||
picture = prs.slides[0].shapes.add_picture(str(image), Inches(1), Inches(3))
|
||||
frame = etree.Element('{http://schemas.openxmlformats.org/presentationml/2006/main}graphicFrame')
|
||||
self._wrap_in_alternate_content(picture._element, 'am3d', frame)
|
||||
md = self._convert(prs)
|
||||
self.assertIn('caption', md)
|
||||
self.assertRegex(md, r'!\[[^\]]*\]\([^)]+\.png\)')
|
||||
|
||||
def test_chart_header_uses_the_visible_chart_title(self) -> None:
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.enum.chart import XL_CHART_TYPE
|
||||
|
||||
prs, _ = self._presentation()
|
||||
data = CategoryChartData()
|
||||
data.categories = ['North']
|
||||
data.add_series('Sales', (3,))
|
||||
chart = prs.slides[0].shapes.add_chart(
|
||||
XL_CHART_TYPE.COLUMN_CLUSTERED, Inches(1), Inches(3), Inches(4), Inches(3), data,
|
||||
).chart
|
||||
chart.has_title = True
|
||||
chart.chart_title.text_frame.text = 'Revenue by region'
|
||||
md = self._convert(prs)
|
||||
self.assertIn('> [Chart] Revenue by region', md)
|
||||
|
||||
def test_picture_alt_text_becomes_markdown_alt(self) -> None:
|
||||
from PIL import Image
|
||||
|
||||
prs, _ = self._presentation()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
image = Path(tmp) / 'map.png'
|
||||
Image.new('RGB', (40, 40), 'blue').save(image)
|
||||
picture = prs.slides[0].shapes.add_picture(str(image), Inches(1), Inches(3))
|
||||
picture._element.nvPicPr.cNvPr.set('descr', 'Map of [north] sites')
|
||||
md = self._convert(prs)
|
||||
self.assertIn(r'![Map of \[north\] sites](', md)
|
||||
|
||||
def test_chartex_fallback_text_is_not_read_as_slide_text(self) -> None:
|
||||
prs, shape = self._presentation()
|
||||
shape.text = "This chart isn't available in your version of PowerPoint"
|
||||
frame = etree.fromstring(
|
||||
'<p:graphicFrame xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
|
||||
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
|
||||
'<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/drawing/2014/chartex"/>'
|
||||
'</a:graphic></p:graphicFrame>'
|
||||
)
|
||||
self._wrap_in_alternate_content(shape._element, 'cx1', frame)
|
||||
md = self._convert(prs)
|
||||
self.assertNotIn("isn't available", md)
|
||||
self.assertIn('ChartEx', md)
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for document conversion content survival."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1] / "source_to_md"
|
||||
if str(BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
|
||||
import doc_to_md # noqa: E402
|
||||
|
||||
|
||||
class EpubSurvivalTests(unittest.TestCase):
|
||||
def _book(self, path: Path) -> None:
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("mimetype", "application/epub+zip")
|
||||
archive.writestr("META-INF/container.xml", (
|
||||
'<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
||||
'<rootfiles><rootfile full-path="content.opf" '
|
||||
'media-type="application/oebps-package+xml"/></rootfiles></container>'
|
||||
))
|
||||
archive.writestr("content.opf", (
|
||||
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">'
|
||||
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">'
|
||||
'<dc:identifier id="id">book</dc:identifier><dc:title>Book</dc:title>'
|
||||
'<dc:language>en</dc:language></metadata><manifest>'
|
||||
'<item id="a" href="a/ch.xhtml" media-type="application/xhtml+xml"/>'
|
||||
'<item id="b" href="b/ch.xhtml" media-type="application/xhtml+xml"/>'
|
||||
'<item id="note" href="note.xhtml" media-type="application/xhtml+xml"/>'
|
||||
'<item id="ia" href="a/pic.svg" media-type="image/svg+xml"/>'
|
||||
'<item id="ib" href="b/pic.svg" media-type="image/svg+xml"/>'
|
||||
'</manifest><spine><itemref idref="b"/><itemref idref="a"/></spine></package>'
|
||||
))
|
||||
for chapter in ("a", "b"):
|
||||
archive.writestr(f"{chapter}/ch.xhtml", (
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head>'
|
||||
f'<body><p>CHAPTER{chapter}</p><img src="pic.svg" alt="IMAGE{chapter}"/>'
|
||||
'<p hidden="hidden">INTERACTIVEHIDDEN</p>'
|
||||
'<p style="display:none">INTERACTIVECSS</p>'
|
||||
'<template>INERTTEMPLATE</template><script>INERTSCRIPT</script>'
|
||||
'<style>INERTSTYLE</style><a href="../note.xhtml">NOTELINK</a></body></html>'
|
||||
))
|
||||
archive.writestr(f"{chapter}/pic.svg", (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
f'<text>PIXELS{chapter}</text></svg>'
|
||||
))
|
||||
archive.writestr("note.xhtml", (
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Note</title></head>'
|
||||
'<body><p>LINKEDNOTE</p></body></html>'
|
||||
))
|
||||
|
||||
def test_spine_supplemental_text_and_inert_content(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "book.epub"
|
||||
self._book(source)
|
||||
diagnostics = io.StringIO()
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(diagnostics):
|
||||
text = doc_to_md.convert_to_markdown(str(source), str(root / "book.md"))
|
||||
for marker in ("CHAPTERa", "CHAPTERb", "LINKEDNOTE", "INTERACTIVEHIDDEN", "INTERACTIVECSS"):
|
||||
self.assertIn(marker, text)
|
||||
for marker in ("INERTTEMPLATE", "INERTSCRIPT", "INERTSTYLE"):
|
||||
self.assertNotIn(marker, text)
|
||||
self.assertLess(text.index("CHAPTERb"), text.index("CHAPTERa"))
|
||||
self.assertLess(text.index("CHAPTERa"), text.index("LINKEDNOTE"))
|
||||
self.assertIn("outside the reading spine", diagnostics.getvalue())
|
||||
self.assertIn("non-rendered", diagnostics.getvalue())
|
||||
|
||||
def test_navigation_document_is_not_appended_as_text(self) -> None:
|
||||
from ebooklib import epub
|
||||
|
||||
book = epub.EpubBook()
|
||||
book.set_identifier("nav-test")
|
||||
book.set_title("Book")
|
||||
book.set_language("en")
|
||||
chapter = epub.EpubHtml(title="Chapter", file_name="c1.xhtml")
|
||||
chapter.content = "<h1>Chapter</h1><p>CHAPTERBODY</p>"
|
||||
book.add_item(chapter)
|
||||
book.toc = (chapter,)
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
book.spine = [chapter]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
epub.write_epub(root / "book.epub", book)
|
||||
diagnostics = io.StringIO()
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(diagnostics):
|
||||
text = doc_to_md.convert_to_markdown(str(root / "book.epub"), str(root / "book.md"))
|
||||
self.assertIn("CHAPTERBODY", text)
|
||||
self.assertNotIn("](c1.xhtml)", text)
|
||||
self.assertNotIn("outside the reading spine", diagnostics.getvalue())
|
||||
|
||||
def test_same_basename_images_resolve_relative_to_each_chapter(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "book.epub"
|
||||
self._book(source)
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
text = doc_to_md.convert_to_markdown(str(source), str(root / "book.md"))
|
||||
for chapter in ("a", "b"):
|
||||
match = re.search(rf"!\[IMAGE{chapter}\]\(([^)]+)\)", text)
|
||||
self.assertIsNotNone(match)
|
||||
self.assertIn(f"PIXELS{chapter}", (root / match[1]).read_text())
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for document conversion content survival."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1] / "source_to_md"
|
||||
if str(BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
|
||||
import doc_to_md # noqa: E402
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("pandoc"), "Pandoc fallback requires the optional system binary")
|
||||
class OdtSurvivalTests(unittest.TestCase):
|
||||
def test_odt_table_header_textbox_and_conditional_text(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "document.odt"
|
||||
subprocess.run(
|
||||
["pandoc", "-f", "markdown", "-o", str(source)],
|
||||
input="| HEADERMARK |\n|---|\n| BODYMARK |\n", text=True,
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
with zipfile.ZipFile(source) as archive:
|
||||
parts = {name: archive.read(name) for name in archive.namelist()}
|
||||
parts["content.xml"] = parts["content.xml"].replace(b"</office:text>", (
|
||||
'<text:p>LEFTMARK <draw:frame text:anchor-type="as-char"><draw:text-box><text:p>BOXMARK '
|
||||
'<text:a xlink:href="https://example.invalid/BOXURL">BOXLINK</text:a>'
|
||||
'</text:p><text:p>BOXSECOND</text:p></draw:text-box></draw:frame> RIGHTMARK</text:p>'
|
||||
'<text:p><text:hidden-text text:is-hidden="false">CONDITIONVISIBLE</text:hidden-text>'
|
||||
'<text:hidden-text text:is-hidden="true">CONDITIONHIDDEN</text:hidden-text>'
|
||||
'TAILMARK</text:p></office:text>'
|
||||
).encode())
|
||||
with zipfile.ZipFile(source, "w") as archive:
|
||||
for name, data in parts.items():
|
||||
archive.writestr(name, data)
|
||||
original = source.read_bytes()
|
||||
diagnostics = io.StringIO()
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(diagnostics):
|
||||
text = doc_to_md.convert_to_markdown(str(source), str(root / "document.md"))
|
||||
for marker in (
|
||||
"HEADERMARK", "BODYMARK", "BOXMARK", "BOXSECOND", "BOXLINK", "BOXURL",
|
||||
"CONDITIONVISIBLE", "TAILMARK",
|
||||
):
|
||||
self.assertIn(marker, text)
|
||||
self.assertNotIn("CONDITIONHIDDEN", text)
|
||||
self.assertLess(text.index("LEFTMARK"), text.index("BOXMARK"))
|
||||
self.assertLess(text.index("BOXSECOND"), text.index("RIGHTMARK"))
|
||||
self.assertIn("1 author-hidden field(s) omitted", diagnostics.getvalue())
|
||||
self.assertEqual(source.read_bytes(), original)
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for document conversion content survival."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1] / "source_to_md"
|
||||
if str(BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
|
||||
import doc_to_md # noqa: E402
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("pandoc"), "Pandoc fallback requires the optional system binary")
|
||||
class RtfSurvivalTests(unittest.TestCase):
|
||||
def test_rtf_flat_hyperlink_fields_and_hidden_text(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "document.rtf"
|
||||
source.write_text(
|
||||
r'{\rtf1\ansi BEFORE {\v HIDDENMARK} '
|
||||
r'{\field{\*\fldinst HYPERLINK "https://example.invalid/LINKURL"}'
|
||||
r'{\fldrslt LINKTEXT}} AFTER}', encoding="ascii",
|
||||
)
|
||||
original = source.read_bytes()
|
||||
diagnostics = io.StringIO()
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(diagnostics):
|
||||
text = doc_to_md.convert_to_markdown(str(source), str(root / "document.md"))
|
||||
for marker in ("BEFORE", "LINKURL", "LINKTEXT", "AFTER"):
|
||||
self.assertIn(marker, text)
|
||||
self.assertNotIn("HIDDENMARK", text)
|
||||
self.assertIn("author-hidden", diagnostics.getvalue())
|
||||
self.assertEqual(source.read_bytes(), original)
|
||||
|
||||
def test_rtf_nested_field_results_and_literal_backslashes_stay_visible(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "nested.rtf"
|
||||
source.write_text(
|
||||
r'{\rtf1\ansi \\v VISIBLEMARK '
|
||||
r'{\field{\*\fldinst{HYPERLINK "https://example.invalid/NESTEDURL"}}'
|
||||
r'{\fldrslt{NESTEDTEXT}}}}', encoding="ascii",
|
||||
)
|
||||
diagnostics = io.StringIO()
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(diagnostics):
|
||||
text = doc_to_md.convert_to_markdown(str(source), str(root / "nested.md"))
|
||||
self.assertIn("VISIBLEMARK", text)
|
||||
self.assertIn("NESTEDTEXT", text)
|
||||
self.assertIn("NESTEDURL", text)
|
||||
self.assertNotIn("author-hidden", diagnostics.getvalue())
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise untouched local references through the live-preview save routes."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
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))
|
||||
|
||||
from svg_editor.server import create_app # noqa: E402
|
||||
|
||||
|
||||
class EditorContentSurvivalTests(unittest.TestCase):
|
||||
def test_save_preserves_referenced_ids_and_untouched_content(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
project = Path(temporary)
|
||||
(project / 'svg_output').mkdir()
|
||||
path = project / 'svg_output' / '01.svg'
|
||||
path.write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
'xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 720">'
|
||||
'<defs><linearGradient id="_edit_0"><stop offset="0" stop-color="#123456"/>'
|
||||
'<stop offset="1" stop-color="#ABCDEF"/></linearGradient>'
|
||||
'<clipPath id="_edit_2"><rect width="60" height="40"/></clipPath>'
|
||||
'<g id="_edit_3"><text x="10" y="30">USE_MARK</text></g></defs>'
|
||||
'<style>#_edit_4 { visibility:hidden }</style>'
|
||||
'<rect id="box" x="10" width="100" height="80" fill="url(#_edit_0)" '
|
||||
'style="clip-path:url(\'#_edit_2\')" data-pptx-role="decoration"/>'
|
||||
'<use xlink:href="#_edit_3"/>'
|
||||
'<text id="text" x="10" y="200">TEXT_MARK<tspan>RUN_MARK</tspan></text>'
|
||||
'<a href="https://example.com/LINK_MARK"><text>LINK_TEXT_MARK</text></a>'
|
||||
'<text id="_edit_4" style="display:none">HIDDEN_MARK</text></svg>',
|
||||
encoding='utf-8',
|
||||
)
|
||||
client = create_app(str(project), idle_timeout=0).test_client()
|
||||
preview = ET.fromstring(client.get('/api/slide/01.svg').get_json()['content'])
|
||||
preview_ids = [elem.get('id') for elem in preview.iter() if elem.get('id')]
|
||||
self.assertEqual(len(preview_ids), len(set(preview_ids)))
|
||||
for marker in ('_edit_0', '_edit_2', '_edit_3', '_edit_4'):
|
||||
self.assertIn(marker, preview_ids)
|
||||
response = client.post('/api/slide/01.svg/edit', json={
|
||||
'element_id': 'box', 'attrs': {'x': '15'},
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
client.post('/api/slide/01.svg/annotate', json={
|
||||
'element_id': 'text', 'annotation': 'ANNOTATION_MARK',
|
||||
})
|
||||
saved = client.post('/api/save-all').get_json()
|
||||
self.assertEqual(saved['files_modified'], ['01.svg'])
|
||||
root = ET.parse(path).getroot()
|
||||
ids = {elem.get('id') for elem in root.iter()}
|
||||
for marker in ('_edit_0', '_edit_2', '_edit_3', '_edit_4'):
|
||||
self.assertIn(marker, ids)
|
||||
text = path.read_text(encoding='utf-8')
|
||||
for marker in ('USE_MARK', 'TEXT_MARK', 'RUN_MARK', 'LINK_MARK',
|
||||
'LINK_TEXT_MARK', 'HIDDEN_MARK', 'ANNOTATION_MARK'):
|
||||
self.assertIn(marker, text)
|
||||
hidden = next(elem for elem in root.iter() if elem.text == 'HIDDEN_MARK')
|
||||
self.assertEqual(hidden.get('style'), 'display:none')
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regressions for imported fixed geometry and placeholder source frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import mirror_template_materialize as mirror
|
||||
from compact_svg_coordinates import compact_svg_tree
|
||||
from pptx_gradients import native_gradient_metadata
|
||||
from svg_quality.checker import SVGQualityChecker
|
||||
from svg_to_pptx.drawingml.converter import SvgNativeConversionError, convert_svg_to_slide_shapes
|
||||
from template_import.manifest import extract_placeholders
|
||||
|
||||
SVG = "http://www.w3.org/2000/svg"
|
||||
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
P = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
NS = {"a": A, "p": P}
|
||||
|
||||
|
||||
def _document(body: str = "") -> ET.Element:
|
||||
return ET.fromstring(f'<svg xmlns="{SVG}" viewBox="0 0 960 720">{body}</svg>')
|
||||
|
||||
|
||||
def _compose(
|
||||
content: str, scope: str = "slide", plans: list | None = None,
|
||||
warnings: list[str] | None = None,
|
||||
) -> ET.Element:
|
||||
empty = _document()
|
||||
master = {"key": "m", "name": "Master", "placeholders": []}
|
||||
layout = {"key": "l", "name": "Layout", "showMasterShapes": True, "placeholders": []}
|
||||
return mirror._compose_template(
|
||||
native={"slideSize": {"width_px": 960, "height_px": 720}},
|
||||
master=master, layout=layout,
|
||||
master_root=_document(content) if scope == "master" else empty,
|
||||
layout_root=_document(content) if scope == "layout" else empty,
|
||||
slide={"index": 1, "showInheritedShapes": True},
|
||||
slide_root=_document(content) if scope == "slide" else empty,
|
||||
slot_plans=plans or [],
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _source_placeholder(width_emu: int) -> dict:
|
||||
source = ET.fromstring(
|
||||
f'<p:sldLayout xmlns:p="{P}" xmlns:a="{A}"><p:cSld><p:spTree><p:sp>'
|
||||
'<p:nvSpPr><p:cNvPr id="2" name="Body"/><p:cNvSpPr/><p:nvPr>'
|
||||
'<p:ph type="body" idx="42"/></p:nvPr></p:nvSpPr><p:spPr><a:xfrm>'
|
||||
f'<a:off x="1143000" y="1714500"/><a:ext cx="{width_emu}" cy="952500"/>'
|
||||
'</a:xfrm></p:spPr></p:sp></p:spTree></p:cSld></p:sldLayout>'
|
||||
)
|
||||
return extract_placeholders(source)[0]
|
||||
|
||||
|
||||
class TemplateDegenerateSweepTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
|
||||
def _export(self, root: ET.Element) -> ET.Element:
|
||||
path = self.root / "source.svg"
|
||||
ET.ElementTree(root).write(path, encoding="utf-8")
|
||||
xml, *_ = convert_svg_to_slide_shapes(path, resource_root=self.root)
|
||||
return ET.fromstring(xml)
|
||||
|
||||
def test_fixed_custom_shapes_keep_kind_payload_and_zero_extent(self) -> None:
|
||||
for scope in ("master", "layout"):
|
||||
for width, height in ((300, 0), (0, 100)):
|
||||
for count in (1, 2):
|
||||
with self.subTest(scope=scope, frame=(width, height), paths=count):
|
||||
custom = ET.fromstring(
|
||||
f'<a:custGeom xmlns:a="{A}"><a:avLst/><a:gdLst/><a:ahLst/>'
|
||||
'<a:cxnLst/><a:rect l="l" t="t" r="r" b="b"/><a:pathLst>'
|
||||
+ '<a:path w="300" h="100" fill="none"><a:moveTo><a:pt x="0" y="0"/>'
|
||||
'</a:moveTo><a:lnTo><a:pt x="300" y="100"/></a:lnTo></a:path>' * count
|
||||
+ '</a:pathLst></a:custGeom>'
|
||||
)
|
||||
payload = base64.b64encode(ET.tostring(custom)).decode("ascii")
|
||||
path = f"M 120 180 L {120 + width} {180 + height}"
|
||||
digest = hashlib.sha256(path.encode()).hexdigest()
|
||||
page = _compose(
|
||||
'<g id="source" data-pptx-object="shape" data-pptx-geometry-kind="custom" '
|
||||
f'data-pptx-frame="120 180 {width} {height}"><path d="{path}" '
|
||||
f'data-pptx-custgeom="{payload}" data-pptx-geometry-sha256="{digest}" '
|
||||
'stroke="#3060C0" stroke-width="4" fill="none"/></g>', scope,
|
||||
)
|
||||
geometry = next(element for element in page if element.tag == f"{{{SVG}}}path")
|
||||
self.assertEqual(geometry.get("data-pptx-geometry-kind"), "custom")
|
||||
result = {"errors": []}
|
||||
SVGQualityChecker(template_mode=True)._check_preset_geometry_metadata(page, result)
|
||||
self.assertEqual(result["errors"], [])
|
||||
exported = self._export(page)
|
||||
self.assertEqual(
|
||||
ET.tostring(exported.find(".//a:custGeom", NS)).strip(), ET.tostring(custom),
|
||||
)
|
||||
self.assertEqual(exported.find(".//p:sp/p:spPr/a:xfrm/a:ext", NS).attrib, {
|
||||
"cx": str(width * 9525), "cy": str(height * 9525),
|
||||
})
|
||||
|
||||
def test_fixed_custom_both_zero_stays_rejected(self) -> None:
|
||||
page = _compose(
|
||||
'<g id="source" data-pptx-object="shape" data-pptx-geometry-kind="custom" '
|
||||
'data-pptx-frame="120 180 0 0"><path d="M 120 180 L 120 180" '
|
||||
'stroke="#3060C0" fill="none"/></g>', "master",
|
||||
)
|
||||
with self.assertRaisesRegex(SvgNativeConversionError, "width and height must be positive"):
|
||||
self._export(page)
|
||||
|
||||
def test_layout_native_frame_precedes_inherited_fallback(self) -> None:
|
||||
placeholder = _source_placeholder(2857500)
|
||||
master_placeholder = {"shapeId": "7", "type": "body", "idx": "42"}
|
||||
plans = mirror._slot_plans(
|
||||
{"key": "l", "placeholders": [placeholder]}, _document(), [], {},
|
||||
{"placeholders": [master_placeholder]},
|
||||
_document('<g data-pptx-source-ref="master:7" data-pptx-frame="48 168 864 475"/>'),
|
||||
)
|
||||
self.assertEqual(plans[0].bounds, (120, 180, 300, 100))
|
||||
master_guide = ET.fromstring(
|
||||
f'<g xmlns="{SVG}" data-pptx-frame="48 168 864 475">'
|
||||
'<text x="58" y="195" font-size="24">Master sample</text></g>'
|
||||
)
|
||||
carrier = mirror._blank_text_carrier(plans[0], None, master_guide)
|
||||
self.assertEqual(carrier.get("data-pptx-frame"), "120 180 300 100")
|
||||
self.assertIsNone(carrier.text)
|
||||
|
||||
def test_single_emu_placeholder_keeps_positive_source_extent(self) -> None:
|
||||
placeholder = _source_placeholder(1)
|
||||
plans = mirror._slot_plans(
|
||||
{"key": "l", "placeholders": [placeholder]}, _document(), [], {},
|
||||
{"placeholders": []}, _document(),
|
||||
)
|
||||
self.assertEqual(round(plans[0].bounds[2] * 9525), 1)
|
||||
wrapper, _extras = mirror._slot_wrapper(
|
||||
plans[0], None, layout_guide=None, master_guide=None,
|
||||
)
|
||||
page = _document()
|
||||
page.append(wrapper)
|
||||
compact_svg_tree(page, compact_native_frames=False)
|
||||
bounds = tuple(float(value) for value in wrapper.get("data-pptx-bounds").split())
|
||||
self.assertEqual(round(bounds[2] * 9525), 1)
|
||||
text = wrapper.find(f"{{{SVG}}}text")
|
||||
self.assertEqual(round(float(text.get("data-pptx-frame").split()[2]) * 9525), 1)
|
||||
result = {"errors": []}
|
||||
SVGQualityChecker(template_mode=True)._check_preset_geometry_metadata(page, result)
|
||||
self.assertEqual(result["errors"], [])
|
||||
authored = _document('<g data-pptx-bounds="120 180 0.00010499 100"/>')
|
||||
compact_svg_tree(authored)
|
||||
self.assertEqual(authored[0].get("data-pptx-bounds"), "120 180 0 100")
|
||||
|
||||
def test_nonpositive_and_missing_placeholder_frames_still_fail(self) -> None:
|
||||
for geometry in (
|
||||
None,
|
||||
{"x": 120, "y": 180, "width": 0, "height": 100},
|
||||
{"x": 120, "y": 180, "width": 0, "height": 0},
|
||||
{"x": 120, "y": 180, "width": -1, "height": 100},
|
||||
):
|
||||
with self.subTest(geometry=geometry), self.assertRaisesRegex(
|
||||
mirror.MirrorMaterializationError, "no positive deterministic bounds",
|
||||
):
|
||||
mirror._slot_plans(
|
||||
{"key": "l", "placeholders": [{"shapeId": "2", "geometry": geometry}]},
|
||||
_document(), [], {}, {"placeholders": []}, _document(),
|
||||
)
|
||||
|
||||
def test_nonempty_placeholder_recovers_from_slot_bounds(self) -> None:
|
||||
plan = mirror.SlotPlan("slot", "body", "body", 42, "2", (120, 180, 300, 100))
|
||||
for frame in ("120 180 300 0", "120 180 0 100", "120 180 0 0"):
|
||||
with self.subTest(frame=frame):
|
||||
source = ET.fromstring(
|
||||
f'<g xmlns="{SVG}" data-pptx-frame="{frame}">'
|
||||
'<text x="120" y="195" font-size="16">Visible source text</text></g>'
|
||||
)
|
||||
wrapper, _extras = mirror._slot_wrapper(
|
||||
plan, source, layout_guide=None, master_guide=None,
|
||||
)
|
||||
self.assertIsNone(wrapper[0].get("data-pptx-frame"))
|
||||
self.assertEqual(wrapper.get("data-pptx-bounds"), "120 180 300 100")
|
||||
self.assertEqual("".join(wrapper[0].itertext()), "Visible source text")
|
||||
page = _document()
|
||||
page.append(wrapper)
|
||||
result = {"errors": []}
|
||||
SVGQualityChecker(template_mode=True)._check_preset_geometry_metadata(page, result)
|
||||
self.assertEqual(result["errors"], [])
|
||||
exported = self._export(page)
|
||||
self.assertIn("Visible source text", [node.text for node in exported.findall(".//a:t", NS)])
|
||||
blank = mirror._blank_text_carrier(plan, source, None)
|
||||
self.assertEqual(blank.get("data-pptx-frame"), "120 180 300 100")
|
||||
self.assertFalse("".join(blank.itertext()))
|
||||
|
||||
def test_gradient_source_bounds_use_resolved_document_definition(self) -> None:
|
||||
for alpha in (0, 1):
|
||||
with self.subTest(alpha=alpha):
|
||||
gradient = ET.fromstring(
|
||||
f'<linearGradient xmlns="{SVG}" id="stroke" gradientUnits="userSpaceOnUse" '
|
||||
'x1="120" y1="180" x2="420" y2="180">'
|
||||
f'<stop offset="0" stop-color="#3060C0" stop-opacity="{alpha}"/>'
|
||||
f'<stop offset="1" stop-color="#103050" stop-opacity="{alpha}"/></linearGradient>'
|
||||
)
|
||||
native = ET.fromstring(
|
||||
f'<a:gradFill xmlns:a="{A}"><a:gsLst><a:gs pos="0"><a:srgbClr val="3060C0">'
|
||||
f'<a:alpha val="{alpha * 100000}"/></a:srgbClr></a:gs><a:gs pos="100000">'
|
||||
f'<a:srgbClr val="103050"><a:alpha val="{alpha * 100000}"/></a:srgbClr></a:gs>'
|
||||
'</a:gsLst><a:lin ang="0" scaled="1"/></a:gradFill>'
|
||||
)
|
||||
gradient.attrib.update(native_gradient_metadata(native, gradient))
|
||||
content = (
|
||||
'<defs>' + ET.tostring(gradient, encoding="unicode") + '</defs>'
|
||||
'<g id="source" data-pptx-object="shape" data-pptx-prst="rect" '
|
||||
'data-pptx-semantic-object="shape" data-pptx-frame="120 180 300 0">'
|
||||
'<path data-pptx-part="geometry" d="M 120 180 L 420 180" '
|
||||
'stroke="url(#stroke)" stroke-width="4" fill="none"/></g>'
|
||||
)
|
||||
if alpha == 0:
|
||||
with self.assertRaisesRegex(mirror.MirrorMaterializationError, "no positive source frame"):
|
||||
_compose(content)
|
||||
else:
|
||||
page = _compose(content)
|
||||
source = next(element for element in page if element.get("id") == "source")
|
||||
self.assertEqual(source.get("data-pptx-bounds"), "120 178 300 4")
|
||||
result = {"errors": []}
|
||||
SVGQualityChecker(template_mode=True)._check_preset_geometry_metadata(page, result)
|
||||
self.assertEqual(result["errors"], [])
|
||||
exported = self._export(page)
|
||||
self.assertEqual(exported.find(".//p:sp/p:spPr/a:xfrm/a:ext", NS).get("cy"), "0")
|
||||
|
||||
def test_authored_gradient_does_not_grant_source_zero_axis_exception(self) -> None:
|
||||
with self.assertRaisesRegex(mirror.MirrorMaterializationError, "no positive source frame"):
|
||||
_compose(
|
||||
'<defs><linearGradient id="stroke"><stop offset="0" stop-color="#3060C0"/>'
|
||||
'<stop offset="1" stop-color="#103050"/></linearGradient></defs>'
|
||||
'<g id="source" data-pptx-object="shape" data-pptx-prst="rect" '
|
||||
'data-pptx-frame="120 180 300 0"><path d="M 120 180 L 420 180" '
|
||||
'stroke="url(#stroke)" stroke-width="4" fill="none"/></g>'
|
||||
)
|
||||
|
||||
def test_invisible_degenerate_roots_are_omitted_with_context_in_all_layers(self) -> None:
|
||||
for scope in ("slide", "master", "layout"):
|
||||
for frame in ("", ' data-pptx-frame="120 180 640 0"', ' data-pptx-frame="120 180 0 0"'):
|
||||
with self.subTest(scope=scope, frame=frame):
|
||||
warnings = []
|
||||
page = _compose(
|
||||
f'<g id="invisible-rule" data-pptx-object="group"{frame}>'
|
||||
'<g data-pptx-object="connector"><path d="M 120 180 L 420 180" '
|
||||
'fill="none" stroke="none"/></g></g>', scope, warnings=warnings,
|
||||
)
|
||||
self.assertFalse(any(element.get("id") == "invisible-rule" for element in page.iter()))
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertIn("Slide 1", warnings[0])
|
||||
self.assertIn("invisible-rule", warnings[0])
|
||||
self.assertIn("omitted invisible content", warnings[0])
|
||||
if scope != "slide":
|
||||
self.assertIn(scope.title(), warnings[0])
|
||||
self.assertIsNone(self._export(page).find(".//p:sp", NS))
|
||||
|
||||
def test_invisible_positive_slide_frame_is_retained(self) -> None:
|
||||
warnings = []
|
||||
page = _compose(
|
||||
'<g id="outline" data-pptx-frame="120 180 300 100">'
|
||||
'<path d="M 120 180 L 420 180" fill="none" stroke="none"/></g>', warnings=warnings,
|
||||
)
|
||||
self.assertEqual(next(node for node in page if node.get("id") == "outline").get(
|
||||
"data-pptx-bounds"), "120 180 300 100")
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_visible_unbounded_source_content_is_never_omitted(self) -> None:
|
||||
for frame in ("", ' data-pptx-frame="120 180 0 0"'):
|
||||
for content in (
|
||||
'<path d="M 120 180 L 420 180" stroke="#123456" fill="none"/>',
|
||||
'<text x="120" y="180">Visible text</text>',
|
||||
'<image x="120" y="180" width="300" height="100" href="image.png"/>',
|
||||
):
|
||||
with self.subTest(frame=frame, content=content):
|
||||
warnings = []
|
||||
with self.assertRaisesRegex(mirror.MirrorMaterializationError, "no positive source frame"):
|
||||
_compose(f'<g id="visible"{frame}>{content}</g>', warnings=warnings)
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_omission_predicate_preserves_inherited_strokes_and_uncertain_images(self) -> None:
|
||||
for content in (
|
||||
'<g stroke="#123456"><path d="M 120 180 L 420 180" fill="none"/></g>',
|
||||
'<g><rect x="120" y="180" width="300" height="0" fill="none" stroke="#123456"/></g>',
|
||||
'<g><image x="120" y="180" width="0" height="0" href="image.png"/></g>',
|
||||
'<g visibility="hidden"><text visibility="visible">Visible text</text></g>',
|
||||
'<g fill="none"><path d="M 0 0 L 20 0 L 20 20 Z" style="fill:#ff0000"/></g>',
|
||||
'<g style="visibility:hidden"><text visibility="visible">Visible text</text></g>',
|
||||
'<g opacity="0" style="opacity:1"><text>Visible text</text></g>',
|
||||
):
|
||||
with self.subTest(content=content):
|
||||
self.assertFalse(mirror._invisible_degenerate_source(ET.fromstring(content)))
|
||||
warnings = []
|
||||
page = _compose(
|
||||
'<g id="rule" data-pptx-object="connector" data-pptx-prst="straightConnector1" '
|
||||
'data-pptx-frame="120 180 300 0" stroke="#123456">'
|
||||
'<path d="M 120 180 L 420 180" fill="none"/></g>', warnings=warnings,
|
||||
)
|
||||
self.assertEqual(next(node for node in page if node.get("id") == "rule").get(
|
||||
"data-pptx-frame"), "120 180 300 0")
|
||||
self.assertEqual(warnings, [])
|
||||
+118
@@ -115,6 +115,124 @@ class TemplateFlowBatch3Tests(unittest.TestCase):
|
||||
self.assertEqual(line.tag, f'{{{SVG_NS}}}path')
|
||||
self.assertEqual(line.get('d'), 'M 0 80 L 1280 80')
|
||||
|
||||
def _compose_slide(self, content: str) -> ET.Element:
|
||||
empty = ET.fromstring(f'<svg xmlns="{SVG_NS}"/>')
|
||||
return mirror._compose_template(
|
||||
native={'slideSize': {'width_px': 1280, 'height_px': 720}},
|
||||
master={'key': 'm', 'name': 'Master'},
|
||||
layout={'key': 'l', 'name': 'Layout', 'showMasterShapes': True},
|
||||
master_root=empty, layout_root=empty,
|
||||
slide={'index': 1, 'showInheritedShapes': True},
|
||||
slide_root=ET.fromstring(f'<svg xmlns="{SVG_NS}">{content}</svg>'),
|
||||
slot_plans=[],
|
||||
)
|
||||
|
||||
def test_a_mirror_bounds_zero_axis_connectors_and_collinear_groups(self) -> None:
|
||||
result = self._compose_slide(
|
||||
'<g id="rule" data-pptx-object="connector" '
|
||||
'data-pptx-prst="straightConnector1" data-pptx-frame="100 200 400 0">'
|
||||
'<path d="M 100 200 L 500 200" stroke="#000000" stroke-width="4" '
|
||||
'fill="none"/></g>'
|
||||
'<g id="rules" data-pptx-object="group" data-pptx-frame="96 300 744 0">'
|
||||
'<g data-pptx-object="connector" data-pptx-prst="line" '
|
||||
'data-pptx-frame="96 300 429 0"><path d="M 96 300 L 525 300" '
|
||||
'stroke="#000000" stroke-width="2" fill="none"/></g>'
|
||||
'<g data-pptx-object="connector" data-pptx-prst="line" '
|
||||
'data-pptx-frame="546 300 294 0"><path d="M 546 300 L 840 300" '
|
||||
'stroke="#000000" stroke-width="2" fill="none"/></g></g>'
|
||||
)
|
||||
by_id = {e.get('id'): e for e in result.iter() if e.get('id')}
|
||||
self.assertEqual(by_id['rule'].get('data-pptx-bounds'), '100 198 400 4')
|
||||
self.assertEqual(by_id['rule'].get('data-pptx-frame'), '100 200 400 0')
|
||||
self.assertEqual(by_id['rules'].get('data-pptx-bounds'), '96 299 744 2')
|
||||
self.assertEqual(by_id['rules'].get('data-pptx-frame'), '96 300 744 0')
|
||||
|
||||
def test_a_mirror_still_rejects_unknown_degenerate_groups(self) -> None:
|
||||
for frame in ('', ' data-pptx-frame="100 200 0 0"'):
|
||||
with self.subTest(frame=frame), self.assertRaisesRegex(
|
||||
mirror.MirrorMaterializationError, 'no positive source frame'
|
||||
):
|
||||
self._compose_slide(
|
||||
f'<g id="lost" data-pptx-object="group"{frame}>'
|
||||
'<path d="M 100 200 L 500 200" stroke="#000000" '
|
||||
'fill="none"/></g>'
|
||||
)
|
||||
|
||||
def test_a_mirror_bounds_stroked_zero_axis_ordinary_shapes(self) -> None:
|
||||
for geometry in ('data-pptx-prst="rect"', 'data-pptx-geometry-kind="custom"'):
|
||||
for frame, path, expected in (
|
||||
('100 200 400 0', 'M 100 200 L 500 200', '100 198 400 4'),
|
||||
('100 200 0 300', 'M 100 200 L 100 500', '98 200 4 300'),
|
||||
):
|
||||
with self.subTest(geometry=geometry, frame=frame):
|
||||
result = self._compose_slide(
|
||||
f'<g id="rule" data-pptx-object="shape" {geometry} '
|
||||
f'data-pptx-frame="{frame}"><path d="{path}" '
|
||||
'stroke="#123456" stroke-width="4" fill="none"/></g>'
|
||||
)
|
||||
group = next(element for element in result.iter() if element.get('id') == 'rule')
|
||||
self.assertEqual(group.get('data-pptx-bounds'), expected)
|
||||
self.assertEqual(group.get('data-pptx-frame'), frame)
|
||||
self.assertEqual(group.find(f'{{{SVG_NS}}}path').get('d'), path)
|
||||
|
||||
def test_a_mirror_transforms_and_clips_stroked_shape_validation_bounds_only(self) -> None:
|
||||
for transform, expected in (('', '0 0 400 2'), ('translate(10 20)', '10 18 400 4')):
|
||||
with self.subTest(transform=transform):
|
||||
result = self._compose_slide(
|
||||
'<g id="rule" data-pptx-object="shape" data-pptx-prst="rect" '
|
||||
f'data-pptx-frame="0 0 400 0" transform="{transform}" '
|
||||
'style="stroke:#123456;stroke-width:4"><path d="M 0 0 L 400 0" '
|
||||
'fill="none"/></g>'
|
||||
)
|
||||
group = next(element for element in result.iter() if element.get('id') == 'rule')
|
||||
self.assertEqual(group.get('data-pptx-bounds'), expected)
|
||||
self.assertEqual(group.get('data-pptx-frame'), '0 0 400 0')
|
||||
self.assertEqual(group.get('transform'), transform)
|
||||
|
||||
def test_a_mirror_keeps_degenerate_ordinary_shape_refusal_boundaries(self) -> None:
|
||||
for frame, paint, content in (
|
||||
('100 200 400 0', 'stroke="none"', '<path d="M 100 200 L 500 200"/>'),
|
||||
('100 200 400 0', 'stroke="#000000" stroke-width="0"', '<path d="M 100 200 L 500 200"/>'),
|
||||
('100 200 400 0', 'stroke="#000000" stroke-opacity="0"', '<path d="M 100 200 L 500 200"/>'),
|
||||
('100 200 0 0', 'stroke="#000000"', '<path d="M 100 200 L 500 200"/>'),
|
||||
('100 200 -1 100', 'stroke="#000000"', '<path d="M 100 200 L 500 200"/>'),
|
||||
('100 200 400 0', 'stroke="#000000"', '<text x="100" y="200">Text</text>'),
|
||||
):
|
||||
with self.subTest(frame=frame, paint=paint, content=content):
|
||||
element = ET.fromstring(
|
||||
f'<g xmlns="{SVG_NS}" data-pptx-object="shape" data-pptx-prst="rect" '
|
||||
f'data-pptx-frame="{frame}" {paint}>{content}</g>'
|
||||
)
|
||||
self.assertIsNone(mirror._root_group_bounds(element, 1280, 720))
|
||||
|
||||
def test_a_mirror_bounds_retained_unpainted_positive_frame(self) -> None:
|
||||
result = self._compose_slide(
|
||||
'<g id="text-outline" data-pptx-object="shape" '
|
||||
'data-pptx-frame="120 180 80 320">'
|
||||
'<path d="M 120 180 L 200 180 L 200 500 L 120 500 Z" fill="none"/></g>'
|
||||
'<g id="vertical-text" data-pptx-frame="120 180 80 320">'
|
||||
'<text x="160" y="205" fill="#000000" font-size="24">A</text></g>'
|
||||
)
|
||||
by_id = {element.get('id'): element for element in result.iter() if element.get('id')}
|
||||
self.assertEqual(by_id['text-outline'].get('data-pptx-bounds'), '120 180 80 320')
|
||||
self.assertEqual(by_id['vertical-text'].get('data-pptx-bounds'), '120 180 80 320')
|
||||
|
||||
def test_a_export_accepts_native_group_with_one_zero_axis(self) -> None:
|
||||
from svg_to_pptx.drawingml.elements import (
|
||||
_parse_preset_geometry_metadata,
|
||||
)
|
||||
|
||||
group = ET.fromstring(
|
||||
f'<g xmlns="{SVG_NS}" data-pptx-object="group" '
|
||||
'data-pptx-frame="96 300 744 0"/>'
|
||||
)
|
||||
self.assertEqual(
|
||||
_parse_preset_geometry_metadata(group)[2], (96.0, 300.0, 744.0, 0.0)
|
||||
)
|
||||
group.set('data-pptx-frame', '96 300 0 0')
|
||||
with self.assertRaisesRegex(ValueError, 'not both zero'):
|
||||
_parse_preset_geometry_metadata(group)
|
||||
|
||||
def test_a_type_b_has_actionable_rejection(self) -> None:
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diagnose hidden-slide visibility unsupported by template preview export.
|
||||
|
||||
Usage:
|
||||
python3 -m unittest tests.test_template_hidden_slide_diagnostic
|
||||
|
||||
Dependencies:
|
||||
python-pptx and the script runtime dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
|
||||
import pptx_template_import
|
||||
from pptx_to_svg.converter import ConvertOptions, convert_pptx_to_svg
|
||||
|
||||
|
||||
class TemplateHiddenSlideDiagnosticTests(unittest.TestCase):
|
||||
def test_template_warns_without_dropping_slide_or_warning_about_roundtrip(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
presentation = Presentation()
|
||||
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||
slide.element.set("show", "0")
|
||||
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
|
||||
textbox.text = "HIDDEN_SLIDE_MARKER"
|
||||
source = root / "source.pptx"
|
||||
presentation.save(source)
|
||||
warnings = io.StringIO()
|
||||
imported = root / "template"
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(warnings), patch.object(
|
||||
sys, "argv", ["pptx_template_import.py", str(source), "-o", str(imported)],
|
||||
):
|
||||
self.assertEqual(pptx_template_import.main(), 0)
|
||||
self.assertIn("source slide 1 (ppt/slides/slide1.xml)", warnings.getvalue())
|
||||
self.assertIn("template preview export does not preserve", warnings.getvalue())
|
||||
self.assertIn("HIDDEN_SLIDE_MARKER", (imported / "svg/slide_01.svg").read_text(encoding="utf-8"))
|
||||
self.assertEqual((imported / "sources/source.pptx").read_bytes(), source.read_bytes())
|
||||
native_warnings = io.StringIO()
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(native_warnings):
|
||||
converted = convert_pptx_to_svg(
|
||||
source, root / "roundtrip", ConvertOptions(roundtrip=True),
|
||||
)
|
||||
self.assertEqual(len(converted.slides), 1)
|
||||
self.assertNotIn("template preview export", native_warnings.getvalue())
|
||||
+162
-1
@@ -18,7 +18,9 @@ Dependencies:
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
@@ -28,8 +30,10 @@ if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from pptx_to_svg.emu_units import Xfrm # noqa: E402
|
||||
from pptx_to_svg.ooxml_loader import OoxmlPackage # noqa: E402
|
||||
from pptx_to_svg.slide_to_svg import assemble_part_solo, assemble_slide # noqa: E402
|
||||
from pptx_to_svg.tbl_to_svg import convert_tbl # noqa: E402
|
||||
from pptx_to_svg.txbody_to_svg import convert_txbody # noqa: E402
|
||||
from pptx_to_svg.txbody_to_svg import convert_txbody, convert_vertical_txbody # noqa: E402
|
||||
|
||||
|
||||
A = 'http://schemas.openxmlformats.org/drawingml/2006/main'
|
||||
@@ -211,3 +215,160 @@ class RunEmphasisImportTests(unittest.TestCase):
|
||||
svg = convert_txbody(body, Xfrm(x=0, y=0, w=300, h=100), None).svg
|
||||
self.assertIn('font-weight="bold"', svg)
|
||||
self.assertIn('font-style="italic"', svg)
|
||||
|
||||
|
||||
def _vertical_body(mode: str | None, overflow: str | None = None) -> ET.Element:
|
||||
mode_attr = f' vert="{mode}"' if mode is not None else ''
|
||||
overflow_attr = f' vertOverflow="{overflow}"' if overflow is not None else ''
|
||||
return ET.fromstring(
|
||||
f'<p:txBody xmlns:p="{P}" xmlns:a="{A}">'
|
||||
f'<a:bodyPr lIns="0" tIns="0" rIns="0" bIns="0"{mode_attr}{overflow_attr}/>'
|
||||
'<a:lstStyle/><a:p><a:r><a:rPr sz="1800"><a:latin typeface="Arial"/>'
|
||||
'<a:ea typeface="Microsoft YaHei"/></a:rPr><a:t>中文ABC测试</a:t></a:r></a:p>'
|
||||
'</p:txBody>'
|
||||
)
|
||||
|
||||
|
||||
class VerticalTextFrameImportTests(unittest.TestCase):
|
||||
def _import_slide(self, body: ET.Element, frame: Xfrm, *, diagnostics=None) -> ET.Element:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / 'source.pptx'
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr('ppt/presentation.xml', (
|
||||
f'<p:presentation xmlns:p="{P}" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
'<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst>'
|
||||
'<p:sldSz cx="12192000" cy="6858000"/></p:presentation>'
|
||||
))
|
||||
archive.writestr('ppt/_rels/presentation.xml.rels', (
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
|
||||
'Target="slides/slide1.xml"/></Relationships>'
|
||||
))
|
||||
archive.writestr('ppt/slides/slide1.xml', (
|
||||
f'<p:sld xmlns:p="{P}" xmlns:a="{A}"><p:cSld><p:spTree><p:sp>'
|
||||
'<p:nvSpPr><p:cNvPr id="2" name="Vertical"/><p:cNvSpPr txBox="1"/>'
|
||||
'<p:nvPr/></p:nvSpPr><p:spPr>'
|
||||
f'<a:xfrm rot="{int(frame.rot * 60000)}">'
|
||||
f'<a:off x="{int(frame.x * 9525)}" y="{int(frame.y * 9525)}"/>'
|
||||
f'<a:ext cx="{int(frame.w * 9525)}" cy="{int(frame.h * 9525)}"/>'
|
||||
'</a:xfrm><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr>'
|
||||
f'{ET.tostring(body, encoding="unicode")}'
|
||||
'</p:sp></p:spTree></p:cSld></p:sld>'
|
||||
))
|
||||
with OoxmlPackage(path) as package:
|
||||
svg, _ = assemble_slide(
|
||||
package, next(package.iter_slides()), None, diagnostics=diagnostics,
|
||||
)
|
||||
return ET.fromstring(svg)
|
||||
|
||||
def test_vertical_root_groups_keep_positive_source_frame(self) -> None:
|
||||
for mode in ('vert', 'eaVert', 'wordArtVert', 'wordArtVertRtl'):
|
||||
with self.subTest(mode=mode):
|
||||
root = self._import_slide(_vertical_body(mode), Xfrm(x=120, y=180, w=80, h=320))
|
||||
group = root.find('s:g[@id="shape-2-text"]', NS)
|
||||
self.assertIsNotNone(group)
|
||||
self.assertEqual(group.get('data-pptx-frame'), '120 180 80 320')
|
||||
self.assertIsNone(group.get('transform'))
|
||||
self.assertEqual(_visible_text(group), '中文ABC测试')
|
||||
|
||||
def test_auto_stacked_cjk_root_keeps_its_narrow_source_frame(self) -> None:
|
||||
body = _vertical_body(None)
|
||||
body.find('a:p/a:r/a:t', NS).text = '中文窄框测试'
|
||||
root = self._import_slide(body, Xfrm(x=120, y=180, w=20, h=320))
|
||||
group = root.find('s:g[@id="shape-2-text"]', NS)
|
||||
self.assertIsNotNone(group)
|
||||
self.assertEqual(group.get('data-pptx-frame'), '120 180 20 320')
|
||||
self.assertEqual(_visible_text(group), '中文窄框测试')
|
||||
|
||||
def test_rotated_vertical_text_frame_matches_its_positioned_glyphs(self) -> None:
|
||||
root = self._import_slide(
|
||||
_vertical_body('eaVert'), Xfrm(x=120, y=180, w=320, h=80, rot=90),
|
||||
)
|
||||
group = root.find('s:g[@id="shape-2-text"]', NS)
|
||||
self.assertIsNotNone(group)
|
||||
self.assertEqual(group.get('data-pptx-frame'), '240 60 80 320')
|
||||
self.assertIsNone(group.get('transform'))
|
||||
|
||||
def test_degenerate_slide_text_keeps_its_frame_and_reports_its_source_identity(self) -> None:
|
||||
for width, height in ((80, 0), (0, 0), (0, 320), (80, 320)):
|
||||
for mode in ('vert', 'eaVert', 'wordArtVert', 'wordArtVertRtl'):
|
||||
with self.subTest(width=width, height=height, mode=mode):
|
||||
diagnostics = []
|
||||
root = self._import_slide(
|
||||
_vertical_body(mode), Xfrm(x=120, y=180, w=width, h=height),
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
group = root.find('s:g[@id="shape-2-text"]', NS)
|
||||
self.assertIsNotNone(group)
|
||||
self.assertEqual(group.get('data-pptx-frame'), f'120 180 {width} {height}')
|
||||
self.assertEqual(_visible_text(group), '中文ABC测试')
|
||||
self.assertEqual(
|
||||
[row.code for row in diagnostics],
|
||||
['degenerate-text-frame'] if width == 0 or height == 0 else [],
|
||||
)
|
||||
for diagnostic in diagnostics:
|
||||
self.assertEqual(diagnostic.shape_id, '2')
|
||||
self.assertEqual(diagnostic.part_path, 'ppt/slides/slide1.xml')
|
||||
|
||||
def test_layout_guide_without_local_frame_is_not_diagnosed_as_a_source_loss(self) -> None:
|
||||
for explicit_frame in (False, True):
|
||||
with self.subTest(explicit_frame=explicit_frame), tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / 'source.pptx'
|
||||
local_frame = (
|
||||
'<a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></a:xfrm>'
|
||||
if explicit_frame else ''
|
||||
)
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr('ppt/presentation.xml', (
|
||||
f'<p:presentation xmlns:p="{P}">'
|
||||
'<p:sldSz cx="12192000" cy="6858000"/></p:presentation>'
|
||||
))
|
||||
archive.writestr('ppt/slideLayouts/slideLayout1.xml', (
|
||||
f'<p:sldLayout xmlns:p="{P}" xmlns:a="{A}"><p:cSld><p:spTree><p:sp>'
|
||||
'<p:nvSpPr><p:cNvPr id="2" name="Vertical guide"/><p:cNvSpPr/>'
|
||||
'<p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>'
|
||||
f'<p:spPr>{local_frame}</p:spPr>{ET.tostring(_vertical_body("eaVert"), encoding="unicode")}'
|
||||
'</p:sp></p:spTree></p:cSld></p:sldLayout>'
|
||||
))
|
||||
archive.writestr('ppt/slideMasters/slideMaster1.xml', (
|
||||
f'<p:sldMaster xmlns:p="{P}" xmlns:a="{A}"><p:cSld><p:spTree><p:sp>'
|
||||
'<p:nvSpPr><p:cNvPr id="3" name="Body"/><p:cNvSpPr/>'
|
||||
'<p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>'
|
||||
'<p:spPr><a:xfrm><a:off x="914400" y="914400"/>'
|
||||
'<a:ext cx="7620000" cy="3810000"/></a:xfrm></p:spPr>'
|
||||
'</p:sp></p:spTree></p:cSld></p:sldMaster>'
|
||||
))
|
||||
diagnostics = []
|
||||
with OoxmlPackage(path) as package:
|
||||
assemble_part_solo(
|
||||
package, package.load_part('ppt/slideLayouts/slideLayout1.xml'), None,
|
||||
role='layout', parent_master=package.load_part('ppt/slideMasters/slideMaster1.xml'),
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
self.assertEqual(
|
||||
[row.code for row in diagnostics], ['degenerate-text-frame'] if explicit_frame else [],
|
||||
)
|
||||
|
||||
|
||||
class VerticalTextOverflowImportTests(unittest.TestCase):
|
||||
def test_default_and_explicit_overflow_keep_every_vertical_glyph(self) -> None:
|
||||
for mode in ('vert', 'eaVert', 'wordArtVert', 'wordArtVertRtl'):
|
||||
for overflow in (None, 'overflow'):
|
||||
for width, height in ((80, 30), (80, 0), (0, 0)):
|
||||
with self.subTest(mode=mode, overflow=overflow, width=width, height=height):
|
||||
result = convert_vertical_txbody(
|
||||
_vertical_body(mode, overflow), Xfrm(x=120, y=180, w=width, h=height), None,
|
||||
)
|
||||
self.assertEqual(_visible_text(_svg_root(result.svg)), '中文ABC测试')
|
||||
self.assertEqual(result.frame, (120, 180, width, height))
|
||||
|
||||
def test_explicit_clip_and_ellipsis_retain_existing_vertical_truncation(self) -> None:
|
||||
for mode in ('vert', 'eaVert', 'wordArtVert', 'wordArtVertRtl'):
|
||||
for overflow in ('clip', 'ellipsis'):
|
||||
with self.subTest(mode=mode, overflow=overflow):
|
||||
result = convert_vertical_txbody(
|
||||
_vertical_body(mode, overflow), Xfrm(x=120, y=180, w=80, h=30), None,
|
||||
)
|
||||
self.assertEqual(_visible_text(_svg_root(result.svg)), '中')
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "shadcn",
|
||||
"repo": "https://github.com/shadcn-ui/ui.git",
|
||||
"ref": "main",
|
||||
"commit": "8f04138777c1b6996e709993362a7b0a12888ecf",
|
||||
"commit": "96da9cc7f1aa10580a3741664e2a77c721206c00",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/shadcn",
|
||||
"syncedAt": "2026-10-26T16:00:00Z"
|
||||
"syncedAt": "2026-10-28T01:57:49Z"
|
||||
}
|
||||
Reference in new issue
Block a user