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 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 only reads explicit `<a:lnL>`, `<a:lnR>`, `<a:lnT>`, `<a:lnB>` on each cell. python-pptx and most generators rely on style references. |
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
- | 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` | warn | Non-rect shape rendered as opaque PDF instead of CSS |
132
- | `rotation-forces-pdf` | warn | Rotated rect rendered as opaque PDF instead of CSS |
133
- | `group-as-pdf` | warn | Group rendered as single opaque PDF image |
134
- | `vertical-text` | info | Vertical text uses CSS writing-mode |
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 now reports width deltas and upgrades font substitution to `warn` severity when the delta exceeds ±10%.
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-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-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 { 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,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: "effect-forces-pdf",
124
- severity: "warn",
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: "warn",
197
+ severity: "info",
134
198
  slide, element: name,
135
- message: `Rotated rect renders as PDF instead of CSS div`,
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: "warn",
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 some cells lack explicit borders — OfficeImport may not resolve the style`,
224
- suggestion: `Set borders explicitly on each cell (cell.border_top, etc.)`,
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
- // Check for cells with no borders at all
229
- const noBorderCells = table.rows.reduce((count, row) => count + row.cells.filter(cell => !cell.hMerge && !cell.vMerge && !hasAnyBorder(cell)).length, 0);
230
- const totalCells = table.rows.reduce((count, row) => count + row.cells.filter(cell => !cell.hMerge && !cell.vMerge).length, 0);
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, or accept flat color`,
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 && SUBSTITUTED_FONTS[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}" → "${SUBSTITUTED_FONTS[font]}" on macOS${deltaStr}${highRisk ? " — high risk of text reflow/overflow" : ""}`,
297
- suggestion: highRisk ? `Use a cross-platform font (Verdana, Georgia, Trebuchet MS, Arial) or test text layout on macOS` : undefined,
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
  }
@@ -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.3",
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",