@bendyline/squisq-formats 1.4.2 → 2.0.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 +48 -2
- package/dist/{chunk-WWBIKRNP.js → chunk-2CSZJEAL.js} +13 -11
- package/dist/chunk-2CSZJEAL.js.map +1 -0
- package/dist/{chunk-RTS5XBZ7.js → chunk-2EFDWKC6.js} +262 -84
- package/dist/chunk-2EFDWKC6.js.map +1 -0
- package/dist/chunk-2PRVLAU3.js +740 -0
- package/dist/chunk-2PRVLAU3.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-4HJ6AK2Q.js} +54 -193
- package/dist/chunk-4HJ6AK2Q.js.map +1 -0
- package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
- package/dist/chunk-4VUWTSGM.js.map +1 -0
- package/dist/{chunk-YESS7VY3.js → chunk-CVNVD7VH.js} +154 -39
- package/dist/chunk-CVNVD7VH.js.map +1 -0
- package/dist/chunk-CX3LH47J.js +99 -0
- package/dist/chunk-CX3LH47J.js.map +1 -0
- package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
- package/dist/chunk-GVS2XXV6.js.map +1 -0
- package/dist/chunk-H7T5DXUU.js +479 -0
- package/dist/chunk-H7T5DXUU.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-HBSCRS5P.js} +10 -8
- package/dist/chunk-HBSCRS5P.js.map +1 -0
- package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
- package/dist/chunk-LXYLOOST.js.map +1 -0
- package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
- package/dist/chunk-MLX2BOJC.js.map +1 -0
- package/dist/chunk-NKAJPJ4G.js +106 -0
- package/dist/chunk-NKAJPJ4G.js.map +1 -0
- package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
- package/dist/chunk-ODL3SSPT.js +18 -0
- package/dist/chunk-ODL3SSPT.js.map +1 -0
- package/dist/chunk-X4USWOQ2.js +131 -0
- package/dist/chunk-X4USWOQ2.js.map +1 -0
- package/dist/chunk-XTLQXR4U.js +195 -0
- package/dist/chunk-XTLQXR4U.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/chunk-ZZ7D4DN6.js +97 -0
- package/dist/chunk-ZZ7D4DN6.js.map +1 -0
- package/dist/container/index.d.ts +6 -2
- package/dist/container/index.js +15 -17
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +6 -2
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.d.ts +3 -1
- package/dist/epub/index.js +3 -3
- package/dist/export-Djev9BLU.d.ts +69 -0
- package/dist/extract-LNOA7DBK.js +15 -0
- package/dist/extract-LNOA7DBK.js.map +1 -0
- package/dist/html/index.d.ts +15 -85
- package/dist/html/index.js +3 -3
- package/dist/images-7FBWPKE3.js +7 -0
- package/dist/images-7FBWPKE3.js.map +1 -0
- package/dist/import-DTkDxHmZ.d.ts +84 -0
- package/dist/import-Yivc3kZM.d.ts +136 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +30 -11
- package/dist/infer/index.d.ts +138 -0
- package/dist/infer/index.js +28 -0
- package/dist/infer/index.js.map +1 -0
- package/dist/layouts-7OUUVAGU.js +12 -0
- package/dist/layouts-7OUUVAGU.js.map +1 -0
- package/dist/layouts-CZ0bdsNL.d.ts +150 -0
- package/dist/mapTheme-IR27S6IV.js +11 -0
- package/dist/mapTheme-IR27S6IV.js.map +1 -0
- package/dist/ooxml/index.d.ts +21 -127
- package/dist/ooxml/index.js +31 -9
- package/dist/pdf/index.d.ts +3 -1
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +8 -108
- package/dist/pptx/index.js +13 -4
- package/dist/reader-Gof6OrJX.d.ts +140 -0
- package/dist/registry/index.d.ts +46 -8
- package/dist/registry/index.js +5 -2
- package/dist/themeReader-DPw4J5Ss.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-bGYMS9cI.d.ts +29 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +167 -0
- package/src/__tests__/epub.test.ts +102 -1
- package/src/__tests__/html.test.ts +62 -0
- package/src/__tests__/inferTheme.test.ts +135 -0
- package/src/__tests__/lossyWarnings.test.ts +41 -0
- package/src/__tests__/ooxml.test.ts +59 -2
- package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
- package/src/__tests__/plainHtml.test.ts +15 -1
- package/src/__tests__/plainHtmlBundle.test.ts +18 -0
- package/src/__tests__/pptxImport.test.ts +30 -0
- package/src/__tests__/pptxInferFixtures.ts +314 -0
- package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
- package/src/__tests__/zipSafety.test.ts +317 -0
- package/src/container/index.ts +20 -29
- package/src/docx/export.ts +4 -2
- package/src/docx/import.ts +4 -3
- package/src/epub/export.ts +94 -25
- package/src/html/docsHtmlBundle.ts +6 -2
- package/src/html/htmlTemplate.ts +31 -6
- package/src/html/imageUtils.ts +14 -5
- package/src/html/index.ts +12 -1
- package/src/html/plainHtml.ts +7 -3
- package/src/html/plainHtmlBundle.ts +11 -9
- package/src/index.ts +20 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +168 -0
- package/src/infer/mapTheme.ts +176 -0
- package/src/infer/types.ts +27 -0
- package/src/ooxml/index.ts +14 -0
- package/src/ooxml/readUtils.ts +44 -0
- package/src/ooxml/reader.ts +87 -28
- package/src/ooxml/themeReader.ts +197 -0
- package/src/ooxml/types.ts +8 -9
- package/src/ooxml/writer.ts +28 -10
- package/src/pdf/export.ts +4 -2
- package/src/pdf/import.ts +40 -34
- package/src/pptx/export.ts +4 -2
- package/src/pptx/import.ts +214 -32
- package/src/pptx/index.ts +14 -0
- package/src/pptx/layouts.ts +1168 -0
- package/src/registry/convert.ts +54 -23
- package/src/registry/defaultFormats.ts +131 -44
- package/src/registry/index.ts +3 -0
- package/src/registry/types.ts +52 -6
- package/src/shared/boundedZipArchive.ts +342 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +97 -0
- package/src/shared/zipLimits.ts +286 -0
- package/src/shared/zipSafety.ts +19 -0
- package/src/xlsx/import.ts +3 -2
- package/dist/chunk-46FMDOWZ.js.map +0 -1
- package/dist/chunk-5LSSWZPU.js.map +0 -1
- package/dist/chunk-D7IFPWNZ.js.map +0 -1
- package/dist/chunk-EHLGMSTE.js.map +0 -1
- package/dist/chunk-MJGRI6XR.js.map +0 -1
- package/dist/chunk-RTS5XBZ7.js.map +0 -1
- package/dist/chunk-SHLPSADL.js.map +0 -1
- package/dist/chunk-WWBIKRNP.js.map +0 -1
- package/dist/chunk-YESS7VY3.js.map +0 -1
- /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPTX slide-layout inference — reads the layouts/masters a deck actually
|
|
3
|
+
* uses and, per layout, either matches it to a close built-in Squisq
|
|
4
|
+
* template (title, sectionHeader, twoColumn, leftFeature, …) or generates a
|
|
5
|
+
* reusable `CustomTemplateDefinition` from its placeholder geometry.
|
|
6
|
+
*
|
|
7
|
+
* The classifier is a deterministic three-stage waterfall — the layout's
|
|
8
|
+
* `type` attribute, then well-known layout names, then placeholder-set
|
|
9
|
+
* geometry — biased toward *not* templating: a title over a single body
|
|
10
|
+
* flows better as plain markdown, and only genuinely distinctive geometry
|
|
11
|
+
* (side-by-side content, positioned picture placeholders) becomes a custom
|
|
12
|
+
* template.
|
|
13
|
+
*
|
|
14
|
+
* Shared by the PPTX importer (`inferLayouts`) and the theme dialog's
|
|
15
|
+
* `inspectPptxLayouts` — one implementation, two consumers.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
CustomTemplateDefinition,
|
|
20
|
+
CustomTemplateLayer,
|
|
21
|
+
ImageLayer,
|
|
22
|
+
Position,
|
|
23
|
+
ShapeLayer,
|
|
24
|
+
TextLayer,
|
|
25
|
+
TextStyle,
|
|
26
|
+
} from '@bendyline/squisq/schemas';
|
|
27
|
+
import { getPartRelationships, getPartXml, openPackage } from '../ooxml/reader.js';
|
|
28
|
+
import type { OoxmlOpenOptions } from '../ooxml/reader.js';
|
|
29
|
+
import type { OoxmlPackage } from '../ooxml/types.js';
|
|
30
|
+
import {
|
|
31
|
+
CONTENT_TYPE_PPTX_SLIDE_LAYOUT,
|
|
32
|
+
NS_DRAWINGML,
|
|
33
|
+
NS_PML,
|
|
34
|
+
NS_R,
|
|
35
|
+
REL_SLIDE_LAYOUT,
|
|
36
|
+
REL_SLIDE_MASTER,
|
|
37
|
+
} from '../ooxml/namespaces.js';
|
|
38
|
+
import { attrNS, baseDirOf, findRelByType, resolveTarget } from '../ooxml/readUtils.js';
|
|
39
|
+
|
|
40
|
+
// ── Public types ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface EmuRect {
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
cx: number;
|
|
46
|
+
cy: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Percent-of-slide rect, 0–100 per axis (2-decimal precision). */
|
|
50
|
+
export interface PctRect {
|
|
51
|
+
x: number;
|
|
52
|
+
y: number;
|
|
53
|
+
w: number;
|
|
54
|
+
h: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type PlaceholderKind =
|
|
58
|
+
| 'title'
|
|
59
|
+
| 'subtitle'
|
|
60
|
+
| 'text'
|
|
61
|
+
| 'picture'
|
|
62
|
+
| 'table'
|
|
63
|
+
| 'chart'
|
|
64
|
+
| 'other';
|
|
65
|
+
|
|
66
|
+
export interface PlaceholderTextStyle {
|
|
67
|
+
fontSizePt?: number;
|
|
68
|
+
bold?: boolean;
|
|
69
|
+
align?: 'left' | 'center' | 'right';
|
|
70
|
+
anchor?: 'top' | 'middle' | 'bottom';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ExtractedPlaceholder {
|
|
74
|
+
kind: PlaceholderKind;
|
|
75
|
+
/** `p:ph@type` verbatim ('' when absent — spec default is body). */
|
|
76
|
+
rawType: string;
|
|
77
|
+
/** `p:ph@idx`, default 0. */
|
|
78
|
+
idx: number;
|
|
79
|
+
/** Geometry resolved through the layout→master inheritance chain. */
|
|
80
|
+
rect: PctRect;
|
|
81
|
+
inheritedFromMaster: boolean;
|
|
82
|
+
vertical: boolean;
|
|
83
|
+
textStyle?: PlaceholderTextStyle;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ExtractedDecoration {
|
|
87
|
+
shape: 'rect' | 'line';
|
|
88
|
+
rect: PctRect;
|
|
89
|
+
fill?: string;
|
|
90
|
+
stroke?: string;
|
|
91
|
+
strokeWidthPx?: number;
|
|
92
|
+
borderRadius?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ExtractedSlideLayout {
|
|
96
|
+
partPath: string;
|
|
97
|
+
/** `p:cSld@name` ('' when absent). */
|
|
98
|
+
name: string;
|
|
99
|
+
/** `p:sldLayout@type` (ST_SlideLayoutType) when present. */
|
|
100
|
+
typeAttr?: string;
|
|
101
|
+
masterPath?: string;
|
|
102
|
+
masterName?: string;
|
|
103
|
+
/** Content placeholders — dt/ftr/sldNum/hdr chrome already excluded. */
|
|
104
|
+
placeholders: ExtractedPlaceholder[];
|
|
105
|
+
decorations: ExtractedDecoration[];
|
|
106
|
+
/** Number of slides in this deck using this layout (0 = defined but unused). */
|
|
107
|
+
slideCount: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Scheme-color hints for decoration fills, supplied by theme extraction. */
|
|
111
|
+
export interface PptxColorHints {
|
|
112
|
+
text?: string;
|
|
113
|
+
background?: string;
|
|
114
|
+
/** `a:schemeClr@val` (accent1…, bg1, tx1, dk1, …) → resolved hex. */
|
|
115
|
+
schemeColors?: Record<string, string>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type BuiltinParamSpec =
|
|
119
|
+
| 'titleSubtitle'
|
|
120
|
+
| 'comparisonPairs'
|
|
121
|
+
| 'featureImage'
|
|
122
|
+
| 'photoGridGate';
|
|
123
|
+
|
|
124
|
+
export type LayoutVerdict =
|
|
125
|
+
| { kind: 'plain' }
|
|
126
|
+
| { kind: 'skip' }
|
|
127
|
+
| {
|
|
128
|
+
kind: 'builtin';
|
|
129
|
+
template: string;
|
|
130
|
+
paramSpec?: BuiltinParamSpec;
|
|
131
|
+
/** For `comparisonPairs`: placeholder idx lists per column, top-to-bottom. */
|
|
132
|
+
columns?: { left: number[]; right: number[] };
|
|
133
|
+
}
|
|
134
|
+
| { kind: 'custom'; def: CustomTemplateDefinition };
|
|
135
|
+
|
|
136
|
+
export interface AnalyzedLayout {
|
|
137
|
+
extracted: ExtractedSlideLayout;
|
|
138
|
+
verdict: LayoutVerdict;
|
|
139
|
+
/** Human-readable caveats: dropped regions, unsupported placeholders, … */
|
|
140
|
+
notes: string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface PptxLayoutInference {
|
|
144
|
+
slideSize: { cx: number; cy: number };
|
|
145
|
+
layouts: AnalyzedLayout[];
|
|
146
|
+
byLayoutPath: Map<string, AnalyzedLayout>;
|
|
147
|
+
layoutPathBySlide: Map<string, string>;
|
|
148
|
+
warnings: string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface AnalyzePptxLayoutsOptions extends OoxmlOpenOptions {
|
|
152
|
+
/** Also analyze layouts no slide references (default false). */
|
|
153
|
+
includeUnused?: boolean;
|
|
154
|
+
colors?: PptxColorHints;
|
|
155
|
+
/** Cap on generated custom templates (default 12); excess downgrades to plain. */
|
|
156
|
+
maxTemplates?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const DEFAULT_SLIDE_SIZE = { cx: 12192000, cy: 6858000 };
|
|
162
|
+
const CHROME_PH_TYPES = new Set(['dt', 'ftr', 'sldNum', 'hdr']);
|
|
163
|
+
const MAX_INFERRED_TEMPLATES = 12;
|
|
164
|
+
const MAX_DECORATIONS = 4;
|
|
165
|
+
/** Decorative rects smaller than this share of the slide are clutter. */
|
|
166
|
+
const MIN_DECORATION_AREA_PCT = 2;
|
|
167
|
+
|
|
168
|
+
/** Default rects mirroring the exporter's slide proportions (percent). */
|
|
169
|
+
const DEFAULT_TITLE_RECT: PctRect = { x: 5, y: 4, w: 90, h: 16.7 };
|
|
170
|
+
const DEFAULT_BODY_RECT: PctRect = { x: 5, y: 23.3, w: 90, h: 66 };
|
|
171
|
+
|
|
172
|
+
const DEFAULT_TITLE_PT = 44;
|
|
173
|
+
const DEFAULT_SUBTITLE_PT = 24;
|
|
174
|
+
const DEFAULT_BODY_PT = 22;
|
|
175
|
+
|
|
176
|
+
// ── Small helpers ────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function firstPml(parent: Element | Document, local: string): Element | null {
|
|
179
|
+
const els = parent.getElementsByTagNameNS(NS_PML, local);
|
|
180
|
+
return els.length > 0 ? els[0]! : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function firstDml(parent: Element | Document, local: string): Element | null {
|
|
184
|
+
const els = parent.getElementsByTagNameNS(NS_DRAWINGML, local);
|
|
185
|
+
return els.length > 0 ? els[0]! : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function intAttr(el: Element | null, name: string): number | undefined {
|
|
189
|
+
const raw = el?.getAttribute(name);
|
|
190
|
+
if (!raw) return undefined;
|
|
191
|
+
const n = parseInt(raw, 10);
|
|
192
|
+
return Number.isFinite(n) ? n : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function pct(value: number, total: number): number {
|
|
196
|
+
const raw = (value / total) * 100;
|
|
197
|
+
const clamped = Math.max(-10, Math.min(110, raw));
|
|
198
|
+
return Math.round(clamped * 100) / 100;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function emuToPct(rect: EmuRect, sldSz: { cx: number; cy: number }): PctRect {
|
|
202
|
+
return {
|
|
203
|
+
x: pct(rect.x, sldSz.cx),
|
|
204
|
+
y: pct(rect.y, sldSz.cy),
|
|
205
|
+
w: pct(rect.cx, sldSz.cx),
|
|
206
|
+
h: pct(rect.cy, sldSz.cy),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Read `a:xfrm` (off + ext) under a shape's `p:spPr`. Degenerate extents count as missing. */
|
|
211
|
+
function readXfrm(sp: Element): EmuRect | null {
|
|
212
|
+
const spPr = firstPml(sp, 'spPr');
|
|
213
|
+
if (!spPr) return null;
|
|
214
|
+
const xfrm = firstDml(spPr, 'xfrm');
|
|
215
|
+
if (!xfrm) return null;
|
|
216
|
+
const off = firstDml(xfrm, 'off');
|
|
217
|
+
const ext = firstDml(xfrm, 'ext');
|
|
218
|
+
const x = intAttr(off, 'x');
|
|
219
|
+
const y = intAttr(off, 'y');
|
|
220
|
+
const cx = intAttr(ext, 'cx');
|
|
221
|
+
const cy = intAttr(ext, 'cy');
|
|
222
|
+
if (x === undefined || y === undefined || cx === undefined || cy === undefined) return null;
|
|
223
|
+
if (cx <= 0 || cy <= 0) return null;
|
|
224
|
+
return { x, y, cx, cy };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function placeholderKind(rawType: string): PlaceholderKind {
|
|
228
|
+
switch (rawType) {
|
|
229
|
+
case 'title':
|
|
230
|
+
case 'ctrTitle':
|
|
231
|
+
return 'title';
|
|
232
|
+
case 'subTitle':
|
|
233
|
+
return 'subtitle';
|
|
234
|
+
case '':
|
|
235
|
+
case 'body':
|
|
236
|
+
case 'obj':
|
|
237
|
+
return 'text';
|
|
238
|
+
case 'pic':
|
|
239
|
+
case 'clipArt':
|
|
240
|
+
case 'media':
|
|
241
|
+
return 'picture';
|
|
242
|
+
case 'tbl':
|
|
243
|
+
return 'table';
|
|
244
|
+
case 'chart':
|
|
245
|
+
case 'dgm':
|
|
246
|
+
return 'chart';
|
|
247
|
+
default:
|
|
248
|
+
return 'other';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Collapse a ph type onto the master-matching base type (ECMA-376 inheritance). */
|
|
253
|
+
function basePhType(rawType: string): string {
|
|
254
|
+
switch (rawType) {
|
|
255
|
+
case 'ctrTitle':
|
|
256
|
+
return 'title';
|
|
257
|
+
case '':
|
|
258
|
+
case 'subTitle':
|
|
259
|
+
case 'obj':
|
|
260
|
+
case 'pic':
|
|
261
|
+
case 'clipArt':
|
|
262
|
+
case 'media':
|
|
263
|
+
case 'tbl':
|
|
264
|
+
case 'chart':
|
|
265
|
+
case 'dgm':
|
|
266
|
+
return 'body';
|
|
267
|
+
default:
|
|
268
|
+
return rawType;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseLvl1Style(container: Element): PlaceholderTextStyle | undefined {
|
|
273
|
+
const lvl1 = firstDml(container, 'lvl1pPr');
|
|
274
|
+
if (!lvl1) return undefined;
|
|
275
|
+
const style: PlaceholderTextStyle = {};
|
|
276
|
+
const algn = lvl1.getAttribute('algn');
|
|
277
|
+
if (algn === 'ctr') style.align = 'center';
|
|
278
|
+
else if (algn === 'r') style.align = 'right';
|
|
279
|
+
else if (algn === 'l') style.align = 'left';
|
|
280
|
+
const defRPr = firstDml(lvl1, 'defRPr');
|
|
281
|
+
const sz = intAttr(defRPr, 'sz');
|
|
282
|
+
if (sz !== undefined && sz > 0) style.fontSizePt = sz / 100;
|
|
283
|
+
const b = defRPr?.getAttribute('b');
|
|
284
|
+
if (b === '1' || b === 'true') style.bold = true;
|
|
285
|
+
else if (b === '0' || b === 'false') style.bold = false;
|
|
286
|
+
return Object.keys(style).length > 0 ? style : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function mergeTextStyle(
|
|
290
|
+
primary: PlaceholderTextStyle | undefined,
|
|
291
|
+
fallback: PlaceholderTextStyle | undefined,
|
|
292
|
+
): PlaceholderTextStyle | undefined {
|
|
293
|
+
if (!primary) return fallback;
|
|
294
|
+
if (!fallback) return primary;
|
|
295
|
+
return {
|
|
296
|
+
fontSizePt: primary.fontSizePt ?? fallback.fontSizePt,
|
|
297
|
+
bold: primary.bold ?? fallback.bold,
|
|
298
|
+
align: primary.align ?? fallback.align,
|
|
299
|
+
anchor: primary.anchor ?? fallback.anchor,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Master placeholder index ─────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
interface MasterEntry {
|
|
306
|
+
rect: EmuRect | null;
|
|
307
|
+
style?: PlaceholderTextStyle;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface MasterIndex {
|
|
311
|
+
name: string;
|
|
312
|
+
exact: Map<string, MasterEntry>;
|
|
313
|
+
baseIdx: Map<string, MasterEntry>;
|
|
314
|
+
base: Map<string, MasterEntry>;
|
|
315
|
+
titleStyle?: PlaceholderTextStyle;
|
|
316
|
+
bodyStyle?: PlaceholderTextStyle;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function readMasterIndex(pkg: OoxmlPackage, masterPath: string): Promise<MasterIndex> {
|
|
320
|
+
const index: MasterIndex = { name: '', exact: new Map(), baseIdx: new Map(), base: new Map() };
|
|
321
|
+
const doc = await getPartXml(pkg, masterPath);
|
|
322
|
+
if (!doc) return index;
|
|
323
|
+
|
|
324
|
+
index.name = firstPml(doc, 'cSld')?.getAttribute('name')?.trim() ?? '';
|
|
325
|
+
|
|
326
|
+
const txStyles = firstPml(doc, 'txStyles');
|
|
327
|
+
if (txStyles) {
|
|
328
|
+
const titleStyle = firstPml(txStyles, 'titleStyle');
|
|
329
|
+
const bodyStyle = firstPml(txStyles, 'bodyStyle');
|
|
330
|
+
if (titleStyle) index.titleStyle = parseLvl1Style(titleStyle);
|
|
331
|
+
if (bodyStyle) index.bodyStyle = parseLvl1Style(bodyStyle);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const shapes = doc.getElementsByTagNameNS(NS_PML, 'sp');
|
|
335
|
+
for (let i = 0; i < shapes.length; i++) {
|
|
336
|
+
const sp = shapes[i]!;
|
|
337
|
+
const ph = firstPml(sp, 'ph');
|
|
338
|
+
if (!ph) continue;
|
|
339
|
+
const rawType = ph.getAttribute('type') ?? '';
|
|
340
|
+
const idx = intAttr(ph, 'idx') ?? 0;
|
|
341
|
+
const entry: MasterEntry = { rect: readXfrm(sp) };
|
|
342
|
+
const txBody = firstPml(sp, 'txBody');
|
|
343
|
+
const lstStyle = txBody ? firstDml(txBody, 'lstStyle') : null;
|
|
344
|
+
if (lstStyle) entry.style = parseLvl1Style(lstStyle);
|
|
345
|
+
|
|
346
|
+
const typeKey = `${rawType || 'body'}:${idx}`;
|
|
347
|
+
if (!index.exact.has(typeKey)) index.exact.set(typeKey, entry);
|
|
348
|
+
const base = basePhType(rawType);
|
|
349
|
+
const baseIdxKey = `${base}:${idx}`;
|
|
350
|
+
if (!index.baseIdx.has(baseIdxKey)) index.baseIdx.set(baseIdxKey, entry);
|
|
351
|
+
if (!index.base.has(base)) index.base.set(base, entry);
|
|
352
|
+
}
|
|
353
|
+
return index;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Layout extraction ────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
function resolveFillColor(solidFill: Element, hints: PptxColorHints): string | undefined {
|
|
359
|
+
const srgb = firstDml(solidFill, 'srgbClr');
|
|
360
|
+
const srgbVal = srgb?.getAttribute('val');
|
|
361
|
+
if (srgbVal && /^[0-9a-fA-F]{6}$/.test(srgbVal)) return `#${srgbVal.toLowerCase()}`;
|
|
362
|
+
const scheme = firstDml(solidFill, 'schemeClr');
|
|
363
|
+
const schemeVal = scheme?.getAttribute('val');
|
|
364
|
+
if (schemeVal && hints.schemeColors?.[schemeVal]) return hints.schemeColors[schemeVal];
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Direct child element of `parent` with the given DrawingML local name. */
|
|
369
|
+
function directDmlChild(parent: Element, local: string): Element | null {
|
|
370
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
371
|
+
const child = parent.childNodes[i];
|
|
372
|
+
if (child && child.nodeType === 1 && (child as Element).localName === local) {
|
|
373
|
+
const el = child as Element;
|
|
374
|
+
if (el.namespaceURI === NS_DRAWINGML) return el;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function extractDecoration(
|
|
381
|
+
sp: Element,
|
|
382
|
+
sldSz: { cx: number; cy: number },
|
|
383
|
+
pxPerPt: number,
|
|
384
|
+
hints: PptxColorHints,
|
|
385
|
+
): ExtractedDecoration | null {
|
|
386
|
+
const rectEmu = readXfrm(sp);
|
|
387
|
+
if (!rectEmu) return null;
|
|
388
|
+
const spPr = firstPml(sp, 'spPr');
|
|
389
|
+
if (!spPr) return null;
|
|
390
|
+
const prst = firstDml(spPr, 'prstGeom')?.getAttribute('prst');
|
|
391
|
+
|
|
392
|
+
if (prst === 'line' || prst === 'straightConnector1') {
|
|
393
|
+
const ln = directDmlChild(spPr, 'ln');
|
|
394
|
+
const lnFill = ln ? directDmlChild(ln, 'solidFill') : null;
|
|
395
|
+
const stroke = lnFill ? resolveFillColor(lnFill, hints) : undefined;
|
|
396
|
+
if (!stroke) return null;
|
|
397
|
+
const wEmu = intAttr(ln, 'w');
|
|
398
|
+
const strokeWidthPx = wEmu
|
|
399
|
+
? Math.max(1, Math.min(40, Math.round((wEmu / 12700) * pxPerPt)))
|
|
400
|
+
: 2;
|
|
401
|
+
return { shape: 'line', rect: emuToPct(rectEmu, sldSz), stroke, strokeWidthPx };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (prst === 'rect' || prst === 'roundRect') {
|
|
405
|
+
const solidFill = directDmlChild(spPr, 'solidFill');
|
|
406
|
+
const fill = solidFill ? resolveFillColor(solidFill, hints) : undefined;
|
|
407
|
+
if (!fill) return null;
|
|
408
|
+
const areaPct = ((rectEmu.cx * rectEmu.cy) / (sldSz.cx * sldSz.cy)) * 100;
|
|
409
|
+
if (areaPct < MIN_DECORATION_AREA_PCT) return null;
|
|
410
|
+
return {
|
|
411
|
+
shape: 'rect',
|
|
412
|
+
rect: emuToPct(rectEmu, sldSz),
|
|
413
|
+
fill,
|
|
414
|
+
...(prst === 'roundRect' ? { borderRadius: 8 } : {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function extractLayout(
|
|
422
|
+
pkg: OoxmlPackage,
|
|
423
|
+
layoutPath: string,
|
|
424
|
+
sldSz: { cx: number; cy: number },
|
|
425
|
+
pxPerPt: number,
|
|
426
|
+
hints: PptxColorHints,
|
|
427
|
+
masterCache: Map<string, Promise<MasterIndex>>,
|
|
428
|
+
): Promise<ExtractedSlideLayout | null> {
|
|
429
|
+
const doc = await getPartXml(pkg, layoutPath);
|
|
430
|
+
if (!doc) return null;
|
|
431
|
+
|
|
432
|
+
const name = firstPml(doc, 'cSld')?.getAttribute('name')?.trim() ?? '';
|
|
433
|
+
const typeAttr = doc.documentElement?.getAttribute('type') ?? undefined;
|
|
434
|
+
|
|
435
|
+
let masterPath: string | undefined;
|
|
436
|
+
let master: MasterIndex | undefined;
|
|
437
|
+
const layoutRels = await getPartRelationships(pkg, layoutPath);
|
|
438
|
+
const masterRel = findRelByType(layoutRels, REL_SLIDE_MASTER);
|
|
439
|
+
if (masterRel) {
|
|
440
|
+
masterPath = resolveTarget(baseDirOf(layoutPath), masterRel.target);
|
|
441
|
+
let cached = masterCache.get(masterPath);
|
|
442
|
+
if (!cached) {
|
|
443
|
+
cached = readMasterIndex(pkg, masterPath);
|
|
444
|
+
masterCache.set(masterPath, cached);
|
|
445
|
+
}
|
|
446
|
+
master = await cached;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const placeholders: ExtractedPlaceholder[] = [];
|
|
450
|
+
const decorations: ExtractedDecoration[] = [];
|
|
451
|
+
|
|
452
|
+
const shapes = doc.getElementsByTagNameNS(NS_PML, 'sp');
|
|
453
|
+
for (let i = 0; i < shapes.length; i++) {
|
|
454
|
+
const sp = shapes[i]!;
|
|
455
|
+
const ph = firstPml(sp, 'ph');
|
|
456
|
+
|
|
457
|
+
if (!ph) {
|
|
458
|
+
if (decorations.length < MAX_DECORATIONS) {
|
|
459
|
+
const decoration = extractDecoration(sp, sldSz, pxPerPt, hints);
|
|
460
|
+
if (decoration) decorations.push(decoration);
|
|
461
|
+
}
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const rawType = ph.getAttribute('type') ?? '';
|
|
466
|
+
if (CHROME_PH_TYPES.has(rawType)) continue;
|
|
467
|
+
const idx = intAttr(ph, 'idx') ?? 0;
|
|
468
|
+
const kind = placeholderKind(rawType);
|
|
469
|
+
|
|
470
|
+
// Geometry: own xfrm → master by (type, idx) → by (base, idx) → by base → defaults.
|
|
471
|
+
const own = readXfrm(sp);
|
|
472
|
+
let rectEmu = own;
|
|
473
|
+
let inheritedFromMaster = false;
|
|
474
|
+
if (!rectEmu && master) {
|
|
475
|
+
const base = basePhType(rawType);
|
|
476
|
+
const entry =
|
|
477
|
+
master.exact.get(`${rawType || 'body'}:${idx}`) ??
|
|
478
|
+
master.baseIdx.get(`${base}:${idx}`) ??
|
|
479
|
+
master.base.get(base);
|
|
480
|
+
if (entry?.rect) {
|
|
481
|
+
rectEmu = entry.rect;
|
|
482
|
+
inheritedFromMaster = true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const rect = rectEmu
|
|
486
|
+
? emuToPct(rectEmu, sldSz)
|
|
487
|
+
: kind === 'title'
|
|
488
|
+
? { ...DEFAULT_TITLE_RECT }
|
|
489
|
+
: { ...DEFAULT_BODY_RECT };
|
|
490
|
+
|
|
491
|
+
// Text style: layout lstStyle → master ph style → master txStyles.
|
|
492
|
+
const txBody = firstPml(sp, 'txBody');
|
|
493
|
+
const lstStyle = txBody ? firstDml(txBody, 'lstStyle') : null;
|
|
494
|
+
let textStyle = lstStyle ? parseLvl1Style(lstStyle) : undefined;
|
|
495
|
+
if (master) {
|
|
496
|
+
const base = basePhType(rawType);
|
|
497
|
+
const masterEntry =
|
|
498
|
+
master.exact.get(`${rawType || 'body'}:${idx}`) ?? master.baseIdx.get(`${base}:${idx}`);
|
|
499
|
+
textStyle = mergeTextStyle(textStyle, masterEntry?.style);
|
|
500
|
+
textStyle = mergeTextStyle(
|
|
501
|
+
textStyle,
|
|
502
|
+
kind === 'title' || kind === 'subtitle' ? master.titleStyle : master.bodyStyle,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const bodyPr = txBody ? firstDml(txBody, 'bodyPr') : null;
|
|
507
|
+
const anchor = bodyPr?.getAttribute('anchor');
|
|
508
|
+
if (anchor === 't' || anchor === 'ctr' || anchor === 'b') {
|
|
509
|
+
textStyle = {
|
|
510
|
+
...(textStyle ?? {}),
|
|
511
|
+
anchor: anchor === 't' ? 'top' : anchor === 'ctr' ? 'middle' : 'bottom',
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const orient = ph.getAttribute('orient');
|
|
516
|
+
const vert = bodyPr?.getAttribute('vert');
|
|
517
|
+
const vertical = orient === 'vert' || (!!vert && vert !== 'horz');
|
|
518
|
+
|
|
519
|
+
placeholders.push({
|
|
520
|
+
kind,
|
|
521
|
+
rawType,
|
|
522
|
+
idx,
|
|
523
|
+
rect,
|
|
524
|
+
inheritedFromMaster,
|
|
525
|
+
vertical,
|
|
526
|
+
...(textStyle ? { textStyle } : {}),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
partPath: layoutPath,
|
|
532
|
+
name,
|
|
533
|
+
...(typeAttr ? { typeAttr } : {}),
|
|
534
|
+
...(masterPath ? { masterPath } : {}),
|
|
535
|
+
...(master?.name ? { masterName: master.name } : {}),
|
|
536
|
+
placeholders,
|
|
537
|
+
decorations,
|
|
538
|
+
slideCount: 0,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── Geometry predicates ──────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
function overlap1D(aStart: number, aLen: number, bStart: number, bLen: number): number {
|
|
545
|
+
return Math.max(0, Math.min(aStart + aLen, bStart + bLen) - Math.max(aStart, bStart));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function sideBySide(a: PctRect, b: PctRect): boolean {
|
|
549
|
+
const xOverlap = overlap1D(a.x, a.w, b.x, b.w);
|
|
550
|
+
const yOverlap = overlap1D(a.y, a.h, b.y, b.h);
|
|
551
|
+
return (
|
|
552
|
+
xOverlap <= 0.15 * Math.min(a.w, b.w) &&
|
|
553
|
+
yOverlap >= 0.5 * Math.min(a.h, b.h) &&
|
|
554
|
+
a.w >= 25 &&
|
|
555
|
+
b.w >= 25
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function stackedAbove(a: PctRect, b: PctRect): boolean {
|
|
560
|
+
const xOverlap = overlap1D(a.x, a.w, b.x, b.w);
|
|
561
|
+
const yOverlap = overlap1D(a.y, a.h, b.y, b.h);
|
|
562
|
+
return yOverlap <= 0.15 * Math.min(a.h, b.h) && xOverlap >= 0.5 * Math.min(a.w, b.w) && a.y < b.y;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function fullBleed(p: PctRect): boolean {
|
|
566
|
+
return p.w >= 85 && p.h >= 80;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function nearEqualWidth(a: PctRect, b: PctRect): boolean {
|
|
570
|
+
return Math.abs(a.w - b.w) <= 0.15 * Math.max(a.w, b.w);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function rectsOverlap(a: PctRect, b: PctRect): boolean {
|
|
574
|
+
return overlap1D(a.x, a.w, b.x, b.w) > 2 && overlap1D(a.y, a.h, b.y, b.h) > 2;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function centerX(r: PctRect): number {
|
|
578
|
+
return r.x + r.w / 2;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Classifier ───────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
type Directive =
|
|
584
|
+
| { kind: 'plain' }
|
|
585
|
+
| { kind: 'skip' }
|
|
586
|
+
| { kind: 'builtin'; template: string; paramSpec?: BuiltinParamSpec }
|
|
587
|
+
| { kind: 'feature-by-geometry' }
|
|
588
|
+
| { kind: 'comparison' }
|
|
589
|
+
| { kind: 'custom-two-column' }
|
|
590
|
+
| { kind: 'custom-caption' }
|
|
591
|
+
| { kind: 'custom-generic' }
|
|
592
|
+
| { kind: 'by-geometry' };
|
|
593
|
+
|
|
594
|
+
const PLAIN_LAYOUT_TYPES = new Set([
|
|
595
|
+
'titleOnly',
|
|
596
|
+
'tx',
|
|
597
|
+
'obj',
|
|
598
|
+
'objOnly',
|
|
599
|
+
'objTx',
|
|
600
|
+
'txOverObj',
|
|
601
|
+
'objOverTx',
|
|
602
|
+
'tbl',
|
|
603
|
+
'chart',
|
|
604
|
+
'dgm',
|
|
605
|
+
'txAndChart',
|
|
606
|
+
'chartAndTx',
|
|
607
|
+
'vertTitleAndTxOverChart',
|
|
608
|
+
'txAndTwoObj',
|
|
609
|
+
'twoObjAndTx',
|
|
610
|
+
'twoObjOverTx',
|
|
611
|
+
'objAndTwoObj',
|
|
612
|
+
'twoObjAndObj',
|
|
613
|
+
'fourObj',
|
|
614
|
+
'vertTx',
|
|
615
|
+
'vertTitleAndTx',
|
|
616
|
+
'clipArtAndVertTx',
|
|
617
|
+
]);
|
|
618
|
+
|
|
619
|
+
function stage1(typeAttr: string | undefined): Directive | null {
|
|
620
|
+
if (!typeAttr || typeAttr === 'cust') return null;
|
|
621
|
+
if (typeAttr === 'title')
|
|
622
|
+
return { kind: 'builtin', template: 'title', paramSpec: 'titleSubtitle' };
|
|
623
|
+
if (typeAttr === 'secHead') return { kind: 'builtin', template: 'sectionHeader' };
|
|
624
|
+
if (typeAttr === 'blank') return { kind: 'skip' };
|
|
625
|
+
if (PLAIN_LAYOUT_TYPES.has(typeAttr)) return { kind: 'plain' };
|
|
626
|
+
if (typeAttr === 'picTx') return { kind: 'feature-by-geometry' };
|
|
627
|
+
if (typeAttr === 'clipArtAndTx' || typeAttr === 'mediaAndTx') {
|
|
628
|
+
return { kind: 'builtin', template: 'leftFeature', paramSpec: 'featureImage' };
|
|
629
|
+
}
|
|
630
|
+
if (typeAttr === 'txAndClipArt' || typeAttr === 'txAndMedia') {
|
|
631
|
+
return { kind: 'builtin', template: 'rightFeature', paramSpec: 'featureImage' };
|
|
632
|
+
}
|
|
633
|
+
if (
|
|
634
|
+
typeAttr === 'twoColTx' ||
|
|
635
|
+
typeAttr === 'twoObj' ||
|
|
636
|
+
typeAttr === 'txAndObj' ||
|
|
637
|
+
typeAttr === 'objAndTx'
|
|
638
|
+
) {
|
|
639
|
+
return { kind: 'custom-two-column' };
|
|
640
|
+
}
|
|
641
|
+
if (typeAttr === 'twoTxTwoObj') return { kind: 'comparison' };
|
|
642
|
+
// Unknown/future type values fall through to name + geometry stages.
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const WELL_KNOWN_NAMES: Record<string, Directive> = {
|
|
647
|
+
'title slide': { kind: 'builtin', template: 'title', paramSpec: 'titleSubtitle' },
|
|
648
|
+
'section header': { kind: 'builtin', template: 'sectionHeader' },
|
|
649
|
+
'two content': { kind: 'custom-two-column' },
|
|
650
|
+
comparison: { kind: 'comparison' },
|
|
651
|
+
'content with caption': { kind: 'custom-caption' },
|
|
652
|
+
'picture with caption': { kind: 'feature-by-geometry' },
|
|
653
|
+
'title and content': { kind: 'plain' },
|
|
654
|
+
'title only': { kind: 'plain' },
|
|
655
|
+
blank: { kind: 'skip' },
|
|
656
|
+
'vertical title and text': { kind: 'plain' },
|
|
657
|
+
'title and vertical text': { kind: 'plain' },
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
function stage2(name: string): Directive | null {
|
|
661
|
+
return WELL_KNOWN_NAMES[name.trim().toLowerCase()] ?? null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function contentPlaceholders(layout: ExtractedSlideLayout): ExtractedPlaceholder[] {
|
|
665
|
+
return layout.placeholders.filter((p) => p.kind !== 'title' && p.kind !== 'subtitle');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function stage3(layout: ExtractedSlideLayout): Directive {
|
|
669
|
+
if (layout.placeholders.some((p) => p.vertical)) return { kind: 'plain' };
|
|
670
|
+
const hasTitle = layout.placeholders.some((p) => p.kind === 'title');
|
|
671
|
+
const content = contentPlaceholders(layout);
|
|
672
|
+
if (content.length === 0) return hasTitle ? { kind: 'plain' } : { kind: 'skip' };
|
|
673
|
+
|
|
674
|
+
const texts = content.filter((p) => p.kind === 'text');
|
|
675
|
+
const pictures = content.filter((p) => p.kind === 'picture');
|
|
676
|
+
const others = content.filter((p) => p.kind !== 'text' && p.kind !== 'picture');
|
|
677
|
+
|
|
678
|
+
if (pictures.length === 0 && others.length === 0) {
|
|
679
|
+
if (texts.length === 1) return { kind: 'plain' };
|
|
680
|
+
if (texts.length === 2 && sideBySide(texts[0]!.rect, texts[1]!.rect)) {
|
|
681
|
+
return nearEqualWidth(texts[0]!.rect, texts[1]!.rect)
|
|
682
|
+
? { kind: 'custom-two-column' }
|
|
683
|
+
: { kind: 'custom-caption' };
|
|
684
|
+
}
|
|
685
|
+
if (texts.length === 4 && findComparisonColumns(texts)) return { kind: 'comparison' };
|
|
686
|
+
return { kind: 'plain' };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (pictures.length === 1 && others.length === 0) {
|
|
690
|
+
if (texts.length === 0) {
|
|
691
|
+
return fullBleed(pictures[0]!.rect)
|
|
692
|
+
? { kind: 'builtin', template: 'imageWithCaption', paramSpec: 'featureImage' }
|
|
693
|
+
: { kind: 'custom-generic' };
|
|
694
|
+
}
|
|
695
|
+
return { kind: 'feature-by-geometry' };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (
|
|
699
|
+
pictures.length >= 2 &&
|
|
700
|
+
pictures.length <= 4 &&
|
|
701
|
+
texts.length === 0 &&
|
|
702
|
+
others.length === 0 &&
|
|
703
|
+
pictures.every((a, i) => pictures.every((b, j) => i === j || !rectsOverlap(a.rect, b.rect)))
|
|
704
|
+
) {
|
|
705
|
+
return { kind: 'builtin', template: 'photoGrid', paramSpec: 'photoGridGate' };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return { kind: 'plain' };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Detect the Comparison shape: 4 texts forming two side-by-side columns of a
|
|
713
|
+
* short header over a taller body. Returns placeholder idx lists per column,
|
|
714
|
+
* or null when the rects don't form that shape.
|
|
715
|
+
*/
|
|
716
|
+
function findComparisonColumns(
|
|
717
|
+
texts: ExtractedPlaceholder[],
|
|
718
|
+
): { left: number[]; right: number[] } | null {
|
|
719
|
+
if (texts.length !== 4) return null;
|
|
720
|
+
const sorted = [...texts].sort((a, b) => centerX(a.rect) - centerX(b.rect));
|
|
721
|
+
const leftPair = [sorted[0]!, sorted[1]!];
|
|
722
|
+
const rightPair = [sorted[2]!, sorted[3]!];
|
|
723
|
+
const columnWidth = (pair: ExtractedPlaceholder[]) =>
|
|
724
|
+
Math.abs(centerX(pair[0]!.rect) - centerX(pair[1]!.rect));
|
|
725
|
+
if (columnWidth(leftPair) > 10 || columnWidth(rightPair) > 10) return null;
|
|
726
|
+
if (centerX(rightPair[0]!.rect) - centerX(leftPair[1]!.rect) <= 10) return null;
|
|
727
|
+
|
|
728
|
+
const orderColumn = (pair: ExtractedPlaceholder[]): number[] | null => {
|
|
729
|
+
const byY = [...pair].sort((a, b) => a.rect.y - b.rect.y);
|
|
730
|
+
const header = byY[0]!;
|
|
731
|
+
const body = byY[1]!;
|
|
732
|
+
if (header.rect.h > 20 || header.rect.y + header.rect.h > body.rect.y + 2) return null;
|
|
733
|
+
return [header.idx, body.idx];
|
|
734
|
+
};
|
|
735
|
+
const left = orderColumn(leftPair);
|
|
736
|
+
const right = orderColumn(rightPair);
|
|
737
|
+
return left && right ? { left, right } : null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ── Custom template generation ───────────────────────────────────────
|
|
741
|
+
|
|
742
|
+
function slugify(name: string): string {
|
|
743
|
+
const slug = name
|
|
744
|
+
.toLowerCase()
|
|
745
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
746
|
+
.replace(/^-+|-+$/g, '');
|
|
747
|
+
return slug;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function pctPosition(rect: PctRect): Position {
|
|
751
|
+
return { x: `${rect.x}%`, y: `${rect.y}%`, width: `${rect.w}%`, height: `${rect.h}%` };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
interface GeneratorContext {
|
|
755
|
+
hints: PptxColorHints;
|
|
756
|
+
pxPerPt: number;
|
|
757
|
+
viewport: { width: number; height: number };
|
|
758
|
+
notes: string[];
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function fontPx(pt: number | undefined, defaultPt: number, pxPerPt: number): number {
|
|
762
|
+
const sizePt = pt && pt > 0 ? pt : defaultPt;
|
|
763
|
+
return Math.max(16, Math.min(140, Math.round(sizePt * pxPerPt)));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function textLayerFor(
|
|
767
|
+
id: string,
|
|
768
|
+
ph: ExtractedPlaceholder,
|
|
769
|
+
text: string,
|
|
770
|
+
role: 'title' | 'subtitle' | 'body',
|
|
771
|
+
gen: GeneratorContext,
|
|
772
|
+
): TextLayer {
|
|
773
|
+
const defaultPt =
|
|
774
|
+
role === 'title'
|
|
775
|
+
? DEFAULT_TITLE_PT
|
|
776
|
+
: role === 'subtitle'
|
|
777
|
+
? DEFAULT_SUBTITLE_PT
|
|
778
|
+
: DEFAULT_BODY_PT;
|
|
779
|
+
const style: TextStyle = {
|
|
780
|
+
fontSize: fontPx(ph.textStyle?.fontSizePt, defaultPt, gen.pxPerPt),
|
|
781
|
+
color: gen.hints.text ?? '#1a1a1a',
|
|
782
|
+
lineHeight: role === 'title' ? 1.15 : 1.4,
|
|
783
|
+
...(role === 'title'
|
|
784
|
+
? { fontWeight: ph.textStyle?.bold === false ? 'normal' : 'bold' }
|
|
785
|
+
: ph.textStyle?.bold
|
|
786
|
+
? { fontWeight: 'bold' }
|
|
787
|
+
: {}),
|
|
788
|
+
...(ph.textStyle?.align ? { textAlign: ph.textStyle.align } : {}),
|
|
789
|
+
...(ph.textStyle?.anchor ? { verticalAlign: ph.textStyle.anchor } : {}),
|
|
790
|
+
};
|
|
791
|
+
return { id, type: 'text', position: pctPosition(ph.rect), content: { text, style } };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function imageLayerFor(id: string, ph: ExtractedPlaceholder, imageIndex: number): ImageLayer {
|
|
795
|
+
// {image:N} is 0-based; a layer whose token resolves to no image is
|
|
796
|
+
// dropped whole at render time, so picture placeholders degrade cleanly
|
|
797
|
+
// on slides without pictures.
|
|
798
|
+
return {
|
|
799
|
+
id,
|
|
800
|
+
type: 'image',
|
|
801
|
+
position: pctPosition(ph.rect),
|
|
802
|
+
content: { src: `{image:${imageIndex}}`, alt: `{image:${imageIndex}}`, fit: 'cover' },
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function decorationLayers(layout: ExtractedSlideLayout): ShapeLayer[] {
|
|
807
|
+
return layout.decorations.map((d, i) => ({
|
|
808
|
+
id: `decor-${i + 1}`,
|
|
809
|
+
type: 'shape' as const,
|
|
810
|
+
position: pctPosition(d.rect),
|
|
811
|
+
content:
|
|
812
|
+
d.shape === 'line'
|
|
813
|
+
? { shape: 'line' as const, stroke: d.stroke, strokeWidth: d.strokeWidthPx ?? 2 }
|
|
814
|
+
: {
|
|
815
|
+
shape: 'rect' as const,
|
|
816
|
+
fill: d.fill,
|
|
817
|
+
...(d.borderRadius ? { borderRadius: d.borderRadius } : {}),
|
|
818
|
+
},
|
|
819
|
+
}));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function titlePlaceholder(layout: ExtractedSlideLayout): ExtractedPlaceholder | undefined {
|
|
823
|
+
return layout.placeholders.find((p) => p.kind === 'title');
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Synthetic title placeholder used when a layout omits one but a template needs it. */
|
|
827
|
+
function defaultTitlePlaceholder(): ExtractedPlaceholder {
|
|
828
|
+
return {
|
|
829
|
+
kind: 'title',
|
|
830
|
+
rawType: 'title',
|
|
831
|
+
idx: 0,
|
|
832
|
+
rect: { ...DEFAULT_TITLE_RECT },
|
|
833
|
+
inheritedFromMaster: false,
|
|
834
|
+
vertical: false,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function buildTwoColumnDef(
|
|
839
|
+
layout: ExtractedSlideLayout,
|
|
840
|
+
gen: GeneratorContext,
|
|
841
|
+
): CustomTemplateLayer[] {
|
|
842
|
+
const texts = contentPlaceholders(layout).filter((p) => p.kind === 'text');
|
|
843
|
+
let left: ExtractedPlaceholder;
|
|
844
|
+
let right: ExtractedPlaceholder;
|
|
845
|
+
const sorted = [...texts].sort((a, b) => a.rect.x - b.rect.x);
|
|
846
|
+
if (sorted.length >= 2 && sideBySide(sorted[0]!.rect, sorted[sorted.length - 1]!.rect)) {
|
|
847
|
+
left = sorted[0]!;
|
|
848
|
+
right = sorted[sorted.length - 1]!;
|
|
849
|
+
} else {
|
|
850
|
+
// Geometry unavailable (defaulted rects) — synthesize equal columns
|
|
851
|
+
// inside the standard body area with a 4% gutter.
|
|
852
|
+
left = {
|
|
853
|
+
...defaultTitlePlaceholder(),
|
|
854
|
+
kind: 'text',
|
|
855
|
+
rawType: 'body',
|
|
856
|
+
rect: { x: 5, y: 23.3, w: 43, h: 66 },
|
|
857
|
+
};
|
|
858
|
+
right = { ...left, rect: { x: 52, y: 23.3, w: 43, h: 66 } };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const gap = Math.max(0, Math.round((right.rect.x - (left.rect.x + left.rect.w)) * 100) / 100);
|
|
862
|
+
const layers: CustomTemplateLayer[] = [...decorationLayers(layout)];
|
|
863
|
+
const title = titlePlaceholder(layout) ?? defaultTitlePlaceholder();
|
|
864
|
+
layers.push(textLayerFor('title', title, '{title}', 'title', gen));
|
|
865
|
+
const itemLayer: CustomTemplateLayer = {
|
|
866
|
+
...textLayerFor('column', left, '{item}', 'body', gen),
|
|
867
|
+
// gap is in percentage points because the layer's width is a %-string;
|
|
868
|
+
// the repeat resolver offsets clones by width + gap in the same unit.
|
|
869
|
+
repeat: { source: 'listItems', direction: 'row', gap, max: 2 },
|
|
870
|
+
};
|
|
871
|
+
layers.push(itemLayer);
|
|
872
|
+
return layers;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function buildCaptionDef(
|
|
876
|
+
layout: ExtractedSlideLayout,
|
|
877
|
+
gen: GeneratorContext,
|
|
878
|
+
): CustomTemplateLayer[] {
|
|
879
|
+
const texts = contentPlaceholders(layout).filter((p) => p.kind === 'text');
|
|
880
|
+
const byWidth = [...texts].sort((a, b) => b.rect.w - a.rect.w);
|
|
881
|
+
const wide = byWidth[0];
|
|
882
|
+
const narrow = byWidth[1];
|
|
883
|
+
const layers: CustomTemplateLayer[] = [...decorationLayers(layout)];
|
|
884
|
+
const title = titlePlaceholder(layout) ?? defaultTitlePlaceholder();
|
|
885
|
+
layers.push(textLayerFor('title', title, '{title}', 'title', gen));
|
|
886
|
+
if (wide) layers.push(textLayerFor('body', wide, '{content}', 'body', gen));
|
|
887
|
+
if (narrow) {
|
|
888
|
+
// Empty pipe-default: the caption region stays invisible until the
|
|
889
|
+
// author fills it via `{[name caption="…"]}`.
|
|
890
|
+
layers.push(textLayerFor('caption', narrow, '{attr:caption|}', 'body', gen));
|
|
891
|
+
}
|
|
892
|
+
return layers;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function buildGenericDef(
|
|
896
|
+
layout: ExtractedSlideLayout,
|
|
897
|
+
gen: GeneratorContext,
|
|
898
|
+
): CustomTemplateLayer[] {
|
|
899
|
+
const layers: CustomTemplateLayer[] = [...decorationLayers(layout)];
|
|
900
|
+
const title = titlePlaceholder(layout);
|
|
901
|
+
if (title) layers.push(textLayerFor('title', title, '{title}', 'title', gen));
|
|
902
|
+
|
|
903
|
+
const content = contentPlaceholders(layout);
|
|
904
|
+
const pictures = content
|
|
905
|
+
.filter((p) => p.kind === 'picture')
|
|
906
|
+
.sort((a, b) => a.idx - b.idx || a.rect.y - b.rect.y);
|
|
907
|
+
pictures.forEach((ph, k) => layers.push(imageLayerFor(`image-${k}`, ph, k)));
|
|
908
|
+
|
|
909
|
+
const texts = content.filter((p) => p.kind === 'text');
|
|
910
|
+
if (texts.length > 0) layers.push(textLayerFor('body', texts[0]!, '{content}', 'body', gen));
|
|
911
|
+
if (texts.length > 1) {
|
|
912
|
+
gen.notes.push(
|
|
913
|
+
`${texts.length - 1} additional text region(s) could not be bound — the token grammar has no per-region address; full text stays in the markdown body.`,
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
const dropped = content.filter(
|
|
917
|
+
(p) => p.kind === 'table' || p.kind === 'chart' || p.kind === 'other',
|
|
918
|
+
);
|
|
919
|
+
if (dropped.length > 0) {
|
|
920
|
+
gen.notes.push(
|
|
921
|
+
`${dropped.length} table/chart placeholder(s) dropped (content flows as markdown).`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
return layers;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Whether generated layers contain any content-bearing layer (something a
|
|
929
|
+
* slide's title/body/images will bind to) beyond decorations.
|
|
930
|
+
*/
|
|
931
|
+
function hasContentLayer(layers: CustomTemplateLayer[]): boolean {
|
|
932
|
+
return layers.some((l) => l.type === 'text' || l.type === 'image');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ── Verdict resolution ───────────────────────────────────────────────
|
|
936
|
+
|
|
937
|
+
function resolveFeatureByGeometry(layout: ExtractedSlideLayout): LayoutVerdict {
|
|
938
|
+
const content = contentPlaceholders(layout);
|
|
939
|
+
const pic = content.find((p) => p.kind === 'picture');
|
|
940
|
+
if (!pic) return { kind: 'plain' };
|
|
941
|
+
const texts = content.filter((p) => p.kind === 'text');
|
|
942
|
+
const beside = texts.find((t) => sideBySide(pic.rect, t.rect));
|
|
943
|
+
if (beside) {
|
|
944
|
+
const template = centerX(pic.rect) < centerX(beside.rect) ? 'leftFeature' : 'rightFeature';
|
|
945
|
+
return { kind: 'builtin', template, paramSpec: 'featureImage' };
|
|
946
|
+
}
|
|
947
|
+
if (texts.some((t) => stackedAbove(pic.rect, t.rect)) || fullBleed(pic.rect)) {
|
|
948
|
+
return { kind: 'builtin', template: 'imageWithCaption', paramSpec: 'featureImage' };
|
|
949
|
+
}
|
|
950
|
+
return { kind: 'builtin', template: 'leftFeature', paramSpec: 'featureImage' };
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function classifyLayout(layout: ExtractedSlideLayout): Directive {
|
|
954
|
+
return stage1(layout.typeAttr) ?? stage2(layout.name) ?? stage3(layout);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function resolveVerdict(
|
|
958
|
+
layout: ExtractedSlideLayout,
|
|
959
|
+
directive: Directive,
|
|
960
|
+
gen: GeneratorContext,
|
|
961
|
+
templateName: string,
|
|
962
|
+
): LayoutVerdict {
|
|
963
|
+
switch (directive.kind) {
|
|
964
|
+
case 'plain':
|
|
965
|
+
case 'skip':
|
|
966
|
+
return { kind: directive.kind };
|
|
967
|
+
case 'builtin':
|
|
968
|
+
return directive;
|
|
969
|
+
case 'feature-by-geometry':
|
|
970
|
+
return resolveFeatureByGeometry(layout);
|
|
971
|
+
case 'comparison': {
|
|
972
|
+
const texts = contentPlaceholders(layout).filter((p) => p.kind === 'text');
|
|
973
|
+
const columns = findComparisonColumns(texts);
|
|
974
|
+
// Without resolvable column geometry the importer can't attribute
|
|
975
|
+
// slide texts to sides — and a bare {[twoColumn]} renders empty.
|
|
976
|
+
if (!columns) return { kind: 'plain' };
|
|
977
|
+
return { kind: 'builtin', template: 'twoColumn', paramSpec: 'comparisonPairs', columns };
|
|
978
|
+
}
|
|
979
|
+
case 'custom-two-column':
|
|
980
|
+
case 'custom-caption':
|
|
981
|
+
case 'custom-generic':
|
|
982
|
+
case 'by-geometry': {
|
|
983
|
+
const layers =
|
|
984
|
+
directive.kind === 'custom-two-column'
|
|
985
|
+
? buildTwoColumnDef(layout, gen)
|
|
986
|
+
: directive.kind === 'custom-caption'
|
|
987
|
+
? buildCaptionDef(layout, gen)
|
|
988
|
+
: buildGenericDef(layout, gen);
|
|
989
|
+
if (!hasContentLayer(layers)) return { kind: 'plain' };
|
|
990
|
+
const def: CustomTemplateDefinition = {
|
|
991
|
+
name: templateName,
|
|
992
|
+
label: layout.name || 'Imported layout',
|
|
993
|
+
description: `Imported from PowerPoint layout "${layout.name || templateName}"${
|
|
994
|
+
layout.masterName ? ` (master "${layout.masterName}")` : ''
|
|
995
|
+
}`,
|
|
996
|
+
viewport: gen.viewport,
|
|
997
|
+
layers,
|
|
998
|
+
};
|
|
999
|
+
return { kind: 'custom', def };
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ── Deck-level analysis ──────────────────────────────────────────────
|
|
1005
|
+
|
|
1006
|
+
async function readSlideSize(pkg: OoxmlPackage): Promise<{ cx: number; cy: number }> {
|
|
1007
|
+
const pres = await getPartXml(pkg, 'ppt/presentation.xml');
|
|
1008
|
+
const sldSz = pres ? firstPml(pres, 'sldSz') : null;
|
|
1009
|
+
const cx = intAttr(sldSz, 'cx');
|
|
1010
|
+
const cy = intAttr(sldSz, 'cy');
|
|
1011
|
+
if (cx && cy && cx > 0 && cy > 0) return { cx, cy };
|
|
1012
|
+
return { ...DEFAULT_SLIDE_SIZE };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function orderedSlidePathsForLayouts(pkg: OoxmlPackage): Promise<string[]> {
|
|
1016
|
+
const pres = await getPartXml(pkg, 'ppt/presentation.xml');
|
|
1017
|
+
if (!pres) return [];
|
|
1018
|
+
const rels = await getPartRelationships(pkg, 'ppt/presentation.xml');
|
|
1019
|
+
const relById = new Map(rels.map((r) => [r.id, r.target]));
|
|
1020
|
+
const out: string[] = [];
|
|
1021
|
+
const ids = pres.getElementsByTagNameNS(NS_PML, 'sldId');
|
|
1022
|
+
for (let i = 0; i < ids.length; i++) {
|
|
1023
|
+
const rid = attrNS(ids[i]!, NS_R, 'id', 'r:id');
|
|
1024
|
+
const target = rid ? relById.get(rid) : undefined;
|
|
1025
|
+
if (target) out.push(resolveTarget('ppt', target));
|
|
1026
|
+
}
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function viewportFor(sldSz: { cx: number; cy: number }): { width: number; height: number } {
|
|
1031
|
+
if (sldSz.cy > sldSz.cx) {
|
|
1032
|
+
return { width: 1080, height: Math.round((1080 * sldSz.cy) / sldSz.cx) };
|
|
1033
|
+
}
|
|
1034
|
+
return { width: Math.round((1080 * sldSz.cx) / sldSz.cy), height: 1080 };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Analyze the deck's slide layouts: extract placeholder geometry (with
|
|
1039
|
+
* master inheritance), classify each layout against the built-in template
|
|
1040
|
+
* set, and generate custom template definitions for the distinctive ones.
|
|
1041
|
+
*/
|
|
1042
|
+
export async function analyzePptxLayouts(
|
|
1043
|
+
pkg: OoxmlPackage,
|
|
1044
|
+
options: AnalyzePptxLayoutsOptions = {},
|
|
1045
|
+
): Promise<PptxLayoutInference> {
|
|
1046
|
+
const warnings: string[] = [];
|
|
1047
|
+
const hints = options.colors ?? {};
|
|
1048
|
+
const maxTemplates = options.maxTemplates ?? MAX_INFERRED_TEMPLATES;
|
|
1049
|
+
|
|
1050
|
+
const sldSz = await readSlideSize(pkg);
|
|
1051
|
+
const viewport = viewportFor(sldSz);
|
|
1052
|
+
const pxPerPt = viewport.height / (sldSz.cy / 12700);
|
|
1053
|
+
|
|
1054
|
+
// Layouts used by slides, in first-use order, with usage counts.
|
|
1055
|
+
const slidePaths = await orderedSlidePathsForLayouts(pkg);
|
|
1056
|
+
const layoutPathBySlide = new Map<string, string>();
|
|
1057
|
+
const usage = new Map<string, number>();
|
|
1058
|
+
const orderedLayoutPaths: string[] = [];
|
|
1059
|
+
for (const slidePath of slidePaths) {
|
|
1060
|
+
const rels = await getPartRelationships(pkg, slidePath);
|
|
1061
|
+
const layoutRel = findRelByType(rels, REL_SLIDE_LAYOUT);
|
|
1062
|
+
if (!layoutRel) continue;
|
|
1063
|
+
const layoutPath = resolveTarget(baseDirOf(slidePath), layoutRel.target);
|
|
1064
|
+
layoutPathBySlide.set(slidePath, layoutPath);
|
|
1065
|
+
if (!usage.has(layoutPath)) orderedLayoutPaths.push(layoutPath);
|
|
1066
|
+
usage.set(layoutPath, (usage.get(layoutPath) ?? 0) + 1);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (options.includeUnused) {
|
|
1070
|
+
const unused: string[] = [];
|
|
1071
|
+
for (const [partPath, contentType] of pkg.contentTypes.overrides) {
|
|
1072
|
+
if (contentType === CONTENT_TYPE_PPTX_SLIDE_LAYOUT && !usage.has(partPath)) {
|
|
1073
|
+
unused.push(partPath);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
unused.sort();
|
|
1077
|
+
orderedLayoutPaths.push(...unused);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Extract + classify each layout.
|
|
1081
|
+
const masterCache = new Map<string, Promise<MasterIndex>>();
|
|
1082
|
+
const layouts: AnalyzedLayout[] = [];
|
|
1083
|
+
const usedNames = new Set<string>();
|
|
1084
|
+
for (let i = 0; i < orderedLayoutPaths.length; i++) {
|
|
1085
|
+
const layoutPath = orderedLayoutPaths[i]!;
|
|
1086
|
+
const extracted = await extractLayout(pkg, layoutPath, sldSz, pxPerPt, hints, masterCache);
|
|
1087
|
+
if (!extracted) {
|
|
1088
|
+
warnings.push(`layout ${layoutPath} could not be read; its slides import plain`);
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
extracted.slideCount = usage.get(layoutPath) ?? 0;
|
|
1092
|
+
|
|
1093
|
+
// Unique slug for the (potential) custom template. Reserved up front so
|
|
1094
|
+
// two masters sharing a layout name get -2/-3 suffixes deterministically.
|
|
1095
|
+
const baseSlug = slugify(extracted.name) || `layout-${i + 1}`;
|
|
1096
|
+
let templateName = `pptx-${baseSlug}`;
|
|
1097
|
+
for (let n = 2; usedNames.has(templateName); n++) templateName = `pptx-${baseSlug}-${n}`;
|
|
1098
|
+
|
|
1099
|
+
const notes: string[] = [];
|
|
1100
|
+
const gen: GeneratorContext = { hints, pxPerPt, viewport, notes };
|
|
1101
|
+
const directive = classifyLayout(extracted);
|
|
1102
|
+
const verdict = resolveVerdict(extracted, directive, gen, templateName);
|
|
1103
|
+
if (verdict.kind === 'custom') usedNames.add(templateName);
|
|
1104
|
+
layouts.push({ extracted, verdict, notes });
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Cap generated templates, keeping the most-used layouts.
|
|
1108
|
+
const customs = layouts.filter((l) => l.verdict.kind === 'custom');
|
|
1109
|
+
if (customs.length > maxTemplates) {
|
|
1110
|
+
const ranked = [...customs].sort((a, b) => b.extracted.slideCount - a.extracted.slideCount);
|
|
1111
|
+
for (const layout of ranked.slice(maxTemplates)) {
|
|
1112
|
+
layout.verdict = { kind: 'plain' };
|
|
1113
|
+
layout.notes.push('dropped: more inferred layouts than the template cap');
|
|
1114
|
+
}
|
|
1115
|
+
warnings.push(
|
|
1116
|
+
`deck defines ${customs.length} distinctive layouts; kept the ${maxTemplates} most used as custom templates`,
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const byLayoutPath = new Map(layouts.map((l) => [l.extracted.partPath, l]));
|
|
1121
|
+
return { slideSize: sldSz, layouts, byLayoutPath, layoutPathBySlide, warnings };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ── Dialog-path inspection API ───────────────────────────────────────
|
|
1125
|
+
|
|
1126
|
+
export interface PptxLayoutSummary {
|
|
1127
|
+
layoutPath: string;
|
|
1128
|
+
name: string;
|
|
1129
|
+
masterName?: string;
|
|
1130
|
+
typeAttr?: string;
|
|
1131
|
+
slideCount: number;
|
|
1132
|
+
verdict: 'builtin' | 'custom' | 'plain' | 'skip';
|
|
1133
|
+
/** Set when verdict is 'builtin' — the close built-in alternative. */
|
|
1134
|
+
builtinTemplate?: string;
|
|
1135
|
+
/** Set when verdict is 'custom' — ready to save as a doc/library template. */
|
|
1136
|
+
customTemplate?: CustomTemplateDefinition;
|
|
1137
|
+
notes?: string[];
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
export type InspectPptxLayoutsOptions = AnalyzePptxLayoutsOptions;
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Inspect a deck's layouts without importing it — used by the theme dialog
|
|
1144
|
+
* to list what a PPTX would contribute (built-in matches shown as
|
|
1145
|
+
* informational rows; custom templates offered for confirmation).
|
|
1146
|
+
*/
|
|
1147
|
+
export async function inspectPptxLayouts(
|
|
1148
|
+
data: ArrayBuffer | Blob,
|
|
1149
|
+
options: InspectPptxLayoutsOptions = {},
|
|
1150
|
+
): Promise<{ layouts: PptxLayoutSummary[]; slideSize: { cx: number; cy: number } }> {
|
|
1151
|
+
const pkg = await openPackage(data, options);
|
|
1152
|
+
const analysis = await analyzePptxLayouts(pkg, options);
|
|
1153
|
+
const layouts = analysis.layouts.map((l, i): PptxLayoutSummary => {
|
|
1154
|
+
const { extracted, verdict, notes } = l;
|
|
1155
|
+
return {
|
|
1156
|
+
layoutPath: extracted.partPath,
|
|
1157
|
+
name: extracted.name || `Layout ${i + 1}`,
|
|
1158
|
+
...(extracted.masterName ? { masterName: extracted.masterName } : {}),
|
|
1159
|
+
...(extracted.typeAttr ? { typeAttr: extracted.typeAttr } : {}),
|
|
1160
|
+
slideCount: extracted.slideCount,
|
|
1161
|
+
verdict: verdict.kind,
|
|
1162
|
+
...(verdict.kind === 'builtin' ? { builtinTemplate: verdict.template } : {}),
|
|
1163
|
+
...(verdict.kind === 'custom' ? { customTemplate: verdict.def } : {}),
|
|
1164
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
1165
|
+
};
|
|
1166
|
+
});
|
|
1167
|
+
return { layouts, slideSize: analysis.slideSize };
|
|
1168
|
+
}
|