A successful download tells you almost nothing about a PowerPoint export. A file can contain one full-slide JPEG and still have a perfectly valid .pptx extension.

Disclosure: we build Image2PPT, a tool for reconstructing slide images as PowerPoint files. I ran the checker below against our own public output, alongside deliberately simple controls. It found text objects. It also missed numbers that were still baked into pictures. That second result is the reason to keep the tool modest.

The original check ran on November 14, 2026, with macOS 27.0 and Python 3.9.6. I reran the English checker against the same controls and existing public conversion result. The controls were generated with python-pptx 1.0.2. I did not submit a new conversion or test the PowerPoint UI.

Start with the archive, not the preview

A PPTX is a ZIP package. Here's an actual listing from the image-only control:

unzip -l picture_only.pptx | grep -E 'slide1.xml$|media' 
 853 11-14-2026 22:14 ppt/slides/slide1.xml 193108 11-14-2026 22:14 ppt/media/image1.jpg 

The slide XML is 853 bytes. The image carries the content. There is no separate title object to edit.

Slide objects generally live under p:cSld/p:spTree. These are the things the checker counts:

XML What it contributes
p:sp containing nonempty a:t text A text-bearing shape
p:sp without nonempty text A shape with no text
p:graphicFrame with the table URI A native table
p:graphicFrame with the chart URI A native chart reference
p:pic A picture
p:grpSp A group whose children need traversal

A slide's own picture background lives outside that tree, under p:cSld/p:bg, so it needs a separate check. A native chart reference is a useful signal; this script does not inspect whether its underlying data is usable.

The complete checker

Save this standard-library checker as check_pptx.py and run python3 check_pptx.py file.pptx. The four outputs below are from this English-language version; the input files are unchanged.

"""Inspect a PPTX for text, tables, charts, and pictures, and estimate picture coverage. Uses only the Python standard library. Usage: python3 check_pptx.py some.pptx """ import posixpath import sys import zipfile import xml.etree.ElementTree as ET 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", "rel": "http://schemas.openxmlformats.org/package/2006/relationships", } P, A = "{%s}" % NS["p"], "{%s}" % NS["a"] CHART_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart" TABLE_URI = "http://schemas.openxmlformats.org/drawingml/2006/table" def slides_in_order(z): """Read slides in PowerPoint order from <p:sldIdLst> in presentation.xml.""" pres = ET.fromstring(z.read("ppt/presentation.xml")) rels = ET.fromstring(z.read("ppt/_rels/presentation.xml.rels")) target = {r.get("Id"): r.get("Target") for r in rels.findall("rel:Relationship", NS)} sz = pres.find("p:sldSz", NS) paths = [] for sid in pres.findall("p:sldIdLst/p:sldId", NS): t = target[sid.get("{%s}id" % NS["r"])] # Relationship targets can be relative (slides/slide1.xml) or absolute (/ppt/slides/slide1.xml). paths.append(t.lstrip("/") if t.startswith("/") else posixpath.normpath(posixpath.join("ppt", t))) return int(sz.get("cx")), int(sz.get("cy")), paths def xfrm(el): """Return (off, ext, chOff, chExt, turned), or None if the transform is missing.""" x = None for path in ("p:spPr/a:xfrm", "p:grpSpPr/a:xfrm", "p:xfrm"): x = el.find(path, NS) if x is not None: break if x is None: return None def pair(tag, a, b): e = x.find(tag, NS) return (int(e.get(a)), int(e.get(b))) if e is not None else None turned = int(x.get("rot", "0")) % 21600000 != 0 or x.get("flipH") in ("1", "true") or x.get("flipV") in ("1", "true") return (pair("a:off", "x", "y"), pair("a:ext", "cx", "cy"), pair("a:chOff", "x", "y"), pair("a:chExt", "cx", "cy"), turned) def walk(node, transform, stats, page_w, page_h, turned=False): """Walk the shape tree and map child coordinates to slide coordinates. If an enclosing group is rotated or flipped, skip the picture area estimate.""" sx, sy, dx, dy = transform for el in node: tag = el.tag if tag == P + "sp": text = "".join(t.text or "" for t in el.iter(A + "t")).strip() if text: stats["text_boxes"] += 1 stats["chars"] += len(text) else: stats["shapes"] += 1 elif tag == P + "pic": stats["pictures"] += 1 f = xfrm(el) if turned or (f and f[4]): # Flag rotated or flipped pictures and groups for manual inspection. stats["turned_pics"] += 1 elif f and f[0] and f[1]: (x, y), (w, h) = f[0], f[1] # Clip the picture rectangle to the slide; ignore transparency and occlusion. x0, y0 = x * sx + dx, y * sy + dy x1, y1 = x0 + w * sx, y0 + h * sy iw = max(0, min(x1, page_w) - max(x0, 0)) ih = max(0, min(y1, page_h) - max(y0, 0)) pct = round(100 * iw * ih / (page_w * page_h)) stats["max_pic_pct"] = max(stats["max_pic_pct"], pct) elif tag == P + "graphicFrame": uri = el.find(".//a:graphicData", NS).get("uri") if uri == TABLE_URI: stats["tables"] += 1 stats["chars"] += sum(len(t.text or "") for t in el.iter(A + "t")) elif uri == CHART_URI: stats["charts"] += 1 elif tag == P + "grpSp": f = xfrm(el) child = transform if f and all(f[:4]): (ox, oy), (ew, eh), (cx, cy), (cw, ch) = f[:4] gsx, gsy = ew / cw if cw else 1, eh / ch if ch else 1 # Map child coordinates to the group, then apply the parent transform. child = (sx * gsx, sy * gsy, sx * (ox - cx * gsx) + dx, sy * (oy - cy * gsy) + dy) walk(el, child, stats, page_w, page_h, turned or bool(f and f[4])) def inspect_slide(xml_bytes, page_w, page_h): root = ET.fromstring(xml_bytes) stats = {"text_boxes": 0, "chars": 0, "shapes": 0, "pictures": 0, "tables": 0, "charts": 0, "max_pic_pct": 0, "turned_pics": 0, # Check this slide only, not the layout or master. "bg_picture": root.find("p:cSld/p:bg//a:blip", NS) is not None} walk(root.find("p:cSld/p:spTree", NS), (1, 1, 0, 0), stats, page_w, page_h) return stats def note(s): big_pic = s["max_pic_pct"] >= 90 or s["bg_picture"] editable = s["text_boxes"] + s["tables"] + s["charts"] if s["turned_pics"]: return f"Rotated or flipped pictures: {s['turned_pics']}. Their coverage was not estimated; inspect the slide." if editable == 0 and big_pic: return "No text shapes, tables, or charts found. A large picture may be a full-slide screenshot; open the slide to confirm." if editable == 0: return "No text shapes, tables, or charts found. Check for text baked into pictures." if big_pic: return "Found text shapes, tables, or charts as well as a large picture. Check the picture for baked-in text." return "Found text shapes, tables, or charts. Check separately for text baked into pictures." def main(path): with zipfile.ZipFile(path) as z: page_w, page_h, slides = slides_in_order(z) print(f"{path} slide size: {page_w / 914400:.2f} x {page_h / 914400:.2f} in") print("No. Text Chars No text Tables Charts Pics Largest pic Pic bg Slide XML") for i, name in enumerate(slides, 1): s = inspect_slide(z.read(name), page_w, page_h) print(f"{i:<3} {s['text_boxes']:>5} {s['chars']:>6} {s['shapes']:>8} " f"{s['tables']:>7} {s['charts']:>7} {s['pictures']:>5} " f"{s['max_pic_pct']:>11}% {'yes' if s['bg_picture'] else 'no':>7} " f"{posixpath.basename(name)}") print(f" > {note(s)}") if __name__ == "__main__": main(sys.argv[1]) 

There are three details worth keeping if you trim this down.

First, filenames are not presentation order. Read p:sldIdLst from presentation.xml, then resolve its relationship IDs. Handle both relative targets and package-absolute targets. Otherwise a reordered deck can give you a correct finding against the wrong page number.

Second, groups have their own coordinate spaces. Map child offsets and extents through each enclosing group's off, ext, chOff, and chExt. The largest-picture percentage is a clipped rectangle estimate in slide coordinates. It doesn't subtract transparency or occlusion, sum picture areas, or measure rasterized text.

Third, rotation and flipping invalidate that simple area calculation. The script flags those pictures rather than inventing an area. The 90% threshold for a large picture is a heuristic, not an editability boundary.

Controls that catch misleading results

The reordered two-slide fixture includes a grouped full-size picture scaled to half width and height, and a table-only slide:

edge_cases.pptx slide size: 13.33 x 7.50 in No. Text Chars No text Tables Charts Pics Largest pic Pic bg Slide XML 1 0 0 0 0 0 1 25% no slide2.xml > No text shapes, tables, or charts found. Check for text baked into pictures. 2 0 4 0 1 0 0 0% no slide1.xml > Found text shapes, tables, or charts. Check separately for text baked into pictures. 

The first displayed page is slide2.xml, with 25% picture coverage. The second is slide1.xml, and its table counts as an editable object even though it has no text-bearing shapes.

Here is the image-only control:

picture_only.pptx slide size: 13.33 x 7.50 in No. Text Chars No text Tables Charts Pics Largest pic Pic bg Slide XML 1 0 0 0 0 0 1 100% no slide1.xml > No text shapes, tables, or charts found. A large picture may be a full-slide screenshot; open the slide to confirm. 

And a native control containing a title, a 4×2 table, and a column chart:

native.pptx slide size: 13.33 x 7.50 in No. Text Chars No text Tables Charts Pics Largest pic Pic bg Slide XML 1 1 31 0 1 1 0 0% no slide1.xml > Found text shapes, tables, or charts. Check separately for text baked into pictures. 

Reading across each row: page, text boxes, characters, shapes without text, tables, charts, pictures, largest-picture percentage, slide-local picture background, and XML filename. The native control's 31 characters come from the title and table; chart labels aren't included in that count.

The fixture-generation scripts are included alongside this draft. They need python-pptx; the checker doesn't.

Our own output passed the easy check and failed the stronger one

I used the Chinese public SaaS dashboard because it contains titles, KPIs, a stacked bar chart, and a funnel on one page. The language doesn't affect the XML checks.

Public source image, before reconstruction

Source: the public input image, a Chinese-language SaaS revenue expansion dashboard. The Chinese text is part of the original evidence. This is not a PowerPoint screenshot.

zh-saas-revenue-expansion.pptx slide size: 13.33 x 7.50 in No. Text Chars No text Tables Charts Pics Largest pic Pic bg Slide XML 1 47 201 13 0 0 19 12% no slide1.xml > Found text shapes, tables, or charts. Check separately for text baked into pictures. 

That's 47 text boxes, 13 shapes without text, 19 pictures, and zero native tables or charts. The largest picture covers about 12% of the page. This is more than a screenshot wrapper. It still isn't a fully editable dashboard.

I extracted the embedded pictures with this command, also run locally:

unzip -o zh-saas-revenue-expansion.pptx 'ppt/media/*' -d out 

The 19 images extracted from the PPTX, on an added grey background

These are the actual embedded images. The grey background and filenames were added by the contact-sheet script; this isn't a PowerPoint screenshot.

Two pictures contain text that matters:

  • image-1-3.jpg retains the third and sixth bars, including 300/190/100 and 400/240/140, plus the Chinese labels for March and June.
  • image-1-5.jpg retains the funnel's 1,680, 710, and 198.

The text-bearing shapes contain the other four month labels, but not those two. A count of 47 text boxes cannot tell you that six bar values and three funnel values still aren't text. Nor do editable labels make a chart data-driven: the chart count is zero.

Icons and illustrations also remain pictures. That can be a sensible representation for artwork, but it doesn't recover editable internal paths.

What I'd actually use this for

This is a triage script. It can route suspicious pages to manual inspection; it cannot award an “editable” badge.

It doesn't inspect slide layouts or masters, SmartArt, text converted to outlines, or text inside images. It also doesn't judge reading order, font substitution, clipping, or whether one sentence has been split into too many boxes. A text object is evidence of structure, not proof of a good editing experience.

For a batch export check, I'd run this first, then inspect the embedded media on representative pages. For a single deck, opening it in the target editor is still necessary. To reproduce these numbers, download the exact Chinese-language sample PPTX and run the checker against it.

Drafted with AI assistance; the files, scripts and numbers are from real runs.