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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +266 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +175 -0
  5. package/dist/diff/compare.d.ts +17 -0
  6. package/dist/diff/compare.js +71 -0
  7. package/dist/index.d.ts +29 -0
  8. package/dist/index.js +72 -0
  9. package/dist/lint.d.ts +27 -0
  10. package/dist/lint.js +328 -0
  11. package/dist/mapper/bleed-map.d.ts +6 -0
  12. package/dist/mapper/bleed-map.js +1 -0
  13. package/dist/mapper/constants.d.ts +2 -0
  14. package/dist/mapper/constants.js +4 -0
  15. package/dist/mapper/drawable-mapper.d.ts +16 -0
  16. package/dist/mapper/drawable-mapper.js +1464 -0
  17. package/dist/mapper/html-generator.d.ts +13 -0
  18. package/dist/mapper/html-generator.js +539 -0
  19. package/dist/mapper/image-mapper.d.ts +14 -0
  20. package/dist/mapper/image-mapper.js +70 -0
  21. package/dist/mapper/nano-malloc.d.ts +130 -0
  22. package/dist/mapper/nano-malloc.js +197 -0
  23. package/dist/mapper/ql-bleed.d.ts +35 -0
  24. package/dist/mapper/ql-bleed.js +254 -0
  25. package/dist/mapper/shape-mapper.d.ts +41 -0
  26. package/dist/mapper/shape-mapper.js +2384 -0
  27. package/dist/mapper/slide-mapper.d.ts +4 -0
  28. package/dist/mapper/slide-mapper.js +112 -0
  29. package/dist/mapper/style-builder.d.ts +12 -0
  30. package/dist/mapper/style-builder.js +30 -0
  31. package/dist/mapper/text-mapper.d.ts +14 -0
  32. package/dist/mapper/text-mapper.js +302 -0
  33. package/dist/model/enums.d.ts +25 -0
  34. package/dist/model/enums.js +2 -0
  35. package/dist/model/types.d.ts +482 -0
  36. package/dist/model/types.js +7 -0
  37. package/dist/package/content-types.d.ts +1 -0
  38. package/dist/package/content-types.js +4 -0
  39. package/dist/package/package.d.ts +10 -0
  40. package/dist/package/package.js +52 -0
  41. package/dist/package/relationships.d.ts +6 -0
  42. package/dist/package/relationships.js +25 -0
  43. package/dist/package/zip.d.ts +6 -0
  44. package/dist/package/zip.js +17 -0
  45. package/dist/reader/color.d.ts +3 -0
  46. package/dist/reader/color.js +79 -0
  47. package/dist/reader/drawing.d.ts +17 -0
  48. package/dist/reader/drawing.js +403 -0
  49. package/dist/reader/effects.d.ts +2 -0
  50. package/dist/reader/effects.js +83 -0
  51. package/dist/reader/fill.d.ts +2 -0
  52. package/dist/reader/fill.js +94 -0
  53. package/dist/reader/presentation.d.ts +5 -0
  54. package/dist/reader/presentation.js +127 -0
  55. package/dist/reader/slide-layout.d.ts +2 -0
  56. package/dist/reader/slide-layout.js +28 -0
  57. package/dist/reader/slide-master.d.ts +4 -0
  58. package/dist/reader/slide-master.js +49 -0
  59. package/dist/reader/slide.d.ts +2 -0
  60. package/dist/reader/slide.js +26 -0
  61. package/dist/reader/text-list-style.d.ts +2 -0
  62. package/dist/reader/text-list-style.js +9 -0
  63. package/dist/reader/text.d.ts +5 -0
  64. package/dist/reader/text.js +295 -0
  65. package/dist/reader/theme.d.ts +2 -0
  66. package/dist/reader/theme.js +109 -0
  67. package/dist/reader/transform.d.ts +2 -0
  68. package/dist/reader/transform.js +21 -0
  69. package/dist/render/image-renderer.d.ts +3 -0
  70. package/dist/render/image-renderer.js +33 -0
  71. package/dist/render/renderer.d.ts +9 -0
  72. package/dist/render/renderer.js +178 -0
  73. package/dist/render/shape-renderer.d.ts +3 -0
  74. package/dist/render/shape-renderer.js +175 -0
  75. package/dist/render/text-renderer.d.ts +3 -0
  76. package/dist/render/text-renderer.js +152 -0
  77. package/dist/resolve/color-resolver.d.ts +18 -0
  78. package/dist/resolve/color-resolver.js +321 -0
  79. package/dist/resolve/font-map.d.ts +2 -0
  80. package/dist/resolve/font-map.js +66 -0
  81. package/dist/resolve/inheritance.d.ts +5 -0
  82. package/dist/resolve/inheritance.js +106 -0
  83. 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }
@@ -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;