pptx-fix 0.2.0 → 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 +10 -5
- package/dist/analyze.d.ts +1 -1
- package/dist/chart-fallbacks.d.ts +17 -0
- package/dist/chart-fallbacks.js +221 -0
- package/dist/cli.js +4 -0
- package/dist/embedded-fonts.d.ts +11 -0
- package/dist/embedded-fonts.js +170 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/transforms/effects.d.ts +2 -2
- package/dist/transforms/effects.js +45 -5
- package/dist/transforms/fonts.d.ts +9 -2
- package/dist/transforms/fonts.js +69 -9
- package/dist/transforms/geometries.d.ts +2 -2
- package/dist/transforms/geometries.js +35 -5
- package/dist/transforms/gradients.js +29 -3
- package/dist/transforms/groups.d.ts +12 -0
- package/dist/transforms/groups.js +103 -0
- package/dist/transforms/index.d.ts +1 -1
- package/dist/transforms/index.js +4 -0
- package/dist/writer.js +26 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ Common artifacts:
|
|
|
17
17
|
- **Gradients become flat colors** — 3+ color stops are averaged to a single solid color
|
|
18
18
|
- **Drop shadows become opaque blocks** — shapes with effects render as opaque PDF images that cover content behind them
|
|
19
19
|
- **Fonts shift and text reflows** — Calibri becomes Helvetica Neue, Arial becomes Helvetica, with different metrics
|
|
20
|
+
- **Embedded fonts ignored** — custom fonts embedded in the PPTX are completely ignored by QuickLook, falling back to system substitutes
|
|
20
21
|
|
|
21
22
|
`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
|
|
|
@@ -37,7 +38,7 @@ npm install pptx-fix
|
|
|
37
38
|
npx pptx-fix input.pptx -o output.pptx
|
|
38
39
|
|
|
39
40
|
# Apply only specific transforms
|
|
40
|
-
npx pptx-fix input.pptx -o output.pptx --only table-styles,gradients
|
|
41
|
+
npx pptx-fix input.pptx -o output.pptx --only table-styles,gradients,fonts
|
|
41
42
|
|
|
42
43
|
# See what was changed
|
|
43
44
|
npx pptx-fix input.pptx -o output.pptx --report
|
|
@@ -77,9 +78,13 @@ for (const issue of issues) {
|
|
|
77
78
|
| Transform | Status | What it fixes |
|
|
78
79
|
|-----------|--------|--------------|
|
|
79
80
|
| **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** |
|
|
81
|
-
| **gradients** |
|
|
82
|
-
| **effects** |
|
|
81
|
+
| **geometries** | Done | Replaces unsupported `<a:prstGeom>` presets (~120 shapes not in OfficeImport's supported set) with the visually-closest supported alternative (e.g. heart→ellipse, cloud→cloudCallout) so shapes are visible instead of invisible. |
|
|
82
|
+
| **gradients** | Done | Collapses 3+ stop gradients to 2-stop (first + last color) so QuickLook renders a gradient instead of a flat color. |
|
|
83
|
+
| **effects** | Done | Strips `<effectLst>` and `<effectDag>` (drop shadow, glow, reflection) from shape properties to prevent opaque PDF block rendering. |
|
|
84
|
+
| **fonts** | Done | Replaces high-risk Windows fonts (Calibri +14.4%, Segoe UI +14%, Corbel +18.8%, etc.) with metrically-closest cross-platform alternatives to prevent text reflow on macOS. Also fixes fonts in theme XML. |
|
|
85
|
+
| **groups** | Done | Ungroups shape groups so children render individually instead of being merged into a single opaque PDF block. Transforms child coordinates from group-space to slide-space. Skips rotated groups. |
|
|
86
|
+
| **embedded-fonts** | Done | Strips embedded font data (ignored by QuickLook) and replaces font references with the metrically-closest cross-platform alternative. Reduces file size and ensures consistent rendering. |
|
|
87
|
+
| **chart-fallbacks** | Done | Renders charts to PNG and embeds as fallback images so QuickLook displays charts instead of blank rectangles. Requires Playwright (optional — skips silently if not installed). |
|
|
83
88
|
|
|
84
89
|
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
90
|
|
|
@@ -123,7 +128,7 @@ The round-trip preserves all XML elements the tool doesn't explicitly modify —
|
|
|
123
128
|
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
129
|
|
|
125
130
|
```bash
|
|
126
|
-
# Lint (
|
|
131
|
+
# Lint (JSON output for CI)
|
|
127
132
|
npx quicklook-pptx lint presentation.pptx --json
|
|
128
133
|
|
|
129
134
|
# Render slides as PNG (see exactly what Mac users see)
|
package/dist/analyze.d.ts
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
* Analyze a PPTX for issues that will cause QuickLook rendering problems.
|
|
3
3
|
* Delegates to quicklook-pptx-renderer's linter — single source of truth.
|
|
4
4
|
*/
|
|
5
|
-
export { lint as analyze, formatIssues, type LintResult, type LintIssue } from "quicklook-pptx-renderer";
|
|
5
|
+
export { lint as analyze, formatIssues, type LintResult, type LintIssue, type LintFix } from "quicklook-pptx-renderer";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart fallback generator — render charts to PNG and embed as fallback
|
|
3
|
+
* images so QuickLook displays charts instead of blank rectangles.
|
|
4
|
+
*
|
|
5
|
+
* QuickLook (OfficeImport) doesn't parse chart XML — it only uses
|
|
6
|
+
* pre-rendered fallback images stored in the PPTX. Tools like python-pptx
|
|
7
|
+
* and PptxGenJS don't generate these fallback images.
|
|
8
|
+
*
|
|
9
|
+
* Uses Playwright to screenshot chart HTML rendered by quicklook-pptx-renderer.
|
|
10
|
+
* Playwright is a dynamic import — if not installed, this step is skipped.
|
|
11
|
+
*/
|
|
12
|
+
import type JSZip from "jszip";
|
|
13
|
+
/**
|
|
14
|
+
* Add fallback images to charts that don't have them.
|
|
15
|
+
* Requires Playwright — skips silently if not installed.
|
|
16
|
+
*/
|
|
17
|
+
export declare function addChartFallbacks(zip: JSZip, pptxBuffer: Buffer, reportLines: string[]): Promise<void>;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart fallback generator — render charts to PNG and embed as fallback
|
|
3
|
+
* images so QuickLook displays charts instead of blank rectangles.
|
|
4
|
+
*
|
|
5
|
+
* QuickLook (OfficeImport) doesn't parse chart XML — it only uses
|
|
6
|
+
* pre-rendered fallback images stored in the PPTX. Tools like python-pptx
|
|
7
|
+
* and PptxGenJS don't generate these fallback images.
|
|
8
|
+
*
|
|
9
|
+
* Uses Playwright to screenshot chart HTML rendered by quicklook-pptx-renderer.
|
|
10
|
+
* Playwright is a dynamic import — if not installed, this step is skipped.
|
|
11
|
+
*/
|
|
12
|
+
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
|
13
|
+
const CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
|
|
14
|
+
const IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
|
15
|
+
const relsParserOptions = {
|
|
16
|
+
ignoreAttributes: false,
|
|
17
|
+
attributeNamePrefix: "@_",
|
|
18
|
+
removeNSPrefix: true,
|
|
19
|
+
parseTagValue: false,
|
|
20
|
+
parseAttributeValue: false,
|
|
21
|
+
isArray: (name) => name === "Relationship",
|
|
22
|
+
};
|
|
23
|
+
const relsBuilderOptions = {
|
|
24
|
+
ignoreAttributes: false,
|
|
25
|
+
attributeNamePrefix: "@_",
|
|
26
|
+
format: true,
|
|
27
|
+
suppressEmptyNode: false,
|
|
28
|
+
};
|
|
29
|
+
/** Find charts without fallback images in the PPTX. */
|
|
30
|
+
async function findChartsWithoutFallback(zip) {
|
|
31
|
+
const parser = new XMLParser(relsParserOptions);
|
|
32
|
+
const results = [];
|
|
33
|
+
const slideFiles = Object.keys(zip.files)
|
|
34
|
+
.filter(f => /^ppt\/slides\/slide\d+\.xml$/.test(f))
|
|
35
|
+
.sort();
|
|
36
|
+
for (const slidePath of slideFiles) {
|
|
37
|
+
const slideNum = parseInt(slidePath.match(/\d+/)[0]);
|
|
38
|
+
// Read slide rels
|
|
39
|
+
const slideRelsPath = slidePath.replace("slides/", "slides/_rels/").replace(".xml", ".xml.rels");
|
|
40
|
+
const slideRelsFile = zip.file(slideRelsPath);
|
|
41
|
+
if (!slideRelsFile)
|
|
42
|
+
continue;
|
|
43
|
+
const slideRels = parser.parse(await slideRelsFile.async("string"));
|
|
44
|
+
const rels = slideRels.Relationships?.Relationship ?? [];
|
|
45
|
+
// Find chart relationships
|
|
46
|
+
const chartRels = rels.filter((r) => r["@_Type"]?.includes("/chart") || r["@_Type"] === CHART_REL_TYPE);
|
|
47
|
+
for (const chartRel of chartRels) {
|
|
48
|
+
const target = chartRel["@_Target"];
|
|
49
|
+
if (!target)
|
|
50
|
+
continue;
|
|
51
|
+
// Resolve chart path relative to slide
|
|
52
|
+
const chartPath = target.startsWith("../")
|
|
53
|
+
? "ppt/" + target.slice(3)
|
|
54
|
+
: "ppt/slides/" + target;
|
|
55
|
+
// Check if chart already has a fallback image
|
|
56
|
+
const chartRelsPath = chartPath.replace(/([^/]+)$/, "_rels/$1.rels");
|
|
57
|
+
const chartRelsFile = zip.file(chartRelsPath);
|
|
58
|
+
if (chartRelsFile) {
|
|
59
|
+
const chartRelsXml = parser.parse(await chartRelsFile.async("string"));
|
|
60
|
+
const chartRelsList = chartRelsXml.Relationships?.Relationship ?? [];
|
|
61
|
+
const hasImage = chartRelsList.some((r) => r["@_Type"]?.includes("/image") || r["@_Type"] === IMAGE_REL_TYPE);
|
|
62
|
+
if (hasImage)
|
|
63
|
+
continue; // Already has fallback
|
|
64
|
+
}
|
|
65
|
+
// Get chart bounds from slide XML
|
|
66
|
+
const slideXmlStr = await zip.file(slidePath).async("string");
|
|
67
|
+
const slideXml = parser.parse(slideXmlStr);
|
|
68
|
+
const bounds = findChartBounds(slideXml, chartRel["@_Id"]);
|
|
69
|
+
if (!bounds)
|
|
70
|
+
continue;
|
|
71
|
+
results.push({ slideNum, chartPath, chartRelsPath, bounds });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
/** Find the graphicFrame bounds for a chart by its relationship ID. */
|
|
77
|
+
function findChartBounds(slideXml, rId) {
|
|
78
|
+
const frames = findGraphicFrames(slideXml);
|
|
79
|
+
for (const frame of frames) {
|
|
80
|
+
const chartRef = frame.graphic?.graphicData?.chart;
|
|
81
|
+
if (!chartRef)
|
|
82
|
+
continue;
|
|
83
|
+
if (chartRef["@_id"] === rId || chartRef["@_r:id"] === rId) {
|
|
84
|
+
const xfrm = frame.xfrm;
|
|
85
|
+
if (!xfrm?.off || !xfrm?.ext)
|
|
86
|
+
continue;
|
|
87
|
+
return {
|
|
88
|
+
x: Number(xfrm.off["@_x"] ?? 0),
|
|
89
|
+
y: Number(xfrm.off["@_y"] ?? 0),
|
|
90
|
+
cx: Number(xfrm.ext["@_cx"] ?? 0),
|
|
91
|
+
cy: Number(xfrm.ext["@_cy"] ?? 0),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
function findGraphicFrames(node) {
|
|
98
|
+
const results = [];
|
|
99
|
+
if (!node || typeof node !== "object")
|
|
100
|
+
return results;
|
|
101
|
+
if (node.graphicFrame) {
|
|
102
|
+
const frames = Array.isArray(node.graphicFrame) ? node.graphicFrame : [node.graphicFrame];
|
|
103
|
+
results.push(...frames);
|
|
104
|
+
}
|
|
105
|
+
for (const key of Object.keys(node)) {
|
|
106
|
+
if (key.startsWith("@_"))
|
|
107
|
+
continue;
|
|
108
|
+
const children = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
109
|
+
for (const child of children) {
|
|
110
|
+
results.push(...findGraphicFrames(child));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return results;
|
|
114
|
+
}
|
|
115
|
+
/** Render a chart to PNG using Playwright and the renderer. */
|
|
116
|
+
async function renderChartToPng(pptxBuffer, chart, chromium) {
|
|
117
|
+
const { PptxPackage, readPresentation, generateHtml } = await import("quicklook-pptx-renderer");
|
|
118
|
+
const pkg = await PptxPackage.open(pptxBuffer);
|
|
119
|
+
const pres = await readPresentation(pkg);
|
|
120
|
+
const { html } = await generateHtml(pres, { pkg });
|
|
121
|
+
const EMU_PER_PX = 12700;
|
|
122
|
+
const slideWidth = pres.slideSize.cx / EMU_PER_PX;
|
|
123
|
+
const slideHeight = pres.slideSize.cy / EMU_PER_PX;
|
|
124
|
+
const browser = await chromium.launch();
|
|
125
|
+
try {
|
|
126
|
+
const page = await browser.newPage({ viewport: { width: Math.ceil(slideWidth), height: Math.ceil(slideHeight) } });
|
|
127
|
+
await page.setContent(html, { waitUntil: "networkidle" });
|
|
128
|
+
// Navigate to the correct slide (slides are stacked vertically in the HTML)
|
|
129
|
+
const slideSelector = `.slide:nth-child(${chart.slideNum})`;
|
|
130
|
+
const slideEl = await page.$(slideSelector);
|
|
131
|
+
if (slideEl)
|
|
132
|
+
await slideEl.scrollIntoViewIfNeeded();
|
|
133
|
+
const clip = {
|
|
134
|
+
x: chart.bounds.x / EMU_PER_PX,
|
|
135
|
+
y: chart.bounds.y / EMU_PER_PX + (chart.slideNum - 1) * slideHeight,
|
|
136
|
+
width: chart.bounds.cx / EMU_PER_PX,
|
|
137
|
+
height: chart.bounds.cy / EMU_PER_PX,
|
|
138
|
+
};
|
|
139
|
+
const png = await page.screenshot({ clip, type: "png" });
|
|
140
|
+
return Buffer.from(png);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await browser.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Embed a fallback PNG for a chart into the PPTX zip. */
|
|
147
|
+
async function embedFallback(zip, chart, png, index) {
|
|
148
|
+
const parser = new XMLParser(relsParserOptions);
|
|
149
|
+
const builder = new XMLBuilder(relsBuilderOptions);
|
|
150
|
+
// Add PNG to media folder
|
|
151
|
+
const mediaPath = `ppt/media/chart${index}-fallback.png`;
|
|
152
|
+
zip.file(mediaPath, png);
|
|
153
|
+
// Get or create chart rels
|
|
154
|
+
const chartRelsFile = zip.file(chart.chartRelsPath);
|
|
155
|
+
let relsXml;
|
|
156
|
+
if (chartRelsFile) {
|
|
157
|
+
relsXml = parser.parse(await chartRelsFile.async("string"));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
relsXml = {
|
|
161
|
+
Relationships: {
|
|
162
|
+
"@_xmlns": "http://schemas.openxmlformats.org/package/2006/relationships",
|
|
163
|
+
Relationship: [],
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (!relsXml.Relationships)
|
|
168
|
+
relsXml.Relationships = { "@_xmlns": "http://schemas.openxmlformats.org/package/2006/relationships" };
|
|
169
|
+
if (!relsXml.Relationships.Relationship)
|
|
170
|
+
relsXml.Relationships.Relationship = [];
|
|
171
|
+
if (!Array.isArray(relsXml.Relationships.Relationship)) {
|
|
172
|
+
relsXml.Relationships.Relationship = [relsXml.Relationships.Relationship];
|
|
173
|
+
}
|
|
174
|
+
// Compute relative path from chart to media
|
|
175
|
+
const chartDir = chart.chartPath.replace(/[^/]+$/, "");
|
|
176
|
+
const relTarget = "../" + mediaPath.slice(4); // strip "ppt/"
|
|
177
|
+
const rId = `rId${relsXml.Relationships.Relationship.length + 1}`;
|
|
178
|
+
relsXml.Relationships.Relationship.push({
|
|
179
|
+
"@_Id": rId,
|
|
180
|
+
"@_Type": IMAGE_REL_TYPE,
|
|
181
|
+
"@_Target": relTarget,
|
|
182
|
+
});
|
|
183
|
+
zip.file(chart.chartRelsPath, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + builder.build(relsXml));
|
|
184
|
+
// Ensure Content_Types.xml includes png
|
|
185
|
+
const ctFile = zip.file("[Content_Types].xml");
|
|
186
|
+
if (ctFile) {
|
|
187
|
+
let ct = await ctFile.async("string");
|
|
188
|
+
if (!ct.includes('Extension="png"')) {
|
|
189
|
+
ct = ct.replace("</Types>", ' <Default Extension="png" ContentType="image/png"/>\n</Types>');
|
|
190
|
+
zip.file("[Content_Types].xml", ct);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Add fallback images to charts that don't have them.
|
|
196
|
+
* Requires Playwright — skips silently if not installed.
|
|
197
|
+
*/
|
|
198
|
+
export async function addChartFallbacks(zip, pptxBuffer, reportLines) {
|
|
199
|
+
let chromium;
|
|
200
|
+
try {
|
|
201
|
+
const pw = await import("playwright");
|
|
202
|
+
chromium = pw.chromium;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return; // Playwright not installed — skip
|
|
206
|
+
}
|
|
207
|
+
const charts = await findChartsWithoutFallback(zip);
|
|
208
|
+
if (charts.length === 0)
|
|
209
|
+
return;
|
|
210
|
+
for (let i = 0; i < charts.length; i++) {
|
|
211
|
+
const chart = charts[i];
|
|
212
|
+
try {
|
|
213
|
+
const png = await renderChartToPng(pptxBuffer, chart, chromium);
|
|
214
|
+
await embedFallback(zip, chart, png, i + 1);
|
|
215
|
+
reportLines.push(`Slide ${chart.slideNum}: added chart fallback image (${png.length} bytes)`);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
reportLines.push(`Slide ${chart.slideNum}: chart fallback failed — ${err.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -25,6 +25,10 @@ function positional() {
|
|
|
25
25
|
return out;
|
|
26
26
|
}
|
|
27
27
|
async function main() {
|
|
28
|
+
if (hasFlag("--version") || hasFlag("-v")) {
|
|
29
|
+
console.log("0.3.1");
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
28
32
|
const pos = positional();
|
|
29
33
|
const command = pos[0];
|
|
30
34
|
if (command === "analyze") {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip embedded fonts from a PPTX and replace with cross-platform alternatives.
|
|
3
|
+
*
|
|
4
|
+
* QuickLook (OfficeImport) ignores embedded fonts entirely — they bloat the
|
|
5
|
+
* file and the text renders with system substitutes anyway. This:
|
|
6
|
+
* 1. Finds the best cross-platform replacement for each embedded font
|
|
7
|
+
* 2. Replaces all font references across slides and themes
|
|
8
|
+
* 3. Removes the font data files, embeddedFontLst, rels, and content types
|
|
9
|
+
*/
|
|
10
|
+
import type JSZip from "jszip";
|
|
11
|
+
export declare function stripEmbeddedFonts(zip: JSZip, reportLines: string[]): Promise<void>;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip embedded fonts from a PPTX and replace with cross-platform alternatives.
|
|
3
|
+
*
|
|
4
|
+
* QuickLook (OfficeImport) ignores embedded fonts entirely — they bloat the
|
|
5
|
+
* file and the text renders with system substitutes anyway. This:
|
|
6
|
+
* 1. Finds the best cross-platform replacement for each embedded font
|
|
7
|
+
* 2. Replaces all font references across slides and themes
|
|
8
|
+
* 3. Removes the font data files, embeddedFontLst, rels, and content types
|
|
9
|
+
*/
|
|
10
|
+
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
|
11
|
+
import { FONT_METRICS, FONT_SUBSTITUTIONS, findClosestFont } from "quicklook-pptx-renderer";
|
|
12
|
+
const SAFE_CANDIDATES = Object.keys(FONT_METRICS).filter(f => !FONT_SUBSTITUTIONS[f]);
|
|
13
|
+
const FONT_ELEMENTS = new Set(["latin", "ea", "cs", "sym", "buFont"]);
|
|
14
|
+
const relsParserOptions = {
|
|
15
|
+
ignoreAttributes: false,
|
|
16
|
+
attributeNamePrefix: "@_",
|
|
17
|
+
removeNSPrefix: true,
|
|
18
|
+
parseTagValue: false,
|
|
19
|
+
parseAttributeValue: false,
|
|
20
|
+
isArray: (name) => name === "Relationship",
|
|
21
|
+
};
|
|
22
|
+
const relsBuilderOptions = {
|
|
23
|
+
ignoreAttributes: false,
|
|
24
|
+
attributeNamePrefix: "@_",
|
|
25
|
+
format: true,
|
|
26
|
+
suppressEmptyNode: false,
|
|
27
|
+
};
|
|
28
|
+
const presParserOptions = {
|
|
29
|
+
ignoreAttributes: false,
|
|
30
|
+
attributeNamePrefix: "@_",
|
|
31
|
+
removeNSPrefix: true,
|
|
32
|
+
parseTagValue: false,
|
|
33
|
+
parseAttributeValue: false,
|
|
34
|
+
trimValues: false,
|
|
35
|
+
isArray: (name) => name === "embeddedFont",
|
|
36
|
+
};
|
|
37
|
+
const presBuilderOptions = {
|
|
38
|
+
ignoreAttributes: false,
|
|
39
|
+
attributeNamePrefix: "@_",
|
|
40
|
+
format: false,
|
|
41
|
+
suppressEmptyNode: false,
|
|
42
|
+
suppressBooleanAttributes: false,
|
|
43
|
+
};
|
|
44
|
+
/** Find the best cross-platform replacement for a font name. */
|
|
45
|
+
function findReplacement(fontName) {
|
|
46
|
+
if (!FONT_METRICS[fontName])
|
|
47
|
+
return null;
|
|
48
|
+
const matches = findClosestFont(fontName, {
|
|
49
|
+
candidates: SAFE_CANDIDATES,
|
|
50
|
+
sameCategory: true,
|
|
51
|
+
limit: 1,
|
|
52
|
+
});
|
|
53
|
+
return matches.length > 0 ? matches[0].font : null;
|
|
54
|
+
}
|
|
55
|
+
/** Walk an XML tree and replace typeface attributes matching the replacement map. */
|
|
56
|
+
function replaceTypefaces(node, replacements) {
|
|
57
|
+
if (!node || typeof node !== "object")
|
|
58
|
+
return;
|
|
59
|
+
for (const key of FONT_ELEMENTS) {
|
|
60
|
+
if (!node[key])
|
|
61
|
+
continue;
|
|
62
|
+
const elements = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
63
|
+
for (const el of elements) {
|
|
64
|
+
const typeface = el["@_typeface"];
|
|
65
|
+
if (typeface && replacements.has(typeface)) {
|
|
66
|
+
el["@_typeface"] = replacements.get(typeface);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const key of Object.keys(node)) {
|
|
71
|
+
if (key.startsWith("@_") || FONT_ELEMENTS.has(key))
|
|
72
|
+
continue;
|
|
73
|
+
const children = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
74
|
+
for (const child of children) {
|
|
75
|
+
replaceTypefaces(child, replacements);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function stripEmbeddedFonts(zip, reportLines) {
|
|
80
|
+
const presFile = zip.file("ppt/presentation.xml");
|
|
81
|
+
if (!presFile)
|
|
82
|
+
return;
|
|
83
|
+
const parser = new XMLParser(presParserOptions);
|
|
84
|
+
const presXml = parser.parse(await presFile.async("string"));
|
|
85
|
+
const presNode = presXml?.Presentation ?? presXml?.presentation ?? presXml;
|
|
86
|
+
if (!presNode)
|
|
87
|
+
return;
|
|
88
|
+
const embFontLst = presNode.embeddedFontLst?.embeddedFont;
|
|
89
|
+
if (!embFontLst)
|
|
90
|
+
return;
|
|
91
|
+
const entries = Array.isArray(embFontLst) ? embFontLst : [embFontLst];
|
|
92
|
+
if (entries.length === 0)
|
|
93
|
+
return;
|
|
94
|
+
// Build replacement map and collect rIds to remove
|
|
95
|
+
const replacements = new Map();
|
|
96
|
+
const fontNames = [];
|
|
97
|
+
const rIdsToRemove = new Set();
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const typeface = entry.font?.["@_typeface"];
|
|
100
|
+
if (typeface) {
|
|
101
|
+
fontNames.push(typeface);
|
|
102
|
+
const replacement = findReplacement(typeface);
|
|
103
|
+
if (replacement)
|
|
104
|
+
replacements.set(typeface, replacement);
|
|
105
|
+
}
|
|
106
|
+
for (const variant of ["regular", "bold", "italic", "boldItalic"]) {
|
|
107
|
+
const rId = entry[variant]?.["@_id"];
|
|
108
|
+
if (rId)
|
|
109
|
+
rIdsToRemove.add(rId);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (rIdsToRemove.size === 0)
|
|
113
|
+
return;
|
|
114
|
+
// Replace font references across slides and themes
|
|
115
|
+
if (replacements.size > 0) {
|
|
116
|
+
const xmlFiles = Object.keys(zip.files).filter(f => /^ppt\/(slides\/slide|theme\/theme)\d+\.xml$/.test(f));
|
|
117
|
+
for (const path of xmlFiles) {
|
|
118
|
+
const xml = await zip.file(path).async("string");
|
|
119
|
+
const parsed = parser.parse(xml);
|
|
120
|
+
replaceTypefaces(parsed, replacements);
|
|
121
|
+
const builder = new XMLBuilder(presBuilderOptions);
|
|
122
|
+
zip.file(path, builder.build(parsed));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Remove embeddedFontLst from presentation XML
|
|
126
|
+
delete presNode.embeddedFontLst;
|
|
127
|
+
const builder = new XMLBuilder(presBuilderOptions);
|
|
128
|
+
zip.file("ppt/presentation.xml", builder.build(presXml));
|
|
129
|
+
// Remove font files via presentation rels
|
|
130
|
+
const relsPath = "ppt/_rels/presentation.xml.rels";
|
|
131
|
+
const relsFile = zip.file(relsPath);
|
|
132
|
+
if (relsFile) {
|
|
133
|
+
const relsParser = new XMLParser(relsParserOptions);
|
|
134
|
+
const relsXml = relsParser.parse(await relsFile.async("string"));
|
|
135
|
+
const rels = relsXml?.Relationships?.Relationship ?? [];
|
|
136
|
+
const kept = [];
|
|
137
|
+
for (const rel of rels) {
|
|
138
|
+
if (rIdsToRemove.has(rel["@_Id"])) {
|
|
139
|
+
const target = rel["@_Target"];
|
|
140
|
+
const resolved = target.startsWith("/")
|
|
141
|
+
? target.slice(1)
|
|
142
|
+
: "ppt/" + (target.startsWith("../") ? target.slice(3) : target);
|
|
143
|
+
zip.remove(resolved);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
kept.push(rel);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
relsXml.Relationships.Relationship = kept;
|
|
150
|
+
const relsBuilder = new XMLBuilder(relsBuilderOptions);
|
|
151
|
+
zip.file(relsPath, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + relsBuilder.build(relsXml));
|
|
152
|
+
}
|
|
153
|
+
// Clean up Content_Types.xml — remove fntdata entries if no font files remain
|
|
154
|
+
const hasFontFiles = Object.keys(zip.files).some(f => f.endsWith(".fntdata"));
|
|
155
|
+
if (!hasFontFiles) {
|
|
156
|
+
const ctFile = zip.file("[Content_Types].xml");
|
|
157
|
+
if (ctFile) {
|
|
158
|
+
let ct = await ctFile.async("string");
|
|
159
|
+
ct = ct.replace(/<Default[^>]*Extension="fntdata"[^>]*\/>\s*/g, "");
|
|
160
|
+
ct = ct.replace(/<Override[^>]*PartName="[^"]*\/fonts\/[^"]*"[^>]*\/>\s*/g, "");
|
|
161
|
+
zip.file("[Content_Types].xml", ct);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Report
|
|
165
|
+
const details = fontNames.map(f => {
|
|
166
|
+
const r = replacements.get(f);
|
|
167
|
+
return r ? `"${f}" → "${r}"` : `"${f}" (stripped)`;
|
|
168
|
+
}).join(", ");
|
|
169
|
+
reportLines.push(`embedded fonts: ${details}`);
|
|
170
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,6 @@
|
|
|
6
6
|
* output is a valid PPTX that looks correct in Apple's OfficeImport pipeline.
|
|
7
7
|
*/
|
|
8
8
|
export { fix, type FixOptions, type FixResult } from "./writer.js";
|
|
9
|
-
export { analyze, formatIssues, type LintResult, type LintIssue } from "./analyze.js";
|
|
9
|
+
export { analyze, formatIssues, type LintResult, type LintIssue, type LintFix } from "./analyze.js";
|
|
10
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";
|
|
11
|
+
export { FONT_METRICS, FONT_SUBSTITUTIONS, SUPPORTED_GEOMETRIES, GEOMETRY_FALLBACKS, findClosestFont, widthDelta, type FontMetrics, type FontMatch, type FontCategory, } from "quicklook-pptx-renderer";
|
package/dist/index.js
CHANGED
|
@@ -8,4 +8,4 @@
|
|
|
8
8
|
export { fix } from "./writer.js";
|
|
9
9
|
export { analyze, formatIssues } from "./analyze.js";
|
|
10
10
|
export { ALL_TRANSFORMS } from "./transforms/index.js";
|
|
11
|
-
export { FONT_METRICS, findClosestFont, widthDelta } from "quicklook-pptx-renderer";
|
|
11
|
+
export { FONT_METRICS, FONT_SUBSTITUTIONS, SUPPORTED_GEOMETRIES, GEOMETRY_FALLBACKS, findClosestFont, widthDelta, } from "quicklook-pptx-renderer";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* effects transform — Strip
|
|
3
|
-
* as opaque PDF blocks in QuickLook.
|
|
2
|
+
* effects transform — Strip effectLst/effectDag from shape properties so
|
|
3
|
+
* shapes render as CSS instead of opaque PDF blocks in QuickLook.
|
|
4
4
|
*
|
|
5
5
|
* Detection is handled by quicklook-pptx-renderer's linter (opaque-pdf-block rule).
|
|
6
6
|
*/
|
|
@@ -1,13 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* effects transform — Strip
|
|
3
|
-
* as opaque PDF blocks in QuickLook.
|
|
2
|
+
* effects transform — Strip effectLst/effectDag from shape properties so
|
|
3
|
+
* shapes render as CSS instead of opaque PDF blocks in QuickLook.
|
|
4
4
|
*
|
|
5
5
|
* Detection is handled by quicklook-pptx-renderer's linter (opaque-pdf-block rule).
|
|
6
6
|
*/
|
|
7
|
+
function stripEffects(node, changes) {
|
|
8
|
+
if (!node || typeof node !== "object")
|
|
9
|
+
return;
|
|
10
|
+
// Shape elements: sp, cxnSp, pic
|
|
11
|
+
const shapeKeys = ["sp", "cxnSp", "pic"];
|
|
12
|
+
for (const key of shapeKeys) {
|
|
13
|
+
if (!node[key])
|
|
14
|
+
continue;
|
|
15
|
+
const shapes = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
16
|
+
for (const shape of shapes) {
|
|
17
|
+
const spPr = shape.spPr;
|
|
18
|
+
if (!spPr)
|
|
19
|
+
continue;
|
|
20
|
+
const name = shape.nvSpPr?.cNvPr?.["@_name"]
|
|
21
|
+
?? shape.nvCxnSpPr?.cNvPr?.["@_name"]
|
|
22
|
+
?? shape.nvPicPr?.cNvPr?.["@_name"]
|
|
23
|
+
?? "shape";
|
|
24
|
+
if (spPr.effectLst) {
|
|
25
|
+
delete spPr.effectLst;
|
|
26
|
+
changes.push(`stripped effects from "${name}"`);
|
|
27
|
+
}
|
|
28
|
+
if (spPr.effectDag) {
|
|
29
|
+
delete spPr.effectDag;
|
|
30
|
+
changes.push(`stripped effect DAG from "${name}"`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Groups: recurse into grpSp children
|
|
35
|
+
if (node.grpSp) {
|
|
36
|
+
const groups = Array.isArray(node.grpSp) ? node.grpSp : [node.grpSp];
|
|
37
|
+
for (const group of groups) {
|
|
38
|
+
stripEffects(group, changes);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Recurse into spTree (the slide's shape tree)
|
|
42
|
+
if (node.spTree) {
|
|
43
|
+
stripEffects(node.spTree, changes);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
7
46
|
export const effects = {
|
|
8
47
|
name: "effects",
|
|
9
|
-
apply(
|
|
10
|
-
|
|
11
|
-
|
|
48
|
+
apply(slideXml, _slideNum, _ctx) {
|
|
49
|
+
const changes = [];
|
|
50
|
+
stripEffects(slideXml, changes);
|
|
51
|
+
return { changed: changes.length > 0, changes };
|
|
12
52
|
},
|
|
13
53
|
};
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* fonts transform —
|
|
3
|
-
*
|
|
2
|
+
* fonts transform — Replace high-risk Windows fonts with metrically-closest
|
|
3
|
+
* cross-platform alternatives to prevent text reflow on macOS.
|
|
4
|
+
*
|
|
5
|
+
* macOS substitutes Windows fonts (Calibri → Helvetica Neue +14.4%, etc.)
|
|
6
|
+
* with different-width fonts, causing text overflow and line breaks to shift.
|
|
7
|
+
* This replaces those fonts with cross-platform safe fonts that have minimal
|
|
8
|
+
* width delta on macOS, chosen by the findClosestFont similarity algorithm.
|
|
9
|
+
*
|
|
10
|
+
* Detection is handled by quicklook-pptx-renderer's linter (font-substitution rule).
|
|
4
11
|
*/
|
|
5
12
|
import type { Transform } from "./index.js";
|
|
6
13
|
export declare const fonts: Transform;
|
package/dist/transforms/fonts.js
CHANGED
|
@@ -1,15 +1,75 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* fonts transform —
|
|
3
|
-
*
|
|
2
|
+
* fonts transform — Replace high-risk Windows fonts with metrically-closest
|
|
3
|
+
* cross-platform alternatives to prevent text reflow on macOS.
|
|
4
|
+
*
|
|
5
|
+
* macOS substitutes Windows fonts (Calibri → Helvetica Neue +14.4%, etc.)
|
|
6
|
+
* with different-width fonts, causing text overflow and line breaks to shift.
|
|
7
|
+
* This replaces those fonts with cross-platform safe fonts that have minimal
|
|
8
|
+
* width delta on macOS, chosen by the findClosestFont similarity algorithm.
|
|
9
|
+
*
|
|
10
|
+
* Detection is handled by quicklook-pptx-renderer's linter (font-substitution rule).
|
|
4
11
|
*/
|
|
12
|
+
import { FONT_METRICS, FONT_SUBSTITUTIONS, findClosestFont, widthDelta } from "quicklook-pptx-renderer";
|
|
13
|
+
const DELTA_THRESHOLD = 10;
|
|
14
|
+
// Fonts available on macOS — everything in the metrics DB except Windows-only
|
|
15
|
+
const SAFE_CANDIDATES = Object.keys(FONT_METRICS).filter(f => !FONT_SUBSTITUTIONS[f]);
|
|
16
|
+
const replacementCache = new Map();
|
|
17
|
+
function getReplacement(fontName) {
|
|
18
|
+
if (replacementCache.has(fontName))
|
|
19
|
+
return replacementCache.get(fontName);
|
|
20
|
+
const macSub = FONT_SUBSTITUTIONS[fontName];
|
|
21
|
+
const srcMetrics = FONT_METRICS[fontName];
|
|
22
|
+
const subMetrics = macSub ? FONT_METRICS[macSub] : undefined;
|
|
23
|
+
if (!macSub || !srcMetrics || !subMetrics || Math.abs(widthDelta(srcMetrics, subMetrics)) < DELTA_THRESHOLD) {
|
|
24
|
+
replacementCache.set(fontName, null);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const matches = findClosestFont(fontName, {
|
|
28
|
+
candidates: SAFE_CANDIDATES,
|
|
29
|
+
sameCategory: true,
|
|
30
|
+
limit: 1,
|
|
31
|
+
});
|
|
32
|
+
const result = matches.length > 0 ? matches[0].font : null;
|
|
33
|
+
replacementCache.set(fontName, result);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
const FONT_ELEMENTS = new Set(["latin", "ea", "cs", "sym", "buFont"]);
|
|
37
|
+
function replaceFonts(node, seen, changes) {
|
|
38
|
+
if (!node || typeof node !== "object")
|
|
39
|
+
return;
|
|
40
|
+
for (const key of FONT_ELEMENTS) {
|
|
41
|
+
if (!node[key])
|
|
42
|
+
continue;
|
|
43
|
+
const elements = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
44
|
+
for (const el of elements) {
|
|
45
|
+
const typeface = el["@_typeface"];
|
|
46
|
+
if (!typeface || typeface.startsWith("+"))
|
|
47
|
+
continue;
|
|
48
|
+
const replacement = getReplacement(typeface);
|
|
49
|
+
if (replacement) {
|
|
50
|
+
el["@_typeface"] = replacement;
|
|
51
|
+
if (!seen.has(typeface)) {
|
|
52
|
+
seen.add(typeface);
|
|
53
|
+
changes.push(`replaced font "${typeface}" ��� "${replacement}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const key of Object.keys(node)) {
|
|
59
|
+
if (key.startsWith("@_") || FONT_ELEMENTS.has(key))
|
|
60
|
+
continue;
|
|
61
|
+
const children = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
62
|
+
for (const child of children) {
|
|
63
|
+
replaceFonts(child, seen, changes);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
5
67
|
export const fonts = {
|
|
6
68
|
name: "fonts",
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// TODO: implement — add fallback typefaces
|
|
13
|
-
return { changed: false, changes: [] };
|
|
69
|
+
apply(slideXml, _slideNum, _ctx) {
|
|
70
|
+
const changes = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
replaceFonts(slideXml, seen, changes);
|
|
73
|
+
return { changed: changes.length > 0, changes };
|
|
14
74
|
},
|
|
15
75
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* geometries transform — Replace unsupported preset geometries with
|
|
3
|
-
*
|
|
2
|
+
* geometries transform — Replace unsupported preset geometries with the
|
|
3
|
+
* visually-closest supported alternative so shapes are visible in QuickLook.
|
|
4
4
|
*
|
|
5
5
|
* Detection is handled by quicklook-pptx-renderer's linter (unsupported-geometry rule).
|
|
6
6
|
*/
|
|
@@ -1,13 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* geometries transform — Replace unsupported preset geometries with
|
|
3
|
-
*
|
|
2
|
+
* geometries transform — Replace unsupported preset geometries with the
|
|
3
|
+
* visually-closest supported alternative so shapes are visible in QuickLook.
|
|
4
4
|
*
|
|
5
5
|
* Detection is handled by quicklook-pptx-renderer's linter (unsupported-geometry rule).
|
|
6
6
|
*/
|
|
7
|
+
import { SUPPORTED_GEOMETRIES, GEOMETRY_FALLBACKS } from "quicklook-pptx-renderer";
|
|
8
|
+
function fixGeometries(node, changes) {
|
|
9
|
+
if (!node || typeof node !== "object")
|
|
10
|
+
return;
|
|
11
|
+
if (node.sp) {
|
|
12
|
+
const shapes = Array.isArray(node.sp) ? node.sp : [node.sp];
|
|
13
|
+
for (const shape of shapes) {
|
|
14
|
+
const spPr = shape.spPr;
|
|
15
|
+
if (!spPr?.prstGeom)
|
|
16
|
+
continue;
|
|
17
|
+
const prst = spPr.prstGeom["@_prst"];
|
|
18
|
+
if (!prst || SUPPORTED_GEOMETRIES.has(prst))
|
|
19
|
+
continue;
|
|
20
|
+
const fallback = GEOMETRY_FALLBACKS[prst] ?? "rect";
|
|
21
|
+
const name = shape.nvSpPr?.cNvPr?.["@_name"] ?? "shape";
|
|
22
|
+
spPr.prstGeom["@_prst"] = fallback;
|
|
23
|
+
changes.push(`replaced unsupported geometry "${prst}" with ${fallback} on "${name}"`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (node.grpSp) {
|
|
27
|
+
const groups = Array.isArray(node.grpSp) ? node.grpSp : [node.grpSp];
|
|
28
|
+
for (const group of groups) {
|
|
29
|
+
fixGeometries(group, changes);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (node.spTree) {
|
|
33
|
+
fixGeometries(node.spTree, changes);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
7
36
|
export const geometries = {
|
|
8
37
|
name: "geometries",
|
|
9
|
-
apply(
|
|
10
|
-
|
|
11
|
-
|
|
38
|
+
apply(slideXml, _slideNum, _ctx) {
|
|
39
|
+
const changes = [];
|
|
40
|
+
fixGeometries(slideXml, changes);
|
|
41
|
+
return { changed: changes.length > 0, changes };
|
|
12
42
|
},
|
|
13
43
|
};
|
|
@@ -4,10 +4,36 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Detection is handled by quicklook-pptx-renderer's linter (gradient-flattened rule).
|
|
6
6
|
*/
|
|
7
|
+
function collapseGradients(node, changes, slideNum) {
|
|
8
|
+
if (!node || typeof node !== "object")
|
|
9
|
+
return;
|
|
10
|
+
if (node.gradFill) {
|
|
11
|
+
const fills = Array.isArray(node.gradFill) ? node.gradFill : [node.gradFill];
|
|
12
|
+
for (const fill of fills) {
|
|
13
|
+
const gsLst = fill.gsLst;
|
|
14
|
+
if (!gsLst)
|
|
15
|
+
continue;
|
|
16
|
+
const stops = gsLst.gs;
|
|
17
|
+
if (!Array.isArray(stops) || stops.length < 3)
|
|
18
|
+
continue;
|
|
19
|
+
gsLst.gs = [stops[0], stops[stops.length - 1]];
|
|
20
|
+
changes.push(`collapsed ${stops.length}-stop gradient to 2 stops`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const key of Object.keys(node)) {
|
|
24
|
+
if (key.startsWith("@_"))
|
|
25
|
+
continue;
|
|
26
|
+
const children = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
27
|
+
for (const child of children) {
|
|
28
|
+
collapseGradients(child, changes, slideNum);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
7
32
|
export const gradients = {
|
|
8
33
|
name: "gradients",
|
|
9
|
-
apply(
|
|
10
|
-
|
|
11
|
-
|
|
34
|
+
apply(slideXml, slideNum, _ctx) {
|
|
35
|
+
const changes = [];
|
|
36
|
+
collapseGradients(slideXml, changes, slideNum);
|
|
37
|
+
return { changed: changes.length > 0, changes };
|
|
12
38
|
},
|
|
13
39
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groups transform — Ungroup shape groups so children render individually
|
|
3
|
+
* instead of being merged into a single opaque PDF block by QuickLook.
|
|
4
|
+
*
|
|
5
|
+
* Coordinate math: each child's position is relative to the group's
|
|
6
|
+
* chOff/chExt (child coordinate space). To ungroup, we transform each
|
|
7
|
+
* child's coordinates from group-space to slide-space.
|
|
8
|
+
*
|
|
9
|
+
* Detection is handled by quicklook-pptx-renderer's linter (group-as-pdf rule).
|
|
10
|
+
*/
|
|
11
|
+
import type { Transform } from "./index.js";
|
|
12
|
+
export declare const groups: Transform;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groups transform — Ungroup shape groups so children render individually
|
|
3
|
+
* instead of being merged into a single opaque PDF block by QuickLook.
|
|
4
|
+
*
|
|
5
|
+
* Coordinate math: each child's position is relative to the group's
|
|
6
|
+
* chOff/chExt (child coordinate space). To ungroup, we transform each
|
|
7
|
+
* child's coordinates from group-space to slide-space.
|
|
8
|
+
*
|
|
9
|
+
* Detection is handled by quicklook-pptx-renderer's linter (group-as-pdf rule).
|
|
10
|
+
*/
|
|
11
|
+
const CHILD_KEYS = ["sp", "pic", "cxnSp", "graphicFrame"];
|
|
12
|
+
function getXfrm(child, key) {
|
|
13
|
+
if (key === "graphicFrame")
|
|
14
|
+
return child.xfrm;
|
|
15
|
+
return child.spPr?.xfrm;
|
|
16
|
+
}
|
|
17
|
+
function transformCoords(xfrm, groupOff, groupExt, chOff, chExt) {
|
|
18
|
+
const off = xfrm.off;
|
|
19
|
+
const ext = xfrm.ext;
|
|
20
|
+
if (!off || !ext)
|
|
21
|
+
return;
|
|
22
|
+
const scaleX = chExt.cx > 0 ? groupExt.cx / chExt.cx : 1;
|
|
23
|
+
const scaleY = chExt.cy > 0 ? groupExt.cy / chExt.cy : 1;
|
|
24
|
+
off["@_x"] = String(Math.round(groupOff.x + (Number(off["@_x"] ?? 0) - chOff.x) * scaleX));
|
|
25
|
+
off["@_y"] = String(Math.round(groupOff.y + (Number(off["@_y"] ?? 0) - chOff.y) * scaleY));
|
|
26
|
+
ext["@_cx"] = String(Math.round(Number(ext["@_cx"] ?? 0) * scaleX));
|
|
27
|
+
ext["@_cy"] = String(Math.round(Number(ext["@_cy"] ?? 0) * scaleY));
|
|
28
|
+
}
|
|
29
|
+
function ensureArray(parent, key) {
|
|
30
|
+
if (!parent[key])
|
|
31
|
+
parent[key] = [];
|
|
32
|
+
if (!Array.isArray(parent[key]))
|
|
33
|
+
parent[key] = [parent[key]];
|
|
34
|
+
return parent[key];
|
|
35
|
+
}
|
|
36
|
+
function ungroupInto(parent, changes) {
|
|
37
|
+
if (!parent.grpSp)
|
|
38
|
+
return;
|
|
39
|
+
const groups = Array.isArray(parent.grpSp) ? parent.grpSp : [parent.grpSp];
|
|
40
|
+
const kept = [];
|
|
41
|
+
for (const group of groups) {
|
|
42
|
+
// Recurse into nested groups first (ungroup from inside out)
|
|
43
|
+
ungroupInto(group, changes);
|
|
44
|
+
const xfrm = group.grpSpPr?.xfrm;
|
|
45
|
+
if (!xfrm?.off || !xfrm?.ext || !xfrm?.chOff || !xfrm?.chExt) {
|
|
46
|
+
kept.push(group);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// Skip groups with rotation — ungrouping would lose the visual effect
|
|
50
|
+
if (xfrm["@_rot"] && Number(xfrm["@_rot"]) !== 0) {
|
|
51
|
+
kept.push(group);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const groupOff = { x: Number(xfrm.off["@_x"] ?? 0), y: Number(xfrm.off["@_y"] ?? 0) };
|
|
55
|
+
const groupExt = { cx: Number(xfrm.ext["@_cx"] ?? 1), cy: Number(xfrm.ext["@_cy"] ?? 1) };
|
|
56
|
+
const chOff = { x: Number(xfrm.chOff["@_x"] ?? 0), y: Number(xfrm.chOff["@_y"] ?? 0) };
|
|
57
|
+
const chExt = { cx: Number(xfrm.chExt["@_cx"] ?? 1), cy: Number(xfrm.chExt["@_cy"] ?? 1) };
|
|
58
|
+
const name = group.nvGrpSpPr?.cNvPr?.["@_name"] ?? "group";
|
|
59
|
+
let childCount = 0;
|
|
60
|
+
for (const key of CHILD_KEYS) {
|
|
61
|
+
if (!group[key])
|
|
62
|
+
continue;
|
|
63
|
+
const children = Array.isArray(group[key]) ? group[key] : [group[key]];
|
|
64
|
+
const target = ensureArray(parent, key);
|
|
65
|
+
for (const child of children) {
|
|
66
|
+
const childXfrm = getXfrm(child, key);
|
|
67
|
+
if (childXfrm)
|
|
68
|
+
transformCoords(childXfrm, groupOff, groupExt, chOff, chExt);
|
|
69
|
+
target.push(child);
|
|
70
|
+
childCount++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Any nested groups that couldn't be ungrouped stay as top-level groups
|
|
74
|
+
if (group.grpSp) {
|
|
75
|
+
const nested = Array.isArray(group.grpSp) ? group.grpSp : [group.grpSp];
|
|
76
|
+
for (const ng of nested) {
|
|
77
|
+
const ngXfrm = ng.grpSpPr?.xfrm;
|
|
78
|
+
if (ngXfrm)
|
|
79
|
+
transformCoords(ngXfrm, groupOff, groupExt, chOff, chExt);
|
|
80
|
+
kept.push(ng);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (childCount > 0) {
|
|
84
|
+
changes.push(`ungrouped "${name}" (${childCount} children)`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (kept.length > 0) {
|
|
88
|
+
parent.grpSp = kept;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
delete parent.grpSp;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export const groups = {
|
|
95
|
+
name: "groups",
|
|
96
|
+
apply(slideXml, _slideNum, _ctx) {
|
|
97
|
+
const changes = [];
|
|
98
|
+
const spTree = slideXml?.sld?.cSld?.spTree ?? slideXml?.cSld?.spTree;
|
|
99
|
+
if (spTree)
|
|
100
|
+
ungroupInto(spTree, changes);
|
|
101
|
+
return { changed: changes.length > 0, changes };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Detection is handled by quicklook-pptx-renderer's linter.
|
|
6
6
|
* These transforms only apply fixes.
|
|
7
7
|
*/
|
|
8
|
-
export type TransformName = "table-styles" | "gradients" | "geometries" | "effects";
|
|
8
|
+
export type TransformName = "table-styles" | "gradients" | "geometries" | "effects" | "fonts" | "groups" | "embedded-fonts";
|
|
9
9
|
export interface TransformContext {
|
|
10
10
|
tableStyleXml?: any;
|
|
11
11
|
}
|
package/dist/transforms/index.js
CHANGED
|
@@ -9,9 +9,13 @@ import { tableStyles } from "./table-styles.js";
|
|
|
9
9
|
import { gradients } from "./gradients.js";
|
|
10
10
|
import { geometries } from "./geometries.js";
|
|
11
11
|
import { effects } from "./effects.js";
|
|
12
|
+
import { fonts } from "./fonts.js";
|
|
13
|
+
import { groups } from "./groups.js";
|
|
12
14
|
export const ALL_TRANSFORMS = [
|
|
13
15
|
tableStyles,
|
|
14
16
|
gradients,
|
|
15
17
|
geometries,
|
|
16
18
|
effects,
|
|
19
|
+
fonts,
|
|
20
|
+
groups,
|
|
17
21
|
];
|
package/dist/writer.js
CHANGED
|
@@ -8,12 +8,15 @@ import JSZip from "jszip";
|
|
|
8
8
|
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
|
9
9
|
import { xmlParserOptions, xmlBuilderOptions } from "./xml.js";
|
|
10
10
|
import { ALL_TRANSFORMS } from "./transforms/index.js";
|
|
11
|
+
import { addChartFallbacks } from "./chart-fallbacks.js";
|
|
12
|
+
import { stripEmbeddedFonts } from "./embedded-fonts.js";
|
|
11
13
|
export async function fix(pptxBuffer, options) {
|
|
12
14
|
const zip = await JSZip.loadAsync(pptxBuffer);
|
|
13
15
|
const parser = new XMLParser(xmlParserOptions);
|
|
14
16
|
const builder = new XMLBuilder(xmlBuilderOptions);
|
|
15
17
|
const reportLines = [];
|
|
16
|
-
const
|
|
18
|
+
const allNames = [...ALL_TRANSFORMS.map(t => t.name), "embedded-fonts"];
|
|
19
|
+
const enabledNames = new Set(options?.transforms ?? allNames);
|
|
17
20
|
const enabled = ALL_TRANSFORMS.filter(t => enabledNames.has(t.name));
|
|
18
21
|
// Find all slide XML files
|
|
19
22
|
const slideFiles = Object.keys(zip.files)
|
|
@@ -51,6 +54,28 @@ export async function fix(pptxBuffer, options) {
|
|
|
51
54
|
zip.file(slidePath, builder.build(parsed));
|
|
52
55
|
}
|
|
53
56
|
}
|
|
57
|
+
// Process theme XML for font replacement
|
|
58
|
+
if (enabledNames.has("fonts")) {
|
|
59
|
+
const fontsTransform = enabled.find(t => t.name === "fonts");
|
|
60
|
+
const themeFiles = Object.keys(zip.files).filter(f => /^ppt\/theme\/theme\d+\.xml$/.test(f));
|
|
61
|
+
for (const themePath of themeFiles) {
|
|
62
|
+
const xml = await zip.file(themePath).async("string");
|
|
63
|
+
const parsed = parser.parse(xml);
|
|
64
|
+
const result = fontsTransform.apply(parsed, 0, { tableStyleXml });
|
|
65
|
+
if (result.changed) {
|
|
66
|
+
zip.file(themePath, builder.build(parsed));
|
|
67
|
+
for (const line of result.changes) {
|
|
68
|
+
reportLines.push(`Theme: ${line}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Strip embedded fonts (QuickLook ignores them, they just bloat the file)
|
|
74
|
+
if (enabledNames.has("embedded-fonts")) {
|
|
75
|
+
await stripEmbeddedFonts(zip, reportLines);
|
|
76
|
+
}
|
|
77
|
+
// Generate chart fallback images (requires Playwright — skips if not installed)
|
|
78
|
+
await addChartFallbacks(zip, pptxBuffer, reportLines);
|
|
54
79
|
const outBuffer = Buffer.from(await zip.generateAsync({ type: "nodebuffer" }));
|
|
55
80
|
const result = { buffer: outBuffer };
|
|
56
81
|
if (options?.report) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pptx-fix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"fast-xml-parser": "^5.2.0",
|
|
63
63
|
"jszip": "^3.10.1",
|
|
64
|
-
"quicklook-pptx-renderer": "^0.2
|
|
64
|
+
"quicklook-pptx-renderer": "^0.3.2"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@types/node": "^22.0.0",
|