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 +125 -47
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lint.js +20 -2
- package/dist/resolve/font-metrics.d.ts +60 -0
- package/dist/resolve/font-metrics.js +157 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# quicklook-pptx-renderer
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
//
|
|
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 |
|
|
116
|
-
| `table-style-unresolved` | warn | Table style reference that OfficeImport
|
|
117
|
-
| `text-inscription-shift` | warn | Text in non-rect shape uses geometry-inscribed bounds |
|
|
118
|
-
| `font-substitution` | info | Font will be substituted on macOS (
|
|
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
|
|
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
|
-
|
|
296
|
+
If you prefer fixing at the source:
|
|
216
297
|
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 —
|
|
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
|
|
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
|
|
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",
|