pptxtojson-pro 0.1.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/.babelrc.cjs +15 -0
- package/.eslintignore +3 -0
- package/.eslintrc.cjs +78 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/favicon.ico +0 -0
- package/index.html +541 -0
- package/package.json +56 -0
- package/rollup.config.js +42 -0
- package/scripts/extract-pptx-structure.js +115 -0
- package/scripts/transvert.js +34 -0
- package/src/adapter/toPptxtojson.ts +46 -0
- package/src/adapter/types.ts +330 -0
- package/src/export/serializePresentation.ts +200 -0
- package/src/index.ts +27 -0
- package/src/model/Layout.ts +218 -0
- package/src/model/Master.ts +114 -0
- package/src/model/Presentation.ts +502 -0
- package/src/model/Slide.ts +386 -0
- package/src/model/Theme.ts +95 -0
- package/src/model/nodes/BaseNode.ts +169 -0
- package/src/model/nodes/ChartNode.ts +55 -0
- package/src/model/nodes/GroupNode.ts +62 -0
- package/src/model/nodes/PicNode.ts +102 -0
- package/src/model/nodes/ShapeNode.ts +289 -0
- package/src/model/nodes/TableNode.ts +135 -0
- package/src/parser/RelParser.ts +81 -0
- package/src/parser/XmlParser.ts +101 -0
- package/src/parser/ZipParser.ts +277 -0
- package/src/parser/units.ts +59 -0
- package/src/serializer/RenderContext.ts +79 -0
- package/src/serializer/StyleResolver.ts +821 -0
- package/src/serializer/backgroundSerializer.ts +143 -0
- package/src/serializer/borderMapper.ts +93 -0
- package/src/serializer/chartSerializer.ts +97 -0
- package/src/serializer/fillMapper.ts +224 -0
- package/src/serializer/groupSerializer.ts +94 -0
- package/src/serializer/imageSerializer.ts +330 -0
- package/src/serializer/index.ts +27 -0
- package/src/serializer/shapeSerializer.ts +694 -0
- package/src/serializer/slideSerializer.ts +250 -0
- package/src/serializer/tableSerializer.ts +66 -0
- package/src/serializer/textSerializer.md +70 -0
- package/src/serializer/textSerializer.ts +1019 -0
- package/src/shapes/customGeometry.ts +178 -0
- package/src/shapes/presets.ts +6587 -0
- package/src/shapes/shapeArc.ts +44 -0
- package/src/types/vendor-shims.d.ts +20 -0
- package/src/utils/color.ts +488 -0
- package/src/utils/emfParser.ts +298 -0
- package/src/utils/media.ts +73 -0
- package/src/utils/mediaWebConvert.ts +100 -0
- package/src/utils/rgbaToPng.ts +33 -0
- package/src/utils/urlSafety.ts +17 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background serializer — resolves and applies slide/layout/master backgrounds.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SafeXmlNode } from '../parser/XmlParser';
|
|
6
|
+
import type { RenderContext } from './RenderContext';
|
|
7
|
+
import { resolveColor, resolveGradientFill } from './StyleResolver';
|
|
8
|
+
import { hexToRgb, rgbToHex } from '../utils/color';
|
|
9
|
+
import type { RelEntry } from '../parser/RelParser';
|
|
10
|
+
import { encodeMediaForWebDisplay } from '../utils/mediaWebConvert';
|
|
11
|
+
import { gradientFillDataToValue } from './fillMapper';
|
|
12
|
+
import type { Fill } from '../adapter/types';
|
|
13
|
+
import { resolveMediaPath } from '../utils/media';
|
|
14
|
+
|
|
15
|
+
function defaultFill(): Fill {
|
|
16
|
+
return { type: 'color', value: '#ffffff' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Same formula as BackgroundRenderer.compositeOnWhite → opaque hex for JSON. */
|
|
20
|
+
function compositeOnWhiteToHex(r: number, g: number, b: number, a: number): string {
|
|
21
|
+
const cr = Math.round(r * a + 255 * (1 - a));
|
|
22
|
+
const cg = Math.round(g * a + 255 * (1 - a));
|
|
23
|
+
const cb = Math.round(b * a + 255 * (1 - a));
|
|
24
|
+
return rgbToHex(cr, cg, cb);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve slide fill
|
|
29
|
+
*
|
|
30
|
+
* Background priority: slide.background -> layout.background -> master.background.
|
|
31
|
+
* The first found background is used.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveSlideFill(ctx: RenderContext): Fill {
|
|
34
|
+
// Find the first available background in the inheritance chain,
|
|
35
|
+
// and track which rels map to use for resolving image references
|
|
36
|
+
let bgNode: SafeXmlNode | undefined;
|
|
37
|
+
let bgRels: Map<string, RelEntry> = ctx.slide.rels;
|
|
38
|
+
|
|
39
|
+
if (ctx.slide.background?.exists()) {
|
|
40
|
+
bgNode = ctx.slide.background;
|
|
41
|
+
bgRels = ctx.slide.rels;
|
|
42
|
+
} else if (ctx.layout.background?.exists()) {
|
|
43
|
+
bgNode = ctx.layout.background;
|
|
44
|
+
bgRels = ctx.layout.rels;
|
|
45
|
+
} else if (ctx.master.background?.exists()) {
|
|
46
|
+
bgNode = ctx.master.background;
|
|
47
|
+
bgRels = ctx.master.rels;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!bgNode?.exists()) return defaultFill();
|
|
51
|
+
|
|
52
|
+
// Parse p:bg > p:bgPr
|
|
53
|
+
const bgPr = bgNode.child('bgPr');
|
|
54
|
+
if (bgPr.exists()) {
|
|
55
|
+
return renderBgPr(bgPr, ctx, bgRels);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Parse p:bg > p:bgRef (theme reference)
|
|
59
|
+
const bgRef = bgNode.child('bgRef');
|
|
60
|
+
if (bgRef.exists()) {
|
|
61
|
+
return renderBgRef(bgRef, ctx);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return defaultFill();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render background from bgPr (background properties).
|
|
69
|
+
* Contains direct fill definitions: solidFill, gradFill, blipFill, etc.
|
|
70
|
+
*/
|
|
71
|
+
export function renderBgPr(bgPr: SafeXmlNode, ctx: RenderContext, rels?: Map<string, RelEntry>): Fill {
|
|
72
|
+
// solidFill
|
|
73
|
+
const solidFill = bgPr.child('solidFill');
|
|
74
|
+
if (solidFill.exists()) {
|
|
75
|
+
const { color, alpha } = resolveColor(solidFill, ctx);
|
|
76
|
+
const hex = color.startsWith('#') ? color : `#${color}`;
|
|
77
|
+
if (alpha < 1) {
|
|
78
|
+
const { r, g, b } = hexToRgb(hex);
|
|
79
|
+
return { type: 'color', value: compositeOnWhiteToHex(r, g, b, alpha) };
|
|
80
|
+
} else {
|
|
81
|
+
return { type: 'color', value: hex };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// gradFill
|
|
86
|
+
const gradFill = bgPr.child('gradFill');
|
|
87
|
+
if (gradFill.exists()) {
|
|
88
|
+
const data = resolveGradientFill(bgPr, ctx);
|
|
89
|
+
if (data) {
|
|
90
|
+
return { type: 'gradient', value: gradientFillDataToValue(data) };
|
|
91
|
+
}
|
|
92
|
+
return defaultFill();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// blipFill (image background)
|
|
96
|
+
const blipFill = bgPr.child('blipFill');
|
|
97
|
+
if (blipFill.exists()) {
|
|
98
|
+
const blip = blipFill.child('blip');
|
|
99
|
+
const embedId = blip.attr('embed') ?? blip.attr('r:embed');
|
|
100
|
+
if (embedId) {
|
|
101
|
+
const relsMap = rels ?? ctx.slide.rels;
|
|
102
|
+
const rel = relsMap.get(embedId);
|
|
103
|
+
if (rel) {
|
|
104
|
+
const mediaPath = resolveMediaPath(rel.target);
|
|
105
|
+
const data = ctx.presentation.media.get(mediaPath);
|
|
106
|
+
if (data) {
|
|
107
|
+
const picBase64 = encodeMediaForWebDisplay(mediaPath, data);
|
|
108
|
+
// TODO: get opacity from alphaModFix
|
|
109
|
+
return {
|
|
110
|
+
type: 'image',
|
|
111
|
+
value: { picBase64, opacity: 1 },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return defaultFill();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const noFill = bgPr.child('noFill');
|
|
120
|
+
if (noFill.exists()) {
|
|
121
|
+
return defaultFill();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return defaultFill();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Render background from bgRef (theme format scheme reference).
|
|
129
|
+
* Simplified: just resolve the color from the reference.
|
|
130
|
+
*/
|
|
131
|
+
export function renderBgRef(bgRef: SafeXmlNode, ctx: RenderContext): Fill {
|
|
132
|
+
// bgRef may contain a color child (schemeClr, srgbClr, etc.)
|
|
133
|
+
const { color, alpha } = resolveColor(bgRef, ctx);
|
|
134
|
+
if (color && color !== '#000000') {
|
|
135
|
+
const hex = color.startsWith('#') ? color : `#${color}`;
|
|
136
|
+
if (alpha < 1) {
|
|
137
|
+
const { r, g, b } = hexToRgb(hex);
|
|
138
|
+
return { type: 'color', value: compositeOnWhiteToHex(r, g, b, alpha) };
|
|
139
|
+
}
|
|
140
|
+
return { type: 'color', value: hex };
|
|
141
|
+
}
|
|
142
|
+
return defaultFill();
|
|
143
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps StyleResolver line style to pptxtojson Border + borderStrokeDasharray.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SafeXmlNode } from '../parser/XmlParser';
|
|
6
|
+
import type { RenderContext } from './RenderContext';
|
|
7
|
+
import { resolveLineStyle } from './StyleResolver';
|
|
8
|
+
import type { Border } from '../adapter/types';
|
|
9
|
+
|
|
10
|
+
const PX_TO_PT = 0.75;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compute SVG-style dash array string from OOXML dash kind and stroke width (px).
|
|
14
|
+
* Matches ShapeRenderer's svgDashArrayForKind logic for consistent output.
|
|
15
|
+
*/
|
|
16
|
+
export function dashArrayForKind(dashKind: string, strokeWidthPx: number): string {
|
|
17
|
+
const w = Math.max(strokeWidthPx, 1);
|
|
18
|
+
switch (dashKind) {
|
|
19
|
+
case 'dot':
|
|
20
|
+
case 'sysDot':
|
|
21
|
+
return `${w},${w * 2}`;
|
|
22
|
+
case 'dash':
|
|
23
|
+
case 'sysDash':
|
|
24
|
+
return `${w * 4},${w * 2}`;
|
|
25
|
+
case 'lgDash':
|
|
26
|
+
return `${w * 8},${w * 3}`;
|
|
27
|
+
case 'dashDot':
|
|
28
|
+
case 'sysDashDot':
|
|
29
|
+
return `${w * 4},${w * 2},${w},${w * 2}`;
|
|
30
|
+
case 'lgDashDot':
|
|
31
|
+
return `${w * 8},${w * 3},${w},${w * 3}`;
|
|
32
|
+
case 'lgDashDotDot':
|
|
33
|
+
case 'sysDashDotDot':
|
|
34
|
+
return `${w * 8},${w * 3},${w},${w * 2},${w},${w * 2}`;
|
|
35
|
+
default:
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Map OOXML dash kind to types.Border.borderType.
|
|
42
|
+
*/
|
|
43
|
+
function dashKindToBorderType(dashKind: string): Border['borderType'] {
|
|
44
|
+
switch (dashKind) {
|
|
45
|
+
case 'dot':
|
|
46
|
+
case 'sysDot':
|
|
47
|
+
return 'dotted';
|
|
48
|
+
case 'dash':
|
|
49
|
+
case 'sysDash':
|
|
50
|
+
case 'lgDash':
|
|
51
|
+
case 'dashDot':
|
|
52
|
+
case 'lgDashDot':
|
|
53
|
+
case 'lgDashDotDot':
|
|
54
|
+
case 'sysDashDot':
|
|
55
|
+
case 'sysDashDotDot':
|
|
56
|
+
return 'dashed';
|
|
57
|
+
default:
|
|
58
|
+
return 'solid';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureHex(color: string): string {
|
|
63
|
+
const s = color.trim();
|
|
64
|
+
if (s === 'transparent') return '#000000';
|
|
65
|
+
if (s.startsWith('#')) return s;
|
|
66
|
+
return `#${s}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface BorderResult {
|
|
70
|
+
border: Border;
|
|
71
|
+
borderStrokeDasharray: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve ln (a:ln) node to types.Border and borderStrokeDasharray.
|
|
76
|
+
* Width from resolveLineStyle is in px; we convert to pt for output.
|
|
77
|
+
*/
|
|
78
|
+
export function lineStyleToBorder(
|
|
79
|
+
ln: SafeXmlNode,
|
|
80
|
+
ctx: RenderContext,
|
|
81
|
+
lnRef?: SafeXmlNode,
|
|
82
|
+
): BorderResult {
|
|
83
|
+
const { width: widthPx, color, dashKind } = resolveLineStyle(ln, ctx, lnRef);
|
|
84
|
+
const borderWidthPt = widthPx * PX_TO_PT;
|
|
85
|
+
return {
|
|
86
|
+
border: {
|
|
87
|
+
borderColor: ensureHex(color),
|
|
88
|
+
borderWidth: borderWidthPt,
|
|
89
|
+
borderType: dashKindToBorderType(dashKind),
|
|
90
|
+
},
|
|
91
|
+
borderStrokeDasharray: dashArrayForKind(dashKind, widthPx),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes ChartNodeData to pptxtojson CommonChart or ScatterChart.
|
|
3
|
+
* Reads chart XML from presentation.charts; extracts chartType and minimal data.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ChartNodeData } from '../model/nodes/ChartNode';
|
|
7
|
+
import type { RenderContext } from './RenderContext';
|
|
8
|
+
import type { ChartType, CommonChart, ScatterChart, ChartItem, ChartValue } from '../adapter/types';
|
|
9
|
+
|
|
10
|
+
const PX_TO_PT = 0.75;
|
|
11
|
+
|
|
12
|
+
function pxToPt(px: number): number {
|
|
13
|
+
return px * PX_TO_PT;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const OOXML_CHART_TYPES: string[] = [
|
|
17
|
+
'lineChart', 'line3DChart', 'barChart', 'bar3DChart', 'pieChart', 'pie3DChart',
|
|
18
|
+
'doughnutChart', 'areaChart', 'area3DChart', 'scatterChart', 'bubbleChart',
|
|
19
|
+
'radarChart', 'stockChart', 'surfaceChart', 'surface3DChart',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function mapToChartType(ooxmlName: string): ChartType {
|
|
23
|
+
const t = ooxmlName as ChartType;
|
|
24
|
+
if (['scatterChart', 'bubbleChart'].includes(ooxmlName)) return t;
|
|
25
|
+
if (['lineChart', 'line3DChart', 'barChart', 'bar3DChart', 'pieChart', 'pie3DChart',
|
|
26
|
+
'doughnutChart', 'areaChart', 'area3DChart', 'radarChart', 'stockChart', 'surfaceChart', 'surface3DChart'].includes(ooxmlName)) {
|
|
27
|
+
return t;
|
|
28
|
+
}
|
|
29
|
+
return 'barChart';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get theme accent colors for chart (hex strings).
|
|
34
|
+
*/
|
|
35
|
+
function getThemeColors(ctx: RenderContext): string[] {
|
|
36
|
+
const colors: string[] = [];
|
|
37
|
+
for (let i = 1; i <= 6; i++) {
|
|
38
|
+
const hex = ctx.theme.colorScheme.get(`accent${i}`) ?? '000000';
|
|
39
|
+
colors.push(hex.startsWith('#') ? hex : `#${hex}`);
|
|
40
|
+
}
|
|
41
|
+
return colors;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Serialize chart node to Chart element.
|
|
46
|
+
* Uses chart XML from presentation.charts for chartType; data/colors minimal.
|
|
47
|
+
*/
|
|
48
|
+
export function chartToElement(
|
|
49
|
+
node: ChartNodeData,
|
|
50
|
+
ctx: RenderContext,
|
|
51
|
+
order: number,
|
|
52
|
+
): CommonChart | ScatterChart {
|
|
53
|
+
const left = pxToPt(node.position.x);
|
|
54
|
+
const top = pxToPt(node.position.y);
|
|
55
|
+
const width = pxToPt(node.size.w);
|
|
56
|
+
const height = pxToPt(node.size.h);
|
|
57
|
+
const chartRoot = ctx.presentation.charts.get(node.chartPath);
|
|
58
|
+
let chartType: ChartType = 'barChart';
|
|
59
|
+
if (chartRoot?.exists()) {
|
|
60
|
+
const chart = chartRoot.child('chart');
|
|
61
|
+
const plotArea = chart.exists() ? chart.child('plotArea') : chartRoot.child('plotArea');
|
|
62
|
+
if (plotArea.exists()) {
|
|
63
|
+
for (const name of OOXML_CHART_TYPES) {
|
|
64
|
+
const el = plotArea.child(name);
|
|
65
|
+
if (el.exists()) {
|
|
66
|
+
chartType = mapToChartType(name);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const colors = getThemeColors(ctx);
|
|
73
|
+
if (chartType === 'scatterChart' || chartType === 'bubbleChart') {
|
|
74
|
+
return {
|
|
75
|
+
type: 'chart',
|
|
76
|
+
left,
|
|
77
|
+
top,
|
|
78
|
+
width,
|
|
79
|
+
height,
|
|
80
|
+
data: [[], []],
|
|
81
|
+
colors,
|
|
82
|
+
chartType,
|
|
83
|
+
order,
|
|
84
|
+
} as ScatterChart;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
type: 'chart',
|
|
88
|
+
left,
|
|
89
|
+
top,
|
|
90
|
+
width,
|
|
91
|
+
height,
|
|
92
|
+
data: [] as ChartItem[],
|
|
93
|
+
colors,
|
|
94
|
+
chartType: chartType as CommonChart['chartType'],
|
|
95
|
+
order,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps StyleResolver fill output to pptxtojson Fill types (ColorFill, ImageFill, GradientFill, PatternFill).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SafeXmlNode } from '../parser/XmlParser';
|
|
6
|
+
import type { PlaceholderInfo } from '../model/nodes/BaseNode';
|
|
7
|
+
import type { RenderContext } from './RenderContext';
|
|
8
|
+
import { resolveColor, resolveGradientFill, type GradientFillData } from './StyleResolver';
|
|
9
|
+
import { resolveRelTarget } from '../parser/RelParser';
|
|
10
|
+
import type { RelEntry } from '../parser/RelParser';
|
|
11
|
+
import { encodeMediaForWebDisplay } from '../utils/mediaWebConvert';
|
|
12
|
+
import type { Fill, ColorFill, ImageFill, GradientFill, PatternFill } from '../adapter/types';
|
|
13
|
+
|
|
14
|
+
export interface SpPrToFillOptions {
|
|
15
|
+
rels: Map<string, RelEntry>;
|
|
16
|
+
basePath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PX_TO_PT = 0.75;
|
|
20
|
+
|
|
21
|
+
function ensureHex(color: string): string {
|
|
22
|
+
const s = color.trim();
|
|
23
|
+
if (s.startsWith('#')) return s;
|
|
24
|
+
return `#${s}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Convert GradientFillData to types.GradientFill.value (path, rot, colors). */
|
|
28
|
+
export function gradientFillDataToValue(data: GradientFillData): GradientFill['value'] {
|
|
29
|
+
const path: GradientFill['value']['path'] =
|
|
30
|
+
data.type === 'linear'
|
|
31
|
+
? 'line'
|
|
32
|
+
: data.pathType === 'rect'
|
|
33
|
+
? 'rect'
|
|
34
|
+
: data.pathType === 'circle' || data.pathType === 'shape'
|
|
35
|
+
? data.pathType
|
|
36
|
+
: 'circle';
|
|
37
|
+
const rot = data.type === 'linear' ? data.angle : 0;
|
|
38
|
+
const colors = data.stops.map((s) => ({
|
|
39
|
+
pos: `${s.position.toFixed(1)}%`,
|
|
40
|
+
color: ensureHex(s.color),
|
|
41
|
+
}));
|
|
42
|
+
return { path, rot, colors };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve fill from shape properties (`spPr`) to types.Fill.
|
|
47
|
+
* Slide backgrounds use `backgroundSerializer.bgPrToFill` / `bgRefToFill` instead (aligned with BackgroundRenderer).
|
|
48
|
+
* When resolving blip from a shape on layout/master slide, pass options with that part's rels and basePath.
|
|
49
|
+
*/
|
|
50
|
+
export function spPrToFill(
|
|
51
|
+
spPr: SafeXmlNode,
|
|
52
|
+
ctx: RenderContext,
|
|
53
|
+
options?: SpPrToFillOptions,
|
|
54
|
+
): Fill {
|
|
55
|
+
const solidFill = spPr.child('solidFill');
|
|
56
|
+
if (solidFill.exists()) {
|
|
57
|
+
const { color } = resolveColor(solidFill, ctx);
|
|
58
|
+
const value = ensureHex(color);
|
|
59
|
+
return { type: 'color', value };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const gradFill = spPr.child('gradFill');
|
|
63
|
+
if (gradFill.exists()) {
|
|
64
|
+
const data = resolveGradientFill(spPr, ctx);
|
|
65
|
+
if (data) {
|
|
66
|
+
return { type: 'gradient', value: gradientFillDataToValue(data) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const blipFill = spPr.child('blipFill');
|
|
71
|
+
if (blipFill.exists()) {
|
|
72
|
+
const blip = blipFill.child('blip');
|
|
73
|
+
const embedId = blip.attr('embed') ?? blip.attr('r:embed');
|
|
74
|
+
if (embedId) {
|
|
75
|
+
const rels = options?.rels ?? ctx.slide.rels;
|
|
76
|
+
const basePath = options?.basePath ?? ctx.slide.slidePath.replace(/\/[^/]+$/, '');
|
|
77
|
+
const rel = rels.get(embedId);
|
|
78
|
+
if (rel) {
|
|
79
|
+
const mediaPath = resolveRelTarget(basePath, rel.target);
|
|
80
|
+
const data = ctx.presentation.media.get(mediaPath);
|
|
81
|
+
if (data) {
|
|
82
|
+
const picBase64 = encodeMediaForWebDisplay(mediaPath, data);
|
|
83
|
+
return {
|
|
84
|
+
type: 'image',
|
|
85
|
+
value: { picBase64, opacity: 1 },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pattFill = spPr.child('pattFill');
|
|
93
|
+
if (pattFill.exists()) {
|
|
94
|
+
const preset = pattFill.attr('prst') ?? 'solid';
|
|
95
|
+
let foregroundColor = '#000000';
|
|
96
|
+
let backgroundColor = '#ffffff';
|
|
97
|
+
const fgClr = pattFill.child('fgClr');
|
|
98
|
+
if (fgClr.exists()) {
|
|
99
|
+
const { color } = resolveColor(fgClr, ctx);
|
|
100
|
+
foregroundColor = ensureHex(color);
|
|
101
|
+
}
|
|
102
|
+
const bgClr = pattFill.child('bgClr');
|
|
103
|
+
if (bgClr.exists()) {
|
|
104
|
+
const { color } = resolveColor(bgClr, ctx);
|
|
105
|
+
backgroundColor = ensureHex(color);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
type: 'pattern',
|
|
109
|
+
value: { type: preset, foregroundColor, backgroundColor },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const grpFill = spPr.child('grpFill');
|
|
114
|
+
if (grpFill.exists() && ctx.groupFillNode) {
|
|
115
|
+
return spPrToFill(ctx.groupFillNode, ctx);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const noFill = spPr.child('noFill');
|
|
119
|
+
if (noFill.exists()) {
|
|
120
|
+
return { type: 'color', value: 'transparent' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// No explicit fill in OOXML — do not assume white (PPT treats as no fill / see-through).
|
|
124
|
+
return { type: 'color', value: 'transparent' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function dirnamePackagePath(path: string): string {
|
|
128
|
+
if (!path) return '';
|
|
129
|
+
const i = path.lastIndexOf('/');
|
|
130
|
+
return i >= 0 ? path.slice(0, i) : '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True when spPr defines how the interior is painted (including explicit no fill). */
|
|
134
|
+
export function spPrHasExplicitFill(spPr: SafeXmlNode): boolean {
|
|
135
|
+
if (!spPr.exists()) return false;
|
|
136
|
+
return (
|
|
137
|
+
spPr.child('solidFill').exists() ||
|
|
138
|
+
spPr.child('gradFill').exists() ||
|
|
139
|
+
spPr.child('blipFill').exists() ||
|
|
140
|
+
spPr.child('pattFill').exists() ||
|
|
141
|
+
spPr.child('grpFill').exists() ||
|
|
142
|
+
spPr.child('noFill').exists()
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findPlaceholderNode(
|
|
147
|
+
placeholders: SafeXmlNode[],
|
|
148
|
+
info: PlaceholderInfo,
|
|
149
|
+
): SafeXmlNode | undefined {
|
|
150
|
+
for (const ph of placeholders) {
|
|
151
|
+
let phEl: SafeXmlNode | undefined;
|
|
152
|
+
const nvSpPr = ph.child('nvSpPr');
|
|
153
|
+
if (nvSpPr.exists()) {
|
|
154
|
+
phEl = nvSpPr.child('nvPr').child('ph');
|
|
155
|
+
}
|
|
156
|
+
if (!phEl || !phEl.exists()) {
|
|
157
|
+
const nvPicPr = ph.child('nvPicPr');
|
|
158
|
+
if (nvPicPr.exists()) {
|
|
159
|
+
phEl = nvPicPr.child('nvPr').child('ph');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!phEl || !phEl.exists()) continue;
|
|
163
|
+
|
|
164
|
+
const phType = phEl.attr('type');
|
|
165
|
+
const phIdx = phEl.numAttr('idx');
|
|
166
|
+
|
|
167
|
+
if (info.idx !== undefined && phIdx === info.idx) return ph;
|
|
168
|
+
if (info.type && phType === info.type) return ph;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isTransparentOnlyFill(fill: Fill): boolean {
|
|
174
|
+
return fill.type === 'color' && fill.value === 'transparent';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve shape `spPr` fill for serialization. When the slide shape omits fill (only xfrm/geom etc.),
|
|
179
|
+
* PowerPoint inherits from the matching layout then master placeholder — same order as text lstStyle.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveShapeFill(
|
|
182
|
+
spPr: SafeXmlNode,
|
|
183
|
+
ctx: RenderContext,
|
|
184
|
+
placeholder?: PlaceholderInfo,
|
|
185
|
+
): Fill {
|
|
186
|
+
const direct: Fill = spPr.exists()
|
|
187
|
+
? spPrToFill(spPr, ctx)
|
|
188
|
+
: { type: 'color', value: 'transparent' };
|
|
189
|
+
|
|
190
|
+
if (spPr.exists() && spPrHasExplicitFill(spPr)) {
|
|
191
|
+
return direct;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (placeholder) {
|
|
195
|
+
const layoutPh = findPlaceholderNode(
|
|
196
|
+
ctx.layout.placeholders.map((e) => e.node),
|
|
197
|
+
placeholder,
|
|
198
|
+
);
|
|
199
|
+
if (layoutPh) {
|
|
200
|
+
const phSpPr = layoutPh.child('spPr');
|
|
201
|
+
if (phSpPr.exists() && spPrHasExplicitFill(phSpPr)) {
|
|
202
|
+
const fill = spPrToFill(phSpPr, ctx, {
|
|
203
|
+
rels: ctx.layout.rels,
|
|
204
|
+
basePath: dirnamePackagePath(ctx.layoutPath),
|
|
205
|
+
});
|
|
206
|
+
if (!isTransparentOnlyFill(fill)) return fill;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const masterPh = findPlaceholderNode(ctx.master.placeholders, placeholder);
|
|
211
|
+
if (masterPh) {
|
|
212
|
+
const phSpPr = masterPh.child('spPr');
|
|
213
|
+
if (phSpPr.exists() && spPrHasExplicitFill(phSpPr)) {
|
|
214
|
+
const fill = spPrToFill(phSpPr, ctx, {
|
|
215
|
+
rels: ctx.master.rels,
|
|
216
|
+
basePath: dirnamePackagePath(ctx.masterPath),
|
|
217
|
+
});
|
|
218
|
+
if (!isTransparentOnlyFill(fill)) return fill;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return direct;
|
|
224
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes GroupNodeData to pptxtojson Group element.
|
|
3
|
+
* Recursively serializes children via nodeToElement; flattens nested groups into elements.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { GroupNodeData } from '../model/nodes/GroupNode';
|
|
7
|
+
import type { SlideNode } from '../model/Slide';
|
|
8
|
+
import { parseChildNode } from '../model/Slide';
|
|
9
|
+
import type { RenderContext } from './RenderContext';
|
|
10
|
+
import type { PptxFiles } from '../parser/ZipParser';
|
|
11
|
+
import type { Group, Element, BaseElement } from '../adapter/types';
|
|
12
|
+
|
|
13
|
+
// Type guard for Group (Element = BaseElement | Group)
|
|
14
|
+
function isGroup(e: Element): e is Group {
|
|
15
|
+
return (e as Group).type === 'group';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PX_TO_PT = 0.75;
|
|
19
|
+
|
|
20
|
+
function pxToPt(px: number): number {
|
|
21
|
+
return px * PX_TO_PT;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Flatten a group into BaseElement[] with positions in parent (slide) space.
|
|
26
|
+
* Offsets each element by (baseLeft + group.left, baseTop + group.top).
|
|
27
|
+
*/
|
|
28
|
+
function flattenGroupInto(group: Group, baseLeft: number, baseTop: number, out: BaseElement[]): void {
|
|
29
|
+
const offsetLeft = baseLeft + group.left;
|
|
30
|
+
const offsetTop = baseTop + group.top;
|
|
31
|
+
for (const el of group.elements) {
|
|
32
|
+
const e = el as BaseElement & { left: number; top: number };
|
|
33
|
+
out.push({ ...e, left: offsetLeft + e.left, top: offsetTop + e.top } as BaseElement);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type NodeToElement = (
|
|
38
|
+
node: SlideNode,
|
|
39
|
+
ctx: RenderContext,
|
|
40
|
+
order: number,
|
|
41
|
+
files?: PptxFiles,
|
|
42
|
+
) => Element;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Serialize group node to Group element.
|
|
46
|
+
* Children are parsed with parseChildNode and serialized with nodeToElement.
|
|
47
|
+
* Nested groups are flattened so Group.elements is BaseElement[].
|
|
48
|
+
*/
|
|
49
|
+
export function groupToElement(
|
|
50
|
+
node: GroupNodeData,
|
|
51
|
+
ctx: RenderContext,
|
|
52
|
+
order: number,
|
|
53
|
+
files: PptxFiles | undefined,
|
|
54
|
+
nodeToElement: NodeToElement,
|
|
55
|
+
): Group {
|
|
56
|
+
const left = pxToPt(node.position.x);
|
|
57
|
+
const top = pxToPt(node.position.y);
|
|
58
|
+
const width = pxToPt(node.size.w);
|
|
59
|
+
const height = pxToPt(node.size.h);
|
|
60
|
+
const rels = ctx.slide.rels;
|
|
61
|
+
const slidePath = ctx.slide.slidePath;
|
|
62
|
+
const diagramDrawings = files?.diagramDrawings;
|
|
63
|
+
const elements: BaseElement[] = [];
|
|
64
|
+
let idx = 0;
|
|
65
|
+
for (const childXml of node.children) {
|
|
66
|
+
const childNode = parseChildNode(childXml, rels, slidePath, diagramDrawings);
|
|
67
|
+
if (childNode) {
|
|
68
|
+
const el = nodeToElement(childNode, ctx, idx, files);
|
|
69
|
+
if (isGroup(el)) {
|
|
70
|
+
flattenGroupInto(el, left, top, elements);
|
|
71
|
+
} else {
|
|
72
|
+
const be = el as BaseElement & { left: number; top: number };
|
|
73
|
+
elements.push({
|
|
74
|
+
...be,
|
|
75
|
+
left: left + be.left,
|
|
76
|
+
top: top + be.top,
|
|
77
|
+
} as BaseElement);
|
|
78
|
+
}
|
|
79
|
+
idx++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
type: 'group',
|
|
84
|
+
left,
|
|
85
|
+
top,
|
|
86
|
+
width,
|
|
87
|
+
height,
|
|
88
|
+
rotate: node.rotation,
|
|
89
|
+
elements,
|
|
90
|
+
order,
|
|
91
|
+
isFlipH: node.flipH,
|
|
92
|
+
isFlipV: node.flipV,
|
|
93
|
+
};
|
|
94
|
+
}
|