pptx-fix 0.2.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 +149 -0
- package/dist/analyze.d.ts +5 -0
- package/dist/analyze.js +5 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +69 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/transforms/effects.d.ts +8 -0
- package/dist/transforms/effects.js +13 -0
- package/dist/transforms/fonts.d.ts +6 -0
- package/dist/transforms/fonts.js +15 -0
- package/dist/transforms/geometries.d.ts +8 -0
- package/dist/transforms/geometries.js +13 -0
- package/dist/transforms/gradients.d.ts +8 -0
- package/dist/transforms/gradients.js +13 -0
- package/dist/transforms/index.d.ts +20 -0
- package/dist/transforms/index.js +17 -0
- package/dist/transforms/properties.d.ts +7 -0
- package/dist/transforms/properties.js +16 -0
- package/dist/transforms/table-styles.d.ts +9 -0
- package/dist/transforms/table-styles.js +148 -0
- package/dist/writer.d.ts +18 -0
- package/dist/writer.js +62 -0
- package/dist/xml.d.ts +19 -0
- package/dist/xml.js +28 -0
- package/package.json +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# pptx-fix
|
|
2
|
+
|
|
3
|
+
**Fix PowerPoint files that render incorrectly on macOS QuickLook and iOS.** Rewrites PPTX internals so tables, shapes, gradients, and effects display correctly on Apple devices — no manual editing needed.
|
|
4
|
+
|
|
5
|
+
If your `.pptx` looks perfect in PowerPoint but looks wrong when someone opens it on a Mac or iPhone, this tool fixes it.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The Problem
|
|
10
|
+
|
|
11
|
+
Apple's QuickLook (macOS Finder spacebar preview), iOS Files app, and iPadOS use a private rendering engine called `OfficeImport.framework` — a completely independent OOXML parser that renders slides differently than Microsoft PowerPoint. Presentations created by python-pptx, PptxGenJS, Google Slides, Canva, LibreOffice, Pandoc, and other tools contain patterns that PowerPoint handles fine but OfficeImport renders incorrectly.
|
|
12
|
+
|
|
13
|
+
Common artifacts:
|
|
14
|
+
|
|
15
|
+
- **Table borders vanish** — generators set a table style reference, but OfficeImport only reads explicit border properties
|
|
16
|
+
- **Shapes disappear** — ~120 preset geometries (heart, cloud, lightningBolt, sun, moon...) are silently dropped
|
|
17
|
+
- **Gradients become flat colors** — 3+ color stops are averaged to a single solid color
|
|
18
|
+
- **Drop shadows become opaque blocks** — shapes with effects render as opaque PDF images that cover content behind them
|
|
19
|
+
- **Fonts shift and text reflows** — Calibri becomes Helvetica Neue, Arial becomes Helvetica, with different metrics
|
|
20
|
+
|
|
21
|
+
`pptx-fix` rewrites the OOXML XML inside the PPTX ZIP to work around these OfficeImport quirks. The output is a valid `.pptx` that looks correct in both PowerPoint and Apple's preview.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install pptx-fix
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### CLI
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Fix a PPTX file
|
|
37
|
+
npx pptx-fix input.pptx -o output.pptx
|
|
38
|
+
|
|
39
|
+
# Apply only specific transforms
|
|
40
|
+
npx pptx-fix input.pptx -o output.pptx --only table-styles,gradients
|
|
41
|
+
|
|
42
|
+
# See what was changed
|
|
43
|
+
npx pptx-fix input.pptx -o output.pptx --report
|
|
44
|
+
|
|
45
|
+
# Analyze without fixing (dry run)
|
|
46
|
+
npx pptx-fix analyze input.pptx
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### As a Library
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { fix } from "pptx-fix";
|
|
53
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
54
|
+
|
|
55
|
+
const input = readFileSync("presentation.pptx");
|
|
56
|
+
const result = await fix(input, { report: true });
|
|
57
|
+
|
|
58
|
+
writeFileSync("fixed.pptx", result.buffer);
|
|
59
|
+
console.log(result.report);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Analyze Only
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { analyze } from "pptx-fix";
|
|
66
|
+
|
|
67
|
+
const issues = await analyze(pptxBuffer);
|
|
68
|
+
for (const issue of issues) {
|
|
69
|
+
console.log(`[${issue.severity}] Slide ${issue.slide}: ${issue.description}`);
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Transforms
|
|
76
|
+
|
|
77
|
+
| Transform | Status | What it fixes |
|
|
78
|
+
|-----------|--------|--------------|
|
|
79
|
+
| **table-styles** | Done | Resolves `tableStyleId` references and inlines explicit `<a:lnL/R/T/B>` borders on every cell. Handles `firstRow`, `lastRow`, `bandRow` conditional formatting. Only adds borders where none are explicitly defined. |
|
|
80
|
+
| **geometries** | Planned | Replace unsupported `<a:prstGeom>` (heart, cloud, lightningBolt, sun, moon, frame, arc, chord, etc.) with `<a:custGeom>` path data. |
|
|
81
|
+
| **gradients** | Planned | Collapse 3+ stop gradients to 2-stop (start + end color) so QuickLook renders a gradient instead of a flat color. |
|
|
82
|
+
| **effects** | Planned | Strip or adjust shapes with `<effectLst>` (drop shadow, glow, reflection) that render as opaque PDF blocks covering content. |
|
|
83
|
+
|
|
84
|
+
Detection of all issues (including font substitution, chart fallbacks, text inscription shifts, and more) is handled by [quicklook-pptx-renderer](https://www.npmjs.com/package/quicklook-pptx-renderer)'s 12-rule linter. Run `pptx-fix analyze` to see all issues, or use the linter directly in CI.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Which Tools Produce Affected Files
|
|
89
|
+
|
|
90
|
+
| Tool | Primary issue on Mac/iPhone |
|
|
91
|
+
|------|---|
|
|
92
|
+
| **python-pptx** | Tables render without borders — the #1 reported issue. Style references not resolved by OfficeImport. |
|
|
93
|
+
| **PptxGenJS** | Missing thumbnails, shape rendering differences, effect artifacts |
|
|
94
|
+
| **Google Slides** export | Font substitution, formatting shifts, missing content types |
|
|
95
|
+
| **Canva** export | Fonts substituted, layout differences, animation artifacts |
|
|
96
|
+
| **LibreOffice Impress** | Table styles unresolved, gradient rendering differences |
|
|
97
|
+
| **Pandoc** / **Quarto** | Content type corruption, missing shapes, "PowerPoint found a problem with content" errors |
|
|
98
|
+
| **Apache POI** | Content type errors (`InvalidFormatException: Package should contain a content type part [M1.13]`) |
|
|
99
|
+
| **Aspose.Slides** | Missing thumbnail if `refresh_thumbnail` not called |
|
|
100
|
+
| **Open XML SDK** | Repair errors, missing relationships |
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## How It Works
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
PPTX (ZIP) → extract XML → parse → detect issues → apply transforms → serialize → repack ZIP
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
1. **Extract** — JSZip opens the PPTX (which is a ZIP archive)
|
|
111
|
+
2. **Parse** — fast-xml-parser converts slide XML to objects, preserving all unknown elements
|
|
112
|
+
3. **Detect** — each transform scans for its class of issues
|
|
113
|
+
4. **Apply** — transforms mutate the XML objects (e.g., inline borders from table style definitions)
|
|
114
|
+
5. **Serialize** — XMLBuilder converts back to XML
|
|
115
|
+
6. **Repack** — JSZip produces a new valid PPTX
|
|
116
|
+
|
|
117
|
+
The round-trip preserves all XML elements the tool doesn't explicitly modify — the output is always a valid PPTX.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Detecting Issues Without Fixing
|
|
122
|
+
|
|
123
|
+
For comprehensive linting with 12 rules, CI integration, cross-platform rendering, and pixel-diff comparison against actual QuickLook output, see [**quicklook-pptx-renderer**](https://www.npmjs.com/package/quicklook-pptx-renderer) — a companion renderer + linter that replicates Apple's QuickLook output pixel for pixel, runs on Linux/Docker without a Mac.
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Lint (12 rules, JSON output for CI)
|
|
127
|
+
npx quicklook-pptx lint presentation.pptx --json
|
|
128
|
+
|
|
129
|
+
# Render slides as PNG (see exactly what Mac users see)
|
|
130
|
+
npx quicklook-pptx render presentation.pptx --out ./slides/
|
|
131
|
+
|
|
132
|
+
# Fix (this package)
|
|
133
|
+
npx pptx-fix presentation.pptx -o fixed.pptx
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Dependencies
|
|
139
|
+
|
|
140
|
+
| Package | Purpose |
|
|
141
|
+
|---------|---------|
|
|
142
|
+
| [jszip](https://stuk.github.io/jszip/) | ZIP extraction/repacking |
|
|
143
|
+
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | OOXML XML parsing and serialization |
|
|
144
|
+
|
|
145
|
+
Zero native dependencies. Works everywhere Node.js 20+ runs.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
package/dist/analyze.js
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fix } from "./writer.js";
|
|
5
|
+
import { analyze, formatIssues } from "./analyze.js";
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
function getFlag(name) {
|
|
8
|
+
const i = args.indexOf(name);
|
|
9
|
+
if (i === -1 || i + 1 >= args.length)
|
|
10
|
+
return undefined;
|
|
11
|
+
return args[i + 1];
|
|
12
|
+
}
|
|
13
|
+
function hasFlag(name) {
|
|
14
|
+
return args.includes(name);
|
|
15
|
+
}
|
|
16
|
+
function positional() {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
if (args[i].startsWith("--") || args[i] === "-o") {
|
|
20
|
+
i++;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
out.push(args[i]);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
async function main() {
|
|
28
|
+
const pos = positional();
|
|
29
|
+
const command = pos[0];
|
|
30
|
+
if (command === "analyze") {
|
|
31
|
+
const input = pos[1];
|
|
32
|
+
if (!input) {
|
|
33
|
+
console.error("Usage: pptx-fix analyze <input.pptx>");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const buf = await readFile(resolve(input));
|
|
37
|
+
const result = await analyze(buf);
|
|
38
|
+
console.log(formatIssues(result));
|
|
39
|
+
process.exit(result.summary.errors > 0 ? 1 : 0);
|
|
40
|
+
}
|
|
41
|
+
// Default: fix
|
|
42
|
+
const input = command;
|
|
43
|
+
if (!input || input.startsWith("-")) {
|
|
44
|
+
console.error("Usage: pptx-fix <input.pptx> -o <output.pptx> [--only table-styles,gradients] [--report]");
|
|
45
|
+
console.error(" pptx-fix analyze <input.pptx>");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const outPath = getFlag("-o") ?? getFlag("--out");
|
|
49
|
+
if (!outPath) {
|
|
50
|
+
console.error("Error: output path required (-o <output.pptx>)");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const onlyStr = getFlag("--only");
|
|
54
|
+
const transforms = onlyStr
|
|
55
|
+
? onlyStr.split(",").map(s => s.trim())
|
|
56
|
+
: undefined;
|
|
57
|
+
const wantReport = hasFlag("--report");
|
|
58
|
+
const buf = await readFile(resolve(input));
|
|
59
|
+
const result = await fix(buf, { transforms, report: wantReport });
|
|
60
|
+
await writeFile(resolve(outPath), result.buffer);
|
|
61
|
+
if (wantReport && result.report) {
|
|
62
|
+
console.log(result.report);
|
|
63
|
+
}
|
|
64
|
+
console.log(`Fixed → ${outPath}`);
|
|
65
|
+
}
|
|
66
|
+
main().catch(err => {
|
|
67
|
+
console.error(err);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pptx-fix — Fix PPTX files for macOS QuickLook / iOS preview rendering.
|
|
3
|
+
*
|
|
4
|
+
* Detection delegates to quicklook-pptx-renderer's linter.
|
|
5
|
+
* Transforms operate on raw XML (preserving all unknown elements) so the
|
|
6
|
+
* output is a valid PPTX that looks correct in Apple's OfficeImport pipeline.
|
|
7
|
+
*/
|
|
8
|
+
export { fix, type FixOptions, type FixResult } from "./writer.js";
|
|
9
|
+
export { analyze, formatIssues, type LintResult, type LintIssue } from "./analyze.js";
|
|
10
|
+
export { type TransformName, ALL_TRANSFORMS } from "./transforms/index.js";
|
|
11
|
+
export { FONT_METRICS, findClosestFont, widthDelta, type FontMetrics, type FontMatch, type FontCategory } from "quicklook-pptx-renderer";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pptx-fix — Fix PPTX files for macOS QuickLook / iOS preview rendering.
|
|
3
|
+
*
|
|
4
|
+
* Detection delegates to quicklook-pptx-renderer's linter.
|
|
5
|
+
* Transforms operate on raw XML (preserving all unknown elements) so the
|
|
6
|
+
* output is a valid PPTX that looks correct in Apple's OfficeImport pipeline.
|
|
7
|
+
*/
|
|
8
|
+
export { fix } from "./writer.js";
|
|
9
|
+
export { analyze, formatIssues } from "./analyze.js";
|
|
10
|
+
export { ALL_TRANSFORMS } from "./transforms/index.js";
|
|
11
|
+
export { FONT_METRICS, findClosestFont, widthDelta } from "quicklook-pptx-renderer";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* effects transform — Strip or adjust shapes with effectLst that render
|
|
3
|
+
* as opaque PDF blocks in QuickLook.
|
|
4
|
+
*
|
|
5
|
+
* Detection is handled by quicklook-pptx-renderer's linter (opaque-pdf-block rule).
|
|
6
|
+
*/
|
|
7
|
+
import type { Transform } from "./index.js";
|
|
8
|
+
export declare const effects: Transform;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* effects transform — Strip or adjust shapes with effectLst that render
|
|
3
|
+
* as opaque PDF blocks in QuickLook.
|
|
4
|
+
*
|
|
5
|
+
* Detection is handled by quicklook-pptx-renderer's linter (opaque-pdf-block rule).
|
|
6
|
+
*/
|
|
7
|
+
export const effects = {
|
|
8
|
+
name: "effects",
|
|
9
|
+
apply(_slideXml, _slideNum, _ctx) {
|
|
10
|
+
// TODO: configurable — strip effects, reorder z-index, or no-op
|
|
11
|
+
return { changed: false, changes: [] };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fonts transform — Add explicit <a:latin>, <a:ea>, <a:cs> fallback
|
|
3
|
+
* typefaces matching what OfficeImport's TCFontUtils would pick.
|
|
4
|
+
*/
|
|
5
|
+
export const fonts = {
|
|
6
|
+
name: "fonts",
|
|
7
|
+
detect(_slideXml, _slideNum) {
|
|
8
|
+
// TODO: detect text runs missing explicit font declarations
|
|
9
|
+
return [];
|
|
10
|
+
},
|
|
11
|
+
apply(_slideXml, _slideNum, _ctx) {
|
|
12
|
+
// TODO: implement — add fallback typefaces
|
|
13
|
+
return { changed: false, changes: [] };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* geometries transform — Replace unsupported preset geometries with
|
|
3
|
+
* equivalent <a:custGeom> path data.
|
|
4
|
+
*
|
|
5
|
+
* Detection is handled by quicklook-pptx-renderer's linter (unsupported-geometry rule).
|
|
6
|
+
*/
|
|
7
|
+
import type { Transform } from "./index.js";
|
|
8
|
+
export declare const geometries: Transform;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* geometries transform — Replace unsupported preset geometries with
|
|
3
|
+
* equivalent <a:custGeom> path data.
|
|
4
|
+
*
|
|
5
|
+
* Detection is handled by quicklook-pptx-renderer's linter (unsupported-geometry rule).
|
|
6
|
+
*/
|
|
7
|
+
export const geometries = {
|
|
8
|
+
name: "geometries",
|
|
9
|
+
apply(_slideXml, _slideNum, _ctx) {
|
|
10
|
+
// TODO: replace prstGeom with custGeom containing equivalent path data
|
|
11
|
+
return { changed: false, changes: [] };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gradients transform — Collapse 3+ stop gradients to 2-stop so QuickLook
|
|
3
|
+
* renders a gradient instead of averaging to a flat color.
|
|
4
|
+
*
|
|
5
|
+
* Detection is handled by quicklook-pptx-renderer's linter (gradient-flattened rule).
|
|
6
|
+
*/
|
|
7
|
+
import type { Transform } from "./index.js";
|
|
8
|
+
export declare const gradients: Transform;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gradients transform — Collapse 3+ stop gradients to 2-stop so QuickLook
|
|
3
|
+
* renders a gradient instead of averaging to a flat color.
|
|
4
|
+
*
|
|
5
|
+
* Detection is handled by quicklook-pptx-renderer's linter (gradient-flattened rule).
|
|
6
|
+
*/
|
|
7
|
+
export const gradients = {
|
|
8
|
+
name: "gradients",
|
|
9
|
+
apply(_slideXml, _slideNum, _ctx) {
|
|
10
|
+
// TODO: collapse 3+ stop gradients to 2-stop (endpoints only)
|
|
11
|
+
return { changed: false, changes: [] };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transform registry — each transform fixes one class of
|
|
3
|
+
* OfficeImport rendering issues by mutating raw OOXML.
|
|
4
|
+
*
|
|
5
|
+
* Detection is handled by quicklook-pptx-renderer's linter.
|
|
6
|
+
* These transforms only apply fixes.
|
|
7
|
+
*/
|
|
8
|
+
export type TransformName = "table-styles" | "gradients" | "geometries" | "effects";
|
|
9
|
+
export interface TransformContext {
|
|
10
|
+
tableStyleXml?: any;
|
|
11
|
+
}
|
|
12
|
+
export interface TransformResult {
|
|
13
|
+
changed: boolean;
|
|
14
|
+
changes: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface Transform {
|
|
17
|
+
name: TransformName;
|
|
18
|
+
apply: (slideXml: any, slideNum: number, ctx: TransformContext) => TransformResult;
|
|
19
|
+
}
|
|
20
|
+
export declare const ALL_TRANSFORMS: Transform[];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transform registry — each transform fixes one class of
|
|
3
|
+
* OfficeImport rendering issues by mutating raw OOXML.
|
|
4
|
+
*
|
|
5
|
+
* Detection is handled by quicklook-pptx-renderer's linter.
|
|
6
|
+
* These transforms only apply fixes.
|
|
7
|
+
*/
|
|
8
|
+
import { tableStyles } from "./table-styles.js";
|
|
9
|
+
import { gradients } from "./gradients.js";
|
|
10
|
+
import { geometries } from "./geometries.js";
|
|
11
|
+
import { effects } from "./effects.js";
|
|
12
|
+
export const ALL_TRANSFORMS = [
|
|
13
|
+
tableStyles,
|
|
14
|
+
gradients,
|
|
15
|
+
geometries,
|
|
16
|
+
effects,
|
|
17
|
+
];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* properties transform — Resolve full inheritance chain
|
|
3
|
+
* (theme → master → layout → slide) and write explicit fill, font, color
|
|
4
|
+
* on each element.
|
|
5
|
+
*/
|
|
6
|
+
export const properties = {
|
|
7
|
+
name: "properties",
|
|
8
|
+
detect(_slideXml, _slideNum) {
|
|
9
|
+
// TODO: detect elements relying on inherited properties
|
|
10
|
+
return [];
|
|
11
|
+
},
|
|
12
|
+
apply(_slideXml, _slideNum, _ctx) {
|
|
13
|
+
// TODO: implement — resolve inheritance and inline explicit properties
|
|
14
|
+
return { changed: false, changes: [] };
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* table-styles transform — Resolve table style conditional formatting into
|
|
3
|
+
* explicit borders on every <a:tcPr>.
|
|
4
|
+
*
|
|
5
|
+
* OfficeImport doesn't resolve tableStyleId references — it only sees
|
|
6
|
+
* explicit border properties. This inlines them from tableStyles.xml.
|
|
7
|
+
*/
|
|
8
|
+
import type { Transform } from "./index.js";
|
|
9
|
+
export declare const tableStyles: Transform;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* table-styles transform — Resolve table style conditional formatting into
|
|
3
|
+
* explicit borders on every <a:tcPr>.
|
|
4
|
+
*
|
|
5
|
+
* OfficeImport doesn't resolve tableStyleId references — it only sees
|
|
6
|
+
* explicit border properties. This inlines them from tableStyles.xml.
|
|
7
|
+
*/
|
|
8
|
+
function findTables(node, path = "") {
|
|
9
|
+
const results = [];
|
|
10
|
+
if (!node || typeof node !== "object")
|
|
11
|
+
return results;
|
|
12
|
+
if (node.tbl) {
|
|
13
|
+
const tbls = Array.isArray(node.tbl) ? node.tbl : [node.tbl];
|
|
14
|
+
for (const t of tbls)
|
|
15
|
+
results.push({ table: t, path: path + "/tbl" });
|
|
16
|
+
}
|
|
17
|
+
for (const key of Object.keys(node)) {
|
|
18
|
+
if (key.startsWith("@_"))
|
|
19
|
+
continue;
|
|
20
|
+
const children = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
21
|
+
for (const child of children) {
|
|
22
|
+
results.push(...findTables(child, path + "/" + key));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return results;
|
|
26
|
+
}
|
|
27
|
+
function tableNeedsFix(table) {
|
|
28
|
+
const tblPr = table.tblPr;
|
|
29
|
+
if (!tblPr)
|
|
30
|
+
return false;
|
|
31
|
+
const styleId = tblPr["@_tblStyle"] ?? tblPr.tblStyle;
|
|
32
|
+
if (!styleId)
|
|
33
|
+
return false;
|
|
34
|
+
const rows = table.tr;
|
|
35
|
+
if (!rows)
|
|
36
|
+
return false;
|
|
37
|
+
const rowArr = Array.isArray(rows) ? rows : [rows];
|
|
38
|
+
for (const row of rowArr) {
|
|
39
|
+
const cells = row.tc;
|
|
40
|
+
if (!cells)
|
|
41
|
+
continue;
|
|
42
|
+
const cellArr = Array.isArray(cells) ? cells : [cells];
|
|
43
|
+
for (const cell of cellArr) {
|
|
44
|
+
const tcPr = cell.tcPr;
|
|
45
|
+
if (!tcPr)
|
|
46
|
+
return true;
|
|
47
|
+
if (!(tcPr.lnL || tcPr.lnR || tcPr.lnT || tcPr.lnB))
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
function findStyleDef(tableStyleXml, styleId) {
|
|
54
|
+
if (!tableStyleXml)
|
|
55
|
+
return undefined;
|
|
56
|
+
const styleLst = tableStyleXml.tblStyleLst;
|
|
57
|
+
if (!styleLst)
|
|
58
|
+
return undefined;
|
|
59
|
+
const styles = styleLst.tblStyle;
|
|
60
|
+
if (!styles)
|
|
61
|
+
return undefined;
|
|
62
|
+
const arr = Array.isArray(styles) ? styles : [styles];
|
|
63
|
+
return arr.find((s) => s["@_styleId"] === styleId);
|
|
64
|
+
}
|
|
65
|
+
function buildBorder(tcStyle, side) {
|
|
66
|
+
if (!tcStyle)
|
|
67
|
+
return undefined;
|
|
68
|
+
const borders = tcStyle.tcBdr;
|
|
69
|
+
if (!borders)
|
|
70
|
+
return undefined;
|
|
71
|
+
const border = borders[side];
|
|
72
|
+
if (!border)
|
|
73
|
+
return undefined;
|
|
74
|
+
return border.ln ?? border;
|
|
75
|
+
}
|
|
76
|
+
function applyTableStyle(table, styleDef) {
|
|
77
|
+
const changes = [];
|
|
78
|
+
const rows = table.tr;
|
|
79
|
+
if (!rows)
|
|
80
|
+
return changes;
|
|
81
|
+
const rowArr = Array.isArray(rows) ? rows : [rows];
|
|
82
|
+
const tblPr = table.tblPr ?? {};
|
|
83
|
+
const hasFirstRow = tblPr["@_firstRow"] === "1" || tblPr["@_firstRow"] === "true";
|
|
84
|
+
const hasLastRow = tblPr["@_lastRow"] === "1" || tblPr["@_lastRow"] === "true";
|
|
85
|
+
const hasBandRow = tblPr["@_bandRow"] === "1" || tblPr["@_bandRow"] === "true";
|
|
86
|
+
const wholeTbl = styleDef?.wholeTbl?.tcStyle;
|
|
87
|
+
const firstRowStyle = hasFirstRow ? styleDef?.firstRow?.tcStyle : undefined;
|
|
88
|
+
const lastRowStyle = hasLastRow ? styleDef?.lastRow?.tcStyle : undefined;
|
|
89
|
+
const band1H = hasBandRow ? styleDef?.band1H?.tcStyle : undefined;
|
|
90
|
+
const band2H = hasBandRow ? styleDef?.band2H?.tcStyle : undefined;
|
|
91
|
+
let cellCount = 0;
|
|
92
|
+
for (let ri = 0; ri < rowArr.length; ri++) {
|
|
93
|
+
const row = rowArr[ri];
|
|
94
|
+
const cells = row.tc;
|
|
95
|
+
if (!cells)
|
|
96
|
+
continue;
|
|
97
|
+
const cellArr = Array.isArray(cells) ? cells : [cells];
|
|
98
|
+
let activeStyle = wholeTbl;
|
|
99
|
+
if (ri === 0 && firstRowStyle)
|
|
100
|
+
activeStyle = firstRowStyle;
|
|
101
|
+
else if (ri === rowArr.length - 1 && lastRowStyle)
|
|
102
|
+
activeStyle = lastRowStyle;
|
|
103
|
+
else if (hasBandRow)
|
|
104
|
+
activeStyle = (ri % 2 === (hasFirstRow ? 1 : 0)) ? band1H ?? wholeTbl : band2H ?? wholeTbl;
|
|
105
|
+
for (const cell of cellArr) {
|
|
106
|
+
if (!cell.tcPr)
|
|
107
|
+
cell.tcPr = {};
|
|
108
|
+
const tcPr = cell.tcPr;
|
|
109
|
+
let modified = false;
|
|
110
|
+
for (const [xmlSide, styleSide] of [["lnL", "left"], ["lnR", "right"], ["lnT", "top"], ["lnB", "bottom"]]) {
|
|
111
|
+
if (tcPr[xmlSide])
|
|
112
|
+
continue;
|
|
113
|
+
const border = buildBorder(activeStyle, styleSide) ?? buildBorder(wholeTbl, styleSide);
|
|
114
|
+
if (border) {
|
|
115
|
+
tcPr[xmlSide] = border;
|
|
116
|
+
modified = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (modified)
|
|
120
|
+
cellCount++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (cellCount > 0) {
|
|
124
|
+
changes.push(`inlined borders on ${cellCount} table cells`);
|
|
125
|
+
}
|
|
126
|
+
return changes;
|
|
127
|
+
}
|
|
128
|
+
export const tableStyles = {
|
|
129
|
+
name: "table-styles",
|
|
130
|
+
apply(slideXml, _slideNum, ctx) {
|
|
131
|
+
const tables = findTables(slideXml);
|
|
132
|
+
const changes = [];
|
|
133
|
+
for (const { table } of tables) {
|
|
134
|
+
if (!tableNeedsFix(table))
|
|
135
|
+
continue;
|
|
136
|
+
const styleId = table.tblPr?.["@_tblStyle"] ?? table.tblPr?.tblStyle;
|
|
137
|
+
if (!styleId)
|
|
138
|
+
continue;
|
|
139
|
+
const styleDef = findStyleDef(ctx.tableStyleXml, styleId);
|
|
140
|
+
if (!styleDef) {
|
|
141
|
+
changes.push(`table style ${styleId} not found in tableStyles.xml — skipped`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
changes.push(...applyTableStyle(table, styleDef));
|
|
145
|
+
}
|
|
146
|
+
return { changed: changes.length > 0, changes };
|
|
147
|
+
},
|
|
148
|
+
};
|
package/dist/writer.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XML round-trip writer: parse → mutate → serialize → repack ZIP.
|
|
3
|
+
*
|
|
4
|
+
* Transforms operate on fast-xml-parser parsed objects (preserving unknown elements),
|
|
5
|
+
* then XMLBuilder serializes them back to XML. JSZip repacks the modified ZIP.
|
|
6
|
+
*/
|
|
7
|
+
import { type TransformName } from "./transforms/index.js";
|
|
8
|
+
export interface FixOptions {
|
|
9
|
+
/** Apply only these transforms (default: all) */
|
|
10
|
+
transforms?: TransformName[];
|
|
11
|
+
/** Return a human-readable report of changes */
|
|
12
|
+
report?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface FixResult {
|
|
15
|
+
buffer: Buffer;
|
|
16
|
+
report?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function fix(pptxBuffer: Buffer, options?: FixOptions): Promise<FixResult>;
|
package/dist/writer.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XML round-trip writer: parse → mutate → serialize → repack ZIP.
|
|
3
|
+
*
|
|
4
|
+
* Transforms operate on fast-xml-parser parsed objects (preserving unknown elements),
|
|
5
|
+
* then XMLBuilder serializes them back to XML. JSZip repacks the modified ZIP.
|
|
6
|
+
*/
|
|
7
|
+
import JSZip from "jszip";
|
|
8
|
+
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
|
9
|
+
import { xmlParserOptions, xmlBuilderOptions } from "./xml.js";
|
|
10
|
+
import { ALL_TRANSFORMS } from "./transforms/index.js";
|
|
11
|
+
export async function fix(pptxBuffer, options) {
|
|
12
|
+
const zip = await JSZip.loadAsync(pptxBuffer);
|
|
13
|
+
const parser = new XMLParser(xmlParserOptions);
|
|
14
|
+
const builder = new XMLBuilder(xmlBuilderOptions);
|
|
15
|
+
const reportLines = [];
|
|
16
|
+
const enabledNames = new Set(options?.transforms ?? ALL_TRANSFORMS.map(t => t.name));
|
|
17
|
+
const enabled = ALL_TRANSFORMS.filter(t => enabledNames.has(t.name));
|
|
18
|
+
// Find all slide XML files
|
|
19
|
+
const slideFiles = Object.keys(zip.files)
|
|
20
|
+
.filter(f => /^ppt\/slides\/slide\d+\.xml$/.test(f))
|
|
21
|
+
.sort((a, b) => {
|
|
22
|
+
const na = parseInt(a.match(/\d+/)[0]);
|
|
23
|
+
const nb = parseInt(b.match(/\d+/)[0]);
|
|
24
|
+
return na - nb;
|
|
25
|
+
});
|
|
26
|
+
// Load table style XML if needed (for table-styles transform)
|
|
27
|
+
let tableStyleXml = undefined;
|
|
28
|
+
if (enabledNames.has("table-styles")) {
|
|
29
|
+
const tsPath = "ppt/tableStyles.xml";
|
|
30
|
+
const tsFile = zip.file(tsPath);
|
|
31
|
+
if (tsFile) {
|
|
32
|
+
tableStyleXml = parser.parse(await tsFile.async("string"));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Process each slide
|
|
36
|
+
for (const slidePath of slideFiles) {
|
|
37
|
+
const slideNum = parseInt(slidePath.match(/\d+/)[0]);
|
|
38
|
+
const xml = await zip.file(slidePath).async("string");
|
|
39
|
+
const parsed = parser.parse(xml);
|
|
40
|
+
let changed = false;
|
|
41
|
+
for (const transform of enabled) {
|
|
42
|
+
const result = transform.apply(parsed, slideNum, { tableStyleXml });
|
|
43
|
+
if (result.changed) {
|
|
44
|
+
changed = true;
|
|
45
|
+
for (const line of result.changes) {
|
|
46
|
+
reportLines.push(`Slide ${slideNum}: ${line}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (changed) {
|
|
51
|
+
zip.file(slidePath, builder.build(parsed));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const outBuffer = Buffer.from(await zip.generateAsync({ type: "nodebuffer" }));
|
|
55
|
+
const result = { buffer: outBuffer };
|
|
56
|
+
if (options?.report) {
|
|
57
|
+
result.report = reportLines.length > 0
|
|
58
|
+
? reportLines.join("\n")
|
|
59
|
+
: "No issues found — file unchanged.";
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
package/dist/xml.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared XML parser/builder options matching OOXML conventions.
|
|
3
|
+
*/
|
|
4
|
+
export declare const xmlParserOptions: {
|
|
5
|
+
ignoreAttributes: boolean;
|
|
6
|
+
attributeNamePrefix: string;
|
|
7
|
+
removeNSPrefix: boolean;
|
|
8
|
+
parseTagValue: boolean;
|
|
9
|
+
parseAttributeValue: boolean;
|
|
10
|
+
trimValues: boolean;
|
|
11
|
+
isArray: (name: string) => boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare const xmlBuilderOptions: {
|
|
14
|
+
ignoreAttributes: boolean;
|
|
15
|
+
attributeNamePrefix: string;
|
|
16
|
+
format: boolean;
|
|
17
|
+
suppressEmptyNode: boolean;
|
|
18
|
+
suppressBooleanAttributes: boolean;
|
|
19
|
+
};
|
package/dist/xml.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared XML parser/builder options matching OOXML conventions.
|
|
3
|
+
*/
|
|
4
|
+
export const xmlParserOptions = {
|
|
5
|
+
ignoreAttributes: false,
|
|
6
|
+
attributeNamePrefix: "@_",
|
|
7
|
+
removeNSPrefix: true,
|
|
8
|
+
parseTagValue: false,
|
|
9
|
+
parseAttributeValue: false,
|
|
10
|
+
trimValues: false,
|
|
11
|
+
isArray: (name) => ARRAY_ELEMENTS.has(name),
|
|
12
|
+
};
|
|
13
|
+
export const xmlBuilderOptions = {
|
|
14
|
+
ignoreAttributes: false,
|
|
15
|
+
attributeNamePrefix: "@_",
|
|
16
|
+
format: false,
|
|
17
|
+
suppressEmptyNode: false,
|
|
18
|
+
suppressBooleanAttributes: false,
|
|
19
|
+
};
|
|
20
|
+
/** Elements that must always be parsed as arrays (even with a single child). */
|
|
21
|
+
const ARRAY_ELEMENTS = new Set([
|
|
22
|
+
"sp", "pic", "cxnSp", "grpSp", "graphicFrame",
|
|
23
|
+
"p", "r", "br", "fld",
|
|
24
|
+
"gs", "ln", "solidFill", "gradFill",
|
|
25
|
+
"tr", "tc",
|
|
26
|
+
"tblStyleLst",
|
|
27
|
+
"ext",
|
|
28
|
+
]);
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pptx-fix",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Fix PowerPoint files that render incorrectly on macOS QuickLook and iOS — resolves missing table borders, invisible shapes, flattened gradients, and opaque blocks caused by OfficeImport rendering gaps.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"pptx-fix": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pptx",
|
|
13
|
+
"powerpoint",
|
|
14
|
+
"quicklook",
|
|
15
|
+
"macos",
|
|
16
|
+
"ios",
|
|
17
|
+
"fix",
|
|
18
|
+
"repair",
|
|
19
|
+
"officeimport",
|
|
20
|
+
"ooxml",
|
|
21
|
+
"office-open-xml",
|
|
22
|
+
"table-styles",
|
|
23
|
+
"python-pptx",
|
|
24
|
+
"pptxgenjs",
|
|
25
|
+
"presentation",
|
|
26
|
+
"apple",
|
|
27
|
+
"preview",
|
|
28
|
+
"slides",
|
|
29
|
+
"iphone",
|
|
30
|
+
"ipad",
|
|
31
|
+
"finder",
|
|
32
|
+
"thumbnail",
|
|
33
|
+
"table-borders",
|
|
34
|
+
"shapes",
|
|
35
|
+
"gradients",
|
|
36
|
+
"rendering",
|
|
37
|
+
"google-slides",
|
|
38
|
+
"canva",
|
|
39
|
+
"libreoffice",
|
|
40
|
+
"pandoc",
|
|
41
|
+
"cross-platform",
|
|
42
|
+
"compatibility"
|
|
43
|
+
],
|
|
44
|
+
"exports": {
|
|
45
|
+
".": {
|
|
46
|
+
"types": "./dist/index.d.ts",
|
|
47
|
+
"import": "./dist/index.js"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsc",
|
|
55
|
+
"dev": "tsc --watch"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
60
|
+
"license": "MIT",
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"fast-xml-parser": "^5.2.0",
|
|
63
|
+
"jszip": "^3.10.1",
|
|
64
|
+
"quicklook-pptx-renderer": "^0.2.1"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@types/node": "^22.0.0",
|
|
68
|
+
"typescript": "^5.7.0"
|
|
69
|
+
}
|
|
70
|
+
}
|