quicklook-pptx-renderer 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -16
- package/dist/cli.js +12 -6
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/lint.d.ts +42 -1
- package/dist/lint.js +167 -55
- 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 +6 -3
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Slide 4:
|
|
|
51
51
|
[ERR ] "cloud" is not supported by OfficeImport — shape will be invisible (Shape 12)
|
|
52
52
|
-> Use a supported preset or embed as an image
|
|
53
53
|
|
|
54
|
-
0 errors,
|
|
54
|
+
0 errors, 5 warnings, 0 info across 18 slides
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
```bash
|
|
@@ -114,24 +114,85 @@ 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
|
+
break;
|
|
157
|
+
|
|
158
|
+
case "add-borders":
|
|
159
|
+
// Add default 1px solid black borders to all cells
|
|
160
|
+
break;
|
|
161
|
+
|
|
162
|
+
case "add-fallback-image":
|
|
163
|
+
// Generate or embed a chart fallback image via mc:AlternateContent
|
|
164
|
+
break;
|
|
165
|
+
|
|
166
|
+
case "ungroup":
|
|
167
|
+
// issue.fix.childCount = 5, issue.fix.containsPictures = false
|
|
168
|
+
// Promote group children to slide-level drawables
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
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).
|
|
175
|
+
|
|
117
176
|
---
|
|
118
177
|
|
|
119
178
|
## Lint Rules
|
|
120
179
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
|
124
|
-
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
180
|
+
Every issue carries a typed `fix` object with machine-readable remediation data — downstream tools can apply transforms without re-detecting the problem.
|
|
181
|
+
|
|
182
|
+
| Rule | Severity | What It Catches | `fix.action` |
|
|
183
|
+
|------|----------|----------------|-------------|
|
|
184
|
+
| `unsupported-geometry` | error | Shape preset not in OfficeImport's ~60 supported geometries — will be invisible | `replace-geometry` (includes closest supported preset) |
|
|
185
|
+
| `chart-no-fallback` | error | Chart without fallback image — renders as blank rectangle | `add-fallback-image` |
|
|
186
|
+
| `opaque-pdf-block` | warn | Shape with effects rendered as opaque PDF covering content behind it | `strip-effects` |
|
|
187
|
+
| `gradient-flattened` | warn | 3+ gradient stops collapsed to single average color | `reduce-stops` (includes computed 2-stop colors + average) |
|
|
188
|
+
| `table-missing-borders` | warn | Table cells lack explicit borders — will appear borderless | `add-borders` |
|
|
189
|
+
| `table-style-unresolved` | warn | Table style reference that OfficeImport won't resolve | `inline-borders` (includes `tableStyleId`) |
|
|
190
|
+
| `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) |
|
|
191
|
+
| `group-as-pdf` | warn | Group rendered as single opaque PDF image | `ungroup` (includes child count, whether group contains pictures) |
|
|
192
|
+
| `geometry-forces-pdf` | info | Non-rect geometry renders as PDF — opaque background may cover adjacent content | — |
|
|
193
|
+
| `rotation-forces-pdf` | info | Rotated rect renders as PDF — enlarged bounding box may cover adjacent content | — |
|
|
194
|
+
| `text-inscription-shift` | info | Text in non-rect shape uses geometry-inscribed bounds (text may shift) | `replace-geometry` (suggests `rect`) |
|
|
195
|
+
| `vertical-text` | info | Vertical text uses CSS writing-mode | — |
|
|
135
196
|
|
|
136
197
|
---
|
|
137
198
|
|
|
@@ -234,10 +295,35 @@ macOS replaces Windows fonts at render time (from `TCFontUtils` in OfficeImport)
|
|
|
234
295
|
|
|
235
296
|
**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
297
|
|
|
237
|
-
The linter
|
|
298
|
+
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
299
|
|
|
239
300
|
Plus CJK mappings: MS Gothic → Hiragino Sans, SimSun → STSong, Microsoft YaHei → PingFang SC, Malgun Gothic → Apple SD Gothic Neo, and more.
|
|
240
301
|
|
|
302
|
+
### Metric-Compatible Google Fonts (Croscore)
|
|
303
|
+
|
|
304
|
+
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):
|
|
305
|
+
|
|
306
|
+
| Microsoft Font | Google Font Replacement | Width Δ vs Original | License |
|
|
307
|
+
|---|---|---|---|
|
|
308
|
+
| Calibri | **Carlito** | -2.1% | OFL |
|
|
309
|
+
| Cambria | **Caladea** | -5.6% | OFL |
|
|
310
|
+
| Arial | **Arimo** | -1.2% | Apache 2.0 |
|
|
311
|
+
| Times New Roman | **Tinos** | +0.5% | Apache 2.0 |
|
|
312
|
+
| Courier New | **Cousine** | 0.0% | Apache 2.0 |
|
|
313
|
+
|
|
314
|
+
Compare: Calibri → Helvetica Neue (the QL substitution) is **+14.4%**. Carlito at -2.1% is 7x better.
|
|
315
|
+
|
|
316
|
+
### What Doesn't Work for Font Fixes
|
|
317
|
+
|
|
318
|
+
Verified by testing against actual OfficeImport output:
|
|
319
|
+
|
|
320
|
+
- **Embedded fonts (`<p:embeddedFont>`)**: OfficeImport ignores them completely. No `OADEmbeddedFont` class exists in the framework. Embedding helps PowerPoint and LibreOffice, but not QuickLook.
|
|
321
|
+
- **`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.
|
|
322
|
+
- **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.
|
|
323
|
+
- **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.
|
|
324
|
+
|
|
325
|
+
**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.
|
|
326
|
+
|
|
241
327
|
---
|
|
242
328
|
|
|
243
329
|
## Key Discoveries
|
|
@@ -252,6 +338,8 @@ These findings come from reverse engineering OfficeImport via Objective-C runtim
|
|
|
252
338
|
6. **3+ gradient stops → average color** (not rendered as gradient)
|
|
253
339
|
7. **Font substitution**: Calibri → Helvetica Neue, Arial → Helvetica, etc. (full map above)
|
|
254
340
|
8. **Text inscription for non-rect shapes** uses `float` radius and `trunc()` in pixel space
|
|
341
|
+
9. **Embedded fonts are ignored** — no `OADEmbeddedFont` class; `TCImportFontCache` only does CoreText system font lookup
|
|
342
|
+
10. **`pitchFamily` is ignored** — all unknown fonts get bare `font-family:"Name"` in CSS with no generic family fallback, regardless of pitchFamily value
|
|
255
343
|
|
|
256
344
|
---
|
|
257
345
|
|
package/dist/cli.js
CHANGED
|
@@ -19,7 +19,7 @@ function hasFlag(args, name) {
|
|
|
19
19
|
function positional(args) {
|
|
20
20
|
const out = [];
|
|
21
21
|
for (let i = 0; i < args.length; i++) {
|
|
22
|
-
if (args[i].startsWith("--")) {
|
|
22
|
+
if (args[i].startsWith("--") || args[i] === "-o") {
|
|
23
23
|
i++;
|
|
24
24
|
continue;
|
|
25
25
|
}
|
|
@@ -46,17 +46,18 @@ async function cmdRender(args) {
|
|
|
46
46
|
const width = Number(getFlag(args, "--width") ?? 1920);
|
|
47
47
|
const scale = Number(getFlag(args, "--scale") ?? 2);
|
|
48
48
|
const slideNum = getFlag(args, "--slide");
|
|
49
|
-
const outPath = getFlag(args, "--out");
|
|
49
|
+
const outPath = getFlag(args, "--out") ?? getFlag(args, "-o");
|
|
50
50
|
const all = hasFlag(args, "--all") || !slideNum;
|
|
51
51
|
const pptxBuf = await readFile(resolve(input));
|
|
52
52
|
const pkg = await PptxPackage.open(pptxBuf);
|
|
53
53
|
const pres = await readPresentation(pkg);
|
|
54
54
|
if (all) {
|
|
55
|
-
const
|
|
55
|
+
const singleFile = outPath && pres.slides.length === 1;
|
|
56
|
+
const outDir = singleFile ? dirname(outPath) : (outPath ?? ".");
|
|
56
57
|
await mkdir(outDir, { recursive: true });
|
|
57
58
|
for (let i = 0; i < pres.slides.length; i++) {
|
|
58
59
|
const png = await renderSlide(pres.slides[i], pres, { width, scale, pkg, slideIndex: i });
|
|
59
|
-
const dest =
|
|
60
|
+
const dest = singleFile
|
|
60
61
|
? resolve(outPath)
|
|
61
62
|
: resolve(outDir, `slide-${i + 1}.png`);
|
|
62
63
|
await writeFile(dest, png);
|
|
@@ -82,7 +83,7 @@ async function cmdHtml(args) {
|
|
|
82
83
|
console.error(`Usage: ${BIN} html <input.pptx> [--out output.html]`);
|
|
83
84
|
process.exit(1);
|
|
84
85
|
}
|
|
85
|
-
const outPath = getFlag(args, "--out");
|
|
86
|
+
const outPath = getFlag(args, "--out") ?? getFlag(args, "-o") ?? pos[1];
|
|
86
87
|
const pptxBuf = await readFile(resolve(input));
|
|
87
88
|
const pkg = await PptxPackage.open(pptxBuf);
|
|
88
89
|
const pres = await readPresentation(pkg);
|
|
@@ -155,7 +156,12 @@ async function cmdLint(args) {
|
|
|
155
156
|
process.exit(1);
|
|
156
157
|
}
|
|
157
158
|
// ── Main ───────────────────────────────────────────────────────────
|
|
158
|
-
const
|
|
159
|
+
const allArgs = process.argv.slice(2);
|
|
160
|
+
if (allArgs.includes("--version") || allArgs.includes("-v")) {
|
|
161
|
+
console.log("0.2.3");
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
const [command, ...args] = allArgs;
|
|
159
165
|
switch (command) {
|
|
160
166
|
case "render":
|
|
161
167
|
await cmdRender(args);
|
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,47 @@
|
|
|
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-missing-borders" | "table-style-unresolved" | "group-as-pdf" | "chart-no-fallback" | "text-inscription-shift" | "rotation-forces-pdf" | "
|
|
12
|
+
export type RuleId = "unsupported-geometry" | "opaque-pdf-block" | "gradient-flattened" | "font-substitution" | "table-missing-borders" | "table-style-unresolved" | "group-as-pdf" | "chart-no-fallback" | "text-inscription-shift" | "rotation-forces-pdf" | "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-borders";
|
|
40
|
+
} | {
|
|
41
|
+
action: "add-fallback-image";
|
|
42
|
+
} | {
|
|
43
|
+
action: "ungroup";
|
|
44
|
+
childCount: number;
|
|
45
|
+
containsPictures: boolean;
|
|
46
|
+
};
|
|
9
47
|
export interface LintIssue {
|
|
10
48
|
rule: RuleId;
|
|
11
49
|
severity: Severity;
|
|
@@ -13,6 +51,7 @@ export interface LintIssue {
|
|
|
13
51
|
element?: string;
|
|
14
52
|
message: string;
|
|
15
53
|
suggestion?: string;
|
|
54
|
+
fix?: LintFix;
|
|
16
55
|
}
|
|
17
56
|
export interface LintResult {
|
|
18
57
|
issues: LintIssue[];
|
|
@@ -23,5 +62,7 @@ export interface LintResult {
|
|
|
23
62
|
slides: number;
|
|
24
63
|
};
|
|
25
64
|
}
|
|
65
|
+
/** Maps commonly-used unsupported OOXML presets to visually-closest supported alternative. */
|
|
66
|
+
export declare const GEOMETRY_FALLBACKS: Record<string, string>;
|
|
26
67
|
export declare function lint(pptxBuffer: Buffer): Promise<LintResult>;
|
|
27
68
|
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,22 +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: "
|
|
187
|
+
rule: "geometry-forces-pdf",
|
|
124
188
|
severity: "info",
|
|
125
189
|
slide, element: name,
|
|
126
|
-
message: `"${geom}" renders as PDF in QuickLook (only plain rect uses CSS)`,
|
|
190
|
+
message: `"${geom}" renders as PDF in QuickLook (only plain rect uses CSS) — opaque background may cover adjacent content`,
|
|
127
191
|
});
|
|
128
192
|
}
|
|
129
193
|
if (hasRotation && geom === "rect") {
|
|
194
|
+
const rotDeg = Math.round((shape.bounds.rot / 60000) % 360);
|
|
130
195
|
issues.push({
|
|
131
196
|
rule: "rotation-forces-pdf",
|
|
132
197
|
severity: "info",
|
|
133
198
|
slide, element: name,
|
|
134
|
-
message: `Rotated rect renders as PDF
|
|
199
|
+
message: `Rotated rect (${rotDeg}°) renders as PDF — enlarged bounding box may cover adjacent content`,
|
|
135
200
|
});
|
|
136
201
|
}
|
|
137
202
|
}
|
|
@@ -143,20 +208,22 @@ function lintShape(shape, slide, issues) {
|
|
|
143
208
|
slide, element: name,
|
|
144
209
|
message: `rect with effects (shadow/glow) renders as opaque PDF — covers content behind it`,
|
|
145
210
|
suggestion: `Remove effects to keep lightweight CSS rendering`,
|
|
211
|
+
fix: { action: "strip-effects" },
|
|
146
212
|
});
|
|
147
213
|
}
|
|
148
214
|
// 4. Non-rect text inscription shift
|
|
149
215
|
if (NON_RECT_TEXT_GEOMETRIES.has(geom) && hasNonEmptyText(shape.textBody)) {
|
|
150
216
|
issues.push({
|
|
151
217
|
rule: "text-inscription-shift",
|
|
152
|
-
severity: "
|
|
218
|
+
severity: "info",
|
|
153
219
|
slide, element: name,
|
|
154
220
|
message: `Text in "${geom}" uses geometry-inscribed bounds — text position will differ from PowerPoint`,
|
|
155
221
|
suggestion: `Use rect for predictable text positioning, or test the specific geometry`,
|
|
222
|
+
fix: { action: "replace-geometry", current: geom, suggested: "rect" },
|
|
156
223
|
});
|
|
157
224
|
}
|
|
158
225
|
// 5. Gradient flattening
|
|
159
|
-
lintFill(shape.fill, slide, name, issues);
|
|
226
|
+
lintFill(shape.fill, slide, name, issues, ctx);
|
|
160
227
|
// 6. Font substitution in text
|
|
161
228
|
if (shape.textBody)
|
|
162
229
|
lintTextBody(shape.textBody, slide, name, issues);
|
|
@@ -175,23 +242,26 @@ function lintPicture(_pic, _slide, _issues) {
|
|
|
175
242
|
// Pictures are straightforward — OfficeImport handles them well
|
|
176
243
|
}
|
|
177
244
|
// ── Group checks ────────────────────────────────────────────────────
|
|
178
|
-
function lintGroup(group, slide, issues) {
|
|
245
|
+
function lintGroup(group, slide, issues, ctx) {
|
|
179
246
|
const name = group.name || `group #${group.id}`;
|
|
247
|
+
const containsPictures = group.children.some(c => c.drawableType === "pic");
|
|
180
248
|
issues.push({
|
|
181
249
|
rule: "group-as-pdf",
|
|
182
|
-
severity: "
|
|
250
|
+
severity: "warn",
|
|
183
251
|
slide, element: name,
|
|
184
|
-
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`,
|
|
253
|
+
suggestion: `Ungroup shapes for individual rendering, or accept merged PDF`,
|
|
254
|
+
fix: { action: "ungroup", childCount: group.children.length, containsPictures },
|
|
185
255
|
});
|
|
186
256
|
// Still lint children for their own issues
|
|
187
|
-
lintDrawables(group.children, slide, issues);
|
|
257
|
+
lintDrawables(group.children, slide, issues, ctx);
|
|
188
258
|
}
|
|
189
259
|
// ── Connector checks ────────────────────────────────────────────────
|
|
190
260
|
function lintConnector(_conn, _slide, _issues) {
|
|
191
261
|
// Connectors are always PDF, no special warnings needed
|
|
192
262
|
}
|
|
193
263
|
// ── GraphicFrame (tables/charts) ────────────────────────────────────
|
|
194
|
-
function lintGraphicFrame(frame, slide, issues) {
|
|
264
|
+
function lintGraphicFrame(frame, slide, issues, ctx) {
|
|
195
265
|
const name = frame.name || `frame #${frame.id}`;
|
|
196
266
|
// Chart without fallback image
|
|
197
267
|
if (frame.chartRId && !frame.fallbackImageData) {
|
|
@@ -201,14 +271,15 @@ function lintGraphicFrame(frame, slide, issues) {
|
|
|
201
271
|
slide, element: name,
|
|
202
272
|
message: `Chart has no fallback image — will render as blank rectangle in QuickLook`,
|
|
203
273
|
suggestion: `Save from PowerPoint (not python-pptx) to generate fallback images, or use mc:AlternateContent`,
|
|
274
|
+
fix: { action: "add-fallback-image" },
|
|
204
275
|
});
|
|
205
276
|
}
|
|
206
277
|
// Table checks
|
|
207
278
|
if (frame.tableData) {
|
|
208
|
-
lintTable(frame.tableData, slide, name, issues);
|
|
279
|
+
lintTable(frame.tableData, slide, name, issues, ctx);
|
|
209
280
|
}
|
|
210
281
|
}
|
|
211
|
-
function lintTable(table, slide, element, issues) {
|
|
282
|
+
function lintTable(table, slide, element, issues, ctx) {
|
|
212
283
|
// Check for table style reference without explicit borders
|
|
213
284
|
if (table.tableStyleId) {
|
|
214
285
|
const hasMissingBorders = table.rows.some(row => row.cells.some(cell => !hasExplicitBorders(cell)));
|
|
@@ -218,7 +289,8 @@ function lintTable(table, slide, element, issues) {
|
|
|
218
289
|
severity: "warn",
|
|
219
290
|
slide, element,
|
|
220
291
|
message: `Table uses style "${table.tableStyleId}" but some cells lack explicit borders — OfficeImport may not resolve the style`,
|
|
221
|
-
suggestion: `
|
|
292
|
+
suggestion: `Inline borders explicitly on each cell`,
|
|
293
|
+
fix: { action: "inline-borders", tableStyleId: table.tableStyleId },
|
|
222
294
|
});
|
|
223
295
|
}
|
|
224
296
|
}
|
|
@@ -232,6 +304,7 @@ function lintTable(table, slide, element, issues) {
|
|
|
232
304
|
slide, element,
|
|
233
305
|
message: `All ${totalCells} table cells have no explicit borders — table will appear borderless in QuickLook`,
|
|
234
306
|
suggestion: `Add explicit borders to cells if borders are expected`,
|
|
307
|
+
fix: { action: "add-borders" },
|
|
235
308
|
});
|
|
236
309
|
}
|
|
237
310
|
// Lint text in table cells
|
|
@@ -239,7 +312,7 @@ function lintTable(table, slide, element, issues) {
|
|
|
239
312
|
for (const cell of row.cells) {
|
|
240
313
|
if (cell.textBody)
|
|
241
314
|
lintTextBody(cell.textBody, slide, element, issues);
|
|
242
|
-
lintFill(cell.fill, slide, element, issues);
|
|
315
|
+
lintFill(cell.fill, slide, element, issues, ctx);
|
|
243
316
|
}
|
|
244
317
|
}
|
|
245
318
|
}
|
|
@@ -256,17 +329,37 @@ function hasAnyBorder(cell) {
|
|
|
256
329
|
return !!(b.top?.width || b.bottom?.width || b.left?.width || b.right?.width);
|
|
257
330
|
}
|
|
258
331
|
// ── Fill checks ─────────────────────────────────────────────────────
|
|
259
|
-
function lintFill(fill, slide, element, issues) {
|
|
332
|
+
function lintFill(fill, slide, element, issues, ctx) {
|
|
260
333
|
if (!fill || fill.type !== "gradient")
|
|
261
334
|
return;
|
|
262
335
|
const grad = fill;
|
|
263
336
|
if (grad.stops.length >= 3) {
|
|
337
|
+
// Compute resolved colors for fix data
|
|
338
|
+
let firstColor = "#888888", lastColor = "#888888", averageColor = "#888888";
|
|
339
|
+
try {
|
|
340
|
+
const first = resolveColor(grad.stops[0].color, ctx.colorMap, ctx.colorScheme);
|
|
341
|
+
const last = resolveColor(grad.stops[grad.stops.length - 1].color, ctx.colorMap, ctx.colorScheme);
|
|
342
|
+
firstColor = rgbaToHex(first);
|
|
343
|
+
lastColor = rgbaToHex(last);
|
|
344
|
+
let r = 0, g = 0, b = 0;
|
|
345
|
+
for (const stop of grad.stops) {
|
|
346
|
+
const c = resolveColor(stop.color, ctx.colorMap, ctx.colorScheme);
|
|
347
|
+
r += c.r;
|
|
348
|
+
g += c.g;
|
|
349
|
+
b += c.b;
|
|
350
|
+
}
|
|
351
|
+
const n = grad.stops.length;
|
|
352
|
+
averageColor = rgbaToHex({ r: Math.round(r / n), g: Math.round(g / n), b: Math.round(b / n), a: 1 });
|
|
353
|
+
}
|
|
354
|
+
catch { /* color resolution may fail for exotic color types */ }
|
|
355
|
+
const angle = grad.linear?.angle;
|
|
264
356
|
issues.push({
|
|
265
357
|
rule: "gradient-flattened",
|
|
266
358
|
severity: "warn",
|
|
267
359
|
slide, element,
|
|
268
|
-
message: `Gradient with ${grad.stops.length} stops will be flattened to a single average color in QuickLook`,
|
|
269
|
-
suggestion: `Use exactly 2 gradient stops for proper rendering
|
|
360
|
+
message: `Gradient with ${grad.stops.length} stops will be flattened to a single average color (${averageColor}) in QuickLook`,
|
|
361
|
+
suggestion: `Use exactly 2 gradient stops for proper rendering — first: ${firstColor}, last: ${lastColor}`,
|
|
362
|
+
fix: { action: "reduce-stops", firstColor, lastColor, averageColor, angle },
|
|
270
363
|
});
|
|
271
364
|
}
|
|
272
365
|
}
|
|
@@ -280,18 +373,37 @@ function lintTextBody(body, slide, element, issues) {
|
|
|
280
373
|
if (!props)
|
|
281
374
|
continue;
|
|
282
375
|
const font = props.latinFont;
|
|
283
|
-
if (font &&
|
|
376
|
+
if (font && FONT_SUBSTITUTIONS[font]) {
|
|
377
|
+
const macTarget = FONT_SUBSTITUTIONS[font];
|
|
284
378
|
const delta = fontWidthDelta(font);
|
|
285
379
|
const deltaStr = delta != null && Math.abs(delta) >= 0.5
|
|
286
380
|
? ` (${delta > 0 ? "+" : ""}${delta.toFixed(1)}% width)`
|
|
287
381
|
: "";
|
|
288
382
|
const highRisk = delta != null && Math.abs(delta) >= 10;
|
|
383
|
+
// Find metrically-closest alternatives (consumer decides which to use)
|
|
384
|
+
const alternatives = [];
|
|
385
|
+
if (FONT_METRICS[font]) {
|
|
386
|
+
const matches = findClosestFont(font, { limit: 5 });
|
|
387
|
+
for (const m of matches) {
|
|
388
|
+
alternatives.push({ font: m.font, widthDelta: m.widthDelta });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const bestAlt = alternatives[0];
|
|
289
392
|
issues.push({
|
|
290
393
|
rule: "font-substitution",
|
|
291
394
|
severity: highRisk ? "warn" : "info",
|
|
292
395
|
slide, element,
|
|
293
|
-
message: `"${font}" → "${
|
|
294
|
-
suggestion: highRisk
|
|
396
|
+
message: `"${font}" → "${macTarget}" on macOS${deltaStr}${highRisk ? " — high risk of text reflow/overflow" : ""}`,
|
|
397
|
+
suggestion: highRisk
|
|
398
|
+
? `Closest alternative: ${bestAlt ? `${bestAlt.font} (${bestAlt.widthDelta > 0 ? "+" : ""}${bestAlt.widthDelta.toFixed(1)}% width)` : "test on macOS"}`
|
|
399
|
+
: undefined,
|
|
400
|
+
fix: {
|
|
401
|
+
action: "replace-font",
|
|
402
|
+
from: font,
|
|
403
|
+
macTarget,
|
|
404
|
+
widthDelta: delta ?? 0,
|
|
405
|
+
alternatives,
|
|
406
|
+
},
|
|
295
407
|
});
|
|
296
408
|
}
|
|
297
409
|
}
|
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.0",
|
|
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",
|
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
"pptx", "powerpoint", "quicklook", "macos", "ios", "officeimport",
|
|
13
13
|
"pptx-to-html", "pptx-to-png", "ooxml", "office-open-xml",
|
|
14
14
|
"presentation", "renderer", "preview", "apple", "webkit",
|
|
15
|
-
"python-pptx", "pptxgenjs", "
|
|
16
|
-
"
|
|
15
|
+
"python-pptx", "pptxgenjs", "cross-platform", "lint", "compatibility",
|
|
16
|
+
"iphone", "ipad", "finder", "font-metrics", "font-fallback",
|
|
17
|
+
"table-borders", "shapes", "gradients", "rendering",
|
|
18
|
+
"google-slides", "canva", "libreoffice", "pandoc",
|
|
19
|
+
"pptx-linter", "pptx-validator", "slides"
|
|
17
20
|
],
|
|
18
21
|
"exports": {
|
|
19
22
|
".": {
|