quicklook-pptx-renderer 0.2.3 → 0.3.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 +101 -16
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/lint.d.ts +40 -1
- package/dist/lint.js +174 -78
- package/dist/resolve/font-map.d.ts +1 -0
- package/dist/resolve/font-map.js +1 -1
- package/dist/resolve/font-metrics.js +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ It happens because Apple uses a private framework called `OfficeImport.framework
|
|
|
22
22
|
|---|---|
|
|
23
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
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
|
|
25
|
+
| **Table borders missing** — tables appear as plain text without any grid | OfficeImport doesn't resolve `tableStyleId` references. It emits `border-style:none` per-cell unless borders are explicit in `<a:lnL>`, `<a:lnR>`, `<a:lnT>`, `<a:lnB>`. PowerPoint resolves the style and shows borders; QuickLook doesn't. python-pptx and most generators rely on style references. |
|
|
26
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
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
28
|
| **Charts are blank rectangles** — chart content simply vanishes | Charts without an embedded fallback image render as empty rectangles. |
|
|
@@ -114,24 +114,82 @@ for (const issue of result.issues) {
|
|
|
114
114
|
}
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
+
### Building Fix Tools
|
|
118
|
+
|
|
119
|
+
The lint API is designed to power automated PPTX fixers. Each issue's `fix` object tells you exactly what to change — no re-detection needed.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import { lint, SUPPORTED_GEOMETRIES, FONT_SUBSTITUTIONS, GEOMETRY_FALLBACKS } from "quicklook-pptx-renderer";
|
|
123
|
+
|
|
124
|
+
const result = await lint(pptxBuffer);
|
|
125
|
+
|
|
126
|
+
for (const issue of result.issues) {
|
|
127
|
+
if (!issue.fix) continue; // informational only
|
|
128
|
+
|
|
129
|
+
switch (issue.fix.action) {
|
|
130
|
+
case "replace-geometry":
|
|
131
|
+
// issue.fix.current = "heart", issue.fix.suggested = "ellipse"
|
|
132
|
+
// Replace <a:prstGeom prst="heart"> with <a:prstGeom prst="ellipse">
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case "strip-effects":
|
|
136
|
+
// Remove <a:effectLst> and <a:effectDag> from the shape
|
|
137
|
+
break;
|
|
138
|
+
|
|
139
|
+
case "reduce-stops":
|
|
140
|
+
// issue.fix.firstColor = "#FF0000", issue.fix.lastColor = "#0000FF"
|
|
141
|
+
// issue.fix.averageColor = "#7F007F", issue.fix.angle = 5400000
|
|
142
|
+
// Replace 3+ gradient stops with 2-stop using first/last colors
|
|
143
|
+
break;
|
|
144
|
+
|
|
145
|
+
case "replace-font":
|
|
146
|
+
// issue.fix.from = "Calibri", issue.fix.macTarget = "Helvetica Neue"
|
|
147
|
+
// issue.fix.widthDelta = 14.4, issue.fix.alternatives = [
|
|
148
|
+
// { font: "Carlito", widthDelta: 2.1 },
|
|
149
|
+
// { font: "Montserrat", widthDelta: 0.4 }
|
|
150
|
+
// ]
|
|
151
|
+
break;
|
|
152
|
+
|
|
153
|
+
case "inline-borders":
|
|
154
|
+
// issue.fix.tableStyleId = "{5C22544A-...}"
|
|
155
|
+
// Resolve table style and write explicit borders on each cell
|
|
156
|
+
// Note: tables without a style AND without borders are intentionally
|
|
157
|
+
// borderless — both PowerPoint and QuickLook render them the same way.
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
case "add-fallback-image":
|
|
161
|
+
// Generate or embed a chart fallback image via mc:AlternateContent
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case "ungroup":
|
|
165
|
+
// issue.fix.childCount = 5, issue.fix.containsPictures = false
|
|
166
|
+
// Promote group children to slide-level drawables
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Also available: `SUPPORTED_GEOMETRIES` (Set of 60 presets OfficeImport renders), `FONT_SUBSTITUTIONS` (Windows → macOS font map), `GEOMETRY_FALLBACKS` (unsupported → closest supported preset), `findClosestFont()` (metric-based font matching), `widthDelta()` (percentage width difference between fonts).
|
|
173
|
+
|
|
117
174
|
---
|
|
118
175
|
|
|
119
176
|
## Lint Rules
|
|
120
177
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
|
124
|
-
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `font-substitution` | warn/info | Font will be substituted on macOS — includes width delta (warn if ≥10% reflow risk) |
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
178
|
+
Every issue carries a typed `fix` object with machine-readable remediation data — downstream tools can apply transforms without re-detecting the problem.
|
|
179
|
+
|
|
180
|
+
| Rule | Severity | What It Catches | `fix.action` |
|
|
181
|
+
|------|----------|----------------|-------------|
|
|
182
|
+
| `unsupported-geometry` | error | Shape preset not in OfficeImport's ~60 supported geometries — will be invisible | `replace-geometry` (includes closest supported preset) |
|
|
183
|
+
| `chart-no-fallback` | error | Chart without fallback image — renders as blank rectangle | `add-fallback-image` |
|
|
184
|
+
| `opaque-pdf-block` | warn | Shape with effects rendered as opaque PDF covering content behind it | `strip-effects` |
|
|
185
|
+
| `gradient-flattened` | warn | 3+ gradient stops collapsed to single average color | `reduce-stops` (includes computed 2-stop colors + average) |
|
|
186
|
+
| `table-style-unresolved` | warn | Table has style but cells lack explicit borders — PowerPoint shows borders, QL won't | `inline-borders` (includes `tableStyleId`) |
|
|
187
|
+
| `font-substitution` | warn/info | Font will be substituted on macOS — includes width delta (warn if ≥10% reflow risk) | `replace-font` (includes macOS target, delta, metrically-closest alternatives) |
|
|
188
|
+
| `group-as-pdf` | warn | Group rendered as single opaque PDF image | `ungroup` (includes child count, whether group contains pictures) |
|
|
189
|
+
| `geometry-forces-pdf` | info | Non-rect geometry renders as PDF — opaque background may cover adjacent content | — |
|
|
190
|
+
| `rotation-forces-pdf` | info | Rotated rect renders as PDF — enlarged bounding box may cover adjacent content | — |
|
|
191
|
+
| `text-inscription-shift` | info | Text in non-rect shape uses geometry-inscribed bounds (text may shift) | `replace-geometry` (suggests `rect`) |
|
|
192
|
+
| `vertical-text` | info | Vertical text uses CSS writing-mode | — |
|
|
135
193
|
|
|
136
194
|
---
|
|
137
195
|
|
|
@@ -234,10 +292,35 @@ macOS replaces Windows fonts at render time (from `TCFontUtils` in OfficeImport)
|
|
|
234
292
|
|
|
235
293
|
**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
294
|
|
|
237
|
-
The linter
|
|
295
|
+
The linter reports width deltas, upgrades font substitution to `warn` severity when the delta exceeds ±10%, and includes metrically-closest cross-platform alternatives in the `fix` data.
|
|
238
296
|
|
|
239
297
|
Plus CJK mappings: MS Gothic → Hiragino Sans, SimSun → STSong, Microsoft YaHei → PingFang SC, Malgun Gothic → Apple SD Gothic Neo, and more.
|
|
240
298
|
|
|
299
|
+
### Metric-Compatible Google Fonts (Croscore)
|
|
300
|
+
|
|
301
|
+
These open-source fonts (SIL OFL) were designed as drop-in replacements for Microsoft core fonts. Width deltas measured from OS/2 tables and included in the font metrics database (135 fonts total):
|
|
302
|
+
|
|
303
|
+
| Microsoft Font | Google Font Replacement | Width Δ vs Original | License |
|
|
304
|
+
|---|---|---|---|
|
|
305
|
+
| Calibri | **Carlito** | -2.1% | OFL |
|
|
306
|
+
| Cambria | **Caladea** | -5.6% | OFL |
|
|
307
|
+
| Arial | **Arimo** | -1.2% | Apache 2.0 |
|
|
308
|
+
| Times New Roman | **Tinos** | +0.5% | Apache 2.0 |
|
|
309
|
+
| Courier New | **Cousine** | 0.0% | Apache 2.0 |
|
|
310
|
+
|
|
311
|
+
Compare: Calibri → Helvetica Neue (the QL substitution) is **+14.4%**. Carlito at -2.1% is 7x better.
|
|
312
|
+
|
|
313
|
+
### What Doesn't Work for Font Fixes
|
|
314
|
+
|
|
315
|
+
Verified by testing against actual OfficeImport output:
|
|
316
|
+
|
|
317
|
+
- **Embedded fonts (`<p:embeddedFont>`)**: OfficeImport ignores them completely. No `OADEmbeddedFont` class exists in the framework. Embedding helps PowerPoint and LibreOffice, but not QuickLook.
|
|
318
|
+
- **`pitchFamily` attribute**: OfficeImport ignores it. All unknown fonts produce `font-family:"FontName"` in CSS with no generic family fallback. WebKit then falls back to **serif (Times)** regardless of the pitchFamily hint.
|
|
319
|
+
- **Font stacks / fallback chains**: OOXML supports only one `typeface` per text run. No CSS-like `font-family: "Carlito", "Calibri", sans-serif` — it's a single value.
|
|
320
|
+
- **Parent font inheritance as fallback**: OOXML property inheritance only applies when the property is **missing**, not when the font isn't installed. If a run specifies `typeface="Carlito"`, the parent's `typeface="Arial"` is not used as a fallback.
|
|
321
|
+
|
|
322
|
+
**The only way to control fonts in QuickLook is to use a font that macOS has installed.** The `FONT_SUBSTITUTIONS` map and `findClosestFont()` API provide the data to pick the best replacement.
|
|
323
|
+
|
|
241
324
|
---
|
|
242
325
|
|
|
243
326
|
## Key Discoveries
|
|
@@ -252,6 +335,8 @@ These findings come from reverse engineering OfficeImport via Objective-C runtim
|
|
|
252
335
|
6. **3+ gradient stops → average color** (not rendered as gradient)
|
|
253
336
|
7. **Font substitution**: Calibri → Helvetica Neue, Arial → Helvetica, etc. (full map above)
|
|
254
337
|
8. **Text inscription for non-rect shapes** uses `float` radius and `trunc()` in pixel space
|
|
338
|
+
9. **Embedded fonts are ignored** — no `OADEmbeddedFont` class; `TCImportFontCache` only does CoreText system font lookup
|
|
339
|
+
10. **`pitchFamily` is ignored** — all unknown fonts get bare `font-family:"Name"` in CSS with no generic family fallback, regardless of pitchFamily value
|
|
255
340
|
|
|
256
341
|
---
|
|
257
342
|
|
package/dist/index.d.ts
CHANGED
|
@@ -20,11 +20,12 @@ export interface RenderResult {
|
|
|
20
20
|
* embedded CSS — ready to open in WebKit for pixel-perfect rendering.
|
|
21
21
|
*/
|
|
22
22
|
export declare function render(pptxBuffer: Buffer): Promise<RenderResult>;
|
|
23
|
-
export { lint, formatIssues, type LintResult, type LintIssue, type Severity, type RuleId } from "./lint.js";
|
|
23
|
+
export { lint, formatIssues, GEOMETRY_FALLBACKS, type LintResult, type LintIssue, type LintFix, type Severity, type RuleId } from "./lint.js";
|
|
24
24
|
export { PptxPackage } from "./package/package.js";
|
|
25
25
|
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
|
-
export { resolveFontFamily } from "./resolve/font-map.js";
|
|
28
|
+
export { resolveFontFamily, FONT_SUBSTITUTIONS } from "./resolve/font-map.js";
|
|
29
29
|
export { FONT_METRICS, findClosestFont, widthDelta, type FontMetrics, type FontMatch, type FontCategory } from "./resolve/font-metrics.js";
|
|
30
|
+
export { SUPPORTED_GEOMETRIES } from "./mapper/shape-mapper.js";
|
|
30
31
|
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
|
@@ -63,11 +63,12 @@ export async function render(pptxBuffer) {
|
|
|
63
63
|
return { slides, fullHtml: html, attachments };
|
|
64
64
|
}
|
|
65
65
|
// ── Linter (QuickLook compatibility checks) ─────────────────────
|
|
66
|
-
export { lint, formatIssues } from "./lint.js";
|
|
66
|
+
export { lint, formatIssues, GEOMETRY_FALLBACKS } from "./lint.js";
|
|
67
67
|
// ── Building blocks ─────────────────────────────────────────────
|
|
68
68
|
export { PptxPackage } from "./package/package.js";
|
|
69
69
|
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
|
-
export { resolveFontFamily } from "./resolve/font-map.js";
|
|
72
|
+
export { resolveFontFamily, FONT_SUBSTITUTIONS } from "./resolve/font-map.js";
|
|
73
73
|
export { FONT_METRICS, findClosestFont, widthDelta } from "./resolve/font-metrics.js";
|
|
74
|
+
export { SUPPORTED_GEOMETRIES } from "./mapper/shape-mapper.js";
|
package/dist/lint.d.ts
CHANGED
|
@@ -3,9 +3,45 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Parses a PPTX and checks every shape, text run, table, and fill against
|
|
5
5
|
* known OfficeImport quirks. No rendering required — just parse and check.
|
|
6
|
+
*
|
|
7
|
+
* Each issue carries a typed `fix` object with machine-readable remediation
|
|
8
|
+
* data so downstream tools (like pptx-fix) can apply transforms without
|
|
9
|
+
* re-detecting the problem.
|
|
6
10
|
*/
|
|
7
11
|
export type Severity = "error" | "warn" | "info";
|
|
8
|
-
export type RuleId = "unsupported-geometry" | "opaque-pdf-block" | "gradient-flattened" | "font-substitution" | "table-
|
|
12
|
+
export type RuleId = "unsupported-geometry" | "opaque-pdf-block" | "gradient-flattened" | "font-substitution" | "table-style-unresolved" | "group-as-pdf" | "chart-no-fallback" | "text-inscription-shift" | "rotation-forces-pdf" | "geometry-forces-pdf" | "vertical-text";
|
|
13
|
+
/** Machine-readable remediation data — one variant per fix strategy. */
|
|
14
|
+
export type LintFix = {
|
|
15
|
+
action: "replace-geometry";
|
|
16
|
+
current: string;
|
|
17
|
+
suggested: string;
|
|
18
|
+
} | {
|
|
19
|
+
action: "strip-effects";
|
|
20
|
+
} | {
|
|
21
|
+
action: "reduce-stops";
|
|
22
|
+
firstColor: string;
|
|
23
|
+
lastColor: string;
|
|
24
|
+
averageColor: string;
|
|
25
|
+
angle?: number;
|
|
26
|
+
} | {
|
|
27
|
+
action: "replace-font";
|
|
28
|
+
from: string;
|
|
29
|
+
macTarget: string;
|
|
30
|
+
widthDelta: number;
|
|
31
|
+
alternatives: Array<{
|
|
32
|
+
font: string;
|
|
33
|
+
widthDelta: number;
|
|
34
|
+
}>;
|
|
35
|
+
} | {
|
|
36
|
+
action: "inline-borders";
|
|
37
|
+
tableStyleId: string;
|
|
38
|
+
} | {
|
|
39
|
+
action: "add-fallback-image";
|
|
40
|
+
} | {
|
|
41
|
+
action: "ungroup";
|
|
42
|
+
childCount: number;
|
|
43
|
+
containsPictures: boolean;
|
|
44
|
+
};
|
|
9
45
|
export interface LintIssue {
|
|
10
46
|
rule: RuleId;
|
|
11
47
|
severity: Severity;
|
|
@@ -13,6 +49,7 @@ export interface LintIssue {
|
|
|
13
49
|
element?: string;
|
|
14
50
|
message: string;
|
|
15
51
|
suggestion?: string;
|
|
52
|
+
fix?: LintFix;
|
|
16
53
|
}
|
|
17
54
|
export interface LintResult {
|
|
18
55
|
issues: LintIssue[];
|
|
@@ -23,5 +60,7 @@ export interface LintResult {
|
|
|
23
60
|
slides: number;
|
|
24
61
|
};
|
|
25
62
|
}
|
|
63
|
+
/** Maps commonly-used unsupported OOXML presets to visually-closest supported alternative. */
|
|
64
|
+
export declare const GEOMETRY_FALLBACKS: Record<string, string>;
|
|
26
65
|
export declare function lint(pptxBuffer: Buffer): Promise<LintResult>;
|
|
27
66
|
export declare function formatIssues(result: LintResult): string;
|
package/dist/lint.js
CHANGED
|
@@ -3,35 +3,95 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Parses a PPTX and checks every shape, text run, table, and fill against
|
|
5
5
|
* known OfficeImport quirks. No rendering required — just parse and check.
|
|
6
|
+
*
|
|
7
|
+
* Each issue carries a typed `fix` object with machine-readable remediation
|
|
8
|
+
* data so downstream tools (like pptx-fix) can apply transforms without
|
|
9
|
+
* re-detecting the problem.
|
|
6
10
|
*/
|
|
7
11
|
import { PptxPackage } from "./package/package.js";
|
|
8
12
|
import { readPresentation } from "./reader/presentation.js";
|
|
9
13
|
import { SUPPORTED_GEOMETRIES } from "./mapper/shape-mapper.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
14
|
+
import { FONT_SUBSTITUTIONS } from "./resolve/font-map.js";
|
|
15
|
+
import { FONT_METRICS, widthDelta, findClosestFont } from "./resolve/font-metrics.js";
|
|
16
|
+
import { resolveColor } from "./resolve/color-resolver.js";
|
|
17
|
+
// ── Geometry fallback map (unsupported → closest supported) ────────
|
|
18
|
+
/** Maps commonly-used unsupported OOXML presets to visually-closest supported alternative. */
|
|
19
|
+
export const GEOMETRY_FALLBACKS = {
|
|
20
|
+
// Rounded rect variants → roundRect
|
|
21
|
+
round1Rect: "roundRect", round2SameRect: "roundRect", round2DiagRect: "roundRect",
|
|
22
|
+
snipRoundRect: "roundRect", plaque: "roundRect",
|
|
23
|
+
// Snip rect variants → rect
|
|
24
|
+
snip1Rect: "rect", snip2SameRect: "rect", snip2DiagRect: "rect",
|
|
25
|
+
// Organic shapes → ellipse
|
|
26
|
+
heart: "ellipse", donut: "ellipse", noSmoking: "ellipse",
|
|
27
|
+
smileyFace: "ellipse", sun: "ellipse", moon: "ellipse", blockArc: "ellipse",
|
|
28
|
+
// Angular shapes → closest polygon
|
|
29
|
+
decagon: "octagon", heptagon: "hexagon", dodecagon: "octagon",
|
|
30
|
+
// Arrows (unsupported variants → closest supported arrow)
|
|
31
|
+
bentArrow: "rightArrow", curvedLeftArrow: "leftArrow",
|
|
32
|
+
curvedRightArrow: "rightArrow", curvedUpArrow: "upArrow",
|
|
33
|
+
curvedDownArrow: "downArrow", notchedRightArrow: "rightArrow",
|
|
34
|
+
stripedRightArrow: "rightArrow", uturnArrow: "downArrow",
|
|
35
|
+
// Flowcharts → closest match
|
|
36
|
+
flowChartProcess: "rect", flowChartTerminator: "roundRect",
|
|
37
|
+
flowChartDocument: "rect", flowChartInputOutput: "parallelogram",
|
|
38
|
+
flowChartPreparation: "hexagon", flowChartManualInput: "trapezoid",
|
|
39
|
+
flowChartManualOperation: "trapezoid", flowChartConnector: "ellipse",
|
|
40
|
+
flowChartPredefinedProcess: "rect", flowChartInternalStorage: "rect",
|
|
41
|
+
flowChartMultidocument: "rect", flowChartOffpageConnector: "pentagon",
|
|
42
|
+
flowChartPunchedCard: "rect", flowChartPunchedTape: "rect",
|
|
43
|
+
flowChartSummingJunction: "ellipse", flowChartOr: "ellipse",
|
|
44
|
+
flowChartCollate: "diamond", flowChartSort: "diamond",
|
|
45
|
+
flowChartExtract: "triangle", flowChartMerge: "triangle",
|
|
46
|
+
flowChartOnlineStorage: "can", flowChartDelay: "roundRect",
|
|
47
|
+
flowChartMagneticTape: "ellipse", flowChartMagneticDisk: "can",
|
|
48
|
+
flowChartMagneticDrum: "can", flowChartDisplay: "roundRect",
|
|
49
|
+
// Misc
|
|
50
|
+
foldedCorner: "rect", frame: "rect", bevel: "rect",
|
|
51
|
+
lightningBolt: "rightArrow", cloud: "cloudCallout",
|
|
52
|
+
ribbon: "rect", ribbon2: "rect",
|
|
53
|
+
horizontalScroll: "verticalScroll",
|
|
54
|
+
wave: "rect", doubleWave: "rect",
|
|
55
|
+
// Callout variants → closest supported callout
|
|
56
|
+
callout1: "borderCallout1", callout2: "borderCallout1", callout3: "borderCallout1",
|
|
57
|
+
accentCallout1: "borderCallout1", accentCallout2: "borderCallout1", accentCallout3: "borderCallout1",
|
|
58
|
+
accentBorderCallout1: "borderCallout1", accentBorderCallout2: "borderCallout1", accentBorderCallout3: "borderCallout1",
|
|
59
|
+
borderCallout2: "borderCallout1", borderCallout3: "borderCallout1",
|
|
60
|
+
// Math → plus (only supported math-like shape)
|
|
61
|
+
mathPlus: "plus", mathMinus: "rect", mathMultiply: "plus",
|
|
62
|
+
mathDivide: "rect", mathEqual: "rect", mathNotEqual: "rect",
|
|
63
|
+
// Brackets/braces → rect fallback
|
|
64
|
+
leftBrace: "rect", rightBrace: "rect", leftBracket: "rect", rightBracket: "rect",
|
|
65
|
+
bracePair: "rect", bracketPair: "rect",
|
|
66
|
+
// Stars (unsupported sizes) → closest supported star
|
|
67
|
+
star7: "star8",
|
|
68
|
+
// Action buttons → rect
|
|
69
|
+
actionButtonBlank: "rect", actionButtonHome: "rect", actionButtonHelp: "rect",
|
|
70
|
+
actionButtonInformation: "rect", actionButtonBackPrevious: "rect",
|
|
71
|
+
actionButtonForwardNext: "rect", actionButtonBeginning: "rect",
|
|
72
|
+
actionButtonEnd: "rect", actionButtonReturn: "rect",
|
|
73
|
+
actionButtonDocument: "rect", actionButtonSound: "rect",
|
|
74
|
+
actionButtonMovie: "rect",
|
|
75
|
+
// Misc exotic
|
|
76
|
+
gear6: "hexagon", gear9: "octagon",
|
|
77
|
+
funnel: "triangle", pie: "ellipse", pieWedge: "triangle",
|
|
78
|
+
irregularSeal1: "diamond", irregularSeal2: "diamond",
|
|
31
79
|
};
|
|
80
|
+
// Geometries where text inscription differs from a simple rect inset
|
|
81
|
+
const NON_RECT_TEXT_GEOMETRIES = new Set([
|
|
82
|
+
"roundRect", "ellipse", "diamond", "triangle", "rtTriangle",
|
|
83
|
+
"parallelogram", "trapezoid", "hexagon", "octagon", "pentagon",
|
|
84
|
+
"star4", "star5", "star6", "star8",
|
|
85
|
+
]);
|
|
86
|
+
function rgbaToHex(c) {
|
|
87
|
+
const r = c.r.toString(16).padStart(2, "0");
|
|
88
|
+
const g = c.g.toString(16).padStart(2, "0");
|
|
89
|
+
const b = c.b.toString(16).padStart(2, "0");
|
|
90
|
+
return `#${r}${g}${b}`;
|
|
91
|
+
}
|
|
32
92
|
/** Compute width delta between a source font and its OfficeImport substitute. */
|
|
33
93
|
function fontWidthDelta(sourceName) {
|
|
34
|
-
const targetName =
|
|
94
|
+
const targetName = FONT_SUBSTITUTIONS[sourceName];
|
|
35
95
|
if (!targetName)
|
|
36
96
|
return null;
|
|
37
97
|
const src = FONT_METRICS[sourceName];
|
|
@@ -40,12 +100,6 @@ function fontWidthDelta(sourceName) {
|
|
|
40
100
|
return null;
|
|
41
101
|
return widthDelta(src, tgt);
|
|
42
102
|
}
|
|
43
|
-
// Geometries where text inscription differs from a simple rect inset
|
|
44
|
-
const NON_RECT_TEXT_GEOMETRIES = new Set([
|
|
45
|
-
"roundRect", "ellipse", "diamond", "triangle", "rtTriangle",
|
|
46
|
-
"parallelogram", "trapezoid", "hexagon", "octagon", "pentagon",
|
|
47
|
-
"star4", "star5", "star6", "star8",
|
|
48
|
-
]);
|
|
49
103
|
// ── Main API ────────────────────────────────────────────────────────
|
|
50
104
|
export async function lint(pptxBuffer) {
|
|
51
105
|
const pkg = await PptxPackage.open(pptxBuffer);
|
|
@@ -54,7 +108,14 @@ export async function lint(pptxBuffer) {
|
|
|
54
108
|
for (let i = 0; i < pres.slides.length; i++) {
|
|
55
109
|
const slide = pres.slides[i];
|
|
56
110
|
const slideNum = i + 1;
|
|
57
|
-
|
|
111
|
+
const master = slide.slideLayout.slideMaster;
|
|
112
|
+
const ctx = {
|
|
113
|
+
colorMap: slide.colorMapOverride
|
|
114
|
+
?? slide.slideLayout.colorMapOverride
|
|
115
|
+
?? master.colorMap,
|
|
116
|
+
colorScheme: master.theme.colorScheme,
|
|
117
|
+
};
|
|
118
|
+
lintDrawables(slide.drawables, slideNum, issues, ctx);
|
|
58
119
|
}
|
|
59
120
|
// Deduplicate font substitution warnings (one per font, not per run)
|
|
60
121
|
deduplicateFonts(issues);
|
|
@@ -69,39 +130,41 @@ export async function lint(pptxBuffer) {
|
|
|
69
130
|
};
|
|
70
131
|
}
|
|
71
132
|
// ── Drawable walker ─────────────────────────────────────────────────
|
|
72
|
-
function lintDrawables(drawables, slide, issues) {
|
|
133
|
+
function lintDrawables(drawables, slide, issues, ctx) {
|
|
73
134
|
for (const d of drawables) {
|
|
74
135
|
switch (d.drawableType) {
|
|
75
136
|
case "sp":
|
|
76
|
-
lintShape(d, slide, issues);
|
|
137
|
+
lintShape(d, slide, issues, ctx);
|
|
77
138
|
break;
|
|
78
139
|
case "pic":
|
|
79
140
|
lintPicture(d, slide, issues);
|
|
80
141
|
break;
|
|
81
142
|
case "grpSp":
|
|
82
|
-
lintGroup(d, slide, issues);
|
|
143
|
+
lintGroup(d, slide, issues, ctx);
|
|
83
144
|
break;
|
|
84
145
|
case "cxnSp":
|
|
85
146
|
lintConnector(d, slide, issues);
|
|
86
147
|
break;
|
|
87
148
|
case "graphicFrame":
|
|
88
|
-
lintGraphicFrame(d, slide, issues);
|
|
149
|
+
lintGraphicFrame(d, slide, issues, ctx);
|
|
89
150
|
break;
|
|
90
151
|
}
|
|
91
152
|
}
|
|
92
153
|
}
|
|
93
154
|
// ── Shape checks ────────────────────────────────────────────────────
|
|
94
|
-
function lintShape(shape, slide, issues) {
|
|
155
|
+
function lintShape(shape, slide, issues, ctx) {
|
|
95
156
|
const geom = shape.geometry?.preset ?? "rect";
|
|
96
157
|
const name = shape.name || `shape #${shape.id}`;
|
|
97
158
|
// 1. Unsupported geometry → invisible
|
|
98
159
|
if (geom !== "rect" && !SUPPORTED_GEOMETRIES.has(geom)) {
|
|
160
|
+
const suggested = GEOMETRY_FALLBACKS[geom] ?? "rect";
|
|
99
161
|
issues.push({
|
|
100
162
|
rule: "unsupported-geometry",
|
|
101
163
|
severity: "error",
|
|
102
164
|
slide, element: name,
|
|
103
165
|
message: `"${geom}" is not supported by OfficeImport — this shape will be invisible in QuickLook`,
|
|
104
|
-
suggestion: `
|
|
166
|
+
suggestion: `Replace with "${suggested}" (closest supported preset) or embed as an image`,
|
|
167
|
+
fix: { action: "replace-geometry", current: geom, suggested },
|
|
105
168
|
});
|
|
106
169
|
return; // no point checking further
|
|
107
170
|
}
|
|
@@ -116,24 +179,24 @@ function lintShape(shape, slide, issues) {
|
|
|
116
179
|
slide, element: name,
|
|
117
180
|
message: `"${geom}" with effects renders as an opaque PDF image — it will cover content behind it with a white background`,
|
|
118
181
|
suggestion: `Remove drop shadow/glow effects, or use a plain rect to keep CSS rendering`,
|
|
182
|
+
fix: { action: "strip-effects" },
|
|
119
183
|
});
|
|
120
184
|
}
|
|
121
185
|
else if (geom !== "rect") {
|
|
122
186
|
issues.push({
|
|
123
|
-
rule: "
|
|
124
|
-
severity: "
|
|
187
|
+
rule: "geometry-forces-pdf",
|
|
188
|
+
severity: "info",
|
|
125
189
|
slide, element: name,
|
|
126
|
-
message: `"${geom}" renders as PDF in QuickLook (only plain rect uses CSS)`,
|
|
127
|
-
suggestion: `Use rect geometry to keep CSS rendering, or accept PDF output`,
|
|
190
|
+
message: `"${geom}" renders as PDF in QuickLook (only plain rect uses CSS) — opaque background may cover adjacent content`,
|
|
128
191
|
});
|
|
129
192
|
}
|
|
130
193
|
if (hasRotation && geom === "rect") {
|
|
194
|
+
const rotDeg = Math.round((shape.bounds.rot / 60000) % 360);
|
|
131
195
|
issues.push({
|
|
132
196
|
rule: "rotation-forces-pdf",
|
|
133
|
-
severity: "
|
|
197
|
+
severity: "info",
|
|
134
198
|
slide, element: name,
|
|
135
|
-
message: `Rotated rect renders as PDF
|
|
136
|
-
suggestion: `Remove rotation or accept PDF rendering`,
|
|
199
|
+
message: `Rotated rect (${rotDeg}°) renders as PDF — enlarged bounding box may cover adjacent content`,
|
|
137
200
|
});
|
|
138
201
|
}
|
|
139
202
|
}
|
|
@@ -145,20 +208,22 @@ function lintShape(shape, slide, issues) {
|
|
|
145
208
|
slide, element: name,
|
|
146
209
|
message: `rect with effects (shadow/glow) renders as opaque PDF — covers content behind it`,
|
|
147
210
|
suggestion: `Remove effects to keep lightweight CSS rendering`,
|
|
211
|
+
fix: { action: "strip-effects" },
|
|
148
212
|
});
|
|
149
213
|
}
|
|
150
214
|
// 4. Non-rect text inscription shift
|
|
151
215
|
if (NON_RECT_TEXT_GEOMETRIES.has(geom) && hasNonEmptyText(shape.textBody)) {
|
|
152
216
|
issues.push({
|
|
153
217
|
rule: "text-inscription-shift",
|
|
154
|
-
severity: "
|
|
218
|
+
severity: "info",
|
|
155
219
|
slide, element: name,
|
|
156
220
|
message: `Text in "${geom}" uses geometry-inscribed bounds — text position will differ from PowerPoint`,
|
|
157
221
|
suggestion: `Use rect for predictable text positioning, or test the specific geometry`,
|
|
222
|
+
fix: { action: "replace-geometry", current: geom, suggested: "rect" },
|
|
158
223
|
});
|
|
159
224
|
}
|
|
160
225
|
// 5. Gradient flattening
|
|
161
|
-
lintFill(shape.fill, slide, name, issues);
|
|
226
|
+
lintFill(shape.fill, slide, name, issues, ctx);
|
|
162
227
|
// 6. Font substitution in text
|
|
163
228
|
if (shape.textBody)
|
|
164
229
|
lintTextBody(shape.textBody, slide, name, issues);
|
|
@@ -177,24 +242,26 @@ function lintPicture(_pic, _slide, _issues) {
|
|
|
177
242
|
// Pictures are straightforward — OfficeImport handles them well
|
|
178
243
|
}
|
|
179
244
|
// ── Group checks ────────────────────────────────────────────────────
|
|
180
|
-
function lintGroup(group, slide, issues) {
|
|
245
|
+
function lintGroup(group, slide, issues, ctx) {
|
|
181
246
|
const name = group.name || `group #${group.id}`;
|
|
247
|
+
const containsPictures = group.children.some(c => c.drawableType === "pic");
|
|
182
248
|
issues.push({
|
|
183
249
|
rule: "group-as-pdf",
|
|
184
250
|
severity: "warn",
|
|
185
251
|
slide, element: name,
|
|
186
|
-
message: `Group renders as single PDF image in QuickLook — all children merged into one opaque block`,
|
|
252
|
+
message: `Group (${group.children.length} children) renders as single PDF image in QuickLook — all children merged into one opaque block`,
|
|
187
253
|
suggestion: `Ungroup shapes for individual rendering, or accept merged PDF`,
|
|
254
|
+
fix: { action: "ungroup", childCount: group.children.length, containsPictures },
|
|
188
255
|
});
|
|
189
256
|
// Still lint children for their own issues
|
|
190
|
-
lintDrawables(group.children, slide, issues);
|
|
257
|
+
lintDrawables(group.children, slide, issues, ctx);
|
|
191
258
|
}
|
|
192
259
|
// ── Connector checks ────────────────────────────────────────────────
|
|
193
260
|
function lintConnector(_conn, _slide, _issues) {
|
|
194
261
|
// Connectors are always PDF, no special warnings needed
|
|
195
262
|
}
|
|
196
263
|
// ── GraphicFrame (tables/charts) ────────────────────────────────────
|
|
197
|
-
function lintGraphicFrame(frame, slide, issues) {
|
|
264
|
+
function lintGraphicFrame(frame, slide, issues, ctx) {
|
|
198
265
|
const name = frame.name || `frame #${frame.id}`;
|
|
199
266
|
// Chart without fallback image
|
|
200
267
|
if (frame.chartRId && !frame.fallbackImageData) {
|
|
@@ -204,15 +271,19 @@ function lintGraphicFrame(frame, slide, issues) {
|
|
|
204
271
|
slide, element: name,
|
|
205
272
|
message: `Chart has no fallback image — will render as blank rectangle in QuickLook`,
|
|
206
273
|
suggestion: `Save from PowerPoint (not python-pptx) to generate fallback images, or use mc:AlternateContent`,
|
|
274
|
+
fix: { action: "add-fallback-image" },
|
|
207
275
|
});
|
|
208
276
|
}
|
|
209
277
|
// Table checks
|
|
210
278
|
if (frame.tableData) {
|
|
211
|
-
lintTable(frame.tableData, slide, name, issues);
|
|
279
|
+
lintTable(frame.tableData, slide, name, issues, ctx);
|
|
212
280
|
}
|
|
213
281
|
}
|
|
214
|
-
function lintTable(table, slide, element, issues) {
|
|
215
|
-
// Check for table style reference without explicit borders
|
|
282
|
+
function lintTable(table, slide, element, issues, ctx) {
|
|
283
|
+
// Check for table style reference without explicit borders.
|
|
284
|
+
// OfficeImport emits `border-style:none` per-cell unless borders are explicit in XML.
|
|
285
|
+
// It ignores tableStyleId entirely — PowerPoint resolves the style and shows borders,
|
|
286
|
+
// but QuickLook shows a borderless grid. This is the #1 table issue for python-pptx users.
|
|
216
287
|
if (table.tableStyleId) {
|
|
217
288
|
const hasMissingBorders = table.rows.some(row => row.cells.some(cell => !hasExplicitBorders(cell)));
|
|
218
289
|
if (hasMissingBorders) {
|
|
@@ -220,29 +291,21 @@ function lintTable(table, slide, element, issues) {
|
|
|
220
291
|
rule: "table-style-unresolved",
|
|
221
292
|
severity: "warn",
|
|
222
293
|
slide, element,
|
|
223
|
-
message: `Table uses style "${table.tableStyleId}" but
|
|
224
|
-
suggestion: `
|
|
294
|
+
message: `Table uses style "${table.tableStyleId}" but cells lack explicit borders — PowerPoint shows borders, QuickLook won't`,
|
|
295
|
+
suggestion: `Inline the style's borders as explicit <a:lnT>/<a:lnB>/<a:lnL>/<a:lnR> on each cell`,
|
|
296
|
+
fix: { action: "inline-borders", tableStyleId: table.tableStyleId },
|
|
225
297
|
});
|
|
226
298
|
}
|
|
227
299
|
}
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (noBorderCells > 0 && noBorderCells === totalCells) {
|
|
232
|
-
issues.push({
|
|
233
|
-
rule: "table-missing-borders",
|
|
234
|
-
severity: "warn",
|
|
235
|
-
slide, element,
|
|
236
|
-
message: `All ${totalCells} table cells have no explicit borders — table will appear borderless in QuickLook`,
|
|
237
|
-
suggestion: `Add explicit borders to cells if borders are expected`,
|
|
238
|
-
});
|
|
239
|
-
}
|
|
300
|
+
// Note: tables without a style AND without explicit borders are intentionally borderless.
|
|
301
|
+
// OfficeImport correctly renders them with border-style:none. No lint issue needed.
|
|
302
|
+
// (PowerPoint also shows these as borderless — no divergence.)
|
|
240
303
|
// Lint text in table cells
|
|
241
304
|
for (const row of table.rows) {
|
|
242
305
|
for (const cell of row.cells) {
|
|
243
306
|
if (cell.textBody)
|
|
244
307
|
lintTextBody(cell.textBody, slide, element, issues);
|
|
245
|
-
lintFill(cell.fill, slide, element, issues);
|
|
308
|
+
lintFill(cell.fill, slide, element, issues, ctx);
|
|
246
309
|
}
|
|
247
310
|
}
|
|
248
311
|
}
|
|
@@ -252,24 +315,38 @@ function hasExplicitBorders(cell) {
|
|
|
252
315
|
const b = cell.borders;
|
|
253
316
|
return !!(b.top?.fill || b.bottom?.fill || b.left?.fill || b.right?.fill);
|
|
254
317
|
}
|
|
255
|
-
function hasAnyBorder(cell) {
|
|
256
|
-
if (!cell.borders)
|
|
257
|
-
return false;
|
|
258
|
-
const b = cell.borders;
|
|
259
|
-
return !!(b.top?.width || b.bottom?.width || b.left?.width || b.right?.width);
|
|
260
|
-
}
|
|
261
318
|
// ── Fill checks ─────────────────────────────────────────────────────
|
|
262
|
-
function lintFill(fill, slide, element, issues) {
|
|
319
|
+
function lintFill(fill, slide, element, issues, ctx) {
|
|
263
320
|
if (!fill || fill.type !== "gradient")
|
|
264
321
|
return;
|
|
265
322
|
const grad = fill;
|
|
266
323
|
if (grad.stops.length >= 3) {
|
|
324
|
+
// Compute resolved colors for fix data
|
|
325
|
+
let firstColor = "#888888", lastColor = "#888888", averageColor = "#888888";
|
|
326
|
+
try {
|
|
327
|
+
const first = resolveColor(grad.stops[0].color, ctx.colorMap, ctx.colorScheme);
|
|
328
|
+
const last = resolveColor(grad.stops[grad.stops.length - 1].color, ctx.colorMap, ctx.colorScheme);
|
|
329
|
+
firstColor = rgbaToHex(first);
|
|
330
|
+
lastColor = rgbaToHex(last);
|
|
331
|
+
let r = 0, g = 0, b = 0;
|
|
332
|
+
for (const stop of grad.stops) {
|
|
333
|
+
const c = resolveColor(stop.color, ctx.colorMap, ctx.colorScheme);
|
|
334
|
+
r += c.r;
|
|
335
|
+
g += c.g;
|
|
336
|
+
b += c.b;
|
|
337
|
+
}
|
|
338
|
+
const n = grad.stops.length;
|
|
339
|
+
averageColor = rgbaToHex({ r: Math.round(r / n), g: Math.round(g / n), b: Math.round(b / n), a: 1 });
|
|
340
|
+
}
|
|
341
|
+
catch { /* color resolution may fail for exotic color types */ }
|
|
342
|
+
const angle = grad.linear?.angle;
|
|
267
343
|
issues.push({
|
|
268
344
|
rule: "gradient-flattened",
|
|
269
345
|
severity: "warn",
|
|
270
346
|
slide, element,
|
|
271
|
-
message: `Gradient with ${grad.stops.length} stops will be flattened to a single average color in QuickLook`,
|
|
272
|
-
suggestion: `Use exactly 2 gradient stops for proper rendering
|
|
347
|
+
message: `Gradient with ${grad.stops.length} stops will be flattened to a single average color (${averageColor}) in QuickLook`,
|
|
348
|
+
suggestion: `Use exactly 2 gradient stops for proper rendering — first: ${firstColor}, last: ${lastColor}`,
|
|
349
|
+
fix: { action: "reduce-stops", firstColor, lastColor, averageColor, angle },
|
|
273
350
|
});
|
|
274
351
|
}
|
|
275
352
|
}
|
|
@@ -283,18 +360,37 @@ function lintTextBody(body, slide, element, issues) {
|
|
|
283
360
|
if (!props)
|
|
284
361
|
continue;
|
|
285
362
|
const font = props.latinFont;
|
|
286
|
-
if (font &&
|
|
363
|
+
if (font && FONT_SUBSTITUTIONS[font]) {
|
|
364
|
+
const macTarget = FONT_SUBSTITUTIONS[font];
|
|
287
365
|
const delta = fontWidthDelta(font);
|
|
288
366
|
const deltaStr = delta != null && Math.abs(delta) >= 0.5
|
|
289
367
|
? ` (${delta > 0 ? "+" : ""}${delta.toFixed(1)}% width)`
|
|
290
368
|
: "";
|
|
291
369
|
const highRisk = delta != null && Math.abs(delta) >= 10;
|
|
370
|
+
// Find metrically-closest alternatives (consumer decides which to use)
|
|
371
|
+
const alternatives = [];
|
|
372
|
+
if (FONT_METRICS[font]) {
|
|
373
|
+
const matches = findClosestFont(font, { limit: 5 });
|
|
374
|
+
for (const m of matches) {
|
|
375
|
+
alternatives.push({ font: m.font, widthDelta: m.widthDelta });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const bestAlt = alternatives[0];
|
|
292
379
|
issues.push({
|
|
293
380
|
rule: "font-substitution",
|
|
294
381
|
severity: highRisk ? "warn" : "info",
|
|
295
382
|
slide, element,
|
|
296
|
-
message: `"${font}" → "${
|
|
297
|
-
suggestion: highRisk
|
|
383
|
+
message: `"${font}" → "${macTarget}" on macOS${deltaStr}${highRisk ? " — high risk of text reflow/overflow" : ""}`,
|
|
384
|
+
suggestion: highRisk
|
|
385
|
+
? `Closest alternative: ${bestAlt ? `${bestAlt.font} (${bestAlt.widthDelta > 0 ? "+" : ""}${bestAlt.widthDelta.toFixed(1)}% width)` : "test on macOS"}`
|
|
386
|
+
: undefined,
|
|
387
|
+
fix: {
|
|
388
|
+
action: "replace-font",
|
|
389
|
+
from: font,
|
|
390
|
+
macTarget,
|
|
391
|
+
widthDelta: delta ?? 0,
|
|
392
|
+
alternatives,
|
|
393
|
+
},
|
|
298
394
|
});
|
|
299
395
|
}
|
|
300
396
|
}
|
package/dist/resolve/font-map.js
CHANGED
|
@@ -84,6 +84,32 @@ export const FONT_METRICS = {
|
|
|
84
84
|
"PT Serif": { cat: "serif", upm: 1000, xWidthAvg: 448, capH: 700, xH: 500, asc: 1039, desc: -286, lineGap: 0 },
|
|
85
85
|
"Ubuntu": { cat: "sans-serif", upm: 1000, xWidthAvg: 455, capH: 693, xH: 520, asc: 932, desc: -189, lineGap: 28 },
|
|
86
86
|
"Ubuntu Mono": { cat: "monospace", upm: 1000, xWidthAvg: 500, capH: 693, xH: 520, asc: 830, desc: -170, lineGap: 0 },
|
|
87
|
+
// ── Croscore — metric-compatible Google Fonts (OS/2 table) ────────
|
|
88
|
+
"Carlito": { cat: "sans-serif", upm: 2048, xWidthAvg: 1048, capH: 1314, xH: 978, asc: 1536, desc: -512, lineGap: 452 },
|
|
89
|
+
"Caladea": { cat: "serif", upm: 1000, xWidthAvg: 510, capH: 667, xH: 467, asc: 900, desc: -250, lineGap: 0 },
|
|
90
|
+
"Arimo": { cat: "sans-serif", upm: 2048, xWidthAvg: 1190, capH: 1409, xH: 1082, asc: 1854, desc: -434, lineGap: 67 },
|
|
91
|
+
"Tinos": { cat: "serif", upm: 2048, xWidthAvg: 1116, capH: 1341, xH: 940, asc: 1420, desc: -442, lineGap: 307 },
|
|
92
|
+
"Cousine": { cat: "monospace", upm: 2048, xWidthAvg: 1229, capH: 1349, xH: 1082, asc: 1255, desc: -386, lineGap: 0 },
|
|
93
|
+
// ── More Google Fonts (OS/2 table) ────────────────────────────────
|
|
94
|
+
"DM Sans": { cat: "sans-serif", upm: 1000, xWidthAvg: 553, capH: 700, xH: 526, asc: 992, desc: -310, lineGap: 0 },
|
|
95
|
+
"Work Sans": { cat: "sans-serif", upm: 1000, xWidthAvg: 597, capH: 660, xH: 500, asc: 930, desc: -243, lineGap: 0 },
|
|
96
|
+
"Rubik": { cat: "sans-serif", upm: 1000, xWidthAvg: 622, capH: 700, xH: 520, asc: 935, desc: -250, lineGap: 0 },
|
|
97
|
+
"Libre Baskerville": { cat: "serif", upm: 1000, xWidthAvg: 654, capH: 770, xH: 530, asc: 970, desc: -270, lineGap: 0 },
|
|
98
|
+
"Libre Franklin": { cat: "sans-serif", upm: 1000, xWidthAvg: 589, capH: 742, xH: 530, asc: 966, desc: -246, lineGap: 0 },
|
|
99
|
+
"Barlow": { cat: "sans-serif", upm: 1000, xWidthAvg: 513, capH: 700, xH: 506, asc: 1000, desc: -200, lineGap: 0 },
|
|
100
|
+
"Josefin Sans": { cat: "sans-serif", upm: 1000, xWidthAvg: 547, capH: 702, xH: 378, asc: 750, desc: -250, lineGap: 0 },
|
|
101
|
+
"Quicksand": { cat: "sans-serif", upm: 1000, xWidthAvg: 541, capH: 700, xH: 503, asc: 1000, desc: -250, lineGap: 0 },
|
|
102
|
+
"Cabin": { cat: "sans-serif", upm: 2000, xWidthAvg: 1054, capH: 1400, xH: 980, asc: 1930, desc: -500, lineGap: 0 },
|
|
103
|
+
"Karla": { cat: "sans-serif", upm: 2000, xWidthAvg: 1055, capH: 1256, xH: 956, asc: 1834, desc: -504, lineGap: 0 },
|
|
104
|
+
"Manrope": { cat: "sans-serif", upm: 2000, xWidthAvg: 1131, capH: 1440, xH: 1080, asc: 2132, desc: -600, lineGap: 0 },
|
|
105
|
+
"Space Grotesk": { cat: "sans-serif", upm: 1000, xWidthAvg: 559, capH: 700, xH: 486, asc: 984, desc: -292, lineGap: 0 },
|
|
106
|
+
"IBM Plex Sans": { cat: "sans-serif", upm: 1000, xWidthAvg: 592, capH: 698, xH: 516, asc: 1025, desc: -275, lineGap: 0 },
|
|
107
|
+
"IBM Plex Mono": { cat: "monospace", upm: 1000, xWidthAvg: 600, capH: 698, xH: 516, asc: 1025, desc: -275, lineGap: 0 },
|
|
108
|
+
"JetBrains Mono": { cat: "monospace", upm: 1000, xWidthAvg: 602, capH: 730, xH: 550, asc: 1020, desc: -300, lineGap: 0 },
|
|
109
|
+
"Bitter": { cat: "serif", upm: 1000, xWidthAvg: 590, capH: 692, xH: 522, asc: 935, desc: -265, lineGap: 0 },
|
|
110
|
+
"Crimson Text": { cat: "serif", upm: 1024, xWidthAvg: 547, capH: 656, xH: 430, asc: 972, desc: -359, lineGap: 0 },
|
|
111
|
+
"EB Garamond": { cat: "serif", upm: 1000, xWidthAvg: 528, capH: 650, xH: 400, asc: 1007, desc: -298, lineGap: 0 },
|
|
112
|
+
"Zilla Slab": { cat: "serif", upm: 1000, xWidthAvg: 538, capH: 650, xH: 445, asc: 787, desc: -213, lineGap: 200 },
|
|
87
113
|
};
|
|
88
114
|
// ── Similarity matching ─────────────────────────────────────────────
|
|
89
115
|
/** Normalize a metric by unitsPerEm so fonts at different scales are comparable. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quicklook-pptx-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|