quicklook-pptx-renderer 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # quicklook-pptx-renderer
2
2
 
3
- **An open-source, cross-platform PPTX rendering engine that replicates Apple's macOS QuickLook and iOS preview — pixel for pixel.**
3
+ **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.
4
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.
5
+ Runs on Linux, Docker, AWS Lambda, and anywhere Node.js runs. No Apple hardware required.
6
6
 
7
7
  Includes a **linter** that catches QuickLook compatibility issues before your users see them.
8
8
 
@@ -10,19 +10,25 @@ Includes a **linter** that catches QuickLook compatibility issues before your us
10
10
 
11
11
  ## The Problem
12
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**.
13
+ 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.
14
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.
15
+ 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.
16
16
 
17
- ### Common Symptoms
17
+ 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.
18
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
19
+ ### What Goes Wrong
20
+
21
+ | What you see on Mac/iPhone | Why it happens |
22
+ |---|---|
23
+ | **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. |
24
+ | **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. |
25
+ | **Table borders missing** — tables appear as plain text without any grid | OfficeImport doesn't resolve `tableStyleId` references. It only reads explicit `<a:lnL>`, `<a:lnR>`, `<a:lnT>`, `<a:lnB>` on each cell. python-pptx and most generators rely on style references. |
26
+ | **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. |
27
+ | **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. |
28
+ | **Charts are blank rectangles** — chart content simply vanishes | Charts without an embedded fallback image render as empty rectangles. |
29
+ | **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. |
30
+
31
+ 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.
26
32
 
27
33
  ---
28
34
 
@@ -32,7 +38,7 @@ This happens because Apple uses a private framework — `OfficeImport.framework`
32
38
  npm install quicklook-pptx-renderer
33
39
  ```
34
40
 
35
- ### Lint a PPTX for QuickLook Issues
41
+ ### Lint a PPTX for QuickLook Compatibility Issues
36
42
 
37
43
  ```bash
38
44
  npx quicklook-pptx lint presentation.pptx
@@ -49,10 +55,29 @@ Slide 4:
49
55
  ```
50
56
 
51
57
  ```bash
52
- # JSON output for CI integration
58
+ # JSON output for CI pipelines
53
59
  npx quicklook-pptx lint presentation.pptx --json
54
60
  ```
55
61
 
62
+ ### Render Slides (see what Mac users see)
63
+
64
+ ```bash
65
+ # Render all slides to PNG
66
+ npx quicklook-pptx render presentation.pptx --out ./slides/
67
+
68
+ # Single slide, high-DPI
69
+ npx quicklook-pptx render presentation.pptx --slide 3 --width 1920 --scale 2
70
+
71
+ # Get raw HTML (same format as QuickLook's Preview.html)
72
+ npx quicklook-pptx html presentation.pptx
73
+ ```
74
+
75
+ ### Pixel-Diff Against Real QuickLook (macOS only)
76
+
77
+ ```bash
78
+ npx quicklook-pptx diff presentation.pptx --quicklook-dir /tmp/ql-out/
79
+ ```
80
+
56
81
  ### As a Library
57
82
 
58
83
  ```typescript
@@ -81,7 +106,7 @@ const result = await lint(pptxBuffer);
81
106
  // Human-readable output
82
107
  console.log(formatIssues(result));
83
108
 
84
- // Or work with structured issues
109
+ // Structured issues for CI gates
85
110
  for (const issue of result.issues) {
86
111
  if (issue.severity === "error") {
87
112
  console.error(`Slide ${issue.slide}: ${issue.message}`);
@@ -89,19 +114,6 @@ for (const issue of result.issues) {
89
114
  }
90
115
  ```
91
116
 
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
117
  ---
106
118
 
107
119
  ## Lint Rules
@@ -112,12 +124,12 @@ npx quicklook-pptx diff presentation.pptx --quicklook-dir /tmp/ql-out/
112
124
  | `chart-no-fallback` | error | Chart without fallback image — renders as blank rectangle |
113
125
  | `opaque-pdf-block` | warn | Shape with effects rendered as opaque PDF covering content behind it |
114
126
  | `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 |
127
+ | `table-missing-borders` | warn | Table cells lack explicit borders — will appear borderless |
128
+ | `table-style-unresolved` | warn | Table style reference that OfficeImport won't resolve |
129
+ | `text-inscription-shift` | warn | Text in non-rect shape uses geometry-inscribed bounds (text may shift) |
130
+ | `font-substitution` | warn/info | Font will be substituted on macOS — includes width delta (warn if ≥10% reflow risk) |
131
+ | `effect-forces-pdf` | info | Non-rect shape rendered as opaque PDF instead of CSS |
132
+ | `rotation-forces-pdf` | info | Rotated rect rendered as opaque PDF instead of CSS |
121
133
  | `group-as-pdf` | info | Group rendered as single opaque PDF image |
122
134
  | `vertical-text` | info | Vertical text uses CSS writing-mode |
123
135
 
@@ -140,6 +152,26 @@ Pixel-level comparison against actual QuickLook output (`qlmanage -p`):
140
152
 
141
153
  ---
142
154
 
155
+ ## Which Tools Produce Affected Files
156
+
157
+ Any tool that generates `.pptx` files can produce presentations that render incorrectly on Apple devices:
158
+
159
+ | Tool | Typical issues on Mac/iPhone |
160
+ |------|-----|
161
+ | **python-pptx** | Table borders missing, shapes invisible, "PowerPoint found a problem with content" errors |
162
+ | **PptxGenJS** | Missing thumbnails, shape rendering differences |
163
+ | **Google Slides** (File → Download as .pptx) | Font substitution, formatting shifts, missing embedded fonts |
164
+ | **Canva** (Download as .pptx) | Animations stripped, font substitution, layout differences |
165
+ | **LibreOffice Impress** (Save As .pptx) | Table styles unresolved, gradient rendering differences |
166
+ | **Pandoc** / **Quarto** (markdown → pptx) | Content type corruption, shapes missing, repair errors |
167
+ | **Apache POI** (Java) | Content type errors, missing fallback images |
168
+ | **Aspose.Slides** | Thumbnail missing if not explicitly generated |
169
+ | **Open XML SDK** (.NET) | Repair errors, missing relationships |
170
+
171
+ If you generate presentations programmatically and your users view them on Mac or iPhone, you should lint with this tool.
172
+
173
+ ---
174
+
143
175
  ## How It Works
144
176
 
145
177
  ```
@@ -163,12 +195,49 @@ OfficeImport routes every shape through one of two paths:
163
195
 
164
196
  **PDF** — Everything else (roundRect, ellipse, stars, arrows, any rotation/effects) → inline PDF via `CMDrawingContext.copyPDF`, embedded as `<img src="AttachmentN.pdf">`.
165
197
 
198
+ 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.
199
+
166
200
  ### Property Inheritance
167
201
 
168
202
  ```
169
203
  shape local → slide placeholder → layout placeholder → master placeholder → master text style → theme
170
204
  ```
171
205
 
206
+ ### Font Substitution Map
207
+
208
+ macOS replaces Windows fonts at render time (from `TCFontUtils` in OfficeImport). Width deltas computed from `xWidthAvg` in the font metrics database:
209
+
210
+ | Windows Font | macOS Replacement | Width Δ | Text Reflow Risk |
211
+ |---|---|---|---|
212
+ | Calibri | Helvetica Neue | **+14.4%** | High — text overflows boxes |
213
+ | Calibri Light | Helvetica Neue Light | **+20.9%** | High — significant reflow |
214
+ | Arial | Helvetica | +0.0% | None — metrically identical |
215
+ | Arial Black | Helvetica Neue | **-15.9%** | High — text shrinks |
216
+ | Arial Narrow | Helvetica Neue | **+20.0%** | High — significant reflow |
217
+ | Cambria | Georgia | +7.0% | Medium |
218
+ | Consolas | Menlo | +9.5% | Medium — wider monospace |
219
+ | Courier New | Courier | +0.0% | None |
220
+ | Times New Roman | Times | +0.0% | None |
221
+ | Tahoma | Geneva | **+10.0%** | High |
222
+ | Segoe UI | Helvetica Neue | **+14.0%** | High |
223
+ | Century Gothic | Futura | +1.0% | Low |
224
+ | Franklin Gothic Medium | Avenir Next Medium | **+17.8%** | High |
225
+ | Corbel | Avenir Next | **+18.8%** | High |
226
+ | Candara | Avenir | +8.0% | Medium |
227
+ | Constantia | Georgia | +4.2% | Low |
228
+ | Palatino Linotype | Palatino | +0.0% | None |
229
+ | Book Antiqua | Palatino | +0.0% | None |
230
+ | Garamond | Georgia | +8.7% | Medium |
231
+ | Impact | Impact | +0.0% | None |
232
+
233
+ **Safe cross-platform fonts** (identical on Windows and macOS): Arial, Verdana, Georgia, Trebuchet MS, Courier New, Times New Roman, Impact, Palatino Linotype, Book Antiqua.
234
+
235
+ **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%).
236
+
237
+ The linter now reports width deltas and upgrades font substitution to `warn` severity when the delta exceeds ±10%.
238
+
239
+ Plus CJK mappings: MS Gothic → Hiragino Sans, SimSun → STSong, Microsoft YaHei → PingFang SC, Malgun Gothic → Apple SD Gothic Neo, and more.
240
+
172
241
  ---
173
242
 
174
243
  ## Key Discoveries
@@ -181,7 +250,7 @@ These findings come from reverse engineering OfficeImport via Objective-C runtim
181
250
  4. **`CMArchiveManager` has a use-after-free** — PDF cache keyed by pointer identity causes cross-slide bleed
182
251
  5. **Table row heights use `EMU / 101600`**, not `EMU / 12700`
183
252
  6. **3+ gradient stops → average color** (not rendered as gradient)
184
- 7. **Font substitution**: Calibri → Helvetica Neue, Arial → Helvetica, etc.
253
+ 7. **Font substitution**: Calibri → Helvetica Neue, Arial → Helvetica, etc. (full map above)
185
254
  8. **Text inscription for non-rect shapes** uses `float` radius and `trunc()` in pixel space
186
255
 
187
256
  ---
@@ -204,17 +273,29 @@ These findings come from reverse engineering OfficeImport via Objective-C runtim
204
273
  |----------|--------|
205
274
  | Node.js 20+ (Linux, macOS, Windows) | Full support |
206
275
  | Docker / containers | Full support |
207
- | AWS Lambda / Cloud Functions | Full support (HTML; PNG needs Playwright) |
276
+ | AWS Lambda / Cloud Functions | Full support (HTML output; PNG needs Playwright) |
208
277
  | Bun | Untested |
209
278
  | Deno | Untested |
210
279
 
211
280
  ---
212
281
 
213
- ## Fixing Common Issues
282
+ ## Fixing Issues (not just detecting them)
283
+
284
+ 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.
285
+
286
+ ```bash
287
+ # Detect issues
288
+ npx quicklook-pptx lint presentation.pptx
289
+
290
+ # Fix issues (separate package)
291
+ npx pptx-fix presentation.pptx -o fixed.pptx
292
+ ```
293
+
294
+ ### Manual Fixes
214
295
 
215
- ### python-pptx Table Borders Missing
296
+ If you prefer fixing at the source:
216
297
 
217
- The #1 reported issue. python-pptx applies borders via table style reference, which OfficeImport doesn't fully resolve. Set borders explicitly:
298
+ **python-pptx table borders missing:**
218
299
 
219
300
  ```python
220
301
  from pptx.util import Pt
@@ -227,13 +308,9 @@ for cell in table.iter_cells():
227
308
  border.color.rgb = RGBColor(0, 0, 0)
228
309
  ```
229
310
 
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
311
+ **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.
235
312
 
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.
313
+ **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.
237
314
 
238
315
  ---
239
316
 
@@ -241,6 +318,7 @@ Shapes with drop shadows render as opaque PDF images. The PDF has a non-transpar
241
318
 
242
319
  | Project | Difference |
243
320
  |---------|-----------|
321
+ | **[pptx-fix](https://github.com/Fornace/pptx-fix)** | Companion tool — **fixes** PPTX files for QuickLook; this package **detects** issues and **renders** |
244
322
  | [LibreOffice headless](https://www.libreoffice.org/) | Renders like LibreOffice, not like QuickLook |
245
323
  | [python-pptx](https://python-pptx.readthedocs.io/) | Creates/reads PPTX; doesn't render |
246
324
  | [PptxGenJS](https://gitbrent.github.io/PptxGenJS/) | Creates PPTX; doesn't render |
@@ -257,9 +335,9 @@ Shapes with drop shadows render as opaque PDF images. The PDF has a non-transpar
257
335
  | [jszip](https://stuk.github.io/jszip/) | ZIP extraction | Yes |
258
336
  | [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | OOXML XML parsing | Yes |
259
337
  | [@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) |
338
+ | [playwright](https://playwright.dev/) | HTML-to-PNG screenshots | Optional (for render/diff) |
261
339
 
262
- The linter (`quicklook-pptx lint`) needs only jszip + fast-xml-parser — no native dependencies.
340
+ The linter (`npx quicklook-pptx lint`) needs only jszip + fast-xml-parser — zero native dependencies.
263
341
 
264
342
  ## License
265
343
 
package/dist/index.d.ts CHANGED
@@ -26,4 +26,5 @@ export { readPresentation } from "./reader/presentation.js";
26
26
  export { generateHtml, type MapperOptions, type HtmlOutput } from "./mapper/html-generator.js";
27
27
  export { resolveColor } from "./resolve/color-resolver.js";
28
28
  export { resolveFontFamily } from "./resolve/font-map.js";
29
+ export { FONT_METRICS, findClosestFont, widthDelta, type FontMetrics, type FontMatch, type FontCategory } from "./resolve/font-metrics.js";
29
30
  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 CHANGED
@@ -70,3 +70,4 @@ export { readPresentation } from "./reader/presentation.js";
70
70
  export { generateHtml } from "./mapper/html-generator.js";
71
71
  export { resolveColor } from "./resolve/color-resolver.js";
72
72
  export { resolveFontFamily } from "./resolve/font-map.js";
73
+ export { FONT_METRICS, findClosestFont, widthDelta } from "./resolve/font-metrics.js";
package/dist/lint.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { PptxPackage } from "./package/package.js";
8
8
  import { readPresentation } from "./reader/presentation.js";
9
9
  import { SUPPORTED_GEOMETRIES } from "./mapper/shape-mapper.js";
10
+ import { FONT_METRICS, widthDelta } from "./resolve/font-metrics.js";
10
11
  // ── Font substitution table (same as resolve/font-map.ts) ───────────
11
12
  const SUBSTITUTED_FONTS = {
12
13
  "Calibri": "Helvetica Neue",
@@ -28,6 +29,17 @@ const SUBSTITUTED_FONTS = {
28
29
  "Candara": "Avenir",
29
30
  "Constantia": "Georgia",
30
31
  };
32
+ /** Compute width delta between a source font and its OfficeImport substitute. */
33
+ function fontWidthDelta(sourceName) {
34
+ const targetName = SUBSTITUTED_FONTS[sourceName];
35
+ if (!targetName)
36
+ return null;
37
+ const src = FONT_METRICS[sourceName];
38
+ const tgt = FONT_METRICS[targetName];
39
+ if (!src || !tgt)
40
+ return null;
41
+ return widthDelta(src, tgt);
42
+ }
31
43
  // Geometries where text inscription differs from a simple rect inset
32
44
  const NON_RECT_TEXT_GEOMETRIES = new Set([
33
45
  "roundRect", "ellipse", "diamond", "triangle", "rtTriangle",
@@ -269,11 +281,17 @@ function lintTextBody(body, slide, element, issues) {
269
281
  continue;
270
282
  const font = props.latinFont;
271
283
  if (font && SUBSTITUTED_FONTS[font]) {
284
+ const delta = fontWidthDelta(font);
285
+ const deltaStr = delta != null && Math.abs(delta) >= 0.5
286
+ ? ` (${delta > 0 ? "+" : ""}${delta.toFixed(1)}% width)`
287
+ : "";
288
+ const highRisk = delta != null && Math.abs(delta) >= 10;
272
289
  issues.push({
273
290
  rule: "font-substitution",
274
- severity: "info",
291
+ severity: highRisk ? "warn" : "info",
275
292
  slide, element,
276
- message: `"${font}" → "${SUBSTITUTED_FONTS[font]}" on macOS metrics will differ, may cause text reflow`,
293
+ message: `"${font}" → "${SUBSTITUTED_FONTS[font]}" on macOS${deltaStr}${highRisk ? " high risk of text reflow/overflow" : ""}`,
294
+ suggestion: highRisk ? `Use a cross-platform font (Verdana, Georgia, Trebuchet MS, Arial) or test text layout on macOS` : undefined,
277
295
  });
278
296
  }
279
297
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Font metrics database and similarity matching.
3
+ *
4
+ * Combines data from @capsizecss/metrics (Google/system fonts) with
5
+ * fontkit measurements of Microsoft Office and macOS-only fonts.
6
+ *
7
+ * Use findClosestFont() to find the metrically-closest available fallback
8
+ * for a font that may not be present on the target platform.
9
+ */
10
+ export type FontCategory = "sans-serif" | "serif" | "monospace" | "display";
11
+ export interface FontMetrics {
12
+ /** Font category (sans-serif, serif, monospace, display) */
13
+ cat: FontCategory;
14
+ /** Units per em — all other values are in these units */
15
+ upm: number;
16
+ /** Average advance width of Latin alphanumeric + space characters */
17
+ xWidthAvg: number;
18
+ /** Cap height (height of uppercase H) */
19
+ capH: number;
20
+ /** x-height (height of lowercase x) */
21
+ xH: number;
22
+ /** Ascent above baseline */
23
+ asc: number;
24
+ /** Descent below baseline (negative) */
25
+ desc: number;
26
+ /** Line gap */
27
+ lineGap: number;
28
+ }
29
+ /**
30
+ * Pre-computed metrics for common fonts.
31
+ *
32
+ * System fonts (Windows + macOS) use layout-based xWidthAvg measured with
33
+ * fontkit — this includes GPOS kerning/spacing and accurately predicts
34
+ * actual text width. Google Fonts use raw glyph xWidthAvg from
35
+ * @capsizecss/metrics (capsize.dev), which is approximate.
36
+ */
37
+ export declare const FONT_METRICS: Record<string, FontMetrics>;
38
+ /** Width delta (%) between two fonts. Positive = target is wider. */
39
+ export declare function widthDelta(source: FontMetrics, target: FontMetrics): number;
40
+ export interface FontMatch {
41
+ /** Matched font name */
42
+ font: string;
43
+ /** Similarity score (lower = better, 0 = identical) */
44
+ score: number;
45
+ /** Width difference as percentage (positive = wider) */
46
+ widthDelta: number;
47
+ }
48
+ /**
49
+ * Find the closest fonts in the database to the given source font.
50
+ *
51
+ * @param source - Name of the source font, or its FontMetrics
52
+ * @param options.candidates - Limit search to these font names (default: all fonts in DB)
53
+ * @param options.sameCategory - Only match fonts of the same category (default: true)
54
+ * @param options.limit - Max results to return (default: 3)
55
+ */
56
+ export declare function findClosestFont(source: string | FontMetrics, options?: {
57
+ candidates?: string[];
58
+ sameCategory?: boolean;
59
+ limit?: number;
60
+ }): FontMatch[];
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Font metrics database and similarity matching.
3
+ *
4
+ * Combines data from @capsizecss/metrics (Google/system fonts) with
5
+ * fontkit measurements of Microsoft Office and macOS-only fonts.
6
+ *
7
+ * Use findClosestFont() to find the metrically-closest available fallback
8
+ * for a font that may not be present on the target platform.
9
+ */
10
+ /**
11
+ * Pre-computed metrics for common fonts.
12
+ *
13
+ * System fonts (Windows + macOS) use layout-based xWidthAvg measured with
14
+ * fontkit — this includes GPOS kerning/spacing and accurately predicts
15
+ * actual text width. Google Fonts use raw glyph xWidthAvg from
16
+ * @capsizecss/metrics (capsize.dev), which is approximate.
17
+ */
18
+ export const FONT_METRICS = {
19
+ // ── Microsoft Office fonts (fontkit layout-based) ─────────────────
20
+ "Calibri": { cat: "sans-serif", upm: 2048, xWidthAvg: 1026, capH: 1294, xH: 951, asc: 1950, desc: -550, lineGap: 0 },
21
+ "Calibri Light": { cat: "sans-serif", upm: 2048, xWidthAvg: 1013, capH: 1294, xH: 946, asc: 1950, desc: -550, lineGap: 0 },
22
+ "Cambria": { cat: "serif", upm: 2048, xWidthAvg: 1107, capH: 1365, xH: 956, asc: 1946, desc: -455, lineGap: 0 },
23
+ "Consolas": { cat: "monospace", upm: 2048, xWidthAvg: 1126, capH: 1307, xH: 1004, asc: 1521, desc: -527, lineGap: 350 },
24
+ "Arial Black": { cat: "sans-serif", upm: 2048, xWidthAvg: 1396, capH: 1466, xH: 1062, asc: 2254, desc: -634, lineGap: 0 },
25
+ "Century Gothic": { cat: "sans-serif", upm: 2048, xWidthAvg: 1191, capH: 1470, xH: 1088, asc: 2060, desc: -451, lineGap: 0 },
26
+ "Franklin Gothic Medium": { cat: "sans-serif", upm: 2048, xWidthAvg: 1095, capH: 1365, xH: 1010, asc: 1877, desc: -445, lineGap: 0 },
27
+ "Corbel": { cat: "sans-serif", upm: 2048, xWidthAvg: 1076, capH: 1338, xH: 950, asc: 1523, desc: -525, lineGap: 425 },
28
+ "Candara": { cat: "sans-serif", upm: 2048, xWidthAvg: 1073, capH: 1308, xH: 950, asc: 1484, desc: -564, lineGap: 452 },
29
+ "Constantia": { cat: "serif", upm: 2048, xWidthAvg: 1136, capH: 1406, xH: 928, asc: 1538, desc: -510, lineGap: 452 },
30
+ "Lucida Console": { cat: "monospace", upm: 2048, xWidthAvg: 1234, capH: 1282, xH: 1086, asc: 1616, desc: -432, lineGap: 0 },
31
+ "Lucida Sans Unicode": { cat: "sans-serif", upm: 2048, xWidthAvg: 1220, capH: 1480, xH: 1086, asc: 2246, desc: -901, lineGap: 0 },
32
+ "Palatino Linotype": { cat: "serif", upm: 2048, xWidthAvg: 1186, capH: 1466, xH: 1062, asc: 2150, desc: -613, lineGap: 0 },
33
+ "Book Antiqua": { cat: "serif", upm: 2048, xWidthAvg: 1186, capH: 1415, xH: 957, asc: 1891, desc: -578, lineGap: 0 },
34
+ "Garamond": { cat: "serif", upm: 2048, xWidthAvg: 1089, capH: 1289, xH: 789, asc: 1765, desc: -539, lineGap: 0 },
35
+ "Segoe UI": { cat: "sans-serif", upm: 2048, xWidthAvg: 1029, capH: 1434, xH: 1057, asc: 2210, desc: -514, lineGap: 0 },
36
+ "Segoe UI Light": { cat: "sans-serif", upm: 2048, xWidthAvg: 985, capH: 1434, xH: 1057, asc: 2210, desc: -514, lineGap: 0 },
37
+ "Segoe UI Semibold": { cat: "sans-serif", upm: 2048, xWidthAvg: 1064, capH: 1434, xH: 1057, asc: 2210, desc: -514, lineGap: 0 },
38
+ "Arial Narrow": { cat: "sans-serif", upm: 2048, xWidthAvg: 978, capH: 1466, xH: 1062, asc: 1854, desc: -434, lineGap: 67 },
39
+ "Aptos": { cat: "sans-serif", upm: 2048, xWidthAvg: 1098, capH: 1346, xH: 974, asc: 1923, desc: -577, lineGap: 0 },
40
+ // ── macOS system fonts (fontkit layout-based) ─────────────────────
41
+ "Arial": { cat: "sans-serif", upm: 2048, xWidthAvg: 1176, capH: 1467, xH: 1062, asc: 1854, desc: -434, lineGap: 67 },
42
+ "Helvetica": { cat: "sans-serif", upm: 2048, xWidthAvg: 1176, capH: 1469, xH: 1071, asc: 1577, desc: -471, lineGap: 0 },
43
+ "Helvetica Neue": { cat: "sans-serif", upm: 1000, xWidthAvg: 573, capH: 714, xH: 517, asc: 952, desc: -213, lineGap: 28 },
44
+ "Helvetica Neue Light": { cat: "sans-serif", upm: 1000, xWidthAvg: 598, capH: 714, xH: 517, asc: 975, desc: -217, lineGap: 29 },
45
+ "Helvetica Neue Medium": { cat: "sans-serif", upm: 1000, xWidthAvg: 552, capH: 714, xH: 517, asc: 951, desc: -213, lineGap: 28 },
46
+ "Georgia": { cat: "serif", upm: 2048, xWidthAvg: 1184, capH: 1419, xH: 986, asc: 1878, desc: -449, lineGap: 0 },
47
+ "Verdana": { cat: "sans-serif", upm: 2048, xWidthAvg: 1273, capH: 1489, xH: 1117, asc: 2059, desc: -430, lineGap: 0 },
48
+ "Trebuchet MS": { cat: "sans-serif", upm: 2048, xWidthAvg: 1099, capH: 1465, xH: 1071, asc: 1923, desc: -455, lineGap: 0 },
49
+ "Tahoma": { cat: "sans-serif", upm: 2048, xWidthAvg: 1114, capH: 1489, xH: 1117, asc: 2049, desc: -423, lineGap: 0 },
50
+ "Courier New": { cat: "monospace", upm: 2048, xWidthAvg: 1229, capH: 1170, xH: 866, asc: 1705, desc: -615, lineGap: 0 },
51
+ "Times New Roman": { cat: "serif", upm: 2048, xWidthAvg: 1122, capH: 1356, xH: 916, asc: 1825, desc: -443, lineGap: 87 },
52
+ "Times": { cat: "serif", upm: 2048, xWidthAvg: 1122, capH: 1355, xH: 929, asc: 1536, desc: -512, lineGap: 0 },
53
+ "Menlo": { cat: "monospace", upm: 2048, xWidthAvg: 1233, capH: 1493, xH: 1120, asc: 1901, desc: -483, lineGap: 0 },
54
+ "Courier": { cat: "monospace", upm: 2048, xWidthAvg: 1229, capH: 1544, xH: 929, asc: 1544, desc: -504, lineGap: 0 },
55
+ "Palatino": { cat: "serif", upm: 2048, xWidthAvg: 1186, capH: 1423, xH: 965, asc: 1685, desc: -568, lineGap: 0 },
56
+ "Futura": { cat: "sans-serif", upm: 2048, xWidthAvg: 1203, capH: 1559, xH: 988, asc: 2127, desc: -532, lineGap: 61 },
57
+ "Avenir": { cat: "sans-serif", upm: 1000, xWidthAvg: 566, capH: 708, xH: 468, asc: 1000, desc: -366, lineGap: 0 },
58
+ "Avenir Next": { cat: "sans-serif", upm: 1000, xWidthAvg: 624, capH: 708, xH: 498, asc: 1000, desc: -366, lineGap: 0 },
59
+ "Avenir Next Medium": { cat: "sans-serif", upm: 1000, xWidthAvg: 630, capH: 708, xH: 498, asc: 1000, desc: -366, lineGap: 0 },
60
+ "Geneva": { cat: "sans-serif", upm: 2048, xWidthAvg: 1225, capH: 1552, xH: 1117, asc: 2048, desc: -512, lineGap: 171 },
61
+ "Impact": { cat: "sans-serif", upm: 2048, xWidthAvg: 989, capH: 1620, xH: 1327, asc: 2066, desc: -432, lineGap: 0 },
62
+ // ── Google Fonts (from @capsizecss/metrics — raw glyph widths) ────
63
+ "Roboto": { cat: "sans-serif", upm: 2048, xWidthAvg: 911, capH: 1456, xH: 1082, asc: 1900, desc: -500, lineGap: 0 },
64
+ "Open Sans": { cat: "sans-serif", upm: 2048, xWidthAvg: 960, capH: 1462, xH: 1096, asc: 2189, desc: -600, lineGap: 0 },
65
+ "Lato": { cat: "sans-serif", upm: 2000, xWidthAvg: 871, capH: 1433, xH: 1013, asc: 1974, desc: -426, lineGap: 0 },
66
+ "Montserrat": { cat: "sans-serif", upm: 1000, xWidthAvg: 503, capH: 700, xH: 525, asc: 968, desc: -251, lineGap: 0 },
67
+ "Oswald": { cat: "sans-serif", upm: 1000, xWidthAvg: 363, capH: 810, xH: 578, asc: 1193, desc: -289, lineGap: 0 },
68
+ "Raleway": { cat: "sans-serif", upm: 1000, xWidthAvg: 463, capH: 710, xH: 519, asc: 940, desc: -234, lineGap: 0 },
69
+ "Poppins": { cat: "sans-serif", upm: 1000, xWidthAvg: 500, capH: 698, xH: 548, asc: 1050, desc: -350, lineGap: 100 },
70
+ "Nunito": { cat: "sans-serif", upm: 1000, xWidthAvg: 452, capH: 705, xH: 484, asc: 1011, desc: -353, lineGap: 0 },
71
+ "Inter": { cat: "sans-serif", upm: 2048, xWidthAvg: 978, capH: 1490, xH: 1118, asc: 1984, desc: -494, lineGap: 0 },
72
+ "Playfair Display": { cat: "serif", upm: 1000, xWidthAvg: 452, capH: 708, xH: 514, asc: 1082, desc: -251, lineGap: 0 },
73
+ "Merriweather": { cat: "serif", upm: 2000, xWidthAvg: 970, capH: 1486, xH: 1111, asc: 1968, desc: -546, lineGap: 0 },
74
+ "Fira Sans": { cat: "sans-serif", upm: 1000, xWidthAvg: 458, capH: 689, xH: 527, asc: 935, desc: -265, lineGap: 0 },
75
+ "Fira Mono": { cat: "monospace", upm: 1000, xWidthAvg: 600, capH: 689, xH: 527, asc: 935, desc: -265, lineGap: 0 },
76
+ "Fira Code": { cat: "monospace", upm: 2000, xWidthAvg: 1200, capH: 1377, xH: 1053, asc: 1980, desc: -644, lineGap: 0 },
77
+ "Source Code Pro": { cat: "monospace", upm: 1000, xWidthAvg: 600, capH: 660, xH: 486, asc: 984, desc: -273, lineGap: 0 },
78
+ "Source Sans 3": { cat: "sans-serif", upm: 1000, xWidthAvg: 418, capH: 660, xH: 486, asc: 1024, desc: -400, lineGap: 0 },
79
+ "Source Serif 4": { cat: "serif", upm: 1000, xWidthAvg: 479, capH: 670, xH: 475, asc: 1036, desc: -335, lineGap: 0 },
80
+ "Noto Sans": { cat: "sans-serif", upm: 1000, xWidthAvg: 474, capH: 714, xH: 536, asc: 1069, desc: -293, lineGap: 0 },
81
+ "Noto Serif": { cat: "serif", upm: 1000, xWidthAvg: 481, capH: 714, xH: 536, asc: 1069, desc: -293, lineGap: 0 },
82
+ "Noto Sans Mono": { cat: "monospace", upm: 1000, xWidthAvg: 600, capH: 714, xH: 536, asc: 1069, desc: -293, lineGap: 0 },
83
+ "PT Sans": { cat: "sans-serif", upm: 1000, xWidthAvg: 431, capH: 700, xH: 500, asc: 1018, desc: -276, lineGap: 0 },
84
+ "PT Serif": { cat: "serif", upm: 1000, xWidthAvg: 448, capH: 700, xH: 500, asc: 1039, desc: -286, lineGap: 0 },
85
+ "Ubuntu": { cat: "sans-serif", upm: 1000, xWidthAvg: 455, capH: 693, xH: 520, asc: 932, desc: -189, lineGap: 28 },
86
+ "Ubuntu Mono": { cat: "monospace", upm: 1000, xWidthAvg: 500, capH: 693, xH: 520, asc: 830, desc: -170, lineGap: 0 },
87
+ };
88
+ // ── Similarity matching ─────────────────────────────────────────────
89
+ /** Normalize a metric by unitsPerEm so fonts at different scales are comparable. */
90
+ function norm(m) {
91
+ const u = m.upm;
92
+ return {
93
+ w: m.xWidthAvg / u,
94
+ lh: (m.asc - m.desc + m.lineGap) / u,
95
+ capH: m.capH / u,
96
+ xH: m.xH / u,
97
+ };
98
+ }
99
+ /**
100
+ * Compute similarity score between two fonts. Lower = more similar.
101
+ * Weights character width (60%) above vertical metrics (40%) because
102
+ * width determines text reflow / overflow in bounded text boxes.
103
+ */
104
+ function similarity(a, b) {
105
+ const na = norm(a), nb = norm(b);
106
+ const wd = Math.abs(na.w - nb.w) / (na.w || 1);
107
+ const lh = Math.abs(na.lh - nb.lh) / (na.lh || 1);
108
+ // capH/xH might be 0 for some fonts — only include if both have data
109
+ let vertScore = 0, vertWeight = 0;
110
+ if (na.capH > 0 && nb.capH > 0) {
111
+ vertScore += Math.abs(na.capH - nb.capH) / na.capH;
112
+ vertWeight += 1;
113
+ }
114
+ if (na.xH > 0 && nb.xH > 0) {
115
+ vertScore += Math.abs(na.xH - nb.xH) / na.xH;
116
+ vertWeight += 1;
117
+ }
118
+ const vert = vertWeight > 0 ? vertScore / vertWeight : 0;
119
+ return wd * 0.60 + lh * 0.20 + vert * 0.20;
120
+ }
121
+ /** Width delta (%) between two fonts. Positive = target is wider. */
122
+ export function widthDelta(source, target) {
123
+ const sw = source.xWidthAvg / source.upm;
124
+ const tw = target.xWidthAvg / target.upm;
125
+ return ((tw / sw) - 1) * 100;
126
+ }
127
+ /**
128
+ * Find the closest fonts in the database to the given source font.
129
+ *
130
+ * @param source - Name of the source font, or its FontMetrics
131
+ * @param options.candidates - Limit search to these font names (default: all fonts in DB)
132
+ * @param options.sameCategory - Only match fonts of the same category (default: true)
133
+ * @param options.limit - Max results to return (default: 3)
134
+ */
135
+ export function findClosestFont(source, options = {}) {
136
+ const { sameCategory = true, limit = 3 } = options;
137
+ const sourceMetrics = typeof source === "string" ? FONT_METRICS[source] : source;
138
+ if (!sourceMetrics)
139
+ return [];
140
+ const candidateNames = options.candidates ?? Object.keys(FONT_METRICS);
141
+ const results = [];
142
+ for (const name of candidateNames) {
143
+ const m = FONT_METRICS[name];
144
+ if (!m)
145
+ continue;
146
+ if (typeof source === "string" && name === source)
147
+ continue;
148
+ if (sameCategory && m.cat !== sourceMetrics.cat)
149
+ continue;
150
+ results.push({
151
+ font: name,
152
+ score: similarity(sourceMetrics, m),
153
+ widthDelta: widthDelta(sourceMetrics, m),
154
+ });
155
+ }
156
+ return results.sort((a, b) => a.score - b.score).slice(0, limit);
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quicklook-pptx-renderer",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Open-source PPTX rendering engine that replicates Apple's macOS QuickLook and iOS preview — pixel for pixel. Test how PowerPoint files look on Mac/iPhone without a Mac.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",