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 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, 3 warnings, 2 info across 18 slides
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
- | Rule | Severity | What It Catches |
122
- |------|----------|----------------|
123
- | `unsupported-geometry` | error | Shape preset not in OfficeImport's ~60 supported geometries — will be invisible |
124
- | `chart-no-fallback` | error | Chart without fallback image — renders as blank rectangle |
125
- | `opaque-pdf-block` | warn | Shape with effects rendered as opaque PDF covering content behind it |
126
- | `gradient-flattened` | warn | 3+ gradient stops collapsed to single average color |
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 |
133
- | `group-as-pdf` | info | Group rendered as single opaque PDF image |
134
- | `vertical-text` | info | Vertical text uses CSS writing-mode |
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 now reports width deltas and upgrades font substitution to `warn` severity when the delta exceeds ±10%.
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 outDir = outPath ? dirname(outPath) : ".";
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 = outPath && pres.slides.length === 1
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 [command, ...args] = process.argv.slice(2);
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" | "effect-forces-pdf" | "vertical-text";
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 { FONT_METRICS, widthDelta } from "./resolve/font-metrics.js";
11
- // ── Font substitution table (same as resolve/font-map.ts) ───────────
12
- const SUBSTITUTED_FONTS = {
13
- "Calibri": "Helvetica Neue",
14
- "Calibri Light": "Helvetica Neue Light",
15
- "Arial": "Helvetica",
16
- "Arial Black": "Helvetica Neue",
17
- "Arial Narrow": "Helvetica Neue",
18
- "Cambria": "Georgia",
19
- "Consolas": "Menlo",
20
- "Courier New": "Courier",
21
- "Times New Roman": "Times",
22
- "Tahoma": "Geneva",
23
- "Segoe UI": "Helvetica Neue",
24
- "Segoe UI Light": "Helvetica Neue Light",
25
- "Segoe UI Semibold": "Helvetica Neue Medium",
26
- "Century Gothic": "Futura",
27
- "Franklin Gothic Medium": "Avenir Next Medium",
28
- "Corbel": "Avenir Next",
29
- "Candara": "Avenir",
30
- "Constantia": "Georgia",
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 = SUBSTITUTED_FONTS[sourceName];
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
- lintDrawables(slide.drawables, slideNum, issues);
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: `Use a supported preset (rect, roundRect, ellipse, etc.) or embed as an image`,
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: "effect-forces-pdf",
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 instead of CSS div`,
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: "warn",
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: "info",
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: `Set borders explicitly on each cell (cell.border_top, etc.)`,
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, or accept flat color`,
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 && SUBSTITUTED_FONTS[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}" → "${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,
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
  }
@@ -1,2 +1,3 @@
1
1
  import type { FontScheme } from "../model/types.js";
2
+ export declare const FONT_SUBSTITUTIONS: Record<string, string>;
2
3
  export declare function resolveFontFamily(family: string | undefined, fontScheme?: FontScheme): string;
@@ -1,5 +1,5 @@
1
1
  // ── macOS font substitution (mirrors TCFontUtils from OfficeImport) ─
2
- const FONT_MAP = {
2
+ export const FONT_SUBSTITUTIONS = {
3
3
  "Calibri": "Helvetica Neue",
4
4
  "Calibri Light": "Helvetica Neue Light",
5
5
  "Arial": "Helvetica",
@@ -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.2.1",
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", "document-viewer", "cross-platform",
16
- "slides", "converter", "thumbnail", "lint", "compatibility"
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
  ".": {