| 1 | import type { SlideSizePreset } from '@shared/slide-size' |
| 2 | |
| 3 | export interface PptxExportLayout { |
| 4 | captureWidthPx: number |
| 5 | captureHeightPx: number |
| 6 | slideWidthIn: number |
| 7 | slideHeightIn: number |
| 8 | } |
| 9 | |
| 10 | export interface PptxCaptureRect { |
| 11 | x: number |
| 12 | y: number |
| 13 | width: number |
| 14 | height: number |
| 15 | } |
| 16 | |
| 17 | export const PPTX_WIDE_LAYOUT: Omit<PptxExportLayout, 'captureWidthPx' | 'captureHeightPx'> = { |
| 18 | slideWidthIn: 13.333333333, |
| 19 | slideHeightIn: 7.5 |
| 20 | } |
| 21 | |
| 22 | export const PPTX_STANDARD_LAYOUT: Omit<PptxExportLayout, 'captureWidthPx' | 'captureHeightPx'> = { |
| 23 | slideWidthIn: 10, |
| 24 | slideHeightIn: 7.5 |
| 25 | } |
| 26 | |
| 27 | export const resolvePptxExportLayout = (slideSize: SlideSizePreset): PptxExportLayout => { |
| 28 | const physicalSize = |
| 29 | slideSize.id === 'wide-16-9' |
| 30 | ? PPTX_WIDE_LAYOUT |
| 31 | : slideSize.id === 'standard-4-3' |
| 32 | ? PPTX_STANDARD_LAYOUT |
| 33 | : null |
| 34 | if (!physicalSize) { |
| 35 | throw new Error(`Unsupported PPTX slide size: ${slideSize.id}`) |
| 36 | } |
| 37 | |
| 38 | return { |
| 39 | captureWidthPx: slideSize.width, |
| 40 | captureHeightPx: slideSize.height, |
| 41 | ...physicalSize |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | export const resolvePptxCaptureRect = ( |
| 46 | rect: { x: number; y: number; w: number; h: number }, |
| 47 | layout: Pick<PptxExportLayout, 'captureWidthPx' | 'captureHeightPx'>, |
| 48 | paddingPx = 0 |
| 49 | ): PptxCaptureRect | null => { |
| 50 | const x = Math.min(layout.captureWidthPx, Math.max(0, rect.x - paddingPx)) |
| 51 | const y = Math.min(layout.captureHeightPx, Math.max(0, rect.y - paddingPx)) |
| 52 | const width = Math.max(0, Math.min(layout.captureWidthPx - x, rect.w + paddingPx * 2)) |
| 53 | const height = Math.max(0, Math.min(layout.captureHeightPx - y, rect.h + paddingPx * 2)) |
| 54 | return width > 0 && height > 0 ? { x, y, width, height } : null |
| 55 | } |
| 56 | |
| 57 | const STATIC_BACKGROUND_MIN_AREA_RATIO = 0.2 |
| 58 | const STATIC_BACKGROUND_EDGE_TOLERANCE_PX = 2 |
| 59 | |
| 60 | type PptxShapeBox = { |
| 61 | x: number |
| 62 | y: number |
| 63 | w: number |
| 64 | h: number |
| 65 | } |
| 66 | |
| 67 | // Large edge-anchored fills are structural slide backgrounds. Keeping them in |
| 68 | // the raster base avoids both z-order coverage and accidental animation matches. |
| 69 | export const isPptxStaticBackgroundShape = ( |
| 70 | shape: PptxShapeBox, |
| 71 | layout: PptxExportLayout = { |
| 72 | captureWidthPx: 1600, |
| 73 | captureHeightPx: 900, |
| 74 | ...PPTX_WIDE_LAYOUT |
| 75 | } |
| 76 | ): boolean => { |
| 77 | const horizontalToleranceIn = |
| 78 | (layout.slideWidthIn / layout.captureWidthPx) * STATIC_BACKGROUND_EDGE_TOLERANCE_PX |
| 79 | const verticalToleranceIn = |
| 80 | (layout.slideHeightIn / layout.captureHeightPx) * STATIC_BACKGROUND_EDGE_TOLERANCE_PX |
| 81 | const area = Math.max(0, shape.w) * Math.max(0, shape.h) |
| 82 | const slideArea = layout.slideWidthIn * layout.slideHeightIn |
| 83 | if (area < slideArea * STATIC_BACKGROUND_MIN_AREA_RATIO) return false |
| 84 | |
| 85 | const spansHeight = shape.h >= layout.slideHeightIn - verticalToleranceIn |
| 86 | // Only full-height columns and full-page fills belong in the raster base. |
| 87 | // Full-width header or footer bands should remain editable shapes. |
| 88 | if (!spansHeight) return false |
| 89 | |
| 90 | const touchesHorizontalEdge = |
| 91 | shape.x <= horizontalToleranceIn || |
| 92 | shape.x + shape.w >= layout.slideWidthIn - horizontalToleranceIn |
| 93 | const touchesVerticalEdge = |
| 94 | shape.y <= verticalToleranceIn || |
| 95 | shape.y + shape.h >= layout.slideHeightIn - verticalToleranceIn |
| 96 | |
| 97 | return touchesHorizontalEdge && touchesVerticalEdge |
| 98 | } |
| 99 |