Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot committed 2026-10-28 10:01:14 +08:00
1 parent 360779e38d
commit 79c20679c5
59 files changed
+4095 -343

No files matched your search

@@ -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.
@@ -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", []):
@@ -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.
@@ -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"]]
+ [
@@ -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
@@ -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(
@@ -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:
@@ -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;
@@ -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),
)
@@ -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)
@@ -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 ""
@@ -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"![{alt_text}]({asset_dir.name}/{saved_picture.filename})"
)
image_link = _shape_click_target(shape)
if image_link is not None:
@@ -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)
@@ -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'):
@@ -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>'''
@@ -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(
@@ -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',
@@ -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
@@ -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]]:
@@ -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
@@ -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"], [])
@@ -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)
@@ -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 &amp; 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')
@@ -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))
@@ -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'))
@@ -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"]))
@@ -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 [
@@ -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'], [])
@@ -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)
@@ -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)])
@@ -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])
@@ -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>'))
@@ -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)
@@ -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())
@@ -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)
@@ -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())
@@ -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')
@@ -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, [])
@@ -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):
@@ -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())
@@ -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"
}