quicklook-pptx-renderer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +175 -0
- package/dist/diff/compare.d.ts +17 -0
- package/dist/diff/compare.js +71 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +72 -0
- package/dist/lint.d.ts +27 -0
- package/dist/lint.js +328 -0
- package/dist/mapper/bleed-map.d.ts +6 -0
- package/dist/mapper/bleed-map.js +1 -0
- package/dist/mapper/constants.d.ts +2 -0
- package/dist/mapper/constants.js +4 -0
- package/dist/mapper/drawable-mapper.d.ts +16 -0
- package/dist/mapper/drawable-mapper.js +1464 -0
- package/dist/mapper/html-generator.d.ts +13 -0
- package/dist/mapper/html-generator.js +539 -0
- package/dist/mapper/image-mapper.d.ts +14 -0
- package/dist/mapper/image-mapper.js +70 -0
- package/dist/mapper/nano-malloc.d.ts +130 -0
- package/dist/mapper/nano-malloc.js +197 -0
- package/dist/mapper/ql-bleed.d.ts +35 -0
- package/dist/mapper/ql-bleed.js +254 -0
- package/dist/mapper/shape-mapper.d.ts +41 -0
- package/dist/mapper/shape-mapper.js +2384 -0
- package/dist/mapper/slide-mapper.d.ts +4 -0
- package/dist/mapper/slide-mapper.js +112 -0
- package/dist/mapper/style-builder.d.ts +12 -0
- package/dist/mapper/style-builder.js +30 -0
- package/dist/mapper/text-mapper.d.ts +14 -0
- package/dist/mapper/text-mapper.js +302 -0
- package/dist/model/enums.d.ts +25 -0
- package/dist/model/enums.js +2 -0
- package/dist/model/types.d.ts +482 -0
- package/dist/model/types.js +7 -0
- package/dist/package/content-types.d.ts +1 -0
- package/dist/package/content-types.js +4 -0
- package/dist/package/package.d.ts +10 -0
- package/dist/package/package.js +52 -0
- package/dist/package/relationships.d.ts +6 -0
- package/dist/package/relationships.js +25 -0
- package/dist/package/zip.d.ts +6 -0
- package/dist/package/zip.js +17 -0
- package/dist/reader/color.d.ts +3 -0
- package/dist/reader/color.js +79 -0
- package/dist/reader/drawing.d.ts +17 -0
- package/dist/reader/drawing.js +403 -0
- package/dist/reader/effects.d.ts +2 -0
- package/dist/reader/effects.js +83 -0
- package/dist/reader/fill.d.ts +2 -0
- package/dist/reader/fill.js +94 -0
- package/dist/reader/presentation.d.ts +5 -0
- package/dist/reader/presentation.js +127 -0
- package/dist/reader/slide-layout.d.ts +2 -0
- package/dist/reader/slide-layout.js +28 -0
- package/dist/reader/slide-master.d.ts +4 -0
- package/dist/reader/slide-master.js +49 -0
- package/dist/reader/slide.d.ts +2 -0
- package/dist/reader/slide.js +26 -0
- package/dist/reader/text-list-style.d.ts +2 -0
- package/dist/reader/text-list-style.js +9 -0
- package/dist/reader/text.d.ts +5 -0
- package/dist/reader/text.js +295 -0
- package/dist/reader/theme.d.ts +2 -0
- package/dist/reader/theme.js +109 -0
- package/dist/reader/transform.d.ts +2 -0
- package/dist/reader/transform.js +21 -0
- package/dist/render/image-renderer.d.ts +3 -0
- package/dist/render/image-renderer.js +33 -0
- package/dist/render/renderer.d.ts +9 -0
- package/dist/render/renderer.js +178 -0
- package/dist/render/shape-renderer.d.ts +3 -0
- package/dist/render/shape-renderer.js +175 -0
- package/dist/render/text-renderer.d.ts +3 -0
- package/dist/render/text-renderer.js +152 -0
- package/dist/resolve/color-resolver.d.ts +18 -0
- package/dist/resolve/color-resolver.js +321 -0
- package/dist/resolve/font-map.d.ts +2 -0
- package/dist/resolve/font-map.js +66 -0
- package/dist/resolve/inheritance.d.ts +5 -0
- package/dist/resolve/inheritance.js +106 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Francesco Frapporti
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# quicklook-pptx-renderer
|
|
2
|
+
|
|
3
|
+
**An open-source, cross-platform PPTX rendering engine that replicates Apple's macOS QuickLook and iOS preview — pixel for pixel.**
|
|
4
|
+
|
|
5
|
+
Build presentation pipelines that render identically across Microsoft PowerPoint, macOS Finder preview, iOS Files, and iPadOS. Runs on Linux, Docker, and the cloud — no Apple dependencies.
|
|
6
|
+
|
|
7
|
+
Includes a **linter** that catches QuickLook compatibility issues before your users see them.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The Problem
|
|
12
|
+
|
|
13
|
+
You generate a `.pptx` with [python-pptx](https://python-pptx.readthedocs.io/), [PptxGenJS](https://gitbrent.github.io/PptxGenJS/), or any OOXML library. It looks perfect in PowerPoint. Then someone opens it on a Mac or iPhone and it looks **completely wrong**.
|
|
14
|
+
|
|
15
|
+
This happens because Apple uses a private framework — `OfficeImport.framework` — that is 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.
|
|
16
|
+
|
|
17
|
+
### Common Symptoms
|
|
18
|
+
|
|
19
|
+
- **Shapes disappear** — unsupported presets (heart, cloud, lightningBolt) are silently dropped
|
|
20
|
+
- **Opaque white blocks** — rounded rectangles with shadows become solid PDF blocks covering content behind them
|
|
21
|
+
- **Phantom shapes** — shapes from other slides appear due to a use-after-free cache bug
|
|
22
|
+
- **Fonts shift** — Calibri becomes Helvetica Neue with different metrics, causing text reflow
|
|
23
|
+
- **Table borders missing** — style references not resolved, borders vanish
|
|
24
|
+
- **Gradients flattened** — 3+ color stops averaged to a single solid color
|
|
25
|
+
- **Charts blank** — no fallback image means empty rectangle
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install quicklook-pptx-renderer
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Lint a PPTX for QuickLook Issues
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx quicklook-pptx lint presentation.pptx
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Slide 4:
|
|
43
|
+
[WARN] "roundRect" with effects renders as an opaque PDF image (Shape 9)
|
|
44
|
+
-> Remove drop shadow/glow effects, or use a plain rect
|
|
45
|
+
[ERR ] "cloud" is not supported by OfficeImport — shape will be invisible (Shape 12)
|
|
46
|
+
-> Use a supported preset or embed as an image
|
|
47
|
+
|
|
48
|
+
0 errors, 3 warnings, 2 info across 18 slides
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# JSON output for CI integration
|
|
53
|
+
npx quicklook-pptx lint presentation.pptx --json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### As a Library
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { render } from "quicklook-pptx-renderer";
|
|
60
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
61
|
+
|
|
62
|
+
const pptx = readFileSync("presentation.pptx");
|
|
63
|
+
const result = await render(pptx);
|
|
64
|
+
|
|
65
|
+
// Each slide has the exact HTML that QuickLook would generate
|
|
66
|
+
for (const slide of result.slides) {
|
|
67
|
+
writeFileSync(`slide-${slide.index + 1}.html`, slide.html);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Full HTML document with CSS (open in WebKit for pixel-perfect result)
|
|
71
|
+
writeFileSync("preview.html", result.fullHtml);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Lint Programmatically
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { lint, formatIssues } from "quicklook-pptx-renderer";
|
|
78
|
+
|
|
79
|
+
const result = await lint(pptxBuffer);
|
|
80
|
+
|
|
81
|
+
// Human-readable output
|
|
82
|
+
console.log(formatIssues(result));
|
|
83
|
+
|
|
84
|
+
// Or work with structured issues
|
|
85
|
+
for (const issue of result.issues) {
|
|
86
|
+
if (issue.severity === "error") {
|
|
87
|
+
console.error(`Slide ${issue.slide}: ${issue.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### CLI Commands
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Render all slides to PNG (requires Playwright)
|
|
96
|
+
npx quicklook-pptx render presentation.pptx --out ./slides/
|
|
97
|
+
|
|
98
|
+
# Get raw HTML (same format as QuickLook's Preview.html)
|
|
99
|
+
npx quicklook-pptx html presentation.pptx
|
|
100
|
+
|
|
101
|
+
# Pixel-diff against actual QuickLook output (macOS only)
|
|
102
|
+
npx quicklook-pptx diff presentation.pptx --quicklook-dir /tmp/ql-out/
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Lint Rules
|
|
108
|
+
|
|
109
|
+
| Rule | Severity | What It Catches |
|
|
110
|
+
|------|----------|----------------|
|
|
111
|
+
| `unsupported-geometry` | error | Shape preset not in OfficeImport's ~60 supported geometries — will be invisible |
|
|
112
|
+
| `chart-no-fallback` | error | Chart without fallback image — renders as blank rectangle |
|
|
113
|
+
| `opaque-pdf-block` | warn | Shape with effects rendered as opaque PDF covering content behind it |
|
|
114
|
+
| `gradient-flattened` | warn | 3+ gradient stops collapsed to single average color |
|
|
115
|
+
| `table-missing-borders` | warn | All table cells lack explicit borders — will appear borderless |
|
|
116
|
+
| `table-style-unresolved` | warn | Table style reference that OfficeImport may not resolve |
|
|
117
|
+
| `text-inscription-shift` | warn | Text in non-rect shape uses geometry-inscribed bounds |
|
|
118
|
+
| `font-substitution` | info | Font will be substituted on macOS (Calibri → Helvetica Neue, etc.) |
|
|
119
|
+
| `effect-forces-pdf` | info | Non-rect shape rendered as PDF instead of CSS |
|
|
120
|
+
| `rotation-forces-pdf` | info | Rotated rect rendered as PDF instead of CSS |
|
|
121
|
+
| `group-as-pdf` | info | Group rendered as single opaque PDF image |
|
|
122
|
+
| `vertical-text` | info | Vertical text uses CSS writing-mode |
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Rendering Accuracy
|
|
127
|
+
|
|
128
|
+
Pixel-level comparison against actual QuickLook output (`qlmanage -p`):
|
|
129
|
+
|
|
130
|
+
| Source | Slides | Avg Pixel Diff | Perfect (< 0.05%) |
|
|
131
|
+
|--------|--------|----------------|---------------------|
|
|
132
|
+
| Real-world presentation | 18 | 0.15% | 16/18 |
|
|
133
|
+
| python-pptx generated | 10 | 0.17% | 6/10 |
|
|
134
|
+
| python-pptx stress test | 10 | 0.42% | 4/10 |
|
|
135
|
+
| PptxGenJS generated | 10 | 0.11% | 6/10 |
|
|
136
|
+
| Chart test suite | 7 | 0.08% | 7/7 |
|
|
137
|
+
| **Overall** | **55** | **0.19%** | **39/55** |
|
|
138
|
+
|
|
139
|
+
236/236 synthetic HTML structure tests pass.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## How It Works
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
PPTX (ZIP) → Package → Reader → Resolve → Mapper → HTML + PDF attachments
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
| Module | OfficeImport Equivalent | Purpose |
|
|
150
|
+
|--------|------------------------|---------|
|
|
151
|
+
| `package/` | OCP/OCX | ZIP extraction, `.rels` relationships |
|
|
152
|
+
| `reader/` | PX/OAX | OOXML XML → typed domain objects |
|
|
153
|
+
| `model/` | PD/OAD | TypeScript interfaces for slides, shapes, text, tables |
|
|
154
|
+
| `resolve/` | — | Color themes, font substitution, property inheritance |
|
|
155
|
+
| `mapper/` | PM/CM | HTML + CSS + PDF generation |
|
|
156
|
+
| `lint` | — | Static analysis against known OfficeImport quirks |
|
|
157
|
+
|
|
158
|
+
### Two Rendering Paths
|
|
159
|
+
|
|
160
|
+
OfficeImport routes every shape through one of two paths:
|
|
161
|
+
|
|
162
|
+
**CSS** — Only plain `rect` with no rotation and no effects → `<div>` with CSS properties.
|
|
163
|
+
|
|
164
|
+
**PDF** — Everything else (roundRect, ellipse, stars, arrows, any rotation/effects) → inline PDF via `CMDrawingContext.copyPDF`, embedded as `<img src="AttachmentN.pdf">`.
|
|
165
|
+
|
|
166
|
+
### Property Inheritance
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
shape local → slide placeholder → layout placeholder → master placeholder → master text style → theme
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Key Discoveries
|
|
175
|
+
|
|
176
|
+
These findings come from reverse engineering OfficeImport via Objective-C runtime introspection and ARM64 disassembly. They are not documented anywhere else:
|
|
177
|
+
|
|
178
|
+
1. **Only `rect` takes the CSS path** — even `roundRect` and `ellipse` always go to PDF
|
|
179
|
+
2. **~60 of 187 shape presets are supported** — the rest are silently dropped (no error, no fallback)
|
|
180
|
+
3. **PDF shapes use the `B` operator** (simultaneous fill+stroke) with invisible default stroke
|
|
181
|
+
4. **`CMArchiveManager` has a use-after-free** — PDF cache keyed by pointer identity causes cross-slide bleed
|
|
182
|
+
5. **Table row heights use `EMU / 101600`**, not `EMU / 12700`
|
|
183
|
+
6. **3+ gradient stops → average color** (not rendered as gradient)
|
|
184
|
+
7. **Font substitution**: Calibri → Helvetica Neue, Arial → Helvetica, etc.
|
|
185
|
+
8. **Text inscription for non-rect shapes** uses `float` radius and `trunc()` in pixel space
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Coordinate System
|
|
190
|
+
|
|
191
|
+
| Measurement | Unit | Conversion |
|
|
192
|
+
|------------|------|-----------|
|
|
193
|
+
| Position / Size | EMU | 12,700 EMU = 1 CSS pixel |
|
|
194
|
+
| Font size | Hundredths of a point | 1800 = 18pt |
|
|
195
|
+
| Angles | 60,000ths of a degree | 5,400,000 = 90deg |
|
|
196
|
+
| Color transforms | 1,000ths of percent | 100,000 = 100% |
|
|
197
|
+
| Table row height | EMU / 101,600 | (OfficeImport quirk) |
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Compatibility
|
|
202
|
+
|
|
203
|
+
| Platform | Status |
|
|
204
|
+
|----------|--------|
|
|
205
|
+
| Node.js 20+ (Linux, macOS, Windows) | Full support |
|
|
206
|
+
| Docker / containers | Full support |
|
|
207
|
+
| AWS Lambda / Cloud Functions | Full support (HTML; PNG needs Playwright) |
|
|
208
|
+
| Bun | Untested |
|
|
209
|
+
| Deno | Untested |
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Fixing Common Issues
|
|
214
|
+
|
|
215
|
+
### python-pptx Table Borders Missing
|
|
216
|
+
|
|
217
|
+
The #1 reported issue. python-pptx applies borders via table style reference, which OfficeImport doesn't fully resolve. Set borders explicitly:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from pptx.util import Pt
|
|
221
|
+
from pptx.dml.color import RGBColor
|
|
222
|
+
|
|
223
|
+
for cell in table.iter_cells():
|
|
224
|
+
for border in [cell.border_top, cell.border_bottom,
|
|
225
|
+
cell.border_left, cell.border_right]:
|
|
226
|
+
border.width = Pt(1)
|
|
227
|
+
border.color.rgb = RGBColor(0, 0, 0)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Shapes Disappearing
|
|
231
|
+
|
|
232
|
+
Your shape preset isn't in OfficeImport's supported list. Run `npx quicklook-pptx lint your-file.pptx` to find which shapes will be invisible. Use supported presets or embed complex shapes as images.
|
|
233
|
+
|
|
234
|
+
### Opaque White Blocks
|
|
235
|
+
|
|
236
|
+
Shapes with drop shadows render as opaque PDF images. The PDF has a non-transparent background that covers content behind it. Remove effects or restructure z-ordering.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Related Projects
|
|
241
|
+
|
|
242
|
+
| Project | Difference |
|
|
243
|
+
|---------|-----------|
|
|
244
|
+
| [LibreOffice headless](https://www.libreoffice.org/) | Renders like LibreOffice, not like QuickLook |
|
|
245
|
+
| [python-pptx](https://python-pptx.readthedocs.io/) | Creates/reads PPTX; doesn't render |
|
|
246
|
+
| [PptxGenJS](https://gitbrent.github.io/PptxGenJS/) | Creates PPTX; doesn't render |
|
|
247
|
+
| [Apache POI](https://poi.apache.org/) | Java; renders like Java, not like QuickLook |
|
|
248
|
+
|
|
249
|
+
**This is the only open-source project that replicates Apple's exact QuickLook rendering.**
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Dependencies
|
|
254
|
+
|
|
255
|
+
| Package | Purpose | Required |
|
|
256
|
+
|---------|---------|----------|
|
|
257
|
+
| [jszip](https://stuk.github.io/jszip/) | ZIP extraction | Yes |
|
|
258
|
+
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | OOXML XML parsing | Yes |
|
|
259
|
+
| [@napi-rs/canvas](https://github.com/nickthedude/napi-rs-canvas) | PDF image generation | Optional (for render) |
|
|
260
|
+
| [playwright](https://playwright.dev/) | HTML-to-PNG screenshots | Optional (dev) |
|
|
261
|
+
|
|
262
|
+
The linter (`quicklook-pptx lint`) needs only jszip + fast-xml-parser — no native dependencies.
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve, basename } from "node:path";
|
|
4
|
+
import { PptxPackage } from "./package/package.js";
|
|
5
|
+
import { readPresentation } from "./reader/presentation.js";
|
|
6
|
+
import { generateHtml } from "./mapper/html-generator.js";
|
|
7
|
+
import { lint, formatIssues } from "./lint.js";
|
|
8
|
+
const BIN = "quicklook-pptx";
|
|
9
|
+
// ── Arg parsing ────────────────────────────────────────────────────
|
|
10
|
+
function getFlag(args, name) {
|
|
11
|
+
const i = args.indexOf(name);
|
|
12
|
+
if (i === -1 || i + 1 >= args.length)
|
|
13
|
+
return undefined;
|
|
14
|
+
return args[i + 1];
|
|
15
|
+
}
|
|
16
|
+
function hasFlag(args, name) {
|
|
17
|
+
return args.includes(name);
|
|
18
|
+
}
|
|
19
|
+
function positional(args) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
if (args[i].startsWith("--")) {
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
out.push(args[i]);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
// ── Commands ───────────────────────────────────────────────────────
|
|
31
|
+
async function cmdRender(args) {
|
|
32
|
+
const pos = positional(args);
|
|
33
|
+
const input = pos[0];
|
|
34
|
+
if (!input) {
|
|
35
|
+
console.error(`Usage: ${BIN} render <input.pptx> [--slide N] [--out output.png] [--width 1920] [--scale 2] [--all]`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
let renderSlide;
|
|
39
|
+
try {
|
|
40
|
+
({ renderSlide } = await import("./render/renderer.js"));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
console.error("The 'render' command requires @napi-rs/canvas. Install it with:\n npm install @napi-rs/canvas");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const width = Number(getFlag(args, "--width") ?? 1920);
|
|
47
|
+
const scale = Number(getFlag(args, "--scale") ?? 2);
|
|
48
|
+
const slideNum = getFlag(args, "--slide");
|
|
49
|
+
const outPath = getFlag(args, "--out");
|
|
50
|
+
const all = hasFlag(args, "--all") || !slideNum;
|
|
51
|
+
const pptxBuf = await readFile(resolve(input));
|
|
52
|
+
const pkg = await PptxPackage.open(pptxBuf);
|
|
53
|
+
const pres = await readPresentation(pkg);
|
|
54
|
+
if (all) {
|
|
55
|
+
const outDir = outPath ? dirname(outPath) : ".";
|
|
56
|
+
await mkdir(outDir, { recursive: true });
|
|
57
|
+
for (let i = 0; i < pres.slides.length; i++) {
|
|
58
|
+
const png = await renderSlide(pres.slides[i], pres, { width, scale, pkg, slideIndex: i });
|
|
59
|
+
const dest = outPath && pres.slides.length === 1
|
|
60
|
+
? resolve(outPath)
|
|
61
|
+
: resolve(outDir, `slide-${i + 1}.png`);
|
|
62
|
+
await writeFile(dest, png);
|
|
63
|
+
console.log(`slide ${i + 1} → ${dest}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const idx = Number(slideNum) - 1;
|
|
68
|
+
if (idx < 0 || idx >= pres.slides.length) {
|
|
69
|
+
console.error(`Slide ${slideNum} out of range (1-${pres.slides.length})`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
const png = await renderSlide(pres.slides[idx], pres, { width, scale, pkg, slideIndex: idx });
|
|
73
|
+
const dest = resolve(outPath ?? `slide-${idx + 1}.png`);
|
|
74
|
+
await writeFile(dest, png);
|
|
75
|
+
console.log(`slide ${idx + 1} → ${dest}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function cmdHtml(args) {
|
|
79
|
+
const pos = positional(args);
|
|
80
|
+
const input = pos[0];
|
|
81
|
+
if (!input) {
|
|
82
|
+
console.error(`Usage: ${BIN} html <input.pptx> [--out output.html]`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const outPath = getFlag(args, "--out");
|
|
86
|
+
const pptxBuf = await readFile(resolve(input));
|
|
87
|
+
const pkg = await PptxPackage.open(pptxBuf);
|
|
88
|
+
const pres = await readPresentation(pkg);
|
|
89
|
+
const { html } = await generateHtml(pres, { pkg });
|
|
90
|
+
if (outPath) {
|
|
91
|
+
await writeFile(resolve(outPath), html, "utf8");
|
|
92
|
+
console.error(`HTML written to ${outPath}`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
process.stdout.write(html);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function cmdDiff(args) {
|
|
99
|
+
const pos = positional(args);
|
|
100
|
+
const input = pos[0];
|
|
101
|
+
const quicklookDir = getFlag(args, "--quicklook-dir");
|
|
102
|
+
if (!input || !quicklookDir) {
|
|
103
|
+
console.error(`Usage: ${BIN} diff <input.pptx> --quicklook-dir <dir> [--out-dir <dir>] [--width 960] [--slide N]`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
let compareSlidesToQuickLook;
|
|
107
|
+
try {
|
|
108
|
+
({ compareSlidesToQuickLook } = await import("./diff/compare.js"));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
console.error("The 'diff' command requires optional dependencies. Install them with:\n npm install @napi-rs/canvas pixelmatch pngjs");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
const width = Number(getFlag(args, "--width") ?? 960);
|
|
115
|
+
const scale = Number(getFlag(args, "--scale") ?? 2);
|
|
116
|
+
const slideFilter = getFlag(args, "--slide");
|
|
117
|
+
const outDir = resolve(getFlag(args, "--out-dir") ?? `diff-${basename(input, ".pptx")}`);
|
|
118
|
+
await mkdir(outDir, { recursive: true });
|
|
119
|
+
const comparisons = await compareSlidesToQuickLook(resolve(input), resolve(quicklookDir), { width, scale });
|
|
120
|
+
let hasOutput = false;
|
|
121
|
+
for (const c of comparisons) {
|
|
122
|
+
const num = c.slideIndex + 1;
|
|
123
|
+
if (slideFilter && num !== Number(slideFilter))
|
|
124
|
+
continue;
|
|
125
|
+
hasOutput = true;
|
|
126
|
+
await writeFile(`${outDir}/${num}-rendered.png`, c.rendered);
|
|
127
|
+
await writeFile(`${outDir}/${num}-diff.png`, c.diff);
|
|
128
|
+
const pct = c.diffPercent.toFixed(2);
|
|
129
|
+
const bar = c.diffPercent < 1 ? "OK" : c.diffPercent < 5 ? "WARN" : "FAIL";
|
|
130
|
+
console.log(`slide ${num}: ${pct}% diff (${c.diffPixels} px) [${bar}]`);
|
|
131
|
+
}
|
|
132
|
+
if (!hasOutput) {
|
|
133
|
+
console.error("No slides matched or no quicklook images found.");
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
console.log(`\nDiff images written to ${outDir}/`);
|
|
137
|
+
}
|
|
138
|
+
async function cmdLint(args) {
|
|
139
|
+
const pos = positional(args);
|
|
140
|
+
const input = pos[0];
|
|
141
|
+
if (!input) {
|
|
142
|
+
console.error(`Usage: ${BIN} lint <input.pptx> [--json]`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
const json = hasFlag(args, "--json");
|
|
146
|
+
const pptxBuf = await readFile(resolve(input));
|
|
147
|
+
const result = await lint(pptxBuf);
|
|
148
|
+
if (json) {
|
|
149
|
+
console.log(JSON.stringify(result, null, 2));
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
console.log(formatIssues(result));
|
|
153
|
+
}
|
|
154
|
+
if (result.summary.errors > 0)
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
// ── Main ───────────────────────────────────────────────────────────
|
|
158
|
+
const [command, ...args] = process.argv.slice(2);
|
|
159
|
+
switch (command) {
|
|
160
|
+
case "render":
|
|
161
|
+
await cmdRender(args);
|
|
162
|
+
break;
|
|
163
|
+
case "html":
|
|
164
|
+
await cmdHtml(args);
|
|
165
|
+
break;
|
|
166
|
+
case "diff":
|
|
167
|
+
await cmdDiff(args);
|
|
168
|
+
break;
|
|
169
|
+
case "lint":
|
|
170
|
+
await cmdLint(args);
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
console.error(`Usage: ${BIN} <render|html|lint|diff> [options]`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type RenderOptions } from "../render/renderer.js";
|
|
2
|
+
export interface CompareResult {
|
|
3
|
+
diffPercent: number;
|
|
4
|
+
diffPixels: number;
|
|
5
|
+
totalPixels: number;
|
|
6
|
+
diffImage: Buffer;
|
|
7
|
+
}
|
|
8
|
+
export interface SlideComparison {
|
|
9
|
+
slideIndex: number;
|
|
10
|
+
diffPercent: number;
|
|
11
|
+
diffPixels: number;
|
|
12
|
+
rendered: Buffer;
|
|
13
|
+
expected: Buffer;
|
|
14
|
+
diff: Buffer;
|
|
15
|
+
}
|
|
16
|
+
export declare function compareImages(actual: Buffer, expected: Buffer): Promise<CompareResult>;
|
|
17
|
+
export declare function compareSlidesToQuickLook(pptxPath: string, quicklookDir: string, options?: RenderOptions): Promise<SlideComparison[]>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import pixelmatch from "pixelmatch";
|
|
2
|
+
import { PNG } from "pngjs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { PptxPackage } from "../package/package.js";
|
|
5
|
+
import { readPresentation } from "../reader/presentation.js";
|
|
6
|
+
import { renderSlide } from "../render/renderer.js";
|
|
7
|
+
function decodePng(buf) {
|
|
8
|
+
return PNG.sync.read(buf);
|
|
9
|
+
}
|
|
10
|
+
function encodePng(png) {
|
|
11
|
+
return PNG.sync.write(png);
|
|
12
|
+
}
|
|
13
|
+
/** Pad the smaller image with transparent pixels to match the larger dimensions. */
|
|
14
|
+
function matchDimensions(a, b) {
|
|
15
|
+
const w = Math.max(a.width, b.width);
|
|
16
|
+
const h = Math.max(a.height, b.height);
|
|
17
|
+
return [padTo(a, w, h), padTo(b, w, h)];
|
|
18
|
+
}
|
|
19
|
+
function padTo(img, w, h) {
|
|
20
|
+
if (img.width === w && img.height === h)
|
|
21
|
+
return img;
|
|
22
|
+
const out = new PNG({ width: w, height: h, fill: true });
|
|
23
|
+
// fill white background so padding differences are visible
|
|
24
|
+
out.data.fill(255);
|
|
25
|
+
PNG.bitblt(img, out, 0, 0, img.width, img.height, 0, 0);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
export async function compareImages(actual, expected) {
|
|
29
|
+
let a = decodePng(actual);
|
|
30
|
+
let b = decodePng(expected);
|
|
31
|
+
[a, b] = matchDimensions(a, b);
|
|
32
|
+
const { width, height } = a;
|
|
33
|
+
const totalPixels = width * height;
|
|
34
|
+
const diff = new PNG({ width, height });
|
|
35
|
+
const diffPixels = pixelmatch(a.data, b.data, diff.data, width, height, {
|
|
36
|
+
threshold: 0.1,
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
diffPercent: totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0,
|
|
40
|
+
diffPixels,
|
|
41
|
+
totalPixels,
|
|
42
|
+
diffImage: encodePng(diff),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function compareSlidesToQuickLook(pptxPath, quicklookDir, options) {
|
|
46
|
+
const pptxBuf = await readFile(pptxPath);
|
|
47
|
+
const pkg = await PptxPackage.open(pptxBuf);
|
|
48
|
+
const pres = await readPresentation(pkg);
|
|
49
|
+
const results = [];
|
|
50
|
+
for (let i = 0; i < pres.slides.length; i++) {
|
|
51
|
+
const expectedPath = `${quicklookDir}/${i + 1}-quicklook.png`;
|
|
52
|
+
let expectedBuf;
|
|
53
|
+
try {
|
|
54
|
+
expectedBuf = await readFile(expectedPath);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue; // no reference image for this slide
|
|
58
|
+
}
|
|
59
|
+
const rendered = await renderSlide(pres.slides[i], pres, { ...options, pkg, slideIndex: i });
|
|
60
|
+
const { diffPercent, diffPixels, diffImage } = await compareImages(rendered, expectedBuf);
|
|
61
|
+
results.push({
|
|
62
|
+
slideIndex: i,
|
|
63
|
+
diffPercent,
|
|
64
|
+
diffPixels,
|
|
65
|
+
rendered,
|
|
66
|
+
expected: expectedBuf,
|
|
67
|
+
diff: diffImage,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return results;
|
|
71
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quicklook-pptx-renderer — Replicate macOS QuickLook PPTX rendering in pure TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* Main entry point with both high-level render() API and raw building blocks.
|
|
5
|
+
*/
|
|
6
|
+
export interface SlideResult {
|
|
7
|
+
index: number;
|
|
8
|
+
html: string;
|
|
9
|
+
attachments: Map<string, Buffer>;
|
|
10
|
+
}
|
|
11
|
+
export interface RenderResult {
|
|
12
|
+
slides: SlideResult[];
|
|
13
|
+
fullHtml: string;
|
|
14
|
+
attachments: Map<string, Buffer>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Render a PPTX buffer to QuickLook-identical HTML output.
|
|
18
|
+
*
|
|
19
|
+
* Returns per-slide HTML fragments plus the full HTML document with
|
|
20
|
+
* embedded CSS — ready to open in WebKit for pixel-perfect rendering.
|
|
21
|
+
*/
|
|
22
|
+
export declare function render(pptxBuffer: Buffer): Promise<RenderResult>;
|
|
23
|
+
export { lint, formatIssues, type LintResult, type LintIssue, type Severity, type RuleId } from "./lint.js";
|
|
24
|
+
export { PptxPackage } from "./package/package.js";
|
|
25
|
+
export { readPresentation } from "./reader/presentation.js";
|
|
26
|
+
export { generateHtml, type MapperOptions, type HtmlOutput } from "./mapper/html-generator.js";
|
|
27
|
+
export { resolveColor } from "./resolve/color-resolver.js";
|
|
28
|
+
export { resolveFontFamily } from "./resolve/font-map.js";
|
|
29
|
+
export type { Presentation, Slide, SlideLayout, SlideMaster, Shape, Picture, Group, Connector, GraphicFrame, TextBody, Paragraph, TextRun, CharacterProperties, ParagraphProperties, Color, Fill, Stroke, Effect, Theme, Drawable, DrawableBase, OrientedBounds, } from "./model/types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quicklook-pptx-renderer — Replicate macOS QuickLook PPTX rendering in pure TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* Main entry point with both high-level render() API and raw building blocks.
|
|
5
|
+
*/
|
|
6
|
+
import { PptxPackage } from "./package/package.js";
|
|
7
|
+
import { readPresentation } from "./reader/presentation.js";
|
|
8
|
+
import { generateHtml } from "./mapper/html-generator.js";
|
|
9
|
+
/**
|
|
10
|
+
* Render a PPTX buffer to QuickLook-identical HTML output.
|
|
11
|
+
*
|
|
12
|
+
* Returns per-slide HTML fragments plus the full HTML document with
|
|
13
|
+
* embedded CSS — ready to open in WebKit for pixel-perfect rendering.
|
|
14
|
+
*/
|
|
15
|
+
export async function render(pptxBuffer) {
|
|
16
|
+
const pkg = await PptxPackage.open(pptxBuffer);
|
|
17
|
+
const pres = await readPresentation(pkg);
|
|
18
|
+
const { html, attachments } = await generateHtml(pres, { pkg });
|
|
19
|
+
// Split full HTML into per-slide fragments
|
|
20
|
+
const slides = [];
|
|
21
|
+
const marker = '<div class="slide" style="top:0; left:0;">';
|
|
22
|
+
let pos = 0;
|
|
23
|
+
let idx = 0;
|
|
24
|
+
while (true) {
|
|
25
|
+
const start = html.indexOf(marker, pos);
|
|
26
|
+
if (start === -1)
|
|
27
|
+
break;
|
|
28
|
+
const contentStart = start;
|
|
29
|
+
// Find matching closing </div>
|
|
30
|
+
let depth = 0;
|
|
31
|
+
let j = start;
|
|
32
|
+
while (j < html.length) {
|
|
33
|
+
if (html[j] === '<') {
|
|
34
|
+
if (html.substring(j, j + 6) === '</div>') {
|
|
35
|
+
depth--;
|
|
36
|
+
if (depth === 0) {
|
|
37
|
+
slides.push({
|
|
38
|
+
index: idx,
|
|
39
|
+
html: html.substring(contentStart, j + 6),
|
|
40
|
+
attachments: new Map(),
|
|
41
|
+
});
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
j += 6;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (html.substring(j, j + 4) === '<div')
|
|
48
|
+
depth++;
|
|
49
|
+
}
|
|
50
|
+
j++;
|
|
51
|
+
}
|
|
52
|
+
pos = j;
|
|
53
|
+
idx++;
|
|
54
|
+
}
|
|
55
|
+
// Distribute attachments to their slides by scanning for src="AttachmentN.xxx"
|
|
56
|
+
for (const slide of slides) {
|
|
57
|
+
for (const [name, buf] of attachments) {
|
|
58
|
+
if (slide.html.includes(`src="${name}"`)) {
|
|
59
|
+
slide.attachments.set(name, buf);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { slides, fullHtml: html, attachments };
|
|
64
|
+
}
|
|
65
|
+
// ── Linter (QuickLook compatibility checks) ─────────────────────
|
|
66
|
+
export { lint, formatIssues } from "./lint.js";
|
|
67
|
+
// ── Building blocks ─────────────────────────────────────────────
|
|
68
|
+
export { PptxPackage } from "./package/package.js";
|
|
69
|
+
export { readPresentation } from "./reader/presentation.js";
|
|
70
|
+
export { generateHtml } from "./mapper/html-generator.js";
|
|
71
|
+
export { resolveColor } from "./resolve/color-resolver.js";
|
|
72
|
+
export { resolveFontFamily } from "./resolve/font-map.js";
|
package/dist/lint.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QuickLook PPTX Linter — detect compatibility issues before they surprise users.
|
|
3
|
+
*
|
|
4
|
+
* Parses a PPTX and checks every shape, text run, table, and fill against
|
|
5
|
+
* known OfficeImport quirks. No rendering required — just parse and check.
|
|
6
|
+
*/
|
|
7
|
+
export type Severity = "error" | "warn" | "info";
|
|
8
|
+
export type RuleId = "unsupported-geometry" | "opaque-pdf-block" | "gradient-flattened" | "font-substitution" | "table-missing-borders" | "table-style-unresolved" | "group-as-pdf" | "chart-no-fallback" | "text-inscription-shift" | "rotation-forces-pdf" | "effect-forces-pdf" | "vertical-text";
|
|
9
|
+
export interface LintIssue {
|
|
10
|
+
rule: RuleId;
|
|
11
|
+
severity: Severity;
|
|
12
|
+
slide: number;
|
|
13
|
+
element?: string;
|
|
14
|
+
message: string;
|
|
15
|
+
suggestion?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface LintResult {
|
|
18
|
+
issues: LintIssue[];
|
|
19
|
+
summary: {
|
|
20
|
+
errors: number;
|
|
21
|
+
warnings: number;
|
|
22
|
+
info: number;
|
|
23
|
+
slides: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export declare function lint(pptxBuffer: Buffer): Promise<LintResult>;
|
|
27
|
+
export declare function formatIssues(result: LintResult): string;
|