# quicklook-pptx-renderer

**Open-source PPTX rendering engine that replicates Apple's macOS QuickLook and iOS preview — pixel for pixel.** Lint, render, and diff PowerPoint files without a Mac.

Runs on Linux, Docker, AWS Lambda, and anywhere Node.js runs. No Apple hardware required.

Includes a **linter** that catches QuickLook compatibility issues before your users see them.

---

## The Problem

PowerPoint files look different on Mac and iPhone than on Windows. Shapes disappear. Table borders vanish. Gradients become flat colors. Shadows turn into opaque white blocks that cover content.

This affects every `.pptx` created by [python-pptx](https://python-pptx.readthedocs.io/), [PptxGenJS](https://gitbrent.github.io/PptxGenJS/), [Google Slides](https://slides.google.com) export, [Canva](https://www.canva.com) export, [LibreOffice Impress](https://www.libreoffice.org/), [Pandoc](https://pandoc.org/), [Quarto](https://quarto.org/), [Apache POI](https://poi.apache.org/), [Aspose.Slides](https://products.aspose.com/slides/), and the [Open XML SDK](https://github.com/dotnet/Open-XML-SDK) — any tool that generates OOXML presentations.

It happens because Apple uses a private framework called `OfficeImport.framework` — a completely independent OOXML parser with its own bugs and rendering quirks. It converts slides to HTML + CSS, renders complex shapes as opaque PDF images, and silently drops unsupported geometries. This framework powers QuickLook in macOS Finder (spacebar preview), the iOS Files app, Mail.app attachments, and iPadOS Quick Look.

### What Goes Wrong

| What you see on Mac/iPhone | Why it happens |
|---|---|
| **Shapes disappear** — heart, cloud, lightningBolt, sun, moon, and ~120 more | OfficeImport's `CMCanonicalShapeBuilder` only supports ~60 of 187 preset geometries. The rest are silently dropped — no error, no fallback. |
| **Opaque white blocks cover content** — rounded rectangles with shadows become solid rectangles | Any non-`rect` shape or any shape with effects (drop shadow, glow, reflection) is rendered as an opaque PDF image via `CMDrawingContext.copyPDF`. The PDF has a non-transparent background. |
| **Table borders missing** — tables appear as plain text without any grid | OfficeImport doesn't resolve `tableStyleId` references. It emits `border-style:none` per-cell unless borders are explicit in `<a:lnL>`, `<a:lnR>`, `<a:lnT>`, `<a:lnB>`. PowerPoint resolves the style and shows borders; QuickLook doesn't. python-pptx and most generators rely on style references. |
| **Gradients become flat colors** — gradient fills show as a single solid color | Gradients with 3+ color stops are averaged to one color instead of being rendered as a gradient. |
| **Fonts shift and text reflows** — text overflows boxes, lines break differently | Calibri → Helvetica Neue, Arial → Helvetica, Segoe UI → Helvetica Neue. Different metrics cause text reflow. |
| **Charts are blank rectangles** — chart content simply vanishes | Charts without an embedded fallback image render as empty rectangles. |
| **Phantom shapes from other slides** — shapes you didn't put there appear | A `CMArchiveManager` use-after-free bug: the PDF cache is keyed by pointer identity, causing cross-slide bleed. |

These issues affect presentations viewed in **macOS Finder** (spacebar / QuickLook), **iOS Files app**, **iPadOS Quick Look**, **Mail.app** attachment previews, and **Messages.app** link previews — anywhere Apple renders PPTX without Microsoft PowerPoint.

---

## Quick Start

```bash
npm install quicklook-pptx-renderer
```

### Lint a PPTX for QuickLook Compatibility Issues

```bash
npx quicklook-pptx lint presentation.pptx
```

```
Slide 4:
  [WARN] "roundRect" with effects renders as an opaque PDF image (Shape 9)
         -> Remove drop shadow/glow effects, or use a plain rect
  [ERR ] "cloud" is not supported by OfficeImport — shape will be invisible (Shape 12)
         -> Use a supported preset or embed as an image

0 errors, 5 warnings, 0 info across 18 slides
```

```bash
# JSON output for CI pipelines
npx quicklook-pptx lint presentation.pptx --json
```

### Render Slides (see what Mac users see)

```bash
# Render all slides to PNG
npx quicklook-pptx render presentation.pptx --out ./slides/

# Single slide, high-DPI
npx quicklook-pptx render presentation.pptx --slide 3 --width 1920 --scale 2

# Get raw HTML (same format as QuickLook's Preview.html)
npx quicklook-pptx html presentation.pptx
```

### Pixel-Diff Against Real QuickLook (macOS only)

```bash
npx quicklook-pptx diff presentation.pptx --quicklook-dir /tmp/ql-out/
```

### Render to Pixel-Perfect PNGs

The renderer outputs the exact HTML+CSS that QuickLook generates internally. Rendering that HTML in Playwright WebKit produces PNGs identical to what macOS QuickLook shows — on any platform, no Mac required.

```typescript
import { render } from "quicklook-pptx-renderer";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { webkit } from "playwright";

const pptx = readFileSync("presentation.pptx");
const result = await render(pptx);

// Write HTML + attachments (PDF/PNG) to a servable directory
const dir = "/tmp/pptx-preview";
mkdirSync(dir, { recursive: true });
writeFileSync(`${dir}/Preview.html`, result.fullHtml);
for (const [name, buf] of result.attachments) {
  writeFileSync(`${dir}/${name}`, buf);
}

// Render in Playwright WebKit → pixel-perfect QuickLook PNGs
const browser = await webkit.launch();
const page = await browser.newPage({ viewport: { width: 736, height: 800 } });
await page.goto(`file://${dir}/Preview.html`);

const slides = await page.locator("div.slide").all();
for (let i = 0; i < slides.length; i++) {
  await slides[i].screenshot({ path: `slide-${i + 1}.png` });
}
await browser.close();
```

> **Why WebKit?** QuickLook uses WebKit to render its HTML output. Using Playwright's WebKit engine reproduces the exact same rendering — fonts, CSS quirks, PDF image display — on Linux, Docker, or CI. Chromium or Firefox will produce slightly different results.

### As a Library

```typescript
import { render } from "quicklook-pptx-renderer";
import { readFileSync, writeFileSync } from "fs";

const pptx = readFileSync("presentation.pptx");
const result = await render(pptx);

// Each slide has the exact HTML that QuickLook would generate
for (const slide of result.slides) {
  writeFileSync(`slide-${slide.index + 1}.html`, slide.html);
}

// Full HTML document with CSS (open in WebKit for pixel-perfect result)
writeFileSync("preview.html", result.fullHtml);
```

### Lint Programmatically

```typescript
import { lint, formatIssues } from "quicklook-pptx-renderer";

const result = await lint(pptxBuffer);

// Human-readable output
console.log(formatIssues(result));

// Structured issues for CI gates
for (const issue of result.issues) {
  if (issue.severity === "error") {
    console.error(`Slide ${issue.slide}: ${issue.message}`);
  }
}
```

### Building Fix Tools

The lint API is designed to power automated PPTX fixers. Each issue's `fix` object tells you exactly what to change — no re-detection needed.

```typescript
import { lint, SUPPORTED_GEOMETRIES, FONT_SUBSTITUTIONS, GEOMETRY_FALLBACKS } from "quicklook-pptx-renderer";

const result = await lint(pptxBuffer);

for (const issue of result.issues) {
  if (!issue.fix) continue; // informational only

  switch (issue.fix.action) {
    case "replace-geometry":
      // issue.fix.current = "heart", issue.fix.suggested = "ellipse"
      // Replace <a:prstGeom prst="heart"> with <a:prstGeom prst="ellipse">
      break;

    case "strip-effects":
      // Remove <a:effectLst> and <a:effectDag> from the shape
      break;

    case "reduce-stops":
      // issue.fix.firstColor = "#FF0000", issue.fix.lastColor = "#0000FF"
      // issue.fix.averageColor = "#7F007F", issue.fix.angle = 5400000
      // Replace 3+ gradient stops with 2-stop using first/last colors
      break;

    case "replace-font":
      // issue.fix.from = "Calibri", issue.fix.macTarget = "Helvetica Neue"
      // issue.fix.widthDelta = 14.4, issue.fix.alternatives = [
      //   { font: "Carlito", widthDelta: 2.1 },
      //   { font: "Montserrat", widthDelta: 0.4 }
      // ]
      break;

    case "inline-borders":
      // issue.fix.tableStyleId = "{5C22544A-...}"
      // Resolve table style and write explicit borders on each cell
      // Note: tables without a style AND without borders are intentionally
      // borderless — both PowerPoint and QuickLook render them the same way.
      break;

    case "add-fallback-image":
      // Generate or embed a chart fallback image via mc:AlternateContent
      break;

    case "ungroup":
      // issue.fix.childCount = 5, issue.fix.containsPictures = false
      // Promote group children to slide-level drawables
      break;

    case "strip-embedded-fonts":
      // issue.fix.fonts = [{ name: "Montserrat", replacement: "DIN Alternate" }]
      // Strip embedded font data and replace with cross-platform alternatives
      break;
  }
}
```

Also available: `SUPPORTED_GEOMETRIES` (Set of 60 presets OfficeImport renders), `FONT_SUBSTITUTIONS` (Windows → macOS font map), `GEOMETRY_FALLBACKS` (unsupported → closest supported preset), `findClosestFont()` (metric-based font matching with narrower-preference), `widthDelta()` (percentage width difference between fonts), `APPLE_SYSTEM_FONTS` (29 fonts preinstalled on both macOS and iOS), `MACOS_SYSTEM_FONTS` (38 fonts on macOS).

---

## Lint Rules

Every issue carries a typed `fix` object with machine-readable remediation data — downstream tools can apply transforms without re-detecting the problem.

| Rule | Severity | What It Catches | `fix.action` |
|------|----------|----------------|-------------|
| `unsupported-geometry` | error | Shape preset not in OfficeImport's ~60 supported geometries — will be invisible | `replace-geometry` (includes closest supported preset) |
| `chart-no-fallback` | error | Chart without fallback image — renders as blank rectangle | `add-fallback-image` |
| `opaque-pdf-block` | warn | Shape with effects rendered as opaque PDF covering content behind it | `strip-effects` |
| `gradient-flattened` | warn | 3+ gradient stops collapsed to single average color | `reduce-stops` (includes computed 2-stop colors + average) |
| `table-style-unresolved` | warn | Table has style but cells lack explicit borders — PowerPoint shows borders, QL won't | `inline-borders` (includes `tableStyleId`) |
| `font-substitution` | warn/info | Font will be substituted on macOS — includes width delta (warn if ≥10% reflow risk) | `replace-font` (includes macOS target, delta, metrically-closest alternatives) |
| `group-as-pdf` | warn | Group rendered as single opaque PDF image | `ungroup` (includes child count, whether group contains pictures) |
| `geometry-forces-pdf` | info | Non-rect geometry renders as PDF — opaque background may cover adjacent content | — |
| `rotation-forces-pdf` | info | Rotated rect renders as PDF — enlarged bounding box may cover adjacent content | — |
| `text-inscription-shift` | info | Text in non-rect shape uses geometry-inscribed bounds (text may shift) | `replace-geometry` (suggests `rect`) |
| `embedded-font` | warn | Embedded fonts ignored by QuickLook — text renders with system substitutes | `strip-embedded-fonts` (includes per-font replacement when metrics available) |
| `vertical-text` | info | Vertical text uses CSS writing-mode | — |

---

## Rendering Accuracy

Pixel-level comparison against actual QuickLook output (`qlmanage -p`):

| Source | Slides | Avg Pixel Diff | Perfect (< 0.05%) |
|--------|--------|----------------|---------------------|
| Real-world presentation | 18 | 0.15% | 16/18 |
| python-pptx generated | 10 | 0.17% | 6/10 |
| python-pptx stress test | 10 | 0.42% | 4/10 |
| PptxGenJS generated | 10 | 0.11% | 6/10 |
| Chart test suite | 7 | 0.08% | 7/7 |
| **Overall** | **55** | **0.19%** | **39/55** |

236/236 synthetic HTML structure tests pass.

---

## Which Tools Produce Affected Files

Any tool that generates `.pptx` files can produce presentations that render incorrectly on Apple devices:

| Tool | Typical issues on Mac/iPhone |
|------|-----|
| **python-pptx** | Table borders missing, shapes invisible, "PowerPoint found a problem with content" errors |
| **PptxGenJS** | Missing thumbnails, shape rendering differences |
| **Google Slides** (File → Download as .pptx) | Font substitution, formatting shifts, missing embedded fonts |
| **Canva** (Download as .pptx) | Animations stripped, font substitution, layout differences |
| **LibreOffice Impress** (Save As .pptx) | Table styles unresolved, gradient rendering differences |
| **Pandoc** / **Quarto** (markdown → pptx) | Content type corruption, shapes missing, repair errors |
| **Apache POI** (Java) | Content type errors, missing fallback images |
| **Aspose.Slides** | Thumbnail missing if not explicitly generated |
| **Open XML SDK** (.NET) | Repair errors, missing relationships |

If you generate presentations programmatically and your users view them on Mac or iPhone, you should lint with this tool.

---

## How It Works

```
PPTX (ZIP) → Package → Reader → Resolve → Mapper → HTML + PDF attachments
```

| Module | OfficeImport Equivalent | Purpose |
|--------|------------------------|---------|
| `package/` | OCP/OCX | ZIP extraction, `.rels` relationships |
| `reader/` | PX/OAX | OOXML XML → typed domain objects |
| `model/` | PD/OAD | TypeScript interfaces for slides, shapes, text, tables |
| `resolve/` | — | Color themes, font substitution, property inheritance |
| `mapper/` | PM/CM | HTML + CSS + PDF generation |
| `lint` | — | Static analysis against known OfficeImport quirks |

### Two Rendering Paths

OfficeImport routes every shape through one of two paths:

**CSS** — Only plain `rect` with no rotation and no effects → `<div>` with CSS properties.

**PDF** — Everything else (roundRect, ellipse, stars, arrows, any rotation/effects) → inline PDF via `CMDrawingContext.copyPDF`, embedded as `<img src="AttachmentN.pdf">`.

This is why shapes with drop shadows or rounded corners render as opaque blocks — the PDF image has a non-transparent background that covers content behind it.

### Property Inheritance

```
shape local → slide placeholder → layout placeholder → master placeholder → master text style → theme
```

### Font Substitution Map

macOS replaces Windows fonts at render time (from `TCFontUtils` in OfficeImport). Width deltas computed from `xWidthAvg` in the font metrics database:

| Windows Font | macOS Replacement | Width Δ | Text Reflow Risk |
|---|---|---|---|
| Calibri | Helvetica Neue | **+14.4%** | High — text overflows boxes |
| Calibri Light | Helvetica Neue Light | **+20.9%** | High — significant reflow |
| Arial | Helvetica | +0.0% | None — metrically identical |
| Arial Black | Helvetica Neue | **-15.9%** | High — text shrinks |
| Arial Narrow | Helvetica Neue | **+20.0%** | High — significant reflow |
| Cambria | Georgia | +7.0% | Medium |
| Consolas | Menlo | +9.5% | Medium — wider monospace |
| Courier New | Courier | +0.0% | None |
| Times New Roman | Times | +0.0% | None |
| Tahoma | Geneva | **+10.0%** | High |
| Segoe UI | Helvetica Neue | **+14.0%** | High |
| Century Gothic | Futura | +1.0% | Low |
| Franklin Gothic Medium | Avenir Next Medium | **+17.8%** | High |
| Corbel | Avenir Next | **+18.8%** | High |
| Candara | Avenir | +8.0% | Medium |
| Constantia | Georgia | +4.2% | Low |
| Palatino Linotype | Palatino | +0.0% | None |
| Book Antiqua | Palatino | +0.0% | None |
| Garamond | Georgia | +8.7% | Medium |
| Impact | Impact | +0.0% | None |

**Safe cross-platform fonts** (identical on Windows and macOS): Arial, Verdana, Georgia, Trebuchet MS, Courier New, Times New Roman, Impact, Palatino Linotype, Book Antiqua.

**Highest reflow risk**: Calibri Light (+20.9%), Arial Narrow (+20.0%), Corbel (+18.8%), Franklin Gothic Medium (+17.8%), Arial Black (-15.9%), Calibri (+14.4%), Segoe UI (+14.0%), Tahoma (+10.0%).

The linter reports width deltas, upgrades font substitution to `warn` severity when the delta exceeds ±10%, and includes metrically-closest cross-platform alternatives in the `fix` data.

Plus CJK mappings: MS Gothic → Hiragino Sans, SimSun → STSong, Microsoft YaHei → PingFang SC, Malgun Gothic → Apple SD Gothic Neo, and more.

### Metric-Compatible Google Fonts (Croscore)

These open-source fonts (SIL OFL) were designed as drop-in replacements for Microsoft core fonts. Width deltas measured from OS/2 tables and included in the font metrics database (106 fonts total):

| Microsoft Font | Google Font Replacement | Width Δ vs Original | License |
|---|---|---|---|
| Calibri | **Carlito** | -2.1% | OFL |
| Cambria | **Caladea** | -5.6% | OFL |
| Arial | **Arimo** | -1.2% | Apache 2.0 |
| Times New Roman | **Tinos** | +0.5% | Apache 2.0 |
| Courier New | **Cousine** | 0.0% | Apache 2.0 |

Compare: Calibri → Helvetica Neue (the QL substitution) is **+14.4%**. Carlito at -2.1% is 7x better.

### What Doesn't Work for Font Fixes

Verified by testing against actual OfficeImport output:

- **Embedded fonts (`<p:embeddedFont>`)**: OfficeImport ignores them completely. No `OADEmbeddedFont` class exists in the framework. Embedding helps PowerPoint and LibreOffice, but not QuickLook.
- **`pitchFamily` attribute**: OfficeImport ignores it. All unknown fonts produce `font-family:"FontName"` in CSS with no generic family fallback. WebKit then falls back to **serif (Times)** regardless of the pitchFamily hint.
- **Font stacks / fallback chains**: OOXML supports only one `typeface` per text run. No CSS-like `font-family: "Carlito", "Calibri", sans-serif` — it's a single value.
- **Parent font inheritance as fallback**: OOXML property inheritance only applies when the property is **missing**, not when the font isn't installed. If a run specifies `typeface="Carlito"`, the parent's `typeface="Arial"` is not used as a fallback.

**The only way to control fonts in QuickLook is to use a font that macOS has installed.** The `FONT_SUBSTITUTIONS` map and `findClosestFont()` API provide the data to pick the best replacement. Use `APPLE_SYSTEM_FONT_LIST` as candidates to ensure results work on both macOS and iOS:

```typescript
import { findClosestFont, APPLE_SYSTEM_FONT_LIST } from "quicklook-pptx-renderer";

// Montserrat → DIN Alternate (-4.3% width) — narrower is preferred over wider
const [best] = findClosestFont("Montserrat", { candidates: APPLE_SYSTEM_FONT_LIST });
```

---

## Key Discoveries

These findings come from reverse engineering OfficeImport via Objective-C runtime introspection and ARM64 disassembly. They are not documented anywhere else:

1. **Only `rect` takes the CSS path** — even `roundRect` and `ellipse` always go to PDF
2. **~60 of 187 shape presets are supported** — the rest are silently dropped (no error, no fallback)
3. **PDF shapes use the `B` operator** (simultaneous fill+stroke) with invisible default stroke
4. **`CMArchiveManager` has a use-after-free** — PDF cache keyed by pointer identity causes cross-slide bleed
5. **Table row heights use `EMU / 101600`**, not `EMU / 12700`
6. **3+ gradient stops → average color** (not rendered as gradient)
7. **Font substitution**: Calibri → Helvetica Neue, Arial → Helvetica, etc. (full map above)
8. **Text inscription for non-rect shapes** uses `float` radius and `trunc()` in pixel space
9. **Embedded fonts are ignored** — no `OADEmbeddedFont` class; `TCImportFontCache` only does CoreText system font lookup
10. **`pitchFamily` is ignored** — all unknown fonts get bare `font-family:"Name"` in CSS with no generic family fallback, regardless of pitchFamily value

---

## Coordinate System

| Measurement | Unit | Conversion |
|------------|------|-----------|
| Position / Size | EMU | 12,700 EMU = 1 CSS pixel |
| Font size | Hundredths of a point | 1800 = 18pt |
| Angles | 60,000ths of a degree | 5,400,000 = 90deg |
| Color transforms | 1,000ths of percent | 100,000 = 100% |
| Table row height | EMU / 101,600 | (OfficeImport quirk) |

---

## Compatibility

| Platform | Status |
|----------|--------|
| Node.js 20+ (Linux, macOS, Windows) | Full support |
| Docker / containers | Full support |
| AWS Lambda / Cloud Functions | Full support (HTML output; PNG needs Playwright) |
| Bun | Untested |
| Deno | Untested |

---

## Fixing Issues (not just detecting them)

This package **detects** QuickLook rendering issues. To **automatically fix** PPTX files so they render correctly on Apple devices, see [**pptx-fix**](https://github.com/Fornace/pptx-fix) — a companion tool that rewrites the OOXML to inline explicit borders, collapse gradients, replace unsupported geometries, and more.

```bash
# Detect issues
npx quicklook-pptx lint presentation.pptx

# Fix issues (separate package)
npx pptx-fix presentation.pptx -o fixed.pptx
```

### Manual Fixes

If you prefer fixing at the source:

**python-pptx — table borders missing:**

```python
from pptx.util import Pt
from pptx.dml.color import RGBColor

for cell in table.iter_cells():
    for border in [cell.border_top, cell.border_bottom,
                   cell.border_left, cell.border_right]:
        border.width = Pt(1)
        border.color.rgb = RGBColor(0, 0, 0)
```

**Shapes disappearing:** Your shape preset isn't in OfficeImport's supported list. Run the linter to find which shapes will be invisible. Use supported presets or embed complex shapes as images.

**Opaque white blocks:** Shapes with drop shadows render as opaque PDF images. The PDF has a non-transparent background. Remove effects or restructure z-ordering.

---

## Related Projects

| Project | Difference |
|---------|-----------|
| **[pptx-fix](https://github.com/Fornace/pptx-fix)** | Companion tool — **fixes** PPTX files for QuickLook; this package **detects** issues and **renders** |
| [LibreOffice headless](https://www.libreoffice.org/) | Renders like LibreOffice, not like QuickLook |
| [python-pptx](https://python-pptx.readthedocs.io/) | Creates/reads PPTX; doesn't render |
| [PptxGenJS](https://gitbrent.github.io/PptxGenJS/) | Creates PPTX; doesn't render |
| [Apache POI](https://poi.apache.org/) | Java; renders like Java, not like QuickLook |

**This is the only open-source project that replicates Apple's exact QuickLook rendering.**

---

## Dependencies

| Package | Purpose | Required |
|---------|---------|----------|
| [jszip](https://stuk.github.io/jszip/) | ZIP extraction | Yes |
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | OOXML XML parsing | Yes |
| [@napi-rs/canvas](https://github.com/nickthedude/napi-rs-canvas) | PDF image generation | Optional (for render) |
| [playwright](https://playwright.dev/) | HTML-to-PNG screenshots | Optional (for render/diff) |

The linter (`npx quicklook-pptx lint`) needs only jszip + fast-xml-parser — zero native dependencies.

## License

MIT
