pptx-angular-viewer 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.
@@ -0,0 +1,1358 @@
1
+ import { NgStyle, NgClass } from '@angular/common';
2
+ import * as i0 from '@angular/core';
3
+ import { InjectionToken, signal, computed, inject, DestroyRef, Injectable, input, ChangeDetectionStrategy, Component, output, effect } from '@angular/core';
4
+ import { guideEmuToPx, PptxHandler, EncryptedFileError, hasShapeProperties, hasTextProperties } from 'pptx-viewer-core';
5
+
6
+ /**
7
+ * Default dark-theme color values.
8
+ *
9
+ * These correspond to the built-in dark UI of the PowerPoint viewer and
10
+ * use Tailwind's gray palette as the neutral scale with indigo as the
11
+ * primary accent.
12
+ */
13
+ const defaultThemeColors = {
14
+ background: '#030712', // gray-950
15
+ foreground: '#f3f4f6', // gray-100
16
+ card: '#111827', // gray-900
17
+ cardForeground: '#f3f4f6', // gray-100
18
+ popover: '#111827', // gray-900
19
+ popoverForeground: '#f3f4f6', // gray-100
20
+ primary: '#6366f1', // indigo-500
21
+ primaryForeground: '#ffffff', // white
22
+ secondary: '#1f2937', // gray-800
23
+ secondaryForeground: '#f3f4f6', // gray-100
24
+ muted: '#1f2937', // gray-800
25
+ mutedForeground: '#9ca3af', // gray-400
26
+ accent: '#1f2937', // gray-800
27
+ accentForeground: '#f3f4f6', // gray-100
28
+ destructive: '#ef4444', // red-500
29
+ destructiveForeground: '#ffffff', // white
30
+ border: '#374151', // gray-700
31
+ input: '#374151', // gray-700
32
+ ring: '#6366f1', // indigo-500
33
+ };
34
+ /** Default border-radius. */
35
+ const defaultRadius = '0.5rem';
36
+
37
+ /**
38
+ * Map from camelCase ViewerThemeColors keys to kebab-case CSS custom
39
+ * property suffixes (the part after `--pptx-`).
40
+ */
41
+ const COLOR_KEY_TO_CSS = {
42
+ background: 'background',
43
+ foreground: 'foreground',
44
+ card: 'card',
45
+ cardForeground: 'card-foreground',
46
+ popover: 'popover',
47
+ popoverForeground: 'popover-foreground',
48
+ primary: 'primary',
49
+ primaryForeground: 'primary-foreground',
50
+ secondary: 'secondary',
51
+ secondaryForeground: 'secondary-foreground',
52
+ muted: 'muted',
53
+ mutedForeground: 'muted-foreground',
54
+ accent: 'accent',
55
+ accentForeground: 'accent-foreground',
56
+ destructive: 'destructive',
57
+ destructiveForeground: 'destructive-foreground',
58
+ border: 'border',
59
+ input: 'input',
60
+ ring: 'ring',
61
+ };
62
+ /**
63
+ * Convert a `ViewerTheme` into a flat `Record<string, string>` of CSS
64
+ * custom properties (including the `--` prefix) ready to be spread onto
65
+ * a `style` attribute.
66
+ *
67
+ * Only properties that differ from the built-in defaults are emitted when
68
+ * `omitDefaults` is true (the default).
69
+ */
70
+ function themeToCssVars(theme, omitDefaults = false) {
71
+ const vars = {};
72
+ if (!theme) {
73
+ return vars;
74
+ }
75
+ // ── Colors ───────────────────────────────────────────────────────
76
+ const colors = theme.colors;
77
+ if (colors) {
78
+ for (const [key, cssSuffix] of Object.entries(COLOR_KEY_TO_CSS)) {
79
+ const value = colors[key];
80
+ if (value === undefined) {
81
+ continue;
82
+ }
83
+ if (omitDefaults && value === defaultThemeColors[key]) {
84
+ continue;
85
+ }
86
+ vars[`--pptx-${cssSuffix}`] = value;
87
+ }
88
+ }
89
+ // ── Radius ───────────────────────────────────────────────────────
90
+ if (theme.radius !== undefined) {
91
+ if (!omitDefaults || theme.radius !== defaultRadius) {
92
+ vars['--pptx-radius'] = theme.radius;
93
+ }
94
+ }
95
+ // ── Escape-hatch custom properties ───────────────────────────────
96
+ if (theme.cssVars) {
97
+ for (const [key, value] of Object.entries(theme.cssVars)) {
98
+ vars[key] = value;
99
+ }
100
+ }
101
+ return vars;
102
+ }
103
+ /**
104
+ * Build the complete set of CSS custom properties with all defaults.
105
+ * Useful for generating a full fallback stylesheet.
106
+ */
107
+ function defaultCssVars() {
108
+ const vars = {};
109
+ for (const [key, cssSuffix] of Object.entries(COLOR_KEY_TO_CSS)) {
110
+ vars[`--pptx-${cssSuffix}`] = defaultThemeColors[key];
111
+ }
112
+ vars['--pptx-radius'] = defaultRadius;
113
+ return vars;
114
+ }
115
+
116
+ /**
117
+ * Recursively walks an element tree and pushes every media element
118
+ * into the supplied collector array.
119
+ */
120
+ function collectMediaElements(elements, collector) {
121
+ for (const element of elements) {
122
+ if (element.type === 'media') {
123
+ collector.push(element);
124
+ continue;
125
+ }
126
+ if (element.type === 'group' && element.children?.length) {
127
+ collectMediaElements(element.children, collector);
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Collect all unique image archive paths across all slides that need
133
+ * to be resolved to displayable URLs (Blob URLs).
134
+ *
135
+ * This covers:
136
+ * - Picture elements (`imageData`, `svgData`)
137
+ * - Media poster frames (`posterFrameData`)
138
+ *
139
+ * Returns the set of unique archive paths, plus a list of element/field
140
+ * references that need to be updated once each path resolves.
141
+ */
142
+ function collectImagePaths(slides) {
143
+ const paths = new Set();
144
+ const refs = [];
145
+ const walkElements = (elements) => {
146
+ for (const el of elements) {
147
+ if (el.type === 'picture' || el.type === 'image') {
148
+ const pic = el;
149
+ if (pic.imagePath && !pic.imageData && !isExternalUrl(pic.imagePath)) {
150
+ paths.add(pic.imagePath);
151
+ refs.push({ element: el, field: 'imageData', path: pic.imagePath });
152
+ }
153
+ if (pic.svgPath && !pic.svgData && !isExternalUrl(pic.svgPath)) {
154
+ paths.add(pic.svgPath);
155
+ refs.push({ element: el, field: 'svgData', path: pic.svgPath });
156
+ }
157
+ }
158
+ if (el.type === 'media') {
159
+ const media = el;
160
+ if (media.posterFramePath &&
161
+ !media.posterFrameData &&
162
+ !isExternalUrl(media.posterFramePath)) {
163
+ paths.add(media.posterFramePath);
164
+ refs.push({
165
+ element: el,
166
+ field: 'posterFrameData',
167
+ path: media.posterFramePath,
168
+ });
169
+ }
170
+ }
171
+ if (el.type === 'group' && el.children?.length) {
172
+ walkElements(el.children);
173
+ }
174
+ }
175
+ };
176
+ for (const slide of slides) {
177
+ walkElements(slide.elements);
178
+ }
179
+ return { paths, refs };
180
+ }
181
+ function isExternalUrl(path) {
182
+ return (path.startsWith('http://') ||
183
+ path.startsWith('https://') ||
184
+ path.startsWith('data:') ||
185
+ path.startsWith('blob:'));
186
+ }
187
+ /**
188
+ * Converts raw EMU-based drawing guides from the parsed presentation
189
+ * and the first slide into pixel-based `GuideEntry` objects.
190
+ */
191
+ function buildInitialGuides(presentationGuides, firstSlideGuides) {
192
+ const guides = [];
193
+ if (presentationGuides) {
194
+ for (const g of presentationGuides) {
195
+ guides.push({
196
+ id: g.id,
197
+ axis: g.orientation === 'horz' ? 'h' : 'v',
198
+ position: guideEmuToPx(g.positionEmu),
199
+ });
200
+ }
201
+ }
202
+ if (firstSlideGuides) {
203
+ for (const g of firstSlideGuides) {
204
+ guides.push({
205
+ id: g.id,
206
+ axis: g.orientation === 'horz' ? 'h' : 'v',
207
+ position: guideEmuToPx(g.positionEmu),
208
+ });
209
+ }
210
+ }
211
+ return guides;
212
+ }
213
+
214
+ /**
215
+ * Framework-agnostic public types shared by the viewer bindings.
216
+ *
217
+ * These were duplicated in the React (`types-ui.ts`) and Vue (`viewer/types.ts`)
218
+ * packages; this is the canonical copy. Each binding layers its own
219
+ * framework-specific prop/event/handle types on top of these.
220
+ */
221
+
222
+ /**
223
+ * Scalar viewer defaults shared by the UI bindings.
224
+ *
225
+ * Subset of the React package's `constants/scalar.ts` that the Vue and Angular
226
+ * viewers also need. Additional constant groups (toolbar presets, shape styles,
227
+ * transitions, etc.) remain per-binding until those features are ported.
228
+ */
229
+ /** Default slide canvas width in pixels when the file declares none. */
230
+ const DEFAULT_CANVAS_WIDTH = 1280;
231
+ /** Default slide canvas height in pixels when the file declares none. */
232
+ const DEFAULT_CANVAS_HEIGHT = 720;
233
+ /** Fallback text colour. */
234
+ const DEFAULT_TEXT_COLOR = '#111827';
235
+ /** Fallback shape fill colour. */
236
+ const DEFAULT_FILL_COLOR = '#3b82f6';
237
+ /** Fallback shape stroke colour. */
238
+ const DEFAULT_STROKE_COLOR = '#1f2937';
239
+
240
+ /**
241
+ * pptx-viewer-shared — framework-agnostic viewer logic shared by the
242
+ * React (`pptx-viewer`), Vue (`pptx-vue-viewer`), and Angular
243
+ * (`pptx-angular-viewer`) bindings.
244
+ *
245
+ * Everything exported here is pure TypeScript (no framework imports), so each
246
+ * UI binding consumes one copy instead of duplicating it.
247
+ *
248
+ * Current surface:
249
+ * - theme: ViewerTheme types, default palette, CSS-variable helpers.
250
+ * - loader: load-pipeline helpers (media/image collection, guides).
251
+ * - types: CanvasSize, CollaborationConfig, CollaborationRole.
252
+ * - constants: scalar viewer defaults (canvas size, fallback colours).
253
+ *
254
+ * Roadmap (see packages/angular/PORTING.md and packages/vue/PORTING.md):
255
+ * color resolution, geometry/clip-paths, connector routing, animation
256
+ * timeline engine, table-merge math, morph matching, export data helpers.
257
+ */
258
+
259
+ /**
260
+ * Internal re-export of `pptx-viewer-shared`.
261
+ *
262
+ * `pptx-viewer-shared` is an INTERNAL, non-published package (`"private": true`).
263
+ * Its source is vendored into `./shared-src` at build time by
264
+ * `scripts/inline-shared.mjs` (a generated, git-ignored directory), so the
265
+ * shared code compiles as part of THIS library and ships **inlined** in the
266
+ * published FESM. As a result `pptx-viewer-shared` never appears in the
267
+ * published `package.json`.
268
+ *
269
+ * All Angular sources import shared symbols from THIS barrel, never from the
270
+ * bare `'pptx-viewer-shared'` specifier (which ng-packagr would externalize).
271
+ */
272
+
273
+ /**
274
+ * Theme system for the Angular PowerPoint viewer.
275
+ *
276
+ * Angular counterpart of the React `ViewerThemeProvider` / `useViewerTheme`
277
+ * context and the Vue `provide`/`inject` theme provider. The `ViewerTheme`
278
+ * type, default palette, and `themeToCssVars` helper are framework-agnostic
279
+ * and live in `pptx-viewer-shared`.
280
+ */
281
+ /** DI token carrying the active `ViewerTheme` (or `undefined`). */
282
+ const VIEWER_THEME = new InjectionToken('PPTX_VIEWER_THEME');
283
+ /**
284
+ * Provide a `ViewerTheme` to a subtree.
285
+ *
286
+ * Typically you do **not** need this — passing a `theme` input to
287
+ * `<pptx-viewer>` is sufficient. Use this to share a theme across multiple
288
+ * viewers or a wider component subtree.
289
+ *
290
+ * @example
291
+ * ```ts
292
+ * bootstrapApplication(AppComponent, {
293
+ * providers: [provideViewerTheme({ colors: { primary: '#6366f1' } })],
294
+ * });
295
+ * ```
296
+ */
297
+ function provideViewerTheme(theme) {
298
+ return { provide: VIEWER_THEME, useValue: theme };
299
+ }
300
+ /**
301
+ * Build an `[ngStyle]`-compatible map of CSS custom properties for a theme.
302
+ * Returns an empty object when the theme contributes no variables.
303
+ */
304
+ function themeStyle(theme) {
305
+ if (!theme) {
306
+ return {};
307
+ }
308
+ return themeToCssVars(theme);
309
+ }
310
+
311
+ /**
312
+ * `LoadContentService` — Angular port of the React `useLoadContent` hook and
313
+ * the Vue `useLoadContent` composable.
314
+ *
315
+ * Parses `.pptx` bytes into reactive signals via the framework-agnostic
316
+ * `PptxHandler` from `pptx-viewer-core`. All heavy lifting (ZIP, XML parse,
317
+ * theme/master/layout resolution, media extraction) lives in core and the
318
+ * pure helpers live in `pptx-viewer-shared`; this service only wires the async
319
+ * load into Angular signals and manages Blob-URL / handler lifecycle.
320
+ *
321
+ * Provide it at the component level so its lifetime tracks the host viewer:
322
+ * `@Component({ providers: [LoadContentService] })`.
323
+ *
324
+ * This is the viewer-first subset; the React hook also populated ~25 extra
325
+ * pieces of presentation metadata (sections, custom shows, embedded fonts,
326
+ * digital signatures, …). Those are tracked in PORTING.md and added here as
327
+ * the corresponding features are ported.
328
+ */
329
+ class LoadContentService {
330
+ /** Parsed slides (with image Blob URLs patched in). */
331
+ slides = signal([], /* @ts-ignore */
332
+ ...(ngDevMode ? [{ debugName: "slides" }] : /* istanbul ignore next */ []));
333
+ /** Slide canvas size in pixels. */
334
+ canvasSize = signal({
335
+ width: DEFAULT_CANVAS_WIDTH,
336
+ height: DEFAULT_CANVAS_HEIGHT,
337
+ }, /* @ts-ignore */
338
+ ...(ngDevMode ? [{ debugName: "canvasSize" }] : /* istanbul ignore next */ []));
339
+ /** Resolved presentation theme. */
340
+ theme = signal(undefined, /* @ts-ignore */
341
+ ...(ngDevMode ? [{ debugName: "theme" }] : /* istanbul ignore next */ []));
342
+ /** Slide masters (for placeholder/background resolution). */
343
+ slideMasters = signal([], /* @ts-ignore */
344
+ ...(ngDevMode ? [{ debugName: "slideMasters" }] : /* istanbul ignore next */ []));
345
+ /** Archive-path → displayable URL map for media + poster frames. */
346
+ mediaDataUrls = signal(new Map(), /* @ts-ignore */
347
+ ...(ngDevMode ? [{ debugName: "mediaDataUrls" }] : /* istanbul ignore next */ []));
348
+ /** True while a load is in flight. */
349
+ loading = signal(false, /* @ts-ignore */
350
+ ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
351
+ /** Error message from the last failed load, or null. */
352
+ error = signal(null, /* @ts-ignore */
353
+ ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
354
+ /** True when the file is password-protected and could not be opened. */
355
+ isEncrypted = signal(false, /* @ts-ignore */
356
+ ...(ngDevMode ? [{ debugName: "isEncrypted" }] : /* istanbul ignore next */ []));
357
+ /** Number of loaded slides. */
358
+ slideCount = computed(() => this.slides().length, /* @ts-ignore */
359
+ ...(ngDevMode ? [{ debugName: "slideCount" }] : /* istanbul ignore next */ []));
360
+ handler = null;
361
+ renderToken = 0;
362
+ activeBlobUrls = [];
363
+ constructor() {
364
+ inject(DestroyRef).onDestroy(() => {
365
+ this.renderToken++;
366
+ this.revokeBlobUrls(this.activeBlobUrls);
367
+ this.revokeBlobUrls(Array.from(this.mediaDataUrls().values()));
368
+ this.disposeHandler();
369
+ });
370
+ }
371
+ /** Serialise the current presentation back to `.pptx` bytes. */
372
+ async getContent() {
373
+ if (!this.handler) {
374
+ throw new Error('No presentation is loaded.');
375
+ }
376
+ return this.handler.save(this.slides());
377
+ }
378
+ /** Parse the supplied `.pptx` bytes into the reactive signals. */
379
+ async load(raw) {
380
+ if (!raw) {
381
+ return;
382
+ }
383
+ const token = ++this.renderToken;
384
+ const loadBlobUrls = [];
385
+ try {
386
+ this.loading.set(true);
387
+ this.error.set(null);
388
+ this.isEncrypted.set(false);
389
+ const buffer = raw instanceof Uint8Array
390
+ ? raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength)
391
+ : raw;
392
+ const fileSizeMB = buffer instanceof ArrayBuffer ? buffer.byteLength / (1024 * 1024) : 0;
393
+ if (fileSizeMB > 50) {
394
+ console.warn(`[pptx] Large file detected (${fileSizeMB.toFixed(1)} MB). ` +
395
+ `Loading may use significant memory.`);
396
+ }
397
+ const previousHandler = this.handler;
398
+ const newHandler = new PptxHandler();
399
+ const parsed = await newHandler.load(buffer);
400
+ if (token !== this.renderToken) {
401
+ newHandler.dispose();
402
+ return;
403
+ }
404
+ previousHandler?.dispose();
405
+ // ── Resolve media Blob URLs (audio/video + poster frames) ──
406
+ const mediaElements = [];
407
+ for (const slide of parsed.slides) {
408
+ collectMediaElements(slide.elements, mediaElements);
409
+ }
410
+ this.revokeBlobUrls(Array.from(this.mediaDataUrls().values()));
411
+ const nextMediaUrls = new Map();
412
+ await Promise.all(mediaElements.map(async (mediaElement) => {
413
+ const mediaPath = mediaElement.mediaPath;
414
+ if (!mediaPath) {
415
+ mediaElement.mediaMissing = true;
416
+ return;
417
+ }
418
+ try {
419
+ const isAudioVideo = mediaElement.mediaType === 'audio' || mediaElement.mediaType === 'video';
420
+ if (isAudioVideo) {
421
+ const arrayBuffer = await newHandler.getMediaArrayBuffer(mediaPath);
422
+ if (arrayBuffer) {
423
+ const mimeType = mediaElement.mediaMimeType || 'application/octet-stream';
424
+ const blob = new Blob([arrayBuffer], { type: mimeType });
425
+ const blobUrl = URL.createObjectURL(blob);
426
+ loadBlobUrls.push(blobUrl);
427
+ nextMediaUrls.set(mediaPath, blobUrl);
428
+ }
429
+ else {
430
+ mediaElement.mediaMissing = true;
431
+ }
432
+ }
433
+ else {
434
+ const dataUrl = await newHandler.getImageData(mediaPath);
435
+ if (dataUrl) {
436
+ nextMediaUrls.set(mediaPath, dataUrl);
437
+ }
438
+ else {
439
+ mediaElement.mediaMissing = true;
440
+ }
441
+ }
442
+ }
443
+ catch {
444
+ mediaElement.mediaMissing = true;
445
+ }
446
+ }));
447
+ // ── Resolve lazily-loaded picture Blob URLs ──
448
+ const { paths: imagePaths, refs: imageRefs } = collectImagePaths(parsed.slides);
449
+ let nextSlides = parsed.slides;
450
+ if (imagePaths.size > 0) {
451
+ const resolvedMap = new Map();
452
+ await Promise.all(Array.from(imagePaths).map(async (path) => {
453
+ try {
454
+ const url = await newHandler.getImageData(path);
455
+ if (url) {
456
+ resolvedMap.set(path, url);
457
+ }
458
+ }
459
+ catch {
460
+ // Non-critical: image will show as broken.
461
+ }
462
+ }));
463
+ const elementPatches = new Map();
464
+ for (const refEntry of imageRefs) {
465
+ const url = resolvedMap.get(refEntry.path);
466
+ if (!url) {
467
+ continue;
468
+ }
469
+ const id = refEntry.element.id;
470
+ const existing = elementPatches.get(id) ?? {};
471
+ existing[refEntry.field] = url;
472
+ elementPatches.set(id, existing);
473
+ }
474
+ if (elementPatches.size > 0) {
475
+ const patchElements = (elements) => {
476
+ let mutated = false;
477
+ const next = elements.map((el) => {
478
+ let updated = el;
479
+ const patch = elementPatches.get(el.id);
480
+ if (patch) {
481
+ updated = { ...el, ...patch };
482
+ }
483
+ if (updated.type === 'group' && updated.children?.length) {
484
+ const newChildren = patchElements(updated.children);
485
+ if (newChildren !== updated.children) {
486
+ updated = { ...updated, children: newChildren };
487
+ }
488
+ }
489
+ if (updated !== el) {
490
+ mutated = true;
491
+ }
492
+ return updated;
493
+ });
494
+ return mutated ? next : elements;
495
+ };
496
+ nextSlides = parsed.slides.map((s) => {
497
+ const newElements = patchElements(s.elements);
498
+ return newElements === s.elements ? s : { ...s, elements: newElements };
499
+ });
500
+ }
501
+ }
502
+ // Commit reactive state.
503
+ this.revokeBlobUrls(this.activeBlobUrls);
504
+ this.activeBlobUrls = loadBlobUrls;
505
+ this.handler = newHandler;
506
+ this.slides.set(nextSlides);
507
+ this.mediaDataUrls.set(nextMediaUrls);
508
+ this.canvasSize.set({
509
+ width: parsed.width ?? DEFAULT_CANVAS_WIDTH,
510
+ height: parsed.height ?? DEFAULT_CANVAS_HEIGHT,
511
+ });
512
+ this.theme.set(parsed.theme);
513
+ this.slideMasters.set(parsed.slideMasters ?? []);
514
+ }
515
+ catch (err) {
516
+ if (token === this.renderToken) {
517
+ if (err instanceof EncryptedFileError) {
518
+ this.isEncrypted.set(true);
519
+ }
520
+ else {
521
+ this.error.set(err instanceof Error ? err.message : String(err));
522
+ }
523
+ }
524
+ }
525
+ finally {
526
+ if (token === this.renderToken) {
527
+ this.loading.set(false);
528
+ }
529
+ }
530
+ }
531
+ disposeHandler() {
532
+ if (this.handler) {
533
+ this.handler.dispose();
534
+ this.handler = null;
535
+ }
536
+ }
537
+ revokeBlobUrls(urls) {
538
+ for (const url of urls) {
539
+ if (url.startsWith('blob:')) {
540
+ URL.revokeObjectURL(url);
541
+ }
542
+ }
543
+ }
544
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: LoadContentService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
545
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: LoadContentService });
546
+ }
547
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: LoadContentService, decorators: [{
548
+ type: Injectable
549
+ }], ctorParameters: () => [] });
550
+
551
+ /** Map a number to a CSS pixel string. */
552
+ const px = (n) => `${n}px`;
553
+ /**
554
+ * Absolute container style: position, size, rotation, flip, opacity, z-index.
555
+ * Mirrors the essentials of the React `getContainerStyle`.
556
+ */
557
+ function getContainerStyle(el, zIndex) {
558
+ const transforms = [];
559
+ if (el.rotation) {
560
+ transforms.push(`rotate(${el.rotation}deg)`);
561
+ }
562
+ if (el.flipHorizontal) {
563
+ transforms.push('scaleX(-1)');
564
+ }
565
+ if (el.flipVertical) {
566
+ transforms.push('scaleY(-1)');
567
+ }
568
+ const style = {
569
+ position: 'absolute',
570
+ left: px(el.x),
571
+ top: px(el.y),
572
+ width: px(el.width),
573
+ height: px(el.height),
574
+ 'z-index': zIndex,
575
+ 'box-sizing': 'border-box',
576
+ };
577
+ if (transforms.length > 0) {
578
+ style['transform'] = transforms.join(' ');
579
+ }
580
+ if (typeof el.opacity === 'number') {
581
+ style['opacity'] = el.opacity;
582
+ }
583
+ if (el.hidden) {
584
+ style['display'] = 'none';
585
+ }
586
+ return style;
587
+ }
588
+ /**
589
+ * Fill / stroke / corner-radius for shape-like elements. Returns an empty
590
+ * object when the element carries no shape styling.
591
+ */
592
+ function getShapeFillStrokeStyle(el) {
593
+ if (!hasShapeProperties(el)) {
594
+ return {};
595
+ }
596
+ const ss = el.shapeStyle;
597
+ const style = {};
598
+ if (ss) {
599
+ // Fill — solid only for now (gradients/patterns/images: TODO).
600
+ if (ss.fillColor && ss.fillColor !== 'transparent' && ss.fillMode !== 'none') {
601
+ style['background-color'] = ss.fillColor;
602
+ }
603
+ // Stroke.
604
+ const strokeWidth = Math.max(0, ss.strokeWidth ?? 0);
605
+ if (strokeWidth > 0) {
606
+ const dash = ss.strokeDash && ss.strokeDash !== 'solid'
607
+ ? ss.strokeDash === 'dot' || ss.strokeDash === 'sysDot'
608
+ ? 'dotted'
609
+ : 'dashed'
610
+ : 'solid';
611
+ style['border'] = `${px(strokeWidth)} ${dash} ${ss.strokeColor ?? DEFAULT_STROKE_COLOR}`;
612
+ }
613
+ }
614
+ // Corner radius — approximate common preset geometries.
615
+ const shapeType = 'shapeType' in el ? el.shapeType : undefined;
616
+ if (shapeType === 'ellipse' || shapeType === 'circle') {
617
+ style['border-radius'] = '50%';
618
+ }
619
+ else if (shapeType === 'roundRect') {
620
+ style['border-radius'] = px(Math.min(el.width, el.height) * 0.1);
621
+ }
622
+ return style;
623
+ }
624
+ /**
625
+ * Text block style for elements that carry text. Mirrors the essentials of the
626
+ * React `getTextStyleForElement`.
627
+ */
628
+ function getTextBlockStyle(el) {
629
+ if (!hasTextProperties(el)) {
630
+ return {};
631
+ }
632
+ const ts = el.textStyle;
633
+ const style = {
634
+ display: 'flex',
635
+ 'flex-direction': 'column',
636
+ width: '100%',
637
+ height: '100%',
638
+ overflow: 'hidden',
639
+ 'white-space': 'pre-wrap',
640
+ 'word-break': 'break-word',
641
+ };
642
+ if (!ts) {
643
+ style['color'] = DEFAULT_TEXT_COLOR;
644
+ return style;
645
+ }
646
+ style['color'] = ts.color ?? DEFAULT_TEXT_COLOR;
647
+ if (ts.fontFamily) {
648
+ style['font-family'] = ts.fontFamily;
649
+ }
650
+ if (typeof ts.fontSize === 'number') {
651
+ style['font-size'] = `${ts.fontSize}pt`;
652
+ }
653
+ if (ts.bold) {
654
+ style['font-weight'] = 'bold';
655
+ }
656
+ if (ts.italic) {
657
+ style['font-style'] = 'italic';
658
+ }
659
+ const decorations = [];
660
+ if (ts.underline) {
661
+ decorations.push('underline');
662
+ }
663
+ if (ts.strikethrough) {
664
+ decorations.push('line-through');
665
+ }
666
+ if (decorations.length > 0) {
667
+ style['text-decoration'] = decorations.join(' ');
668
+ }
669
+ switch (ts.align) {
670
+ case 'center':
671
+ style['text-align'] = 'center';
672
+ break;
673
+ case 'right':
674
+ style['text-align'] = 'right';
675
+ break;
676
+ case 'justify':
677
+ style['text-align'] = 'justify';
678
+ break;
679
+ default:
680
+ style['text-align'] = 'left';
681
+ }
682
+ switch (ts.vAlign) {
683
+ case 'middle':
684
+ style['justify-content'] = 'center';
685
+ break;
686
+ case 'bottom':
687
+ style['justify-content'] = 'flex-end';
688
+ break;
689
+ default:
690
+ style['justify-content'] = 'flex-start';
691
+ }
692
+ return style;
693
+ }
694
+ /** Resolve a displayable image source for picture/image/media poster frames. */
695
+ function getImageSrc(el, mediaDataUrls) {
696
+ if (el.type === 'picture' || el.type === 'image') {
697
+ return el.imageData ?? (el.imagePath ? mediaDataUrls.get(el.imagePath) : undefined);
698
+ }
699
+ if (el.type === 'media') {
700
+ return (el.posterFrameData ?? (el.posterFramePath ? mediaDataUrls.get(el.posterFramePath) : undefined));
701
+ }
702
+ return undefined;
703
+ }
704
+
705
+ /**
706
+ * ElementRendererComponent — Angular port of the React `ElementRenderer.tsx`
707
+ * and the Vue `ElementRenderer.vue`.
708
+ *
709
+ * Renders a single slide element by its `type` discriminant (viewer-first
710
+ * subset):
711
+ * - `text` / `shape` → positioned box with fill/stroke + rich text
712
+ * - `picture` / `image` → `<img>`
713
+ * - `media` → poster frame (`<img>`) — playback TODO
714
+ * - `group` → recursive children (self-referencing selector)
715
+ * - everything else → labelled placeholder (TODO, see PORTING.md)
716
+ *
717
+ * Interaction (selection, resize, inline editing), connectors, charts, tables,
718
+ * SmartArt, ink, OLE, and 3D are not yet ported.
719
+ */
720
+ class ElementRendererComponent {
721
+ element = input.required(/* @ts-ignore */
722
+ ...(ngDevMode ? [{ debugName: "element" }] : /* istanbul ignore next */ []));
723
+ mediaDataUrls = input(new Map(), /* @ts-ignore */
724
+ ...(ngDevMode ? [{ debugName: "mediaDataUrls" }] : /* istanbul ignore next */ []));
725
+ zIndex = input(0, /* @ts-ignore */
726
+ ...(ngDevMode ? [{ debugName: "zIndex" }] : /* istanbul ignore next */ []));
727
+ containerStyle = computed(() => getContainerStyle(this.element(), this.zIndex()), /* @ts-ignore */
728
+ ...(ngDevMode ? [{ debugName: "containerStyle" }] : /* istanbul ignore next */ []));
729
+ shapeContainerStyle = computed(() => ({
730
+ ...this.containerStyle(),
731
+ ...getShapeFillStrokeStyle(this.element()),
732
+ }), /* @ts-ignore */
733
+ ...(ngDevMode ? [{ debugName: "shapeContainerStyle" }] : /* istanbul ignore next */ []));
734
+ textStyle = computed(() => getTextBlockStyle(this.element()), /* @ts-ignore */
735
+ ...(ngDevMode ? [{ debugName: "textStyle" }] : /* istanbul ignore next */ []));
736
+ imageSrc = computed(() => getImageSrc(this.element(), this.mediaDataUrls()), /* @ts-ignore */
737
+ ...(ngDevMode ? [{ debugName: "imageSrc" }] : /* istanbul ignore next */ []));
738
+ children = computed(() => {
739
+ const el = this.element();
740
+ return el.type === 'group' ? (el.children ?? []) : [];
741
+ }, /* @ts-ignore */
742
+ ...(ngDevMode ? [{ debugName: "children" }] : /* istanbul ignore next */ []));
743
+ isShapeLike = computed(() => this.element().type === 'text' || this.element().type === 'shape', /* @ts-ignore */
744
+ ...(ngDevMode ? [{ debugName: "isShapeLike" }] : /* istanbul ignore next */ []));
745
+ isImageLike = computed(() => this.element().type === 'picture' || this.element().type === 'image', /* @ts-ignore */
746
+ ...(ngDevMode ? [{ debugName: "isImageLike" }] : /* istanbul ignore next */ []));
747
+ paragraphs = computed(() => {
748
+ const el = this.element();
749
+ if (!hasTextProperties(el)) {
750
+ return [];
751
+ }
752
+ const segments = el.textSegments;
753
+ if (!segments || segments.length === 0) {
754
+ return el.text ? [[{ text: el.text, style: {} }]] : [];
755
+ }
756
+ const out = [[]];
757
+ for (const seg of segments) {
758
+ if (seg.isParagraphBreak) {
759
+ out.push([]);
760
+ continue;
761
+ }
762
+ const current = out[out.length - 1];
763
+ const text = seg.isLineBreak ? '\n' : seg.text;
764
+ if (text) {
765
+ current.push({ text, style: this.segmentStyle(seg) });
766
+ }
767
+ }
768
+ return out.filter((p) => p.length > 0 || out.length === 1);
769
+ }, /* @ts-ignore */
770
+ ...(ngDevMode ? [{ debugName: "paragraphs" }] : /* istanbul ignore next */ []));
771
+ hasText = computed(() => this.paragraphs().some((p) => p.length > 0), /* @ts-ignore */
772
+ ...(ngDevMode ? [{ debugName: "hasText" }] : /* istanbul ignore next */ []));
773
+ placeholderLabel = computed(() => {
774
+ const map = {
775
+ table: 'Table',
776
+ chart: 'Chart',
777
+ smartArt: 'SmartArt',
778
+ connector: 'Connector',
779
+ group: 'Group',
780
+ media: 'Media',
781
+ ink: 'Ink',
782
+ ole: 'Embedded object',
783
+ model3d: '3D model',
784
+ zoom: 'Zoom',
785
+ };
786
+ return map[this.element().type] ?? this.element().type;
787
+ }, /* @ts-ignore */
788
+ ...(ngDevMode ? [{ debugName: "placeholderLabel" }] : /* istanbul ignore next */ []));
789
+ segmentStyle(seg) {
790
+ const s = seg.style ?? {};
791
+ const style = {};
792
+ if (s.fontFamily) {
793
+ style['font-family'] = s.fontFamily;
794
+ }
795
+ if (typeof s.fontSize === 'number') {
796
+ style['font-size'] = `${s.fontSize}pt`;
797
+ }
798
+ if (s.color) {
799
+ style['color'] = s.color;
800
+ }
801
+ if (s.bold) {
802
+ style['font-weight'] = 'bold';
803
+ }
804
+ if (s.italic) {
805
+ style['font-style'] = 'italic';
806
+ }
807
+ const deco = [];
808
+ if (s.underline) {
809
+ deco.push('underline');
810
+ }
811
+ if (s.strikethrough) {
812
+ deco.push('line-through');
813
+ }
814
+ if (deco.length > 0) {
815
+ style['text-decoration'] = deco.join(' ');
816
+ }
817
+ return style;
818
+ }
819
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ElementRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
820
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.1", type: ElementRendererComponent, isStandalone: true, selector: "pptx-element-renderer", inputs: { element: { classPropertyName: "element", publicName: "element", isSignal: true, isRequired: true, transformFunction: null }, mediaDataUrls: { classPropertyName: "mediaDataUrls", publicName: "mediaDataUrls", isSignal: true, isRequired: false, transformFunction: null }, zIndex: { classPropertyName: "zIndex", publicName: "zIndex", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
821
+ @switch (true) {
822
+ @case (element().type === 'group') {
823
+ <div
824
+ class="pptx-ng-element pptx-ng-group"
825
+ [ngStyle]="containerStyle()"
826
+ [attr.data-element-id]="element().id"
827
+ >
828
+ @for (child of children(); track child.id) {
829
+ <pptx-element-renderer
830
+ [element]="child"
831
+ [mediaDataUrls]="mediaDataUrls()"
832
+ [zIndex]="$index"
833
+ />
834
+ }
835
+ </div>
836
+ }
837
+ @case (isImageLike()) {
838
+ <div
839
+ class="pptx-ng-element pptx-ng-image"
840
+ [ngStyle]="containerStyle()"
841
+ [attr.data-element-id]="element().id"
842
+ >
843
+ @if (imageSrc()) {
844
+ <img [src]="imageSrc()" alt="" class="pptx-ng-img" />
845
+ }
846
+ </div>
847
+ }
848
+ @case (element().type === 'media') {
849
+ <div
850
+ class="pptx-ng-element pptx-ng-media"
851
+ [ngStyle]="containerStyle()"
852
+ [attr.data-element-id]="element().id"
853
+ >
854
+ @if (imageSrc()) {
855
+ <img [src]="imageSrc()" alt="" class="pptx-ng-img" />
856
+ } @else {
857
+ <div class="pptx-ng-placeholder">{{ placeholderLabel() }}</div>
858
+ }
859
+ </div>
860
+ }
861
+ @case (isShapeLike()) {
862
+ <div
863
+ class="pptx-ng-element pptx-ng-shape"
864
+ [ngStyle]="shapeContainerStyle()"
865
+ [attr.data-element-id]="element().id"
866
+ >
867
+ @if (hasText()) {
868
+ <div class="pptx-ng-text" [ngStyle]="textStyle()">
869
+ @for (para of paragraphs(); track $index) {
870
+ <p class="pptx-ng-para">
871
+ @for (run of para; track $index) {
872
+ @if (
873
+ run.text ===
874
+ '
875
+ '
876
+ ) {
877
+ <br />
878
+ } @else {
879
+ <span [ngStyle]="run.style">{{ run.text }}</span>
880
+ }
881
+ }
882
+ </p>
883
+ }
884
+ </div>
885
+ }
886
+ </div>
887
+ }
888
+ @default {
889
+ <div
890
+ class="pptx-ng-element pptx-ng-unsupported"
891
+ [ngStyle]="containerStyle()"
892
+ [attr.data-element-id]="element().id"
893
+ >
894
+ <div class="pptx-ng-placeholder">{{ placeholderLabel() }}</div>
895
+ </div>
896
+ }
897
+ }
898
+ `, isInline: true, dependencies: [{ kind: "component", type: ElementRendererComponent, selector: "pptx-element-renderer", inputs: ["element", "mediaDataUrls", "zIndex"] }, { kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
899
+ }
900
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ElementRendererComponent, decorators: [{
901
+ type: Component,
902
+ args: [{
903
+ selector: 'pptx-element-renderer',
904
+ standalone: true,
905
+ changeDetection: ChangeDetectionStrategy.OnPush,
906
+ imports: [NgStyle],
907
+ template: `
908
+ @switch (true) {
909
+ @case (element().type === 'group') {
910
+ <div
911
+ class="pptx-ng-element pptx-ng-group"
912
+ [ngStyle]="containerStyle()"
913
+ [attr.data-element-id]="element().id"
914
+ >
915
+ @for (child of children(); track child.id) {
916
+ <pptx-element-renderer
917
+ [element]="child"
918
+ [mediaDataUrls]="mediaDataUrls()"
919
+ [zIndex]="$index"
920
+ />
921
+ }
922
+ </div>
923
+ }
924
+ @case (isImageLike()) {
925
+ <div
926
+ class="pptx-ng-element pptx-ng-image"
927
+ [ngStyle]="containerStyle()"
928
+ [attr.data-element-id]="element().id"
929
+ >
930
+ @if (imageSrc()) {
931
+ <img [src]="imageSrc()" alt="" class="pptx-ng-img" />
932
+ }
933
+ </div>
934
+ }
935
+ @case (element().type === 'media') {
936
+ <div
937
+ class="pptx-ng-element pptx-ng-media"
938
+ [ngStyle]="containerStyle()"
939
+ [attr.data-element-id]="element().id"
940
+ >
941
+ @if (imageSrc()) {
942
+ <img [src]="imageSrc()" alt="" class="pptx-ng-img" />
943
+ } @else {
944
+ <div class="pptx-ng-placeholder">{{ placeholderLabel() }}</div>
945
+ }
946
+ </div>
947
+ }
948
+ @case (isShapeLike()) {
949
+ <div
950
+ class="pptx-ng-element pptx-ng-shape"
951
+ [ngStyle]="shapeContainerStyle()"
952
+ [attr.data-element-id]="element().id"
953
+ >
954
+ @if (hasText()) {
955
+ <div class="pptx-ng-text" [ngStyle]="textStyle()">
956
+ @for (para of paragraphs(); track $index) {
957
+ <p class="pptx-ng-para">
958
+ @for (run of para; track $index) {
959
+ @if (
960
+ run.text ===
961
+ '
962
+ '
963
+ ) {
964
+ <br />
965
+ } @else {
966
+ <span [ngStyle]="run.style">{{ run.text }}</span>
967
+ }
968
+ }
969
+ </p>
970
+ }
971
+ </div>
972
+ }
973
+ </div>
974
+ }
975
+ @default {
976
+ <div
977
+ class="pptx-ng-element pptx-ng-unsupported"
978
+ [ngStyle]="containerStyle()"
979
+ [attr.data-element-id]="element().id"
980
+ >
981
+ <div class="pptx-ng-placeholder">{{ placeholderLabel() }}</div>
982
+ </div>
983
+ }
984
+ }
985
+ `,
986
+ }]
987
+ }], propDecorators: { element: [{ type: i0.Input, args: [{ isSignal: true, alias: "element", required: true }] }], mediaDataUrls: [{ type: i0.Input, args: [{ isSignal: true, alias: "mediaDataUrls", required: false }] }], zIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "zIndex", required: false }] }] } });
988
+
989
+ /**
990
+ * SlideCanvasComponent — Angular port of the React `SlideCanvas.tsx` and Vue
991
+ * `SlideCanvas.vue` (viewer-first subset).
992
+ *
993
+ * Renders the active slide as a fixed-size stage scaled by `zoom`, with each
994
+ * element absolutely positioned. The React version additionally layered in
995
+ * rulers, grid, guides, marquee/selection, connector-creation, drawing, and
996
+ * collaboration overlays — all tracked in PORTING.md.
997
+ */
998
+ class SlideCanvasComponent {
999
+ slide = input(undefined, /* @ts-ignore */
1000
+ ...(ngDevMode ? [{ debugName: "slide" }] : /* istanbul ignore next */ []));
1001
+ canvasSize = input.required(/* @ts-ignore */
1002
+ ...(ngDevMode ? [{ debugName: "canvasSize" }] : /* istanbul ignore next */ []));
1003
+ mediaDataUrls = input(new Map(), /* @ts-ignore */
1004
+ ...(ngDevMode ? [{ debugName: "mediaDataUrls" }] : /* istanbul ignore next */ []));
1005
+ zoom = input(1, /* @ts-ignore */
1006
+ ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
1007
+ elements = computed(() => this.slide()?.elements ?? [], /* @ts-ignore */
1008
+ ...(ngDevMode ? [{ debugName: "elements" }] : /* istanbul ignore next */ []));
1009
+ wrapperStyle = computed(() => {
1010
+ const scale = this.zoom();
1011
+ const size = this.canvasSize();
1012
+ return {
1013
+ width: `${size.width * scale}px`,
1014
+ height: `${size.height * scale}px`,
1015
+ position: 'relative',
1016
+ margin: '1rem auto',
1017
+ };
1018
+ }, /* @ts-ignore */
1019
+ ...(ngDevMode ? [{ debugName: "wrapperStyle" }] : /* istanbul ignore next */ []));
1020
+ stageStyle = computed(() => {
1021
+ const scale = this.zoom();
1022
+ const size = this.canvasSize();
1023
+ const slide = this.slide();
1024
+ const style = {
1025
+ width: `${size.width}px`,
1026
+ height: `${size.height}px`,
1027
+ transform: `scale(${scale})`,
1028
+ 'transform-origin': 'top left',
1029
+ position: 'relative',
1030
+ overflow: 'hidden',
1031
+ 'background-color': slide?.backgroundColor && slide.backgroundColor !== 'transparent'
1032
+ ? slide.backgroundColor
1033
+ : '#ffffff',
1034
+ 'background-size': '100% 100%',
1035
+ 'box-shadow': '0 10px 40px rgba(0, 0, 0, 0.35)',
1036
+ };
1037
+ if (slide?.backgroundImage) {
1038
+ style['background-image'] = `url(${slide.backgroundImage})`;
1039
+ }
1040
+ return style;
1041
+ }, /* @ts-ignore */
1042
+ ...(ngDevMode ? [{ debugName: "stageStyle" }] : /* istanbul ignore next */ []));
1043
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SlideCanvasComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1044
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.1", type: SlideCanvasComponent, isStandalone: true, selector: "pptx-slide-canvas", inputs: { slide: { classPropertyName: "slide", publicName: "slide", isSignal: true, isRequired: false, transformFunction: null }, canvasSize: { classPropertyName: "canvasSize", publicName: "canvasSize", isSignal: true, isRequired: true, transformFunction: null }, mediaDataUrls: { classPropertyName: "mediaDataUrls", publicName: "mediaDataUrls", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
1045
+ <div class="pptx-ng-canvas-viewport">
1046
+ <div class="pptx-ng-canvas-wrapper" [ngStyle]="wrapperStyle()">
1047
+ <div
1048
+ class="pptx-ng-canvas-stage"
1049
+ role="region"
1050
+ aria-roledescription="slide"
1051
+ [ngStyle]="stageStyle()"
1052
+ >
1053
+ @for (element of elements(); track element.id; let i = $index) {
1054
+ <pptx-element-renderer
1055
+ [element]="element"
1056
+ [mediaDataUrls]="mediaDataUrls()"
1057
+ [zIndex]="i"
1058
+ />
1059
+ }
1060
+ </div>
1061
+ </div>
1062
+ </div>
1063
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: ElementRendererComponent, selector: "pptx-element-renderer", inputs: ["element", "mediaDataUrls", "zIndex"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1064
+ }
1065
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SlideCanvasComponent, decorators: [{
1066
+ type: Component,
1067
+ args: [{
1068
+ selector: 'pptx-slide-canvas',
1069
+ standalone: true,
1070
+ changeDetection: ChangeDetectionStrategy.OnPush,
1071
+ imports: [NgStyle, ElementRendererComponent],
1072
+ template: `
1073
+ <div class="pptx-ng-canvas-viewport">
1074
+ <div class="pptx-ng-canvas-wrapper" [ngStyle]="wrapperStyle()">
1075
+ <div
1076
+ class="pptx-ng-canvas-stage"
1077
+ role="region"
1078
+ aria-roledescription="slide"
1079
+ [ngStyle]="stageStyle()"
1080
+ >
1081
+ @for (element of elements(); track element.id; let i = $index) {
1082
+ <pptx-element-renderer
1083
+ [element]="element"
1084
+ [mediaDataUrls]="mediaDataUrls()"
1085
+ [zIndex]="i"
1086
+ />
1087
+ }
1088
+ </div>
1089
+ </div>
1090
+ </div>
1091
+ `,
1092
+ }]
1093
+ }], propDecorators: { slide: [{ type: i0.Input, args: [{ isSignal: true, alias: "slide", required: false }] }], canvasSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "canvasSize", required: true }] }], mediaDataUrls: [{ type: i0.Input, args: [{ isSignal: true, alias: "mediaDataUrls", required: false }] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }] } });
1094
+
1095
+ const ZOOM_STEP = 0.1;
1096
+ const ZOOM_MIN = 0.2;
1097
+ const ZOOM_MAX = 3;
1098
+ /**
1099
+ * PowerPointViewerComponent — Angular port of the React `PowerPointViewer.tsx`
1100
+ * and Vue `PowerPointViewer.vue`.
1101
+ *
1102
+ * Top-level orchestrator that loads `.pptx` bytes and renders the slides with
1103
+ * navigation and zoom. This is the viewer-first milestone of the port: the
1104
+ * React component additionally composes a full editor (toolbar, inspector
1105
+ * panels, dialogs, presentation mode, collaboration, export). The roadmap and
1106
+ * per-area status live in `packages/angular/PORTING.md`.
1107
+ *
1108
+ * Conventions vs. React/Vue:
1109
+ * - React `forwardRef` handle / Vue `defineExpose` → public {@link getContent}
1110
+ * method (reach it via a template ref or `viewChild`).
1111
+ * - React callback props / Vue emits → Angular `output()` events.
1112
+ * - React theme context / Vue provide-inject → `themeStyle` CSS vars applied to
1113
+ * the root element (app-wide sharing via `provideViewerTheme`).
1114
+ */
1115
+ class PowerPointViewerComponent {
1116
+ /** PowerPoint content as Uint8Array (or ArrayBuffer). */
1117
+ content = input(null, /* @ts-ignore */
1118
+ ...(ngDevMode ? [{ debugName: "content" }] : /* istanbul ignore next */ []));
1119
+ /** Whether editing actions are enabled. (Editor chrome not yet ported.) */
1120
+ canEdit = input(false, /* @ts-ignore */
1121
+ ...(ngDevMode ? [{ debugName: "canEdit" }] : /* istanbul ignore next */ []));
1122
+ /** Optional class applied to the root element. */
1123
+ class = input('', /* @ts-ignore */
1124
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
1125
+ /** Theme configuration for customising the viewer's appearance. */
1126
+ theme = input(undefined, /* @ts-ignore */
1127
+ ...(ngDevMode ? [{ debugName: "theme" }] : /* istanbul ignore next */ []));
1128
+ /** Optional real-time collaboration config (accepted for API parity; not yet implemented). */
1129
+ collaboration = input(undefined, /* @ts-ignore */
1130
+ ...(ngDevMode ? [{ debugName: "collaboration" }] : /* istanbul ignore next */ []));
1131
+ /** Fired when the active slide changes. */
1132
+ activeSlideChange = output();
1133
+ /** Fired when the unsaved-changes flag toggles. (Editing not yet ported.) */
1134
+ dirtyChange = output();
1135
+ /** Fired when the in-memory content changes after edits. (Editing not yet ported.) */
1136
+ contentChange = output();
1137
+ loader = inject(LoadContentService);
1138
+ activeSlideIndex = signal(0, /* @ts-ignore */
1139
+ ...(ngDevMode ? [{ debugName: "activeSlideIndex" }] : /* istanbul ignore next */ []));
1140
+ slideCount = this.loader.slideCount;
1141
+ activeSlide = computed(() => this.loader.slides()[this.activeSlideIndex()], /* @ts-ignore */
1142
+ ...(ngDevMode ? [{ debugName: "activeSlide" }] : /* istanbul ignore next */ []));
1143
+ rootStyle = computed(() => themeStyle(this.theme()), /* @ts-ignore */
1144
+ ...(ngDevMode ? [{ debugName: "rootStyle" }] : /* istanbul ignore next */ []));
1145
+ zoom = signal(1, /* @ts-ignore */
1146
+ ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
1147
+ zoomPercent = computed(() => Math.round(this.zoom() * 100), /* @ts-ignore */
1148
+ ...(ngDevMode ? [{ debugName: "zoomPercent" }] : /* istanbul ignore next */ []));
1149
+ constructor() {
1150
+ // Load whenever the `content` input changes.
1151
+ effect(() => {
1152
+ const content = this.content();
1153
+ void this.loader.load(content);
1154
+ });
1155
+ // Reset to the first slide whenever a new presentation finishes loading.
1156
+ effect(() => {
1157
+ // Read slides to track; reset index out of band.
1158
+ this.loader.slides();
1159
+ this.activeSlideIndex.set(0);
1160
+ });
1161
+ // Emit navigation changes.
1162
+ effect(() => {
1163
+ this.activeSlideChange.emit(this.activeSlideIndex());
1164
+ });
1165
+ }
1166
+ /** Serialise the current presentation to `.pptx` bytes (imperative handle). */
1167
+ getContent() {
1168
+ return this.loader.getContent();
1169
+ }
1170
+ goTo(index) {
1171
+ if (index < 0 || index >= this.slideCount()) {
1172
+ return;
1173
+ }
1174
+ this.activeSlideIndex.set(index);
1175
+ }
1176
+ goPrev() {
1177
+ this.goTo(this.activeSlideIndex() - 1);
1178
+ }
1179
+ goNext() {
1180
+ this.goTo(this.activeSlideIndex() + 1);
1181
+ }
1182
+ zoomIn() {
1183
+ this.zoom.set(Math.min(ZOOM_MAX, Number((this.zoom() + ZOOM_STEP).toFixed(2))));
1184
+ }
1185
+ zoomOut() {
1186
+ this.zoom.set(Math.max(ZOOM_MIN, Number((this.zoom() - ZOOM_STEP).toFixed(2))));
1187
+ }
1188
+ zoomReset() {
1189
+ this.zoom.set(1);
1190
+ }
1191
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: PowerPointViewerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1192
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.1", type: PowerPointViewerComponent, isStandalone: true, selector: "pptx-viewer", inputs: { content: { classPropertyName: "content", publicName: "content", isSignal: true, isRequired: false, transformFunction: null }, canEdit: { classPropertyName: "canEdit", publicName: "canEdit", isSignal: true, isRequired: false, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, theme: { classPropertyName: "theme", publicName: "theme", isSignal: true, isRequired: false, transformFunction: null }, collaboration: { classPropertyName: "collaboration", publicName: "collaboration", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { activeSlideChange: "activeSlideChange", dirtyChange: "dirtyChange", contentChange: "contentChange" }, providers: [LoadContentService], ngImport: i0, template: `
1193
+ <div class="pptx-ng-viewer" [ngClass]="class()" [ngStyle]="rootStyle()">
1194
+ @if (loader.loading()) {
1195
+ <div class="pptx-ng-state pptx-ng-loading">
1196
+ <div class="pptx-ng-spinner" aria-hidden="true"></div>
1197
+ <p>Loading presentation…</p>
1198
+ </div>
1199
+ } @else if (loader.isEncrypted()) {
1200
+ <div class="pptx-ng-state pptx-ng-error">
1201
+ <p>This presentation is password-protected and cannot be opened.</p>
1202
+ </div>
1203
+ } @else if (loader.error()) {
1204
+ <div class="pptx-ng-state pptx-ng-error">
1205
+ <p>Failed to load presentation.</p>
1206
+ <pre class="pptx-ng-error-detail">{{ loader.error() }}</pre>
1207
+ </div>
1208
+ } @else {
1209
+ <header class="pptx-ng-toolbar">
1210
+ <div class="pptx-ng-nav">
1211
+ <button type="button" [disabled]="activeSlideIndex() <= 0" (click)="goPrev()">‹</button>
1212
+ <span class="pptx-ng-slide-counter">
1213
+ {{ slideCount() === 0 ? 0 : activeSlideIndex() + 1 }} / {{ slideCount() }}
1214
+ </span>
1215
+ <button
1216
+ type="button"
1217
+ [disabled]="activeSlideIndex() >= slideCount() - 1"
1218
+ (click)="goNext()"
1219
+ >
1220
+
1221
+ </button>
1222
+ </div>
1223
+ <div class="pptx-ng-zoom">
1224
+ <button type="button" (click)="zoomOut()">−</button>
1225
+ <button type="button" class="pptx-ng-zoom-value" (click)="zoomReset()">
1226
+ {{ zoomPercent() }}%
1227
+ </button>
1228
+ <button type="button" (click)="zoomIn()">+</button>
1229
+ </div>
1230
+ </header>
1231
+
1232
+ <div class="pptx-ng-body">
1233
+ <nav class="pptx-ng-thumbnails" aria-label="Slides">
1234
+ @for (slide of loader.slides(); track slide.id; let i = $index) {
1235
+ <button
1236
+ type="button"
1237
+ class="pptx-ng-thumb"
1238
+ [class.is-active]="i === activeSlideIndex()"
1239
+ (click)="goTo(i)"
1240
+ >
1241
+ <span class="pptx-ng-thumb-index">{{ i + 1 }}</span>
1242
+ </button>
1243
+ }
1244
+ </nav>
1245
+
1246
+ <main class="pptx-ng-main">
1247
+ <pptx-slide-canvas
1248
+ [slide]="activeSlide()"
1249
+ [canvasSize]="loader.canvasSize()"
1250
+ [mediaDataUrls]="loader.mediaDataUrls()"
1251
+ [zoom]="zoom()"
1252
+ />
1253
+ </main>
1254
+ </div>
1255
+ }
1256
+ </div>
1257
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: SlideCanvasComponent, selector: "pptx-slide-canvas", inputs: ["slide", "canvasSize", "mediaDataUrls", "zoom"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1258
+ }
1259
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: PowerPointViewerComponent, decorators: [{
1260
+ type: Component,
1261
+ args: [{
1262
+ selector: 'pptx-viewer',
1263
+ standalone: true,
1264
+ changeDetection: ChangeDetectionStrategy.OnPush,
1265
+ providers: [LoadContentService],
1266
+ imports: [NgClass, NgStyle, SlideCanvasComponent],
1267
+ template: `
1268
+ <div class="pptx-ng-viewer" [ngClass]="class()" [ngStyle]="rootStyle()">
1269
+ @if (loader.loading()) {
1270
+ <div class="pptx-ng-state pptx-ng-loading">
1271
+ <div class="pptx-ng-spinner" aria-hidden="true"></div>
1272
+ <p>Loading presentation…</p>
1273
+ </div>
1274
+ } @else if (loader.isEncrypted()) {
1275
+ <div class="pptx-ng-state pptx-ng-error">
1276
+ <p>This presentation is password-protected and cannot be opened.</p>
1277
+ </div>
1278
+ } @else if (loader.error()) {
1279
+ <div class="pptx-ng-state pptx-ng-error">
1280
+ <p>Failed to load presentation.</p>
1281
+ <pre class="pptx-ng-error-detail">{{ loader.error() }}</pre>
1282
+ </div>
1283
+ } @else {
1284
+ <header class="pptx-ng-toolbar">
1285
+ <div class="pptx-ng-nav">
1286
+ <button type="button" [disabled]="activeSlideIndex() <= 0" (click)="goPrev()">‹</button>
1287
+ <span class="pptx-ng-slide-counter">
1288
+ {{ slideCount() === 0 ? 0 : activeSlideIndex() + 1 }} / {{ slideCount() }}
1289
+ </span>
1290
+ <button
1291
+ type="button"
1292
+ [disabled]="activeSlideIndex() >= slideCount() - 1"
1293
+ (click)="goNext()"
1294
+ >
1295
+
1296
+ </button>
1297
+ </div>
1298
+ <div class="pptx-ng-zoom">
1299
+ <button type="button" (click)="zoomOut()">−</button>
1300
+ <button type="button" class="pptx-ng-zoom-value" (click)="zoomReset()">
1301
+ {{ zoomPercent() }}%
1302
+ </button>
1303
+ <button type="button" (click)="zoomIn()">+</button>
1304
+ </div>
1305
+ </header>
1306
+
1307
+ <div class="pptx-ng-body">
1308
+ <nav class="pptx-ng-thumbnails" aria-label="Slides">
1309
+ @for (slide of loader.slides(); track slide.id; let i = $index) {
1310
+ <button
1311
+ type="button"
1312
+ class="pptx-ng-thumb"
1313
+ [class.is-active]="i === activeSlideIndex()"
1314
+ (click)="goTo(i)"
1315
+ >
1316
+ <span class="pptx-ng-thumb-index">{{ i + 1 }}</span>
1317
+ </button>
1318
+ }
1319
+ </nav>
1320
+
1321
+ <main class="pptx-ng-main">
1322
+ <pptx-slide-canvas
1323
+ [slide]="activeSlide()"
1324
+ [canvasSize]="loader.canvasSize()"
1325
+ [mediaDataUrls]="loader.mediaDataUrls()"
1326
+ [zoom]="zoom()"
1327
+ />
1328
+ </main>
1329
+ </div>
1330
+ }
1331
+ </div>
1332
+ `,
1333
+ }]
1334
+ }], ctorParameters: () => [], propDecorators: { content: [{ type: i0.Input, args: [{ isSignal: true, alias: "content", required: false }] }], canEdit: [{ type: i0.Input, args: [{ isSignal: true, alias: "canEdit", required: false }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], theme: [{ type: i0.Input, args: [{ isSignal: true, alias: "theme", required: false }] }], collaboration: [{ type: i0.Input, args: [{ isSignal: true, alias: "collaboration", required: false }] }], activeSlideChange: [{ type: i0.Output, args: ["activeSlideChange"] }], dirtyChange: [{ type: i0.Output, args: ["dirtyChange"] }], contentChange: [{ type: i0.Output, args: ["contentChange"] }] } });
1335
+
1336
+ /**
1337
+ * Scalar viewer defaults — shared across the React, Vue, and Angular bindings
1338
+ * via `pptx-viewer-shared`. Re-exported here for ergonomic local imports.
1339
+ */
1340
+
1341
+ function cn(...values) {
1342
+ return values.filter((v) => Boolean(v)).join(' ');
1343
+ }
1344
+
1345
+ /**
1346
+ * Public API surface for `pptx-angular-viewer`.
1347
+ *
1348
+ * Angular counterpart of the React `pptx-viewer` and Vue `pptx-vue-viewer`
1349
+ * packages. Wraps the framework-agnostic `pptx-viewer-core` engine and shares
1350
+ * cross-framework logic via `pptx-viewer-shared`.
1351
+ */
1352
+
1353
+ /**
1354
+ * Generated bundle index. Do not edit.
1355
+ */
1356
+
1357
+ export { DEFAULT_CANVAS_HEIGHT, DEFAULT_CANVAS_WIDTH, DEFAULT_FILL_COLOR, DEFAULT_STROKE_COLOR, DEFAULT_TEXT_COLOR, ElementRendererComponent, LoadContentService, PowerPointViewerComponent, SlideCanvasComponent, VIEWER_THEME, cn, defaultCssVars, defaultRadius, defaultThemeColors, getContainerStyle, getImageSrc, getShapeFillStrokeStyle, getTextBlockStyle, provideViewerTheme, themeStyle, themeToCssVars };
1358
+ //# sourceMappingURL=pptx-angular-viewer.mjs.map