react-native-ensys-camera-x 1.0.7
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/CustomCamera.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +833 -0
- package/android/build.gradle +135 -0
- package/android/consumer-rules.pro +47 -0
- package/android/src/main/AndroidManifest.xml +72 -0
- package/android/src/main/java/android/print/PdfPrint.java +51 -0
- package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
- package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
- package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
- package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
- package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
- package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
- package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
- package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
- package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
- package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
- package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
- package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
- package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
- package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
- package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
- package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
- package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
- package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
- package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
- package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
- package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
- package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
- package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
- package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
- package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
- package/android/src/main/java/com/customcamera/Localization.kt +206 -0
- package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
- package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
- package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
- package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
- package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
- package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
- package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
- package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
- package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
- package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
- package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
- package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
- package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
- package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
- package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
- package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
- package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
- package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
- package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
- package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
- package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
- package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
- package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
- package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
- package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
- package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
- package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
- package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
- package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
- package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
- package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
- package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
- package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
- package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
- package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
- package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
- package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
- package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
- package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
- package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
- package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
- package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
- package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
- package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
- package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
- package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
- package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
- package/android/src/main/res/drawable/bg_pill.xml +6 -0
- package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
- package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
- package/android/src/main/res/drawable/bg_send_button.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
- package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
- package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
- package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
- package/android/src/main/res/drawable/ic_audio.xml +10 -0
- package/android/src/main/res/drawable/ic_back.xml +10 -0
- package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
- package/android/src/main/res/drawable/ic_check.xml +17 -0
- package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
- package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
- package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
- package/android/src/main/res/drawable/ic_close.xml +10 -0
- package/android/src/main/res/drawable/ic_cover.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +10 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_document.xml +10 -0
- package/android/src/main/res/drawable/ic_done.xml +10 -0
- package/android/src/main/res/drawable/ic_download.xml +10 -0
- package/android/src/main/res/drawable/ic_emoji.xml +10 -0
- package/android/src/main/res/drawable/ic_filter.xml +10 -0
- package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
- package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
- package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
- package/android/src/main/res/drawable/ic_flip.xml +15 -0
- package/android/src/main/res/drawable/ic_gallery.xml +10 -0
- package/android/src/main/res/drawable/ic_gif.xml +24 -0
- package/android/src/main/res/drawable/ic_lock.xml +19 -0
- package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
- package/android/src/main/res/drawable/ic_marker.xml +10 -0
- package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
- package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
- package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
- package/android/src/main/res/drawable/ic_pause.xml +10 -0
- package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_play.xml +10 -0
- package/android/src/main/res/drawable/ic_redo.xml +10 -0
- package/android/src/main/res/drawable/ic_rotate.xml +9 -0
- package/android/src/main/res/drawable/ic_send.xml +10 -0
- package/android/src/main/res/drawable/ic_share.xml +10 -0
- package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
- package/android/src/main/res/drawable/ic_sticker.xml +24 -0
- package/android/src/main/res/drawable/ic_text.xml +10 -0
- package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
- package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
- package/android/src/main/res/drawable/ic_trim.xml +23 -0
- package/android/src/main/res/drawable/ic_undo.xml +10 -0
- package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_video_note.xml +13 -0
- package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
- package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
- package/android/src/main/res/drawable/sticker_heart.xml +9 -0
- package/android/src/main/res/drawable/sticker_smile.xml +9 -0
- package/android/src/main/res/drawable/sticker_star.xml +9 -0
- package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
- package/android/src/main/res/values/colors.xml +34 -0
- package/android/src/main/res/values/ids.xml +15 -0
- package/android/src/main/res/values/strings.xml +15 -0
- package/android/src/main/res/values/styles.xml +20 -0
- package/android/src/main/res/xml/file_paths.xml +34 -0
- package/ios/CustomCamera.h +5 -0
- package/ios/CustomCamera.mm +214 -0
- package/lib/module/AudioPlayerContext.js +233 -0
- package/lib/module/AudioPlayerContext.js.map +1 -0
- package/lib/module/CameraPreview.js +304 -0
- package/lib/module/CameraPreview.js.map +1 -0
- package/lib/module/NativeAnimatedImageView.js +41 -0
- package/lib/module/NativeAnimatedImageView.js.map +1 -0
- package/lib/module/NativeAudioPlayerView.js +33 -0
- package/lib/module/NativeAudioPlayerView.js.map +1 -0
- package/lib/module/NativeBlurView.js +35 -0
- package/lib/module/NativeBlurView.js.map +1 -0
- package/lib/module/NativeCustomCamera.js +128 -0
- package/lib/module/NativeCustomCamera.js.map +1 -0
- package/lib/module/NativeCustomCamera.web.js +143 -0
- package/lib/module/NativeCustomCamera.web.js.map +1 -0
- package/lib/module/NativeCustomVideoView.js +5 -0
- package/lib/module/NativeCustomVideoView.js.map +1 -0
- package/lib/module/NativeDocumentPreviewView.js +156 -0
- package/lib/module/NativeDocumentPreviewView.js.map +1 -0
- package/lib/module/NativeMediaThumbnailView.js +110 -0
- package/lib/module/NativeMediaThumbnailView.js.map +1 -0
- package/lib/module/NativeSvgIcon.js +32 -0
- package/lib/module/NativeSvgIcon.js.map +1 -0
- package/lib/module/ResponsivePreviewContainer.js +171 -0
- package/lib/module/ResponsivePreviewContainer.js.map +1 -0
- package/lib/module/accessibility.js +61 -0
- package/lib/module/accessibility.js.map +1 -0
- package/lib/module/config.js +352 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/cpuOptimization.js +296 -0
- package/lib/module/cpuOptimization.js.map +1 -0
- package/lib/module/db/DatabaseManager.js +191 -0
- package/lib/module/db/DatabaseManager.js.map +1 -0
- package/lib/module/db/MediaMapper.js +284 -0
- package/lib/module/db/MediaMapper.js.map +1 -0
- package/lib/module/db/MediaRepository.js +242 -0
- package/lib/module/db/MediaRepository.js.map +1 -0
- package/lib/module/db/index.js +41 -0
- package/lib/module/db/index.js.map +1 -0
- package/lib/module/db/migrations.js +180 -0
- package/lib/module/db/migrations.js.map +1 -0
- package/lib/module/db/react-native-sqlcipher.d.js +2 -0
- package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
- package/lib/module/db/secureKey.js +87 -0
- package/lib/module/db/secureKey.js.map +1 -0
- package/lib/module/db/types.js +57 -0
- package/lib/module/db/types.js.map +1 -0
- package/lib/module/db/useMediaDatabase.js +123 -0
- package/lib/module/db/useMediaDatabase.js.map +1 -0
- package/lib/module/defaultIcons.js +2259 -0
- package/lib/module/defaultIcons.js.map +1 -0
- package/lib/module/deviceCapabilities.js +408 -0
- package/lib/module/deviceCapabilities.js.map +1 -0
- package/lib/module/errors.js +267 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/iconConfig.js +796 -0
- package/lib/module/iconConfig.js.map +1 -0
- package/lib/module/icons.js +514 -0
- package/lib/module/icons.js.map +1 -0
- package/lib/module/index.js +251 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/jest-globals.d.js +2 -0
- package/lib/module/jest-globals.d.js.map +1 -0
- package/lib/module/library/MediaLibrary.js +107 -0
- package/lib/module/library/MediaLibrary.js.map +1 -0
- package/lib/module/library/MediaLibraryScreen.js +2597 -0
- package/lib/module/library/MediaLibraryScreen.js.map +1 -0
- package/lib/module/library/components/AudioPreview.js +74 -0
- package/lib/module/library/components/AudioPreview.js.map +1 -0
- package/lib/module/library/components/DocumentPreview.js +331 -0
- package/lib/module/library/components/DocumentPreview.js.map +1 -0
- package/lib/module/library/components/ExpandableFab.js +343 -0
- package/lib/module/library/components/ExpandableFab.js.map +1 -0
- package/lib/module/library/components/FileTiles.js +251 -0
- package/lib/module/library/components/FileTiles.js.map +1 -0
- package/lib/module/library/components/ImagePreview.js +70 -0
- package/lib/module/library/components/ImagePreview.js.map +1 -0
- package/lib/module/library/components/MediaMetadataHeader.js +175 -0
- package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
- package/lib/module/library/components/RecordAudioScreen.js +816 -0
- package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
- package/lib/module/library/components/VideoPreview.js +453 -0
- package/lib/module/library/components/VideoPreview.js.map +1 -0
- package/lib/module/library/components/ViewerCounter.js +55 -0
- package/lib/module/library/components/ViewerCounter.js.map +1 -0
- package/lib/module/library/compressionEstimate.js +507 -0
- package/lib/module/library/compressionEstimate.js.map +1 -0
- package/lib/module/library/fileMetadata.js +196 -0
- package/lib/module/library/fileMetadata.js.map +1 -0
- package/lib/module/library/index.js +42 -0
- package/lib/module/library/index.js.map +1 -0
- package/lib/module/library/libraryIconRegistry.js +69 -0
- package/lib/module/library/libraryIconRegistry.js.map +1 -0
- package/lib/module/library/libraryIcons.js +858 -0
- package/lib/module/library/libraryIcons.js.map +1 -0
- package/lib/module/library/localizedFormat.js +181 -0
- package/lib/module/library/localizedFormat.js.map +1 -0
- package/lib/module/library/palette.js +225 -0
- package/lib/module/library/palette.js.map +1 -0
- package/lib/module/library/responsive.js +53 -0
- package/lib/module/library/responsive.js.map +1 -0
- package/lib/module/locales.js +416 -0
- package/lib/module/locales.js.map +1 -0
- package/lib/module/localization.js +1064 -0
- package/lib/module/localization.js.map +1 -0
- package/lib/module/logger.js +20 -0
- package/lib/module/logger.js.map +1 -0
- package/lib/module/lowMemoryCache.js +206 -0
- package/lib/module/lowMemoryCache.js.map +1 -0
- package/lib/module/mediaHistory.js +33 -0
- package/lib/module/mediaHistory.js.map +1 -0
- package/lib/module/mediaStorageService.js +402 -0
- package/lib/module/mediaStorageService.js.map +1 -0
- package/lib/module/memory.js +79 -0
- package/lib/module/memory.js.map +1 -0
- package/lib/module/nativeActions.js +307 -0
- package/lib/module/nativeActions.js.map +1 -0
- package/lib/module/nativeComponentSupport.js +61 -0
- package/lib/module/nativeComponentSupport.js.map +1 -0
- package/lib/module/networkPerformance.js +24 -0
- package/lib/module/networkPerformance.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/performance.js +104 -0
- package/lib/module/performance.js.map +1 -0
- package/lib/module/performanceMonitor.js +155 -0
- package/lib/module/performanceMonitor.js.map +1 -0
- package/lib/module/permissions.js +370 -0
- package/lib/module/permissions.js.map +1 -0
- package/lib/module/rendering.js +40 -0
- package/lib/module/rendering.js.map +1 -0
- package/lib/module/security.js +18 -0
- package/lib/module/security.js.map +1 -0
- package/lib/module/state.js +266 -0
- package/lib/module/state.js.map +1 -0
- package/lib/module/storage.js +400 -0
- package/lib/module/storage.js.map +1 -0
- package/lib/module/theme.js +390 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils.js +115 -0
- package/lib/module/utils.js.map +1 -0
- package/lib/module/validation.js +288 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
- package/lib/typescript/src/CameraPreview.d.ts +13 -0
- package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
- package/lib/typescript/src/NativeBlurView.d.ts +12 -0
- package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
- package/lib/typescript/src/accessibility.d.ts +20 -0
- package/lib/typescript/src/accessibility.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +145 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/cpuOptimization.d.ts +63 -0
- package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
- package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
- package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
- package/lib/typescript/src/db/index.d.ts +23 -0
- package/lib/typescript/src/db/index.d.ts.map +1 -0
- package/lib/typescript/src/db/migrations.d.ts +31 -0
- package/lib/typescript/src/db/migrations.d.ts.map +1 -0
- package/lib/typescript/src/db/secureKey.d.ts +10 -0
- package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
- package/lib/typescript/src/db/types.d.ts +115 -0
- package/lib/typescript/src/db/types.d.ts.map +1 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
- package/lib/typescript/src/defaultIcons.d.ts +139 -0
- package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
- package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
- package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +95 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/iconConfig.d.ts +176 -0
- package/lib/typescript/src/iconConfig.d.ts.map +1 -0
- package/lib/typescript/src/icons.d.ts +217 -0
- package/lib/typescript/src/icons.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +104 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
- package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
- package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
- package/lib/typescript/src/library/index.d.ts +40 -0
- package/lib/typescript/src/library/index.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
- package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
- package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
- package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
- package/lib/typescript/src/library/palette.d.ts +104 -0
- package/lib/typescript/src/library/palette.d.ts.map +1 -0
- package/lib/typescript/src/library/responsive.d.ts +15 -0
- package/lib/typescript/src/library/responsive.d.ts.map +1 -0
- package/lib/typescript/src/locales.d.ts +103 -0
- package/lib/typescript/src/locales.d.ts.map +1 -0
- package/lib/typescript/src/localization.d.ts +1051 -0
- package/lib/typescript/src/localization.d.ts.map +1 -0
- package/lib/typescript/src/logger.d.ts +4 -0
- package/lib/typescript/src/logger.d.ts.map +1 -0
- package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
- package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
- package/lib/typescript/src/mediaHistory.d.ts +3 -0
- package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
- package/lib/typescript/src/mediaStorageService.d.ts +155 -0
- package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
- package/lib/typescript/src/memory.d.ts +21 -0
- package/lib/typescript/src/memory.d.ts.map +1 -0
- package/lib/typescript/src/nativeActions.d.ts +174 -0
- package/lib/typescript/src/nativeActions.d.ts.map +1 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
- package/lib/typescript/src/networkPerformance.d.ts +12 -0
- package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
- package/lib/typescript/src/performance.d.ts +32 -0
- package/lib/typescript/src/performance.d.ts.map +1 -0
- package/lib/typescript/src/performanceMonitor.d.ts +45 -0
- package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
- package/lib/typescript/src/permissions.d.ts +132 -0
- package/lib/typescript/src/permissions.d.ts.map +1 -0
- package/lib/typescript/src/rendering.d.ts +9 -0
- package/lib/typescript/src/rendering.d.ts.map +1 -0
- package/lib/typescript/src/security.d.ts +6 -0
- package/lib/typescript/src/security.d.ts.map +1 -0
- package/lib/typescript/src/state.d.ts +102 -0
- package/lib/typescript/src/state.d.ts.map +1 -0
- package/lib/typescript/src/storage.d.ts +117 -0
- package/lib/typescript/src/storage.d.ts.map +1 -0
- package/lib/typescript/src/theme.d.ts +15 -0
- package/lib/typescript/src/theme.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +802 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utils.d.ts +50 -0
- package/lib/typescript/src/utils.d.ts.map +1 -0
- package/lib/typescript/src/validation.d.ts +52 -0
- package/lib/typescript/src/validation.d.ts.map +1 -0
- package/package.json +204 -0
- package/react-native.config.js +17 -0
- package/src/AudioPlayerContext.tsx +220 -0
- package/src/CameraPreview.tsx +352 -0
- package/src/NativeAnimatedImageView.tsx +71 -0
- package/src/NativeAudioPlayerView.tsx +39 -0
- package/src/NativeBlurView.tsx +40 -0
- package/src/NativeCustomCamera.ts +305 -0
- package/src/NativeCustomCamera.web.ts +192 -0
- package/src/NativeCustomVideoView.ts +30 -0
- package/src/NativeDocumentPreviewView.tsx +196 -0
- package/src/NativeMediaThumbnailView.tsx +167 -0
- package/src/NativeSvgIcon.tsx +46 -0
- package/src/ResponsivePreviewContainer.tsx +227 -0
- package/src/accessibility.ts +80 -0
- package/src/config.ts +406 -0
- package/src/cpuOptimization.ts +365 -0
- package/src/db/DatabaseManager.ts +212 -0
- package/src/db/MediaMapper.ts +377 -0
- package/src/db/MediaRepository.ts +313 -0
- package/src/db/index.ts +73 -0
- package/src/db/migrations.ts +222 -0
- package/src/db/react-native-sqlcipher.d.ts +76 -0
- package/src/db/secureKey.ts +100 -0
- package/src/db/types.ts +123 -0
- package/src/db/useMediaDatabase.ts +191 -0
- package/src/defaultIcons.tsx +2390 -0
- package/src/deviceCapabilities.ts +438 -0
- package/src/errors.ts +350 -0
- package/src/iconConfig.tsx +962 -0
- package/src/icons.tsx +788 -0
- package/src/index.tsx +763 -0
- package/src/jest-globals.d.ts +9 -0
- package/src/library/MediaLibrary.tsx +141 -0
- package/src/library/MediaLibraryScreen.tsx +3003 -0
- package/src/library/components/AudioPreview.tsx +73 -0
- package/src/library/components/DocumentPreview.tsx +361 -0
- package/src/library/components/ExpandableFab.tsx +342 -0
- package/src/library/components/FileTiles.tsx +252 -0
- package/src/library/components/ImagePreview.tsx +72 -0
- package/src/library/components/MediaMetadataHeader.tsx +208 -0
- package/src/library/components/RecordAudioScreen.tsx +845 -0
- package/src/library/components/VideoPreview.tsx +478 -0
- package/src/library/components/ViewerCounter.tsx +71 -0
- package/src/library/compressionEstimate.ts +662 -0
- package/src/library/fileMetadata.ts +212 -0
- package/src/library/index.ts +66 -0
- package/src/library/libraryIconRegistry.tsx +86 -0
- package/src/library/libraryIcons.tsx +928 -0
- package/src/library/localizedFormat.ts +207 -0
- package/src/library/palette.ts +281 -0
- package/src/library/responsive.ts +49 -0
- package/src/locales.ts +381 -0
- package/src/localization.ts +1947 -0
- package/src/logger.ts +20 -0
- package/src/lowMemoryCache.ts +218 -0
- package/src/mediaHistory.ts +42 -0
- package/src/mediaStorageService.ts +546 -0
- package/src/memory.ts +102 -0
- package/src/nativeActions.ts +422 -0
- package/src/nativeComponentSupport.ts +60 -0
- package/src/networkPerformance.ts +30 -0
- package/src/performance.ts +137 -0
- package/src/performanceMonitor.ts +219 -0
- package/src/permissions.ts +403 -0
- package/src/rendering.ts +47 -0
- package/src/security.ts +19 -0
- package/src/state.ts +333 -0
- package/src/storage.ts +453 -0
- package/src/theme.tsx +467 -0
- package/src/types.ts +946 -0
- package/src/utils.ts +177 -0
- package/src/validation.ts +372 -0
|
@@ -0,0 +1,4077 @@
|
|
|
1
|
+
package com.customcamera
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.graphics.Canvas
|
|
7
|
+
import android.graphics.Color
|
|
8
|
+
import android.graphics.Paint
|
|
9
|
+
import android.graphics.RectF
|
|
10
|
+
import android.graphics.Typeface
|
|
11
|
+
import android.text.Layout
|
|
12
|
+
import android.text.SpannableStringBuilder
|
|
13
|
+
import android.text.Spanned
|
|
14
|
+
import android.text.StaticLayout
|
|
15
|
+
import android.text.TextPaint
|
|
16
|
+
import android.text.style.ForegroundColorSpan
|
|
17
|
+
import android.text.style.MetricAffectingSpan
|
|
18
|
+
import android.text.style.StrikethroughSpan
|
|
19
|
+
import android.text.style.UnderlineSpan
|
|
20
|
+
import android.view.View
|
|
21
|
+
import android.view.ViewGroup
|
|
22
|
+
import androidx.recyclerview.widget.RecyclerView
|
|
23
|
+
import org.xmlpull.v1.XmlPullParser
|
|
24
|
+
import java.io.File
|
|
25
|
+
import java.nio.charset.Charset
|
|
26
|
+
import java.util.zip.ZipFile
|
|
27
|
+
import kotlin.math.ceil
|
|
28
|
+
import kotlin.math.max
|
|
29
|
+
import kotlin.math.min
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Dedicated PowerPoint preview pipeline:
|
|
33
|
+
*
|
|
34
|
+
* extractor -> reads PPTX PresentationML or legacy PPT binary data
|
|
35
|
+
* formatter -> normalizes extracted slide data into the preview model
|
|
36
|
+
* viewer -> draws slides directly with Android Canvas inside DocumentPreviewView
|
|
37
|
+
*
|
|
38
|
+
* It does not invoke PowerPoint/WPS, online viewers, PDF conversion, fallback cards, or WebView.
|
|
39
|
+
*/
|
|
40
|
+
internal object PowerPointDocumentProcessor {
|
|
41
|
+
fun process(file: File, kind: String): PowerPointPresentation {
|
|
42
|
+
val extractor: PowerPointPresentationExtractor = when (kind) {
|
|
43
|
+
"ppt" -> PptPresentationExtractor()
|
|
44
|
+
"pptx" -> PptxPresentationExtractor()
|
|
45
|
+
else -> throw IllegalArgumentException("Unsupported PowerPoint kind: $kind")
|
|
46
|
+
}
|
|
47
|
+
return PowerPointPresentationFormatter().format(extractor.extract(file))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fun errorPresentation(sourceName: String, message: String): PowerPointPresentation =
|
|
51
|
+
PowerPointPresentationFormatter().format(
|
|
52
|
+
RawPowerPointPresentation(
|
|
53
|
+
sourceName = sourceName,
|
|
54
|
+
widthPt = DEFAULT_SLIDE_WIDTH_PT,
|
|
55
|
+
heightPt = DEFAULT_SLIDE_HEIGHT_PT,
|
|
56
|
+
slides = listOf(
|
|
57
|
+
RawPowerPointSlide(
|
|
58
|
+
textRuns = listOf(
|
|
59
|
+
sourceName.ifBlank { Localization.getString("powerPointPresentationTitle", "PowerPoint presentation") },
|
|
60
|
+
message
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
internal interface PowerPointPresentationExtractor {
|
|
69
|
+
fun extract(file: File): RawPowerPointPresentation
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
internal data class RawPowerPointPresentation(
|
|
73
|
+
val sourceName: String,
|
|
74
|
+
val widthPt: Float,
|
|
75
|
+
val heightPt: Float,
|
|
76
|
+
val slides: List<RawPowerPointSlide>,
|
|
77
|
+
/** Every bitmap decoded while extracting — see [PowerPointPresentation.ownedBitmaps]. */
|
|
78
|
+
val ownedBitmaps: List<Bitmap> = emptyList()
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
internal data class RawPowerPointSlide(
|
|
82
|
+
val background: PptFill = PptFill.Solid(Color.WHITE),
|
|
83
|
+
val elements: List<PowerPointElement> = emptyList(),
|
|
84
|
+
val textRuns: List<String> = emptyList(),
|
|
85
|
+
val images: List<Bitmap> = emptyList()
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
internal data class PowerPointPresentation(
|
|
89
|
+
val sourceName: String,
|
|
90
|
+
val widthPt: Float,
|
|
91
|
+
val heightPt: Float,
|
|
92
|
+
val slides: List<PowerPointSlide>,
|
|
93
|
+
/**
|
|
94
|
+
* EVERY bitmap decoded during extraction, whether or not it ended up drawn.
|
|
95
|
+
*
|
|
96
|
+
* The single source of truth for recycling. Walking the element tree instead (as this used to)
|
|
97
|
+
* silently missed bitmaps reachable only from a fill, and missed the legacy .ppt path's
|
|
98
|
+
* decoded-but-never-adopted images entirely, leaking them.
|
|
99
|
+
*/
|
|
100
|
+
private val ownedBitmaps: List<Bitmap> = emptyList()
|
|
101
|
+
) {
|
|
102
|
+
fun release() {
|
|
103
|
+
for (bitmap in ownedBitmaps) {
|
|
104
|
+
if (!bitmap.isRecycled) bitmap.recycle()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
internal data class PowerPointSlide(
|
|
110
|
+
val index: Int,
|
|
111
|
+
val background: PptFill,
|
|
112
|
+
val elements: List<PowerPointElement>
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A DrawingML fill, used for slide backgrounds, shapes and text-box bodies alike.
|
|
117
|
+
*
|
|
118
|
+
* Gradients previously had no representation at all, so a `<a:gradFill>` background silently
|
|
119
|
+
* became flat white and a gradient-filled shape ended up with no fill — at which point the shape
|
|
120
|
+
* was discarded entirely.
|
|
121
|
+
*/
|
|
122
|
+
internal sealed class PptFill {
|
|
123
|
+
data class Solid(val color: Int) : PptFill()
|
|
124
|
+
|
|
125
|
+
data class Image(val bitmap: Bitmap, val fallbackColor: Int = Color.WHITE) : PptFill()
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Arrays rather than lists to keep shader construction allocation-free. Note this makes the
|
|
129
|
+
* generated `equals`/`hashCode` identity-based for those fields — fills are never compared.
|
|
130
|
+
*/
|
|
131
|
+
data class Gradient(
|
|
132
|
+
val colors: IntArray,
|
|
133
|
+
val stops: FloatArray,
|
|
134
|
+
/** `<a:lin ang>` in degrees, clockwise from the +x axis. */
|
|
135
|
+
val angleDeg: Float,
|
|
136
|
+
val radial: Boolean
|
|
137
|
+
) : PptFill() {
|
|
138
|
+
/** Cached shader — valid because gradients are built in slide-point space, not pixels. */
|
|
139
|
+
@Transient
|
|
140
|
+
var cachedShader: android.graphics.Shader? = null
|
|
141
|
+
|
|
142
|
+
@Transient
|
|
143
|
+
var cachedW: Float = -1f
|
|
144
|
+
|
|
145
|
+
@Transient
|
|
146
|
+
var cachedH: Float = -1f
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fun bitmapOrNull(): Bitmap? = when (this) {
|
|
150
|
+
is Image -> bitmap
|
|
151
|
+
else -> null
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Immutable 2x3 affine in slide-point space, accumulated from enclosing `<p:grpSp>` transforms:
|
|
157
|
+
* `x' = a*x + c*y + e`, `y' = b*x + d*y + f`.
|
|
158
|
+
*
|
|
159
|
+
* Deliberately our own type rather than `android.graphics.Matrix`, so the group-composition maths
|
|
160
|
+
* — the thing that was silently wrong and put grouped shapes on top of each other — is
|
|
161
|
+
* unit-testable on a bare JVM, and so nothing mutable leaks into the render model.
|
|
162
|
+
*/
|
|
163
|
+
internal data class Affine(
|
|
164
|
+
val a: Float, val b: Float, val c: Float,
|
|
165
|
+
val d: Float, val e: Float, val f: Float
|
|
166
|
+
) {
|
|
167
|
+
/** `outer ∘ this`: apply this transform first, then [outer]. */
|
|
168
|
+
fun then(outer: Affine): Affine = Affine(
|
|
169
|
+
a = outer.a * a + outer.c * b,
|
|
170
|
+
b = outer.b * a + outer.d * b,
|
|
171
|
+
c = outer.a * c + outer.c * d,
|
|
172
|
+
d = outer.b * c + outer.d * d,
|
|
173
|
+
e = outer.a * e + outer.c * f + outer.e,
|
|
174
|
+
f = outer.b * e + outer.d * f + outer.f
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
fun isIdentity(): Boolean =
|
|
178
|
+
a == 1f && b == 0f && c == 0f && d == 1f && e == 0f && f == 0f
|
|
179
|
+
|
|
180
|
+
/** Fills a 9-element array in `android.graphics.Matrix` row-major order. */
|
|
181
|
+
fun toMatrixValues(out: FloatArray) {
|
|
182
|
+
out[0] = a; out[1] = c; out[2] = e
|
|
183
|
+
out[3] = b; out[4] = d; out[5] = f
|
|
184
|
+
out[6] = 0f; out[7] = 0f; out[8] = 1f
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
companion object {
|
|
188
|
+
val IDENTITY = Affine(1f, 0f, 0f, 1f, 0f, 0f)
|
|
189
|
+
fun translate(tx: Float, ty: Float) = Affine(1f, 0f, 0f, 1f, tx, ty)
|
|
190
|
+
fun scale(sx: Float, sy: Float) = Affine(sx, 0f, 0f, sy, 0f, 0f)
|
|
191
|
+
fun rotate(deg: Float): Affine {
|
|
192
|
+
val r = Math.toRadians(deg.toDouble())
|
|
193
|
+
val cos = kotlin.math.cos(r).toFloat()
|
|
194
|
+
val sin = kotlin.math.sin(r).toFloat()
|
|
195
|
+
return Affine(cos, sin, -sin, cos, 0f, 0f)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
internal sealed class PowerPointElement {
|
|
201
|
+
abstract val bounds: RectF
|
|
202
|
+
abstract val rotation: Float
|
|
203
|
+
abstract val flipH: Boolean
|
|
204
|
+
abstract val flipV: Boolean
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Transform from this element's own coordinate space into slide space, accumulated from every
|
|
208
|
+
* enclosing group. Identity for shapes sitting directly on the slide. Groups are flattened at
|
|
209
|
+
* parse time — carrying the transform per element keeps the draw loop and z-order flat.
|
|
210
|
+
*/
|
|
211
|
+
abstract val parent: Affine
|
|
212
|
+
|
|
213
|
+
/** Re-parents this element into an enclosing group's coordinate space. */
|
|
214
|
+
abstract fun reparent(outer: Affine): PowerPointElement
|
|
215
|
+
|
|
216
|
+
data class TextBox(
|
|
217
|
+
override val bounds: RectF,
|
|
218
|
+
override val rotation: Float = 0f,
|
|
219
|
+
val paragraphs: List<PowerPointParagraph>,
|
|
220
|
+
val fill: PptFill? = null,
|
|
221
|
+
val stroke: PowerPointStroke? = null,
|
|
222
|
+
val verticalAnchor: TextVerticalAnchor = TextVerticalAnchor.TOP,
|
|
223
|
+
val insetLeftPt: Float = 4f,
|
|
224
|
+
val insetTopPt: Float = 2f,
|
|
225
|
+
val insetRightPt: Float = 4f,
|
|
226
|
+
val insetBottomPt: Float = 2f,
|
|
227
|
+
val shapeType: PowerPointShapeType = PowerPointShapeType.RECT,
|
|
228
|
+
/** `<a:custGeom>` outline, normalised to 0..1 so one Path serves every drawn size. */
|
|
229
|
+
val customPath: android.graphics.Path? = null,
|
|
230
|
+
/**
|
|
231
|
+
* `<a:normAutofit>`: PowerPoint shrank this text to fit and recorded what it used. Honouring
|
|
232
|
+
* the recorded values is what stops an un-shrunk title colliding with the body below.
|
|
233
|
+
* `fontScale == 1 && !autofit` means the deck asked for no shrinking at all.
|
|
234
|
+
*/
|
|
235
|
+
val autofit: Boolean = false,
|
|
236
|
+
val fontScale: Float = 1f,
|
|
237
|
+
val lnSpcReduction: Float = 0f,
|
|
238
|
+
override val flipH: Boolean = false,
|
|
239
|
+
override val flipV: Boolean = false,
|
|
240
|
+
override val parent: Affine = Affine.IDENTITY
|
|
241
|
+
) : PowerPointElement() {
|
|
242
|
+
override fun reparent(outer: Affine) = copy(parent = parent.then(outer))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
data class Shape(
|
|
246
|
+
override val bounds: RectF,
|
|
247
|
+
override val rotation: Float = 0f,
|
|
248
|
+
val type: PowerPointShapeType = PowerPointShapeType.RECT,
|
|
249
|
+
val fill: PptFill? = null,
|
|
250
|
+
val stroke: PowerPointStroke? = null,
|
|
251
|
+
/** `<a:custGeom>` outline, normalised to 0..1. */
|
|
252
|
+
val customPath: android.graphics.Path? = null,
|
|
253
|
+
override val flipH: Boolean = false,
|
|
254
|
+
override val flipV: Boolean = false,
|
|
255
|
+
override val parent: Affine = Affine.IDENTITY
|
|
256
|
+
) : PowerPointElement() {
|
|
257
|
+
override fun reparent(outer: Affine) = copy(parent = parent.then(outer))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
data class Picture(
|
|
261
|
+
override val bounds: RectF,
|
|
262
|
+
override val rotation: Float = 0f,
|
|
263
|
+
val bitmap: Bitmap,
|
|
264
|
+
/** `<a:srcRect>` crop as fractional insets (l, t, r, b) of the source bitmap, or null. */
|
|
265
|
+
val srcRect: RectF? = null,
|
|
266
|
+
override val flipH: Boolean = false,
|
|
267
|
+
override val flipV: Boolean = false,
|
|
268
|
+
override val parent: Affine = Affine.IDENTITY
|
|
269
|
+
) : PowerPointElement() {
|
|
270
|
+
override fun reparent(outer: Affine) = copy(parent = parent.then(outer))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
data class Table(
|
|
274
|
+
override val bounds: RectF,
|
|
275
|
+
override val rotation: Float = 0f,
|
|
276
|
+
val columnsPt: List<Float>,
|
|
277
|
+
val rows: List<PowerPointTableRow>,
|
|
278
|
+
override val flipH: Boolean = false,
|
|
279
|
+
override val flipV: Boolean = false,
|
|
280
|
+
override val parent: Affine = Affine.IDENTITY
|
|
281
|
+
) : PowerPointElement() {
|
|
282
|
+
override fun reparent(outer: Affine) = copy(parent = parent.then(outer))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
internal data class PowerPointParagraph(
|
|
287
|
+
val runs: List<PowerPointTextRun>,
|
|
288
|
+
val align: TextAlign = TextAlign.LEFT,
|
|
289
|
+
val bullet: String? = null,
|
|
290
|
+
val level: Int = 0,
|
|
291
|
+
// Resolved base appearance for this paragraph: the fallback for runs that set nothing of
|
|
292
|
+
// their own, and the metrics source for the bullet glyph. These are non-null by construction
|
|
293
|
+
// so the renderer can never reach for a hardcoded 14pt black again.
|
|
294
|
+
val baseSizePt: Float = DEFAULT_BODY_SIZE_PT,
|
|
295
|
+
val baseColor: Int = BLACK_ARGB,
|
|
296
|
+
val baseFontFamily: String? = null,
|
|
297
|
+
val baseBold: Boolean = false,
|
|
298
|
+
val baseItalic: Boolean = false,
|
|
299
|
+
// Paragraph metrics, in points. marL is the TEXT left edge; indent is the bullet's offset
|
|
300
|
+
// relative to it (negative = hanging). Previously unread, so bullets were faked with two
|
|
301
|
+
// spaces per level and wrapped lines never lined up under the first.
|
|
302
|
+
val marLPt: Float = 0f,
|
|
303
|
+
val indentPt: Float = 0f,
|
|
304
|
+
/** Line spacing as a multiple (1.0 = single); null means inherit the renderer default. */
|
|
305
|
+
val lineSpacePct: Float? = null,
|
|
306
|
+
val spaceBeforePt: Float = 0f,
|
|
307
|
+
val spaceAfterPt: Float = 0f
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
internal data class PowerPointTextRun(
|
|
311
|
+
val text: String,
|
|
312
|
+
val fontSizePt: Float? = null,
|
|
313
|
+
val color: Int? = null,
|
|
314
|
+
val fontFamily: String? = null,
|
|
315
|
+
// Null means "unspecified" so the layout/master inheritance step can supply a value; a
|
|
316
|
+
// concrete true/false means the run set it explicitly and must win over inherited defaults.
|
|
317
|
+
val bold: Boolean? = null,
|
|
318
|
+
val italic: Boolean? = null,
|
|
319
|
+
val underline: Boolean? = null,
|
|
320
|
+
val strike: Boolean? = null,
|
|
321
|
+
val allCaps: Boolean? = null
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
internal data class PowerPointStroke(
|
|
325
|
+
val color: Int = Color.BLACK,
|
|
326
|
+
val widthPt: Float = 1f
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
internal data class PowerPointTableRow(
|
|
330
|
+
val heightPt: Float?,
|
|
331
|
+
val cells: List<PowerPointTableCell>
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
internal data class PowerPointTableCell(
|
|
335
|
+
val paragraphs: List<PowerPointParagraph>,
|
|
336
|
+
val fillColor: Int? = null,
|
|
337
|
+
val gridSpan: Int = 1,
|
|
338
|
+
/** A continuation cell of a horizontal/vertical merge: occupies grid space but draws nothing. */
|
|
339
|
+
val merged: Boolean = false,
|
|
340
|
+
// Per-edge borders from <a:tcPr>. Null means the deck specified none for that edge, in which
|
|
341
|
+
// case the renderer falls back to a neutral hairline so the grid stays readable.
|
|
342
|
+
val borderLeft: PowerPointStroke? = null,
|
|
343
|
+
val borderRight: PowerPointStroke? = null,
|
|
344
|
+
val borderTop: PowerPointStroke? = null,
|
|
345
|
+
val borderBottom: PowerPointStroke? = null,
|
|
346
|
+
// OOXML cell inset defaults: 0.1" horizontally, 0.05" vertically.
|
|
347
|
+
val marginLeftPt: Float = 7.2f,
|
|
348
|
+
val marginRightPt: Float = 7.2f,
|
|
349
|
+
val marginTopPt: Float = 3.6f,
|
|
350
|
+
val marginBottomPt: Float = 3.6f,
|
|
351
|
+
val anchor: TextVerticalAnchor = TextVerticalAnchor.TOP
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
/** Everything `<a:tcPr>` contributes to a cell. */
|
|
355
|
+
private data class TableCellProps(
|
|
356
|
+
val fillColor: Int? = null,
|
|
357
|
+
val borderLeft: PowerPointStroke? = null,
|
|
358
|
+
val borderRight: PowerPointStroke? = null,
|
|
359
|
+
val borderTop: PowerPointStroke? = null,
|
|
360
|
+
val borderBottom: PowerPointStroke? = null,
|
|
361
|
+
val marginLeftPt: Float = 7.2f,
|
|
362
|
+
val marginRightPt: Float = 7.2f,
|
|
363
|
+
val marginTopPt: Float = 3.6f,
|
|
364
|
+
val marginBottomPt: Float = 3.6f,
|
|
365
|
+
val anchor: TextVerticalAnchor = TextVerticalAnchor.TOP
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Drawable primitives for `prstGeom` presets.
|
|
370
|
+
*
|
|
371
|
+
* Anything not listed still falls back to RECT, which is what PowerPoint does for a closed shape
|
|
372
|
+
* - but the previous six-entry vocabulary meant stars, arrows, chevrons and polygons all became
|
|
373
|
+
* solid rectangles, frequently covering slide content.
|
|
374
|
+
*/
|
|
375
|
+
internal enum class PowerPointShapeType {
|
|
376
|
+
RECT, ROUND_RECT, ELLIPSE, LINE,
|
|
377
|
+
TRIANGLE, RIGHT_TRIANGLE, DIAMOND, PARALLELOGRAM, TRAPEZOID,
|
|
378
|
+
PENTAGON, HEXAGON, OCTAGON,
|
|
379
|
+
STAR4, STAR5, STAR6, STAR8, STAR12, STAR16, STAR24, STAR32,
|
|
380
|
+
ARROW_RIGHT, ARROW_LEFT, ARROW_UP, ARROW_DOWN, ARROW_LEFT_RIGHT, ARROW_UP_DOWN,
|
|
381
|
+
CHEVRON, HOME_PLATE, PLUS, CAN, DONUT, FRAME
|
|
382
|
+
}
|
|
383
|
+
internal enum class TextAlign { LEFT, CENTER, RIGHT, JUSTIFY }
|
|
384
|
+
internal enum class TextVerticalAnchor { TOP, MIDDLE, BOTTOM }
|
|
385
|
+
|
|
386
|
+
// ── DrawingML colour: parse first, resolve later ────────────────────
|
|
387
|
+
//
|
|
388
|
+
// A colour cannot be resolved at parse time: `schemeClr` names go through the slide master's
|
|
389
|
+
// <p:clrMap>, and `phClr` is supplied by whichever style reference is consuming the spec. So
|
|
390
|
+
// parsing produces an unresolved [ColorSpec] and resolution happens at use, against a
|
|
391
|
+
// [ColorContext].
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A DrawingML colour before theme / clrMap / phClr resolution.
|
|
395
|
+
*
|
|
396
|
+
* [transforms] is kept in DOCUMENT ORDER on purpose: the transforms are not commutative, so
|
|
397
|
+
* `tint` then `lumMod` is a different colour from `lumMod` then `tint`. The previous code
|
|
398
|
+
* collapsed lumMod/shade/tint into a single value and applied one formula to all three, which
|
|
399
|
+
* is why theme-tinted colours came out badly wrong.
|
|
400
|
+
*/
|
|
401
|
+
internal data class ColorSpec(val base: ColorBase, val transforms: List<ColorXf> = emptyList())
|
|
402
|
+
|
|
403
|
+
internal sealed class ColorBase {
|
|
404
|
+
/** A literal `srgbClr` / `sysClr` / `prstClr` value, already packed as ARGB. */
|
|
405
|
+
data class Literal(val argb: Int) : ColorBase()
|
|
406
|
+
/** A `schemeClr` name (dk1/lt1/accent1/tx1/bg1/…), resolved through the master's clrMap. */
|
|
407
|
+
data class Scheme(val name: String) : ColorBase()
|
|
408
|
+
/** `phClr` — the colour handed in by the style reference using this spec. */
|
|
409
|
+
object Placeholder : ColorBase()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
internal sealed class ColorXf {
|
|
413
|
+
/** Blends toward WHITE. */
|
|
414
|
+
data class Tint(val v: Float) : ColorXf()
|
|
415
|
+
/** Blends toward BLACK. */
|
|
416
|
+
data class Shade(val v: Float) : ColorXf()
|
|
417
|
+
/** Scales HSL luminance. */
|
|
418
|
+
data class LumMod(val v: Float) : ColorXf()
|
|
419
|
+
/** Offsets HSL luminance. */
|
|
420
|
+
data class LumOff(val v: Float) : ColorXf()
|
|
421
|
+
/** Scales HSL saturation. */
|
|
422
|
+
data class SatMod(val v: Float) : ColorXf()
|
|
423
|
+
data class Alpha(val v: Float) : ColorXf()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The theme palette + colour map a [ColorSpec] resolves against.
|
|
428
|
+
*
|
|
429
|
+
* `schemeColors` holds only the twelve real theme slots (dk1/lt1/dk2/lt2/accent1-6/hlink/
|
|
430
|
+
* folHlink). The `tx1`/`bg1`/`tx2`/`bg2` aliases are deliberately NOT stored there — they are
|
|
431
|
+
* indirections that must go through [clrMap], and pre-seeding them is exactly what used to make
|
|
432
|
+
* `tx1` resolve to hardcoded black on every deck.
|
|
433
|
+
*/
|
|
434
|
+
internal class ColorContext(
|
|
435
|
+
private val schemeColors: Map<String, Int>,
|
|
436
|
+
private val clrMap: Map<String, String>,
|
|
437
|
+
val phClr: Int? = null
|
|
438
|
+
) {
|
|
439
|
+
fun scheme(name: String): Int {
|
|
440
|
+
val mapped = clrMap[name] ?: name
|
|
441
|
+
return schemeColors[mapped]
|
|
442
|
+
?: schemeColors[name]
|
|
443
|
+
?: DEFAULT_SCHEME_COLORS[mapped]
|
|
444
|
+
?: DEFAULT_SCHEME_COLORS[name]
|
|
445
|
+
?: BLACK_ARGB
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
fun withPlaceholder(color: Int?): ColorContext =
|
|
449
|
+
if (color == null) this else ColorContext(schemeColors, clrMap, color)
|
|
450
|
+
|
|
451
|
+
companion object {
|
|
452
|
+
/** Identity for everything except the four alias slots, per the OOXML default clrMap. */
|
|
453
|
+
val DEFAULT_CLR_MAP: Map<String, String> =
|
|
454
|
+
mapOf("bg1" to "lt1", "tx1" to "dk1", "bg2" to "lt2", "tx2" to "dk2")
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Resolves [ColorSpec]s to packed ARGB ints.
|
|
460
|
+
*
|
|
461
|
+
* Intentionally free of `android.graphics` — every channel operation is hand-rolled bit
|
|
462
|
+
* twiddling — so this, the part of the renderer that is easiest to get subtly wrong, can be
|
|
463
|
+
* unit-tested on a bare JVM without Robolectric.
|
|
464
|
+
*/
|
|
465
|
+
internal object PptColor {
|
|
466
|
+
fun resolve(spec: ColorSpec, ctx: ColorContext): Int {
|
|
467
|
+
var argb = when (val base = spec.base) {
|
|
468
|
+
is ColorBase.Literal -> base.argb
|
|
469
|
+
is ColorBase.Scheme -> ctx.scheme(base.name)
|
|
470
|
+
ColorBase.Placeholder -> ctx.phClr ?: ctx.scheme("tx1")
|
|
471
|
+
}
|
|
472
|
+
for (xf in spec.transforms) argb = apply(argb, xf)
|
|
473
|
+
return argb
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private fun apply(argb: Int, xf: ColorXf): Int = when (xf) {
|
|
477
|
+
// ECMA-376 defines tint/shade on LINEAR-light channels. Doing them in gamma-encoded sRGB
|
|
478
|
+
// (the common shortcut) is visibly too dark for shades in the 20-50% range, which is
|
|
479
|
+
// precisely the range Office's "Darker 25%" style variants use.
|
|
480
|
+
is ColorXf.Tint -> mapLinear(argb) { c -> c * xf.v + (1f - xf.v) }
|
|
481
|
+
is ColorXf.Shade -> mapLinear(argb) { c -> c * xf.v }
|
|
482
|
+
is ColorXf.LumMod -> mapHsl(argb) { h, s, l -> Triple(h, s, (l * xf.v).coerceIn(0f, 1f)) }
|
|
483
|
+
is ColorXf.LumOff -> mapHsl(argb) { h, s, l -> Triple(h, s, (l + xf.v).coerceIn(0f, 1f)) }
|
|
484
|
+
is ColorXf.SatMod -> mapHsl(argb) { h, s, l -> Triple(h, (s * xf.v).coerceIn(0f, 1f), l) }
|
|
485
|
+
is ColorXf.Alpha -> (argb and 0x00FFFFFF) or (to255(xf.v) shl 24)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private inline fun mapLinear(argb: Int, f: (Float) -> Float): Int {
|
|
489
|
+
val a = (argb ushr 24) and 0xFF
|
|
490
|
+
val r = linearToSrgb(f(srgbToLinear(((argb ushr 16) and 0xFF) / 255f)))
|
|
491
|
+
val g = linearToSrgb(f(srgbToLinear(((argb ushr 8) and 0xFF) / 255f)))
|
|
492
|
+
val b = linearToSrgb(f(srgbToLinear((argb and 0xFF) / 255f)))
|
|
493
|
+
return pack(a, to255(r), to255(g), to255(b))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private inline fun mapHsl(argb: Int, f: (Float, Float, Float) -> Triple<Float, Float, Float>): Int {
|
|
497
|
+
val a = (argb ushr 24) and 0xFF
|
|
498
|
+
val r = ((argb ushr 16) and 0xFF) / 255f
|
|
499
|
+
val g = ((argb ushr 8) and 0xFF) / 255f
|
|
500
|
+
val b = (argb and 0xFF) / 255f
|
|
501
|
+
val maxC = maxOf(r, g, b)
|
|
502
|
+
val minC = minOf(r, g, b)
|
|
503
|
+
val l = (maxC + minC) / 2f
|
|
504
|
+
val d = maxC - minC
|
|
505
|
+
val s = if (d == 0f) 0f else d / (1f - kotlin.math.abs(2f * l - 1f)).coerceAtLeast(1e-6f)
|
|
506
|
+
val h = when {
|
|
507
|
+
d == 0f -> 0f
|
|
508
|
+
maxC == r -> (((g - b) / d) % 6f + 6f) % 6f
|
|
509
|
+
maxC == g -> (b - r) / d + 2f
|
|
510
|
+
else -> (r - g) / d + 4f
|
|
511
|
+
} * 60f
|
|
512
|
+
val (h2, s2, l2) = f(h, s, l)
|
|
513
|
+
return hslToArgb(a, h2, s2, l2)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private fun hslToArgb(a: Int, h: Float, s: Float, l: Float): Int {
|
|
517
|
+
val c = (1f - kotlin.math.abs(2f * l - 1f)) * s
|
|
518
|
+
val hp = ((h % 360f) + 360f) % 360f / 60f
|
|
519
|
+
val x = c * (1f - kotlin.math.abs(hp % 2f - 1f))
|
|
520
|
+
val (r1, g1, b1) = when (hp.toInt()) {
|
|
521
|
+
0 -> Triple(c, x, 0f)
|
|
522
|
+
1 -> Triple(x, c, 0f)
|
|
523
|
+
2 -> Triple(0f, c, x)
|
|
524
|
+
3 -> Triple(0f, x, c)
|
|
525
|
+
4 -> Triple(x, 0f, c)
|
|
526
|
+
else -> Triple(c, 0f, x)
|
|
527
|
+
}
|
|
528
|
+
val m = l - c / 2f
|
|
529
|
+
return pack(a, to255(r1 + m), to255(g1 + m), to255(b1 + m))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private fun srgbToLinear(c: Float): Float =
|
|
533
|
+
if (c <= 0.04045f) c / 12.92f
|
|
534
|
+
else Math.pow(((c + 0.055f) / 1.055f).toDouble(), 2.4).toFloat()
|
|
535
|
+
|
|
536
|
+
private fun linearToSrgb(c: Float): Float {
|
|
537
|
+
val v = c.coerceIn(0f, 1f)
|
|
538
|
+
return if (v <= 0.0031308f) v * 12.92f
|
|
539
|
+
else (1.055f * Math.pow(v.toDouble(), 1.0 / 2.4).toFloat() - 0.055f)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private fun to255(v: Float): Int = (v * 255f + 0.5f).toInt().coerceIn(0, 255)
|
|
543
|
+
|
|
544
|
+
private fun pack(a: Int, r: Int, g: Int, b: Int): Int =
|
|
545
|
+
(a shl 24) or (r shl 16) or (g shl 8) or b
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
internal class PowerPointPresentationFormatter {
|
|
549
|
+
fun format(raw: RawPowerPointPresentation): PowerPointPresentation {
|
|
550
|
+
val width = raw.widthPt.takeIf { it > 0f } ?: DEFAULT_SLIDE_WIDTH_PT
|
|
551
|
+
val height = raw.heightPt.takeIf { it > 0f } ?: DEFAULT_SLIDE_HEIGHT_PT
|
|
552
|
+
val slides = raw.slides.ifEmpty {
|
|
553
|
+
listOf(
|
|
554
|
+
RawPowerPointSlide(
|
|
555
|
+
textRuns = listOf(raw.sourceName.ifBlank { Localization.getString("powerPointPresentationTitle", "PowerPoint presentation") })
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
}.mapIndexed { index, rawSlide ->
|
|
559
|
+
val normalized = ArrayList<PowerPointElement>(rawSlide.elements)
|
|
560
|
+
appendUnpositionedImages(normalized, rawSlide.images, width, height)
|
|
561
|
+
appendUnpositionedText(normalized, rawSlide.textRuns, width, height)
|
|
562
|
+
PowerPointSlide(index + 1, rawSlide.background, normalized)
|
|
563
|
+
}
|
|
564
|
+
return PowerPointPresentation(raw.sourceName, width, height, slides, raw.ownedBitmaps)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private fun appendUnpositionedImages(
|
|
568
|
+
out: MutableList<PowerPointElement>,
|
|
569
|
+
images: List<Bitmap>,
|
|
570
|
+
widthPt: Float,
|
|
571
|
+
heightPt: Float
|
|
572
|
+
) {
|
|
573
|
+
if (images.isEmpty()) return
|
|
574
|
+
val hasPositionedImages = out.any { it is PowerPointElement.Picture }
|
|
575
|
+
if (hasPositionedImages) return
|
|
576
|
+
val imageAreaTop = if (out.isEmpty()) heightPt * 0.12f else heightPt * 0.48f
|
|
577
|
+
val imageAreaHeight = if (out.isEmpty()) heightPt * 0.76f else heightPt * 0.38f
|
|
578
|
+
val eachHeight = (imageAreaHeight / images.size.coerceAtMost(3)).coerceAtLeast(80f)
|
|
579
|
+
for ((index, bitmap) in images.take(3).withIndex()) {
|
|
580
|
+
val rect = fitBitmapRect(
|
|
581
|
+
bitmap,
|
|
582
|
+
RectF(widthPt * 0.08f, imageAreaTop + index * eachHeight, widthPt * 0.92f, imageAreaTop + (index + 1) * eachHeight - 8f)
|
|
583
|
+
)
|
|
584
|
+
out.add(PowerPointElement.Picture(rect, bitmap = bitmap))
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private fun appendUnpositionedText(
|
|
589
|
+
out: MutableList<PowerPointElement>,
|
|
590
|
+
textRuns: List<String>,
|
|
591
|
+
widthPt: Float,
|
|
592
|
+
heightPt: Float
|
|
593
|
+
) {
|
|
594
|
+
val cleaned = textRuns
|
|
595
|
+
.map { cleanText(it) }
|
|
596
|
+
.filter { it.length >= 2 }
|
|
597
|
+
.distinct()
|
|
598
|
+
.take(24)
|
|
599
|
+
if (cleaned.isEmpty()) return
|
|
600
|
+
val hasText = out.any { it is PowerPointElement.TextBox }
|
|
601
|
+
if (hasText) return
|
|
602
|
+
|
|
603
|
+
val title = cleaned.first()
|
|
604
|
+
val body = cleaned.drop(1)
|
|
605
|
+
out.add(
|
|
606
|
+
PowerPointElement.TextBox(
|
|
607
|
+
bounds = RectF(widthPt * 0.08f, heightPt * 0.08f, widthPt * 0.92f, heightPt * 0.23f),
|
|
608
|
+
paragraphs = listOf(
|
|
609
|
+
PowerPointParagraph(
|
|
610
|
+
runs = listOf(PowerPointTextRun(title, fontSizePt = 28f, bold = true)),
|
|
611
|
+
align = TextAlign.CENTER
|
|
612
|
+
)
|
|
613
|
+
),
|
|
614
|
+
verticalAnchor = TextVerticalAnchor.MIDDLE
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
if (body.isNotEmpty()) {
|
|
618
|
+
out.add(
|
|
619
|
+
PowerPointElement.TextBox(
|
|
620
|
+
bounds = RectF(widthPt * 0.1f, heightPt * 0.29f, widthPt * 0.9f, heightPt * 0.9f),
|
|
621
|
+
paragraphs = body.map {
|
|
622
|
+
PowerPointParagraph(
|
|
623
|
+
runs = listOf(PowerPointTextRun(it, fontSizePt = 16f)),
|
|
624
|
+
bullet = if (body.size > 1) BULLET else null
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private fun fitBitmapRect(bitmap: Bitmap, box: RectF): RectF {
|
|
633
|
+
if (bitmap.width <= 0 || bitmap.height <= 0) return RectF(box)
|
|
634
|
+
val bitmapAspect = bitmap.width.toFloat() / bitmap.height.toFloat()
|
|
635
|
+
val boxAspect = box.width() / box.height()
|
|
636
|
+
return if (bitmapAspect > boxAspect) {
|
|
637
|
+
val h = box.width() / bitmapAspect
|
|
638
|
+
RectF(box.left, box.centerY() - h / 2f, box.right, box.centerY() + h / 2f)
|
|
639
|
+
} else {
|
|
640
|
+
val w = box.height() * bitmapAspect
|
|
641
|
+
RectF(box.centerX() - w / 2f, box.top, box.centerX() + w / 2f, box.bottom)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private class PptxPresentationExtractor : PowerPointPresentationExtractor {
|
|
647
|
+
private lateinit var zip: ZipFile
|
|
648
|
+
private var imageBudget = Ox.MAX_TOTAL_IMAGE_BYTES
|
|
649
|
+
// Theme palette + clrMap that every schemeClr resolves against, and the theme's major/minor
|
|
650
|
+
// latin typefaces that `+mj-lt` / `+mn-lt` font tokens resolve to.
|
|
651
|
+
private var schemeColors: Map<String, Int> = DEFAULT_SCHEME_COLORS
|
|
652
|
+
private var clrMapping: Map<String, String> = ColorContext.DEFAULT_CLR_MAP
|
|
653
|
+
private var colorContext = ColorContext(DEFAULT_SCHEME_COLORS, ColorContext.DEFAULT_CLR_MAP)
|
|
654
|
+
private var majorLatinFont: String? = null
|
|
655
|
+
private var minorLatinFont: String? = null
|
|
656
|
+
|
|
657
|
+
private fun refreshColorContext() {
|
|
658
|
+
colorContext = ColorContext(schemeColors, clrMapping)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Presentation slide size (points), used to synthesize default placeholder bands.
|
|
662
|
+
private var slideWidthPt = DEFAULT_SLIDE_WIDTH_PT
|
|
663
|
+
private var slideHeightPt = DEFAULT_SLIDE_HEIGHT_PT
|
|
664
|
+
// Inheritance context for the slide currently being parsed (layout + master placeholders,
|
|
665
|
+
// master text styles, resolved background). Reset per slide in [parseSlide].
|
|
666
|
+
private var currentContext: SlideInheritanceContext = SlideInheritanceContext.EMPTY
|
|
667
|
+
// Relationships of the part currently being parsed, so a <a:blipFill> nested in a shape's
|
|
668
|
+
// <p:spPr> can resolve its image without threading the map through every parser.
|
|
669
|
+
private var currentRels: Map<String, String> = emptyMap()
|
|
670
|
+
// Set while reading a layout/master's decoration graphics: placeholders are excluded (the
|
|
671
|
+
// slide draws its own) and their text must not pollute the slide's non-blank fallback.
|
|
672
|
+
private var parsingDecorations = false
|
|
673
|
+
// Plain text collected while parsing the current slide's shapes; used only as the last-resort
|
|
674
|
+
// non-blank fallback when no positioned element survived.
|
|
675
|
+
private val slideTextBucket = ArrayList<String>()
|
|
676
|
+
// Parsed layouts/masters are shared across slides that reference them — parse each once.
|
|
677
|
+
private val layoutCache = HashMap<String, ParsedSlidePart>()
|
|
678
|
+
private val masterCache = HashMap<String, ParsedSlidePart>()
|
|
679
|
+
// Decoded images, keyed by zip path: dedupes shared artwork and is the recycling manifest.
|
|
680
|
+
private val imageCache = HashMap<String, Bitmap>()
|
|
681
|
+
|
|
682
|
+
override fun extract(file: File): RawPowerPointPresentation {
|
|
683
|
+
ZipFile(file).use { z ->
|
|
684
|
+
zip = z
|
|
685
|
+
// Resolved from the first slide master's relationships where possible; theme1.xml is only
|
|
686
|
+
// the fallback, since a deck with several masters can reference several theme parts.
|
|
687
|
+
parseTheme(z, resolveThemePart(z))
|
|
688
|
+
val (widthPt, heightPt) = readSlideSize(z)
|
|
689
|
+
slideWidthPt = widthPt
|
|
690
|
+
slideHeightPt = heightPt
|
|
691
|
+
val slideParts = slideParts(z)
|
|
692
|
+
if (slideParts.isEmpty()) throw java.io.IOException("No slides")
|
|
693
|
+
val slides = slideParts.map { parseSlide(it) }
|
|
694
|
+
return RawPowerPointPresentation(
|
|
695
|
+
file.name, widthPt, heightPt, slides, imageCache.values.toList()
|
|
696
|
+
)
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The theme part backing the deck's first slide master, or `ppt/theme/theme1.xml` when the
|
|
702
|
+
* relationship chain can't be followed. Walking presentation → master → theme matters because
|
|
703
|
+
* the old hardcoded path silently gave multi-master decks the wrong palette.
|
|
704
|
+
*/
|
|
705
|
+
private fun resolveThemePart(z: ZipFile): String {
|
|
706
|
+
return try {
|
|
707
|
+
val masterPart = Ox.parseRels(z, "ppt/presentation.xml").values
|
|
708
|
+
.firstOrNull { it.contains("slideMaster", ignoreCase = true) }
|
|
709
|
+
?: return DEFAULT_THEME_PART
|
|
710
|
+
Ox.parseRels(z, masterPart).values
|
|
711
|
+
.firstOrNull { it.contains("/theme/", ignoreCase = true) || it.contains("theme", ignoreCase = true) }
|
|
712
|
+
?: DEFAULT_THEME_PART
|
|
713
|
+
} catch (_: Throwable) {
|
|
714
|
+
DEFAULT_THEME_PART
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private fun readSlideSize(z: ZipFile): Pair<Float, Float> {
|
|
719
|
+
var widthPt = DEFAULT_SLIDE_WIDTH_PT
|
|
720
|
+
var heightPt = DEFAULT_SLIDE_HEIGHT_PT
|
|
721
|
+
Ox.zipStream(z, "ppt/presentation.xml")?.use { stream ->
|
|
722
|
+
val p = Ox.parser(stream)
|
|
723
|
+
while (p.next() != XmlPullParser.END_DOCUMENT) {
|
|
724
|
+
if (p.eventType == XmlPullParser.START_TAG && Ox.local(p) == "sldSz") {
|
|
725
|
+
Ox.emuToPt(Ox.attr(p, "cx"))?.let { widthPt = it }
|
|
726
|
+
Ox.emuToPt(Ox.attr(p, "cy"))?.let { heightPt = it }
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return widthPt to heightPt
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private fun slideParts(z: ZipFile): List<String> {
|
|
734
|
+
val rels = Ox.parseRels(z, "ppt/presentation.xml")
|
|
735
|
+
val out = ArrayList<String>()
|
|
736
|
+
Ox.zipStream(z, "ppt/presentation.xml")?.use { stream ->
|
|
737
|
+
val p = Ox.parser(stream)
|
|
738
|
+
while (p.next() != XmlPullParser.END_DOCUMENT) {
|
|
739
|
+
if (p.eventType == XmlPullParser.START_TAG && Ox.local(p) == "sldId") {
|
|
740
|
+
rels[Ox.attr(p, "r:id")]?.let { out.add(it) }
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return out
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private fun parseSlide(part: String): RawPowerPointSlide {
|
|
748
|
+
currentContext = loadInheritanceContext(part)
|
|
749
|
+
slideTextBucket.clear()
|
|
750
|
+
val rels = Ox.parseRels(zip, part)
|
|
751
|
+
currentRels = rels
|
|
752
|
+
val elements = ArrayList<PowerPointElement>()
|
|
753
|
+
var slideBackground: PptFill? = null
|
|
754
|
+
var showMasterSp = true
|
|
755
|
+
Ox.zipStream(zip, part)?.use { stream ->
|
|
756
|
+
val p = Ox.parser(stream)
|
|
757
|
+
while (p.next() != XmlPullParser.END_DOCUMENT) {
|
|
758
|
+
if (p.eventType != XmlPullParser.START_TAG) continue
|
|
759
|
+
when (Ox.local(p)) {
|
|
760
|
+
// <p:sld showMasterSp="0"> suppresses the inherited background graphics.
|
|
761
|
+
"sld" -> showMasterSp = Ox.attr(p, "showMasterSp") != "0"
|
|
762
|
+
"bg" -> slideBackground = parseBackground(p, rels)
|
|
763
|
+
// Descend ONLY through the shape tree, and structurally. The previous flat scan
|
|
764
|
+
// matched `sp`/`pic` at any depth, which meant it fell into <p:grpSp> (drawing group
|
|
765
|
+
// children at raw child-space coordinates) and into <mc:AlternateContent> (drawing
|
|
766
|
+
// both the Choice and its Fallback, i.e. duplicate overlapping shapes).
|
|
767
|
+
"spTree" -> walkShapeContainer(p, rels, elements, 0)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// Inherited decoration sits UNDER everything the slide itself draws.
|
|
772
|
+
if (showMasterSp && currentContext.decorations.isNotEmpty()) {
|
|
773
|
+
elements.addAll(0, currentContext.decorations)
|
|
774
|
+
}
|
|
775
|
+
// Background: slide's own bg wins; else inherit layout → master; else white.
|
|
776
|
+
val background = slideBackground
|
|
777
|
+
?: currentContext.background
|
|
778
|
+
?: PptFill.Solid(Color.WHITE)
|
|
779
|
+
// Non-blank guarantee: if nothing positioned survived but the slide had readable text,
|
|
780
|
+
// hand it to the formatter so it synthesizes a titled text box instead of a blank slide.
|
|
781
|
+
if (elements.isEmpty() && slideTextBucket.isNotEmpty()) {
|
|
782
|
+
return RawPowerPointSlide(background = background, textRuns = slideTextBucket.toList())
|
|
783
|
+
}
|
|
784
|
+
return RawPowerPointSlide(background = background, elements = elements)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ── Shape-tree traversal ─────────────────────────────────────────
|
|
788
|
+
//
|
|
789
|
+
// THE DEPTH CONTRACT, which every parser below obeys:
|
|
790
|
+
//
|
|
791
|
+
// Every parseX/walkX is entered with the parser positioned ON X's START_TAG and returns with
|
|
792
|
+
// it positioned ON X's matching END_TAG.
|
|
793
|
+
//
|
|
794
|
+
// A container loop therefore only ever observes its DIRECT children, and its `depth` counter
|
|
795
|
+
// goes 1 -> 0 exactly once, at its own END_TAG. Unknown children are skipped whole with
|
|
796
|
+
// Ox.skip(p) — never with `depth++`. Descending is opt-in and named, never implicit. The old
|
|
797
|
+
// `else -> depth++` branches are precisely what let the scan wander into groups,
|
|
798
|
+
// AlternateContent and <p:style>.
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Walks the direct children of a shape container — `<p:spTree>`, `<p:grpSp>`, or a chosen
|
|
802
|
+
* `<mc:Choice>`/`<mc:Fallback>` — appending elements in document order (which is z-order).
|
|
803
|
+
*/
|
|
804
|
+
private fun walkShapeContainer(
|
|
805
|
+
p: XmlPullParser,
|
|
806
|
+
rels: Map<String, String>,
|
|
807
|
+
out: MutableList<PowerPointElement>,
|
|
808
|
+
depthGuard: Int
|
|
809
|
+
) {
|
|
810
|
+
var depth = 1
|
|
811
|
+
while (depth > 0) {
|
|
812
|
+
when (p.next()) {
|
|
813
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
814
|
+
"sp", "cxnSp" -> guarded(p) { parseShape(p)?.let(out::add) }
|
|
815
|
+
"pic" -> guarded(p) { parsePicture(p, rels)?.let(out::add) }
|
|
816
|
+
"graphicFrame" -> guarded(p) { parseGraphicFrame(p)?.let(out::add) }
|
|
817
|
+
"grpSp" -> guarded(p) { parseGroup(p, rels, out, depthGuard + 1) }
|
|
818
|
+
"AlternateContent" -> guarded(p) { walkAlternateContent(p, rels, out, depthGuard) }
|
|
819
|
+
else -> Ox.skip(p)
|
|
820
|
+
}
|
|
821
|
+
XmlPullParser.END_TAG -> depth--
|
|
822
|
+
XmlPullParser.END_DOCUMENT -> return
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* A `<p:grpSp>`: its children are authored in the group's own CHILD coordinate space, which
|
|
829
|
+
* must be remapped through the group `<a:xfrm>`'s `chOff`/`chExt` -> `off`/`ext`. Skipping that
|
|
830
|
+
* remap is what made grouped shapes land in the wrong place and overlap other content.
|
|
831
|
+
*
|
|
832
|
+
* Children are buffered and re-parented after the loop rather than relying on `grpSpPr`
|
|
833
|
+
* preceding them, so a producer that emits them out of order still renders correctly.
|
|
834
|
+
*/
|
|
835
|
+
private fun parseGroup(
|
|
836
|
+
p: XmlPullParser,
|
|
837
|
+
rels: Map<String, String>,
|
|
838
|
+
out: MutableList<PowerPointElement>,
|
|
839
|
+
depthGuard: Int
|
|
840
|
+
) {
|
|
841
|
+
if (depthGuard > MAX_GROUP_NESTING) {
|
|
842
|
+
Ox.skip(p)
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
var transform = Affine.IDENTITY
|
|
846
|
+
val children = ArrayList<PowerPointElement>()
|
|
847
|
+
var depth = 1
|
|
848
|
+
while (depth > 0) {
|
|
849
|
+
when (p.next()) {
|
|
850
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
851
|
+
"grpSpPr" -> transform = parseGroupTransform(p)
|
|
852
|
+
"sp", "cxnSp" -> guarded(p) { parseShape(p)?.let(children::add) }
|
|
853
|
+
"pic" -> guarded(p) { parsePicture(p, rels)?.let(children::add) }
|
|
854
|
+
"graphicFrame" -> guarded(p) { parseGraphicFrame(p)?.let(children::add) }
|
|
855
|
+
"grpSp" -> guarded(p) { parseGroup(p, rels, children, depthGuard + 1) }
|
|
856
|
+
"AlternateContent" -> guarded(p) { walkAlternateContent(p, rels, children, depthGuard) }
|
|
857
|
+
else -> Ox.skip(p)
|
|
858
|
+
}
|
|
859
|
+
XmlPullParser.END_TAG -> depth--
|
|
860
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (transform.isIdentity()) out.addAll(children)
|
|
864
|
+
else children.mapTo(out) { it.reparent(transform) }
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** `<p:grpSpPr>` -> the child-space to parent-space mapping declared by its `<a:xfrm>`. */
|
|
868
|
+
private fun parseGroupTransform(p: XmlPullParser): Affine {
|
|
869
|
+
var off: RectF? = null
|
|
870
|
+
var ext: FloatArray? = null
|
|
871
|
+
var chOff: FloatArray? = null
|
|
872
|
+
var chExt: FloatArray? = null
|
|
873
|
+
var rot = 0f
|
|
874
|
+
var flipH = false
|
|
875
|
+
var flipV = false
|
|
876
|
+
var depth = 1
|
|
877
|
+
while (depth > 0) {
|
|
878
|
+
when (p.next()) {
|
|
879
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
880
|
+
"xfrm" -> {
|
|
881
|
+
rot = (Ox.attr(p, "rot")?.toFloatOrNull() ?: 0f) / 60000f
|
|
882
|
+
flipH = Ox.attr(p, "flipH") == "1"
|
|
883
|
+
flipV = Ox.attr(p, "flipV") == "1"
|
|
884
|
+
}
|
|
885
|
+
"off" -> {
|
|
886
|
+
val x = Ox.emuToPt(Ox.attr(p, "x")); val y = Ox.emuToPt(Ox.attr(p, "y"))
|
|
887
|
+
if (x != null && y != null) off = RectF(x, y, x, y)
|
|
888
|
+
Ox.skip(p)
|
|
889
|
+
}
|
|
890
|
+
"ext" -> {
|
|
891
|
+
val w = Ox.emuToPt(Ox.attr(p, "cx")); val h = Ox.emuToPt(Ox.attr(p, "cy"))
|
|
892
|
+
if (w != null && h != null) ext = floatArrayOf(w, h)
|
|
893
|
+
Ox.skip(p)
|
|
894
|
+
}
|
|
895
|
+
"chOff" -> {
|
|
896
|
+
val x = Ox.emuToPt(Ox.attr(p, "x")); val y = Ox.emuToPt(Ox.attr(p, "y"))
|
|
897
|
+
if (x != null && y != null) chOff = floatArrayOf(x, y)
|
|
898
|
+
Ox.skip(p)
|
|
899
|
+
}
|
|
900
|
+
"chExt" -> {
|
|
901
|
+
val w = Ox.emuToPt(Ox.attr(p, "cx")); val h = Ox.emuToPt(Ox.attr(p, "cy"))
|
|
902
|
+
if (w != null && h != null) chExt = floatArrayOf(w, h)
|
|
903
|
+
Ox.skip(p)
|
|
904
|
+
}
|
|
905
|
+
else -> Ox.skip(p)
|
|
906
|
+
}
|
|
907
|
+
XmlPullParser.END_TAG -> depth--
|
|
908
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
val o = off ?: return Affine.IDENTITY
|
|
912
|
+
val e = ext ?: return Affine.IDENTITY
|
|
913
|
+
// A group that was never moved or resized after grouping has chOff == off and chExt == ext,
|
|
914
|
+
// which yields identity — that is exactly why the missing remap was invisible on some decks.
|
|
915
|
+
val co = chOff ?: floatArrayOf(o.left, o.top)
|
|
916
|
+
val ce = chExt ?: e
|
|
917
|
+
val sx = if (ce[0] != 0f) e[0] / ce[0] else 1f
|
|
918
|
+
val sy = if (ce[1] != 0f) e[1] / ce[1] else 1f
|
|
919
|
+
|
|
920
|
+
var m = Affine.translate(-co[0], -co[1]).then(Affine.scale(sx, sy))
|
|
921
|
+
if (flipH) m = m.then(Affine.scale(-1f, 1f)).then(Affine.translate(e[0], 0f))
|
|
922
|
+
if (flipV) m = m.then(Affine.scale(1f, -1f)).then(Affine.translate(0f, e[1]))
|
|
923
|
+
if (rot != 0f) {
|
|
924
|
+
m = m.then(Affine.translate(-e[0] / 2f, -e[1] / 2f))
|
|
925
|
+
.then(Affine.rotate(rot))
|
|
926
|
+
.then(Affine.translate(e[0] / 2f, e[1] / 2f))
|
|
927
|
+
}
|
|
928
|
+
return m.then(Affine.translate(o.left, o.top))
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* `<mc:AlternateContent>` offers the same content twice: a `<mc:Choice>` using an extension
|
|
933
|
+
* namespace, and a `<mc:Fallback>` in plain DrawingML. Exactly ONE must be rendered — walking
|
|
934
|
+
* both is what produced duplicate, overlapping shapes.
|
|
935
|
+
*
|
|
936
|
+
* The parser is forward-only, so the decision is made from `Requires` without look-ahead.
|
|
937
|
+
* Anything we can't claim to understand (SmartArt's `dsp`, Word shapes' `wps`/`wpg`, …) falls
|
|
938
|
+
* through to the Fallback, which by construction is plain `p:`/`a:` markup we can draw — and
|
|
939
|
+
* which the group handling above now positions correctly.
|
|
940
|
+
*/
|
|
941
|
+
private fun walkAlternateContent(
|
|
942
|
+
p: XmlPullParser,
|
|
943
|
+
rels: Map<String, String>,
|
|
944
|
+
out: MutableList<PowerPointElement>,
|
|
945
|
+
depthGuard: Int
|
|
946
|
+
) {
|
|
947
|
+
var taken = false
|
|
948
|
+
var depth = 1
|
|
949
|
+
while (depth > 0) {
|
|
950
|
+
when (p.next()) {
|
|
951
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
952
|
+
"Choice" -> {
|
|
953
|
+
val requires = Ox.attr(p, "Requires")?.trim()?.split(' ')?.filter { it.isNotBlank() }
|
|
954
|
+
if (!taken && !requires.isNullOrEmpty() && requires.all { it in RENDERABLE_CHOICE_NS }) {
|
|
955
|
+
taken = true
|
|
956
|
+
walkShapeContainer(p, rels, out, depthGuard)
|
|
957
|
+
} else {
|
|
958
|
+
Ox.skip(p)
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
"Fallback" -> if (!taken) {
|
|
962
|
+
taken = true
|
|
963
|
+
walkShapeContainer(p, rels, out, depthGuard)
|
|
964
|
+
} else {
|
|
965
|
+
Ox.skip(p)
|
|
966
|
+
}
|
|
967
|
+
else -> Ox.skip(p)
|
|
968
|
+
}
|
|
969
|
+
XmlPullParser.END_TAG -> depth--
|
|
970
|
+
XmlPullParser.END_DOCUMENT -> return
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Runs a shape handler, and on failure resynchronises to the end of that element instead of
|
|
977
|
+
* letting the exception abandon the whole slide. Bounds the blast radius of one malformed
|
|
978
|
+
* shape to that shape.
|
|
979
|
+
*/
|
|
980
|
+
private inline fun guarded(p: XmlPullParser, body: () -> Unit) {
|
|
981
|
+
val entryDepth = p.depth
|
|
982
|
+
try {
|
|
983
|
+
body()
|
|
984
|
+
} catch (_: Throwable) {
|
|
985
|
+
try {
|
|
986
|
+
while (p.depth >= entryDepth && p.eventType != XmlPullParser.END_DOCUMENT) p.next()
|
|
987
|
+
} catch (_: Throwable) { /* Parser is unusable; the caller's loop will terminate. */ }
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// ── Layout / master inheritance ──────────────────────────────────
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Resolves the slide → slide-layout → slide-master chain for [slidePart] into the placeholder
|
|
995
|
+
* geometry/text-style/background defaults the slide itself omits. Never throws: any missing or
|
|
996
|
+
* malformed part degrades to whatever resolved, and ultimately to [SlideInheritanceContext.EMPTY].
|
|
997
|
+
*/
|
|
998
|
+
private fun loadInheritanceContext(slidePart: String): SlideInheritanceContext {
|
|
999
|
+
return try {
|
|
1000
|
+
val layoutPart = Ox.parseRels(zip, slidePart).values
|
|
1001
|
+
.firstOrNull { it.contains("slideLayout", ignoreCase = true) }
|
|
1002
|
+
?: return SlideInheritanceContext.EMPTY
|
|
1003
|
+
val layout = layoutCache.getOrPut(layoutPart) { parseSlidePart(layoutPart, isMaster = false) }
|
|
1004
|
+
val masterPart = Ox.parseRels(zip, layoutPart).values
|
|
1005
|
+
.firstOrNull { it.contains("slideMaster", ignoreCase = true) }
|
|
1006
|
+
val master = masterPart?.let { mp -> masterCache.getOrPut(mp) { parseSlidePart(mp, isMaster = true) } }
|
|
1007
|
+
SlideInheritanceContext(
|
|
1008
|
+
layoutPlaceholders = layout.placeholders,
|
|
1009
|
+
masterPlaceholders = master?.placeholders ?: emptyList(),
|
|
1010
|
+
textStyles = master?.textStyles ?: TextStyles.EMPTY,
|
|
1011
|
+
background = layout.background ?: master?.background,
|
|
1012
|
+
decorations = (master?.decorations ?: emptyList()) + layout.decorations
|
|
1013
|
+
)
|
|
1014
|
+
} catch (_: Throwable) {
|
|
1015
|
+
SlideInheritanceContext.EMPTY
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Parses a slide layout or master: its placeholder geometry/list-styles, background, and
|
|
1020
|
+
* (masters only) the `<p:txStyles>` title/body/other default run+paragraph styles. */
|
|
1021
|
+
private fun parseSlidePart(partPath: String, isMaster: Boolean): ParsedSlidePart {
|
|
1022
|
+
val placeholders = ArrayList<StoredPlaceholder>()
|
|
1023
|
+
var background: PptFill? = null
|
|
1024
|
+
var textStyles = TextStyles.EMPTY
|
|
1025
|
+
val rels = Ox.parseRels(zip, partPath)
|
|
1026
|
+
try {
|
|
1027
|
+
Ox.zipStream(zip, partPath)?.use { stream ->
|
|
1028
|
+
val p = Ox.parser(stream)
|
|
1029
|
+
while (p.next() != XmlPullParser.END_DOCUMENT) {
|
|
1030
|
+
if (p.eventType != XmlPullParser.START_TAG) continue
|
|
1031
|
+
when (Ox.local(p)) {
|
|
1032
|
+
"bg" -> background = parseBackground(p, rels)
|
|
1033
|
+
"sp" -> parseLayoutShape(p)?.let { placeholders.add(it) }
|
|
1034
|
+
"txStyles" -> if (isMaster) textStyles = parseTxStyles(p)
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
} catch (_: Throwable) { /* Use whatever parsed before the failure. */ }
|
|
1039
|
+
return ParsedSlidePart(placeholders, background, textStyles, parseDecorations(partPath))
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* The non-placeholder shapes of a layout or master, parsed in their own right so they can be
|
|
1044
|
+
* drawn behind the slide's content.
|
|
1045
|
+
*
|
|
1046
|
+
* Runs with an EMPTY inheritance context: these shapes are not placeholders, so there is
|
|
1047
|
+
* nothing for them to inherit, and using the calling slide's context would be wrong anyway.
|
|
1048
|
+
*/
|
|
1049
|
+
private fun parseDecorations(partPath: String): List<PowerPointElement> {
|
|
1050
|
+
val out = ArrayList<PowerPointElement>()
|
|
1051
|
+
val savedContext = currentContext
|
|
1052
|
+
val savedRels = currentRels
|
|
1053
|
+
currentContext = SlideInheritanceContext.EMPTY
|
|
1054
|
+
currentRels = Ox.parseRels(zip, partPath)
|
|
1055
|
+
parsingDecorations = true
|
|
1056
|
+
try {
|
|
1057
|
+
Ox.zipStream(zip, partPath)?.use { stream ->
|
|
1058
|
+
val p = Ox.parser(stream)
|
|
1059
|
+
while (p.next() != XmlPullParser.END_DOCUMENT) {
|
|
1060
|
+
if (p.eventType == XmlPullParser.START_TAG && Ox.local(p) == "spTree") {
|
|
1061
|
+
walkShapeContainer(p, currentRels, out, 0)
|
|
1062
|
+
break
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
} catch (_: Throwable) { /* Decoration is optional - never fail the slide over it. */ } finally {
|
|
1067
|
+
parsingDecorations = false
|
|
1068
|
+
currentContext = savedContext
|
|
1069
|
+
currentRels = savedRels
|
|
1070
|
+
}
|
|
1071
|
+
return out
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** A single `<p:sp>` on a layout/master: keeps only placeholders, capturing their geometry
|
|
1075
|
+
* and any per-level `<a:lstStyle>` run/paragraph defaults. */
|
|
1076
|
+
private fun parseLayoutShape(p: XmlPullParser): StoredPlaceholder? {
|
|
1077
|
+
var placeholder: PowerPointPlaceholder? = null
|
|
1078
|
+
var bounds: RectF? = null
|
|
1079
|
+
var lstStyle: Map<Int, ParaRunDefaults> = emptyMap()
|
|
1080
|
+
var depth = 1
|
|
1081
|
+
while (depth > 0) {
|
|
1082
|
+
when (p.next()) {
|
|
1083
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1084
|
+
"ph" -> {
|
|
1085
|
+
placeholder = PowerPointPlaceholder(Ox.attr(p, "type"), Ox.attr(p, "idx"))
|
|
1086
|
+
Ox.skip(p)
|
|
1087
|
+
}
|
|
1088
|
+
"spPr" -> bounds = parseShapeProperties(p).transform?.bounds
|
|
1089
|
+
"lstStyle" -> lstStyle = parseLevelStyles(p)
|
|
1090
|
+
// Named descent: <p:ph> lives under nvSpPr/nvPr and <a:lstStyle> under txBody.
|
|
1091
|
+
"nvSpPr", "nvPr", "txBody" -> depth++
|
|
1092
|
+
else -> Ox.skip(p)
|
|
1093
|
+
}
|
|
1094
|
+
XmlPullParser.END_TAG -> depth--
|
|
1095
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
val ph = placeholder ?: return null
|
|
1099
|
+
return StoredPlaceholder(ph, PlaceholderInfo(bounds, lstStyle))
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** Master `<p:txStyles>` → title/body/other style tables (per outline level). */
|
|
1103
|
+
private fun parseTxStyles(p: XmlPullParser): TextStyles {
|
|
1104
|
+
var title: Map<Int, ParaRunDefaults> = emptyMap()
|
|
1105
|
+
var body: Map<Int, ParaRunDefaults> = emptyMap()
|
|
1106
|
+
var other: Map<Int, ParaRunDefaults> = emptyMap()
|
|
1107
|
+
var depth = 1
|
|
1108
|
+
while (depth > 0) {
|
|
1109
|
+
when (p.next()) {
|
|
1110
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1111
|
+
"titleStyle" -> title = parseLevelStyles(p)
|
|
1112
|
+
"bodyStyle" -> body = parseLevelStyles(p)
|
|
1113
|
+
"otherStyle" -> other = parseLevelStyles(p)
|
|
1114
|
+
else -> Ox.skip(p)
|
|
1115
|
+
}
|
|
1116
|
+
XmlPullParser.END_TAG -> depth--
|
|
1117
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return TextStyles(title, body, other)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Parses `<a:lvl1pPr>…<a:lvl9pPr>` (in an `lstStyle`/`titleStyle`/`bodyStyle`/`otherStyle`
|
|
1124
|
+
* container) into a level → [ParaRunDefaults] map. Levels are 0-based (lvl1pPr → 0). */
|
|
1125
|
+
private fun parseLevelStyles(p: XmlPullParser): Map<Int, ParaRunDefaults> {
|
|
1126
|
+
val out = HashMap<Int, ParaRunDefaults>()
|
|
1127
|
+
var depth = 1
|
|
1128
|
+
while (depth > 0) {
|
|
1129
|
+
when (p.next()) {
|
|
1130
|
+
XmlPullParser.START_TAG -> {
|
|
1131
|
+
val match = LVL_STYLE_REGEX.matchEntire(Ox.local(p))
|
|
1132
|
+
if (match != null) {
|
|
1133
|
+
val level = ((match.groupValues[1].toIntOrNull() ?: 1) - 1).coerceAtLeast(0)
|
|
1134
|
+
out[level] = parseLevelPr(p)
|
|
1135
|
+
} else {
|
|
1136
|
+
Ox.skip(p)
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
XmlPullParser.END_TAG -> depth--
|
|
1140
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return out
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** One `<a:lvlNpPr>`: its `<a:defRPr>` run defaults plus paragraph alignment and bullet. */
|
|
1147
|
+
private fun parseLevelPr(p: XmlPullParser): ParaRunDefaults {
|
|
1148
|
+
val align = when (Ox.attr(p, "algn")) {
|
|
1149
|
+
"ctr" -> TextAlign.CENTER
|
|
1150
|
+
"r" -> TextAlign.RIGHT
|
|
1151
|
+
"just" -> TextAlign.JUSTIFY
|
|
1152
|
+
"l" -> TextAlign.LEFT
|
|
1153
|
+
else -> null
|
|
1154
|
+
}
|
|
1155
|
+
val marL = Ox.emuToPt(Ox.attr(p, "marL"))
|
|
1156
|
+
val indent = Ox.emuToPt(Ox.attr(p, "indent"))
|
|
1157
|
+
var run = PowerPointRunProperties()
|
|
1158
|
+
var bullet: String? = null
|
|
1159
|
+
var bulletExplicit = false
|
|
1160
|
+
var lineSpacePct: Float? = null
|
|
1161
|
+
var spaceBefore: Float? = null
|
|
1162
|
+
var spaceAfter: Float? = null
|
|
1163
|
+
var depth = 1
|
|
1164
|
+
while (depth > 0) {
|
|
1165
|
+
when (p.next()) {
|
|
1166
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1167
|
+
"defRPr" -> run = parseRunProperties(p)
|
|
1168
|
+
"buChar" -> {
|
|
1169
|
+
bullet = normalizeBullet(Ox.attr(p, "char"))
|
|
1170
|
+
bulletExplicit = true
|
|
1171
|
+
Ox.skip(p)
|
|
1172
|
+
}
|
|
1173
|
+
"buAutoNum" -> {
|
|
1174
|
+
bullet = autoNumberMarker(Ox.attr(p, "type"), Ox.attr(p, "startAt")?.toIntOrNull() ?: 1)
|
|
1175
|
+
bulletExplicit = true
|
|
1176
|
+
Ox.skip(p)
|
|
1177
|
+
}
|
|
1178
|
+
"buNone" -> {
|
|
1179
|
+
bullet = null
|
|
1180
|
+
bulletExplicit = true
|
|
1181
|
+
Ox.skip(p)
|
|
1182
|
+
}
|
|
1183
|
+
"lnSpc" -> lineSpacePct = parseLineSpacing(p)
|
|
1184
|
+
"spcBef" -> spaceBefore = parseSpacing(p)
|
|
1185
|
+
"spcAft" -> spaceAfter = parseSpacing(p)
|
|
1186
|
+
else -> Ox.skip(p)
|
|
1187
|
+
}
|
|
1188
|
+
XmlPullParser.END_TAG -> depth--
|
|
1189
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return ParaRunDefaults(
|
|
1193
|
+
run, align, bullet, bulletExplicit, marL, indent, lineSpacePct, spaceBefore, spaceAfter
|
|
1194
|
+
)
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Effective per-level defaults for a placeholder: its layout list-style, then the master
|
|
1198
|
+
* placeholder list-style, then the master's category text style (falling back to level 0). */
|
|
1199
|
+
private fun defaultsFor(
|
|
1200
|
+
level: Int,
|
|
1201
|
+
placeholder: PowerPointPlaceholder?,
|
|
1202
|
+
layoutInfo: PlaceholderInfo?,
|
|
1203
|
+
masterInfo: PlaceholderInfo?
|
|
1204
|
+
): ParaRunDefaults? {
|
|
1205
|
+
// Non-placeholder shapes still inherit the master's "other" style rather than nothing.
|
|
1206
|
+
val category = if (placeholder == null) {
|
|
1207
|
+
PlaceholderCategory.OTHER
|
|
1208
|
+
} else {
|
|
1209
|
+
phCategory(placeholder.type)
|
|
1210
|
+
}
|
|
1211
|
+
val styles = currentContext.textStyles.forCategory(category)
|
|
1212
|
+
// MERGE field-by-field in precedence order (master txStyles -> master placeholder -> layout
|
|
1213
|
+
// placeholder). Returning the first source that had ANY value - as this used to - meant a
|
|
1214
|
+
// layout that set only alignment threw away the master's font size and colour, which is why
|
|
1215
|
+
// so much text fell back to the renderer's hardcoded default.
|
|
1216
|
+
var out: ParaRunDefaults? = styles[level] ?: styles[0]
|
|
1217
|
+
if (placeholder != null) {
|
|
1218
|
+
masterInfo?.lstStyle?.get(level)?.let { out = it.overlayOn(out) }
|
|
1219
|
+
layoutInfo?.lstStyle?.get(level)?.let { out = it.overlayOn(out) }
|
|
1220
|
+
}
|
|
1221
|
+
return out
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** Folds inherited [defaults] into a [RawParagraph]: the slide's own run/paragraph values win;
|
|
1225
|
+
* gaps are filled from the placeholder's inherited style. */
|
|
1226
|
+
private fun resolveParagraph(
|
|
1227
|
+
raw: RawParagraph,
|
|
1228
|
+
defaults: ParaRunDefaults?,
|
|
1229
|
+
category: PlaceholderCategory = PlaceholderCategory.BODY,
|
|
1230
|
+
styleTextColor: Int? = null
|
|
1231
|
+
): PowerPointParagraph {
|
|
1232
|
+
val dr = defaults?.run
|
|
1233
|
+
val runs = if (dr == null) raw.runs else raw.runs.map { run ->
|
|
1234
|
+
run.copy(
|
|
1235
|
+
fontSizePt = run.fontSizePt ?: dr.fontSizePt,
|
|
1236
|
+
color = run.color ?: dr.color,
|
|
1237
|
+
fontFamily = run.fontFamily ?: dr.fontFamily,
|
|
1238
|
+
bold = run.bold ?: dr.bold,
|
|
1239
|
+
italic = run.italic ?: dr.italic,
|
|
1240
|
+
underline = run.underline ?: dr.underline,
|
|
1241
|
+
strike = run.strike ?: dr.strike,
|
|
1242
|
+
allCaps = run.allCaps ?: dr.allCaps
|
|
1243
|
+
)
|
|
1244
|
+
}
|
|
1245
|
+
val align = raw.align ?: defaults?.align ?: TextAlign.LEFT
|
|
1246
|
+
val bullet = if (raw.bulletExplicit) raw.bullet else defaults?.bullet
|
|
1247
|
+
// Resolve the paragraph's base appearance once. Order: the inherited placeholder default,
|
|
1248
|
+
// then whatever the paragraph's own first run states, then the OOXML floor for the
|
|
1249
|
+
// placeholder's category (titles are much larger than body text).
|
|
1250
|
+
val first = runs.firstOrNull()
|
|
1251
|
+
val floorSize = when (category) {
|
|
1252
|
+
PlaceholderCategory.TITLE -> DEFAULT_TITLE_SIZE_PT
|
|
1253
|
+
else -> DEFAULT_BODY_SIZE_PT
|
|
1254
|
+
}
|
|
1255
|
+
// Bullet geometry: the deck's own value wins, then the inherited list style, then a
|
|
1256
|
+
// hanging-indent default matching the DOCX preview so the two look like one product.
|
|
1257
|
+
val hasBullet = bullet != null
|
|
1258
|
+
val marL = raw.marLPt ?: defaults?.marLPt
|
|
1259
|
+
?: if (hasBullet) DEFAULT_BULLET_INDENT_PT * (raw.level + 1) else 0f
|
|
1260
|
+
val indent = raw.indentPt ?: defaults?.indentPt
|
|
1261
|
+
?: if (hasBullet) -DEFAULT_BULLET_INDENT_PT else 0f
|
|
1262
|
+
val baseSize = dr?.fontSizePt ?: first?.fontSizePt ?: floorSize
|
|
1263
|
+
// A negative value means "a fraction of the font size" (the spcPct form).
|
|
1264
|
+
fun spacingPt(v: Float?): Float = when {
|
|
1265
|
+
v == null -> 0f
|
|
1266
|
+
v < 0f -> -v * baseSize
|
|
1267
|
+
else -> v
|
|
1268
|
+
}
|
|
1269
|
+
return PowerPointParagraph(
|
|
1270
|
+
runs = runs,
|
|
1271
|
+
align = align,
|
|
1272
|
+
bullet = bullet,
|
|
1273
|
+
level = raw.level,
|
|
1274
|
+
baseSizePt = dr?.fontSizePt ?: first?.fontSizePt ?: floorSize,
|
|
1275
|
+
baseColor = dr?.color ?: first?.color ?: styleTextColor ?: defaultTextColor(),
|
|
1276
|
+
baseFontFamily = dr?.fontFamily ?: first?.fontFamily ?: minorLatinFont,
|
|
1277
|
+
baseBold = dr?.bold ?: first?.bold ?: false,
|
|
1278
|
+
baseItalic = dr?.italic ?: first?.italic ?: false,
|
|
1279
|
+
marLPt = marL,
|
|
1280
|
+
indentPt = indent,
|
|
1281
|
+
lineSpacePct = raw.lineSpacePct ?: defaults?.lineSpacePct,
|
|
1282
|
+
spaceBeforePt = spacingPt(raw.spaceBeforePt ?: defaults?.spaceBeforePt),
|
|
1283
|
+
spaceAfterPt = spacingPt(raw.spaceAfterPt ?: defaults?.spaceAfterPt)
|
|
1284
|
+
)
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* The colour text falls back to when nothing in the inheritance chain specifies one: the
|
|
1289
|
+
* theme's mapped `tx1`, which is what PowerPoint uses. Critically this is NOT hardcoded black,
|
|
1290
|
+
* so a dark-master deck gets its light text instead of black-on-dark.
|
|
1291
|
+
*/
|
|
1292
|
+
private fun defaultTextColor(): Int = colorContext.scheme("tx1")
|
|
1293
|
+
|
|
1294
|
+
/** A synthesized band for a placeholder whose geometry the deck left entirely to the master
|
|
1295
|
+
* (title → top, body/subtitle → centre) so inherited text is never dropped. Decorative
|
|
1296
|
+
* placeholders (footer/date/slide-number) return null and are skipped. */
|
|
1297
|
+
private fun defaultPlaceholderBounds(type: String?): RectF? {
|
|
1298
|
+
val w = slideWidthPt
|
|
1299
|
+
val h = slideHeightPt
|
|
1300
|
+
return when (phCategory(type)) {
|
|
1301
|
+
PlaceholderCategory.TITLE -> RectF(w * 0.06f, h * 0.05f, w * 0.94f, h * 0.22f)
|
|
1302
|
+
PlaceholderCategory.BODY -> RectF(w * 0.06f, h * 0.25f, w * 0.94f, h * 0.92f)
|
|
1303
|
+
PlaceholderCategory.OTHER -> null
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
private fun parseBackground(p: XmlPullParser, rels: Map<String, String>): PptFill {
|
|
1308
|
+
var color: Int? = null
|
|
1309
|
+
var bitmap: Bitmap? = null
|
|
1310
|
+
var depth = 1
|
|
1311
|
+
while (depth > 0) {
|
|
1312
|
+
when (p.next()) {
|
|
1313
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1314
|
+
"solidFill" -> color = resolveColor(parseColorContainer(p))
|
|
1315
|
+
"blipFill" -> bitmap = parseBlipFill(p, rels)
|
|
1316
|
+
// Named descent: the fills sit inside <p:bgPr>.
|
|
1317
|
+
"bgPr" -> depth++
|
|
1318
|
+
else -> Ox.skip(p)
|
|
1319
|
+
}
|
|
1320
|
+
XmlPullParser.END_TAG -> depth--
|
|
1321
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return bitmap?.let { PptFill.Image(it, color ?: Color.WHITE) }
|
|
1325
|
+
?: PptFill.Solid(color ?: Color.WHITE)
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
private fun parseShape(p: XmlPullParser): PowerPointElement? {
|
|
1329
|
+
var transform = PowerPointTransform()
|
|
1330
|
+
var fill: PptFill? = null
|
|
1331
|
+
var explicitNoFill = false
|
|
1332
|
+
var stroke: PowerPointStroke? = null
|
|
1333
|
+
var shapeType = PowerPointShapeType.RECT
|
|
1334
|
+
var customPath: android.graphics.Path? = null
|
|
1335
|
+
var textBody = PowerPointTextBody()
|
|
1336
|
+
var placeholder: PowerPointPlaceholder? = null
|
|
1337
|
+
var styleRefs = ShapeStyleRefs()
|
|
1338
|
+
var depth = 1
|
|
1339
|
+
while (depth > 0) {
|
|
1340
|
+
when (p.next()) {
|
|
1341
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1342
|
+
// <p:nvSpPr><p:nvPr><p:ph type=".." idx=".."/> — the placeholder identity that keys
|
|
1343
|
+
// geometry/text-style inheritance from the slide layout and master.
|
|
1344
|
+
"ph" -> {
|
|
1345
|
+
placeholder = PowerPointPlaceholder(Ox.attr(p, "type"), Ox.attr(p, "idx"))
|
|
1346
|
+
Ox.skip(p)
|
|
1347
|
+
}
|
|
1348
|
+
"spPr" -> {
|
|
1349
|
+
val props = parseShapeProperties(p)
|
|
1350
|
+
transform = props.transform ?: transform
|
|
1351
|
+
fill = props.fill
|
|
1352
|
+
explicitNoFill = props.explicitNoFill
|
|
1353
|
+
stroke = props.stroke
|
|
1354
|
+
shapeType = props.shapeType
|
|
1355
|
+
customPath = props.customPath
|
|
1356
|
+
}
|
|
1357
|
+
"style" -> styleRefs = parseShapeStyle(p)
|
|
1358
|
+
"txBody" -> textBody = parseTextBody(p)
|
|
1359
|
+
// Named descent: <p:ph> lives under nvSpPr/nvCxnSpPr -> nvPr.
|
|
1360
|
+
"nvSpPr", "nvCxnSpPr", "nvPr" -> depth++
|
|
1361
|
+
else -> Ox.skip(p)
|
|
1362
|
+
}
|
|
1363
|
+
XmlPullParser.END_TAG -> depth--
|
|
1364
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// A layout/master placeholder is only a TEMPLATE for the slide's own shape - drawing it as
|
|
1369
|
+
// decoration would double every title and stamp "Click to edit" prompts onto real slides.
|
|
1370
|
+
if (parsingDecorations && placeholder != null) return null
|
|
1371
|
+
|
|
1372
|
+
// Feed the slide's non-blank safety net with this shape's plain text, whether or not the
|
|
1373
|
+
// element itself survives (e.g. a footer placeholder we later drop for lacking geometry).
|
|
1374
|
+
if (!parsingDecorations) {
|
|
1375
|
+
for (raw in textBody.paragraphs) {
|
|
1376
|
+
val line = raw.runs.joinToString("") { it.text }.replace('\n', ' ').trim()
|
|
1377
|
+
if (line.isNotEmpty()) slideTextBucket.add(line)
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Fall back to the theme style matrix for anything the shape didn't state outright. This is
|
|
1382
|
+
// what stops themed shapes (whose colour lives entirely in <p:style>) from ending up with no
|
|
1383
|
+
// fill and being discarded below. An explicit <a:noFill/> still wins.
|
|
1384
|
+
val effectiveFill = fill
|
|
1385
|
+
?: if (explicitNoFill) null else styleRefs.fillColor?.let { PptFill.Solid(it) }
|
|
1386
|
+
val effectiveStroke = stroke
|
|
1387
|
+
?: styleRefs.lineColor?.let { PowerPointStroke(it, 1f) }
|
|
1388
|
+
|
|
1389
|
+
val layoutInfo = placeholder?.let { matchPlaceholder(currentContext.layoutPlaceholders, it) }
|
|
1390
|
+
val masterInfo = placeholder?.let { matchPlaceholder(currentContext.masterPlaceholders, it) }
|
|
1391
|
+
// Geometry: the slide's own xfrm wins; otherwise inherit from the matched layout → master
|
|
1392
|
+
// placeholder; as a last resort synthesize a default band so inherited placeholders that
|
|
1393
|
+
// reference master-only geometry never vanish.
|
|
1394
|
+
val bounds = transform.bounds
|
|
1395
|
+
?: layoutInfo?.bounds
|
|
1396
|
+
?: masterInfo?.bounds
|
|
1397
|
+
?: placeholder?.let { defaultPlaceholderBounds(it.type) }
|
|
1398
|
+
|
|
1399
|
+
if (textBody.paragraphs.isNotEmpty()) {
|
|
1400
|
+
val b = bounds ?: return null
|
|
1401
|
+
val resolved = textBody.paragraphs.map { raw ->
|
|
1402
|
+
resolveParagraph(
|
|
1403
|
+
raw,
|
|
1404
|
+
defaultsFor(raw.level, placeholder, layoutInfo, masterInfo),
|
|
1405
|
+
phCategory(placeholder?.type),
|
|
1406
|
+
// A themed shape carries its text colour in <a:fontRef>; without it, white-on-accent
|
|
1407
|
+
// captions rendered black on a coloured fill.
|
|
1408
|
+
styleRefs.fontColor
|
|
1409
|
+
)
|
|
1410
|
+
}
|
|
1411
|
+
return PowerPointElement.TextBox(
|
|
1412
|
+
bounds = b,
|
|
1413
|
+
rotation = transform.rotation,
|
|
1414
|
+
paragraphs = resolved,
|
|
1415
|
+
fill = effectiveFill,
|
|
1416
|
+
stroke = effectiveStroke,
|
|
1417
|
+
verticalAnchor = textBody.anchor,
|
|
1418
|
+
insetLeftPt = textBody.insetLeftPt,
|
|
1419
|
+
insetTopPt = textBody.insetTopPt,
|
|
1420
|
+
insetRightPt = textBody.insetRightPt,
|
|
1421
|
+
insetBottomPt = textBody.insetBottomPt,
|
|
1422
|
+
shapeType = shapeType,
|
|
1423
|
+
customPath = customPath,
|
|
1424
|
+
autofit = textBody.autofit,
|
|
1425
|
+
fontScale = textBody.fontScale,
|
|
1426
|
+
lnSpcReduction = textBody.lnSpcReduction,
|
|
1427
|
+
flipH = transform.flipH,
|
|
1428
|
+
flipV = transform.flipV
|
|
1429
|
+
)
|
|
1430
|
+
}
|
|
1431
|
+
val b = bounds ?: return null
|
|
1432
|
+
if (effectiveFill == null && effectiveStroke == null) return null
|
|
1433
|
+
return PowerPointElement.Shape(
|
|
1434
|
+
b, transform.rotation, shapeType, effectiveFill, effectiveStroke, customPath,
|
|
1435
|
+
transform.flipH, transform.flipV
|
|
1436
|
+
)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
private fun parsePicture(p: XmlPullParser, rels: Map<String, String>): PowerPointElement? {
|
|
1440
|
+
var transform = PowerPointTransform()
|
|
1441
|
+
var imageRel: String? = null
|
|
1442
|
+
var srcRect: RectF? = null
|
|
1443
|
+
var depth = 1
|
|
1444
|
+
while (depth > 0) {
|
|
1445
|
+
when (p.next()) {
|
|
1446
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1447
|
+
"spPr" -> parseShapeProperties(p).transform?.let { transform = it }
|
|
1448
|
+
"blip" -> {
|
|
1449
|
+
imageRel = Ox.attr(p, "embed")
|
|
1450
|
+
Ox.skip(p)
|
|
1451
|
+
}
|
|
1452
|
+
// Crop, as fractional insets in thousandths of a percent. Ignoring it stretched the
|
|
1453
|
+
// whole source image into a frame that was only meant to show part of it.
|
|
1454
|
+
"srcRect" -> {
|
|
1455
|
+
fun inset(name: String) = (Ox.attr(p, name)?.toFloatOrNull() ?: 0f) / 100000f
|
|
1456
|
+
val r = RectF(inset("l"), inset("t"), inset("r"), inset("b"))
|
|
1457
|
+
if (r.left != 0f || r.top != 0f || r.right != 0f || r.bottom != 0f) srcRect = r
|
|
1458
|
+
Ox.skip(p)
|
|
1459
|
+
}
|
|
1460
|
+
// Named descent: <a:blip>/<a:srcRect> live inside <p:blipFill>.
|
|
1461
|
+
"blipFill" -> depth++
|
|
1462
|
+
else -> Ox.skip(p)
|
|
1463
|
+
}
|
|
1464
|
+
XmlPullParser.END_TAG -> depth--
|
|
1465
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
val bounds = transform.bounds ?: return null
|
|
1469
|
+
val rel = imageRel ?: return null
|
|
1470
|
+
val bitmap = loadBitmap(rels[rel] ?: return null) ?: return null
|
|
1471
|
+
return PowerPointElement.Picture(
|
|
1472
|
+
bounds, transform.rotation, bitmap, srcRect, transform.flipH, transform.flipV
|
|
1473
|
+
)
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
private fun parseGraphicFrame(p: XmlPullParser): PowerPointElement? {
|
|
1477
|
+
var transform = PowerPointTransform()
|
|
1478
|
+
var table: PowerPointElement.Table? = null
|
|
1479
|
+
var depth = 1
|
|
1480
|
+
while (depth > 0) {
|
|
1481
|
+
when (p.next()) {
|
|
1482
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1483
|
+
"xfrm" -> transform = parseTransform(p)
|
|
1484
|
+
"tbl" -> table = parseTable(p, transform)
|
|
1485
|
+
// Named descent: <a:tbl> sits under <a:graphic><a:graphicData>. Charts and SmartArt
|
|
1486
|
+
// land here too and yield no table — they are skipped rather than mis-drawn.
|
|
1487
|
+
"graphic", "graphicData" -> depth++
|
|
1488
|
+
else -> Ox.skip(p)
|
|
1489
|
+
}
|
|
1490
|
+
XmlPullParser.END_TAG -> depth--
|
|
1491
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return table
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
private fun parseShapeProperties(p: XmlPullParser): PowerPointShapeProperties {
|
|
1498
|
+
var transform: PowerPointTransform? = null
|
|
1499
|
+
var fill: PptFill? = null
|
|
1500
|
+
var explicitNoFill = false
|
|
1501
|
+
var stroke: PowerPointStroke? = null
|
|
1502
|
+
var shapeType = PowerPointShapeType.RECT
|
|
1503
|
+
var customPath: android.graphics.Path? = null
|
|
1504
|
+
var depth = 1
|
|
1505
|
+
while (depth > 0) {
|
|
1506
|
+
when (p.next()) {
|
|
1507
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1508
|
+
"xfrm" -> transform = parseTransform(p)
|
|
1509
|
+
"prstGeom" -> {
|
|
1510
|
+
shapeType = mapShapeType(Ox.attr(p, "prst"))
|
|
1511
|
+
Ox.skip(p)
|
|
1512
|
+
}
|
|
1513
|
+
// A hand-edited or imported shape carries its outline explicitly. Without this the
|
|
1514
|
+
// shape fell back to its bounding RECTANGLE, which on a slide full of custom diagram
|
|
1515
|
+
// shapes reads as a wall of solid blocks.
|
|
1516
|
+
"custGeom" -> customPath = parseCustomGeometry(p)
|
|
1517
|
+
"solidFill" -> fill = resolveColor(parseColorContainer(p))?.let { PptFill.Solid(it) }
|
|
1518
|
+
"gradFill" -> fill = parseGradientFill(p)
|
|
1519
|
+
"pattFill" -> fill = parsePatternFill(p)
|
|
1520
|
+
"blipFill" -> fill = parseBlipFill(p, currentRels)?.let { PptFill.Image(it) }
|
|
1521
|
+
"noFill" -> {
|
|
1522
|
+
fill = null
|
|
1523
|
+
explicitNoFill = true
|
|
1524
|
+
Ox.skip(p)
|
|
1525
|
+
}
|
|
1526
|
+
"ln" -> stroke = parseLine(p)
|
|
1527
|
+
// No blind descent here: <a:effectLst>/<a:scene3d> contain their own <a:solidFill>
|
|
1528
|
+
// elements, and falling into them would adopt a shadow's colour as the shape's fill.
|
|
1529
|
+
else -> Ox.skip(p)
|
|
1530
|
+
}
|
|
1531
|
+
XmlPullParser.END_TAG -> depth--
|
|
1532
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return PowerPointShapeProperties(
|
|
1536
|
+
transform, fill, stroke, shapeType, explicitNoFill, customPath
|
|
1537
|
+
)
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* `<a:custGeom>` -> a Path in NORMALISED 0..1 coordinates.
|
|
1542
|
+
*
|
|
1543
|
+
* Normalising against the path's own `w`/`h` (falling back to the shape extent) means the same
|
|
1544
|
+
* Path can be scaled to whatever rect the shape occupies, including after a group transform.
|
|
1545
|
+
*/
|
|
1546
|
+
private fun parseCustomGeometry(p: XmlPullParser): android.graphics.Path? {
|
|
1547
|
+
val out = android.graphics.Path()
|
|
1548
|
+
var pathW = 0f
|
|
1549
|
+
var pathH = 0f
|
|
1550
|
+
var any = false
|
|
1551
|
+
// The pending point list for the current segment: <a:pt> children arrive one at a time.
|
|
1552
|
+
val pts = ArrayList<Float>(6)
|
|
1553
|
+
val arcOval = RectF()
|
|
1554
|
+
var segment = ""
|
|
1555
|
+
var curX = 0f
|
|
1556
|
+
var curY = 0f
|
|
1557
|
+
var arc: FloatArray? = null
|
|
1558
|
+
|
|
1559
|
+
fun nx(v: Float) = if (pathW > 0f) v / pathW else 0f
|
|
1560
|
+
fun ny(v: Float) = if (pathH > 0f) v / pathH else 0f
|
|
1561
|
+
|
|
1562
|
+
fun flush() {
|
|
1563
|
+
when (segment) {
|
|
1564
|
+
"moveTo" -> if (pts.size >= 2) {
|
|
1565
|
+
curX = pts[0]; curY = pts[1]
|
|
1566
|
+
out.moveTo(nx(curX), ny(curY))
|
|
1567
|
+
any = true
|
|
1568
|
+
}
|
|
1569
|
+
"lnTo" -> if (pts.size >= 2) {
|
|
1570
|
+
curX = pts[0]; curY = pts[1]
|
|
1571
|
+
out.lineTo(nx(curX), ny(curY))
|
|
1572
|
+
any = true
|
|
1573
|
+
}
|
|
1574
|
+
"quadBezTo" -> if (pts.size >= 4) {
|
|
1575
|
+
out.quadTo(nx(pts[0]), ny(pts[1]), nx(pts[2]), ny(pts[3]))
|
|
1576
|
+
curX = pts[2]; curY = pts[3]
|
|
1577
|
+
any = true
|
|
1578
|
+
}
|
|
1579
|
+
"cubicBezTo" -> if (pts.size >= 6) {
|
|
1580
|
+
out.cubicTo(
|
|
1581
|
+
nx(pts[0]), ny(pts[1]), nx(pts[2]), ny(pts[3]), nx(pts[4]), ny(pts[5])
|
|
1582
|
+
)
|
|
1583
|
+
curX = pts[4]; curY = pts[5]
|
|
1584
|
+
any = true
|
|
1585
|
+
}
|
|
1586
|
+
"arcTo" -> arc?.let { a ->
|
|
1587
|
+
// <a:arcTo> is relative: the current point lies on the ellipse at stAng, so the centre
|
|
1588
|
+
// is back-computed from it and the sweep drawn from there.
|
|
1589
|
+
val wR = a[0]
|
|
1590
|
+
val hR = a[1]
|
|
1591
|
+
val st = a[2]
|
|
1592
|
+
val sw = a[3]
|
|
1593
|
+
val ccx = curX - wR * kotlin.math.cos(Math.toRadians(st.toDouble())).toFloat()
|
|
1594
|
+
val ccy = curY - hR * kotlin.math.sin(Math.toRadians(st.toDouble())).toFloat()
|
|
1595
|
+
arcOval.set(nx(ccx - wR), ny(ccy - hR), nx(ccx + wR), ny(ccy + hR))
|
|
1596
|
+
out.arcTo(arcOval, st, sw)
|
|
1597
|
+
val end = st + sw
|
|
1598
|
+
curX = ccx + wR * kotlin.math.cos(Math.toRadians(end.toDouble())).toFloat()
|
|
1599
|
+
curY = ccy + hR * kotlin.math.sin(Math.toRadians(end.toDouble())).toFloat()
|
|
1600
|
+
any = true
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
pts.clear()
|
|
1604
|
+
arc = null
|
|
1605
|
+
segment = ""
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
var depth = 1
|
|
1609
|
+
while (depth > 0) {
|
|
1610
|
+
when (p.next()) {
|
|
1611
|
+
XmlPullParser.START_TAG -> when (val local = Ox.local(p)) {
|
|
1612
|
+
"path" -> {
|
|
1613
|
+
pathW = Ox.attr(p, "w")?.toFloatOrNull() ?: pathW
|
|
1614
|
+
pathH = Ox.attr(p, "h")?.toFloatOrNull() ?: pathH
|
|
1615
|
+
depth++
|
|
1616
|
+
}
|
|
1617
|
+
"pathLst" -> depth++
|
|
1618
|
+
"moveTo", "lnTo", "quadBezTo", "cubicBezTo" -> {
|
|
1619
|
+
flush()
|
|
1620
|
+
segment = local
|
|
1621
|
+
depth++
|
|
1622
|
+
}
|
|
1623
|
+
"arcTo" -> {
|
|
1624
|
+
flush()
|
|
1625
|
+
segment = "arcTo"
|
|
1626
|
+
arc = floatArrayOf(
|
|
1627
|
+
Ox.attr(p, "wR")?.toFloatOrNull() ?: 0f,
|
|
1628
|
+
Ox.attr(p, "hR")?.toFloatOrNull() ?: 0f,
|
|
1629
|
+
(Ox.attr(p, "stAng")?.toFloatOrNull() ?: 0f) / 60000f,
|
|
1630
|
+
(Ox.attr(p, "swAng")?.toFloatOrNull() ?: 0f) / 60000f
|
|
1631
|
+
)
|
|
1632
|
+
Ox.skip(p)
|
|
1633
|
+
flush()
|
|
1634
|
+
}
|
|
1635
|
+
"pt" -> {
|
|
1636
|
+
Ox.attr(p, "x")?.toFloatOrNull()?.let { pts.add(it) }
|
|
1637
|
+
Ox.attr(p, "y")?.toFloatOrNull()?.let { pts.add(it) }
|
|
1638
|
+
Ox.skip(p)
|
|
1639
|
+
}
|
|
1640
|
+
"close" -> {
|
|
1641
|
+
flush()
|
|
1642
|
+
out.close()
|
|
1643
|
+
Ox.skip(p)
|
|
1644
|
+
}
|
|
1645
|
+
else -> Ox.skip(p)
|
|
1646
|
+
}
|
|
1647
|
+
XmlPullParser.END_TAG -> {
|
|
1648
|
+
// Leaving a segment element commits whatever <a:pt> children it collected.
|
|
1649
|
+
if (segment.isNotEmpty()) flush()
|
|
1650
|
+
depth--
|
|
1651
|
+
}
|
|
1652
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
if (!any || pathW <= 0f || pathH <= 0f) return null
|
|
1656
|
+
return out
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* `<a:gradFill>` -> stop colours and the gradient direction.
|
|
1661
|
+
*
|
|
1662
|
+
* Gradients had no representation before, so a gradient-filled shape came out with no fill and
|
|
1663
|
+
* was then discarded outright, and a gradient slide background fell back to flat white.
|
|
1664
|
+
*/
|
|
1665
|
+
private fun parseGradientFill(p: XmlPullParser): PptFill? {
|
|
1666
|
+
val stops = ArrayList<Pair<Float, Int>>(4)
|
|
1667
|
+
var angle = 90f
|
|
1668
|
+
var radial = false
|
|
1669
|
+
var depth = 1
|
|
1670
|
+
while (depth > 0) {
|
|
1671
|
+
when (p.next()) {
|
|
1672
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1673
|
+
"gs" -> {
|
|
1674
|
+
val pos = (Ox.attr(p, "pos")?.toFloatOrNull() ?: 0f) / 100000f
|
|
1675
|
+
resolveColor(parseColorContainer(p))?.let { stops.add(pos to it) }
|
|
1676
|
+
}
|
|
1677
|
+
"lin" -> {
|
|
1678
|
+
angle = (Ox.attr(p, "ang")?.toFloatOrNull() ?: 5400000f) / 60000f
|
|
1679
|
+
Ox.skip(p)
|
|
1680
|
+
}
|
|
1681
|
+
"path" -> {
|
|
1682
|
+
radial = true
|
|
1683
|
+
Ox.skip(p)
|
|
1684
|
+
}
|
|
1685
|
+
// Named descent: the stops live inside <a:gsLst>.
|
|
1686
|
+
"gsLst" -> depth++
|
|
1687
|
+
else -> Ox.skip(p)
|
|
1688
|
+
}
|
|
1689
|
+
XmlPullParser.END_TAG -> depth--
|
|
1690
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (stops.isEmpty()) return null
|
|
1694
|
+
if (stops.size == 1) return PptFill.Solid(stops[0].second)
|
|
1695
|
+
stops.sortBy { it.first }
|
|
1696
|
+
return PptFill.Gradient(
|
|
1697
|
+
colors = stops.map { it.second }.toIntArray(),
|
|
1698
|
+
stops = stops.map { it.first.coerceIn(0f, 1f) }.toFloatArray(),
|
|
1699
|
+
angleDeg = angle,
|
|
1700
|
+
radial = radial
|
|
1701
|
+
)
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* `<a:pattFill>` approximated as a blend of its foreground and background colours. A faithful
|
|
1706
|
+
* 8x8 tile per preset would be ~40 bit patterns for almost no visual gain at slide scale.
|
|
1707
|
+
*/
|
|
1708
|
+
private fun parsePatternFill(p: XmlPullParser): PptFill? {
|
|
1709
|
+
var fg: Int? = null
|
|
1710
|
+
var bg: Int? = null
|
|
1711
|
+
var depth = 1
|
|
1712
|
+
while (depth > 0) {
|
|
1713
|
+
when (p.next()) {
|
|
1714
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1715
|
+
"fgClr" -> fg = resolveColor(parseColorContainer(p))
|
|
1716
|
+
"bgClr" -> bg = resolveColor(parseColorContainer(p))
|
|
1717
|
+
else -> Ox.skip(p)
|
|
1718
|
+
}
|
|
1719
|
+
XmlPullParser.END_TAG -> depth--
|
|
1720
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
val f = fg ?: return bg?.let { PptFill.Solid(it) }
|
|
1724
|
+
val b = bg ?: return PptFill.Solid(f)
|
|
1725
|
+
fun mix(shift: Int) =
|
|
1726
|
+
(((f ushr shift and 0xFF) + (b ushr shift and 0xFF)) / 2) and 0xFF
|
|
1727
|
+
return PptFill.Solid(
|
|
1728
|
+
(0xFF shl 24) or (mix(16) shl 16) or (mix(8) shl 8) or mix(0)
|
|
1729
|
+
)
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* `<p:style>`: a shape's fill/line/text colour by reference into the theme's format scheme.
|
|
1734
|
+
*
|
|
1735
|
+
* Not reading this is why theme-styled shapes rendered with no fill at all and were then
|
|
1736
|
+
* dropped by [parseShape]. We take each reference's own colour, which is what `idx > 0`
|
|
1737
|
+
* resolves to for the overwhelming majority of real decks.
|
|
1738
|
+
*/
|
|
1739
|
+
private fun parseShapeStyle(p: XmlPullParser): ShapeStyleRefs {
|
|
1740
|
+
var fill: Int? = null
|
|
1741
|
+
var line: Int? = null
|
|
1742
|
+
var font: Int? = null
|
|
1743
|
+
var fillIdx = 0
|
|
1744
|
+
var depth = 1
|
|
1745
|
+
while (depth > 0) {
|
|
1746
|
+
when (p.next()) {
|
|
1747
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1748
|
+
"fillRef" -> {
|
|
1749
|
+
fillIdx = Ox.attr(p, "idx")?.toIntOrNull() ?: 0
|
|
1750
|
+
fill = resolveColor(parseColorContainer(p))
|
|
1751
|
+
}
|
|
1752
|
+
"lnRef" -> line = resolveColor(parseColorContainer(p))
|
|
1753
|
+
"fontRef" -> font = resolveColor(parseColorContainer(p))
|
|
1754
|
+
else -> Ox.skip(p)
|
|
1755
|
+
}
|
|
1756
|
+
XmlPullParser.END_TAG -> depth--
|
|
1757
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
// idx 0 means "no fill" regardless of the colour carried alongside it.
|
|
1761
|
+
return ShapeStyleRefs(if (fillIdx == 0) null else fill, line, font)
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
private fun parseTransform(p: XmlPullParser): PowerPointTransform {
|
|
1765
|
+
var xPt: Float? = null
|
|
1766
|
+
var yPt: Float? = null
|
|
1767
|
+
var wPt: Float? = null
|
|
1768
|
+
var hPt: Float? = null
|
|
1769
|
+
val rotation = (Ox.attr(p, "rot")?.toFloatOrNull() ?: 0f) / 60000f
|
|
1770
|
+
val flipH = Ox.attr(p, "flipH") == "1"
|
|
1771
|
+
val flipV = Ox.attr(p, "flipV") == "1"
|
|
1772
|
+
var depth = 1
|
|
1773
|
+
while (depth > 0) {
|
|
1774
|
+
when (p.next()) {
|
|
1775
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1776
|
+
"off" -> {
|
|
1777
|
+
xPt = Ox.emuToPt(Ox.attr(p, "x"))
|
|
1778
|
+
yPt = Ox.emuToPt(Ox.attr(p, "y"))
|
|
1779
|
+
Ox.skip(p)
|
|
1780
|
+
}
|
|
1781
|
+
"ext" -> {
|
|
1782
|
+
wPt = Ox.emuToPt(Ox.attr(p, "cx"))
|
|
1783
|
+
hPt = Ox.emuToPt(Ox.attr(p, "cy"))
|
|
1784
|
+
Ox.skip(p)
|
|
1785
|
+
}
|
|
1786
|
+
else -> Ox.skip(p)
|
|
1787
|
+
}
|
|
1788
|
+
XmlPullParser.END_TAG -> depth--
|
|
1789
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
val bounds = if (xPt != null && yPt != null && wPt != null && hPt != null) {
|
|
1793
|
+
RectF(xPt!!, yPt!!, xPt!! + wPt!!, yPt!! + hPt!!)
|
|
1794
|
+
} else null
|
|
1795
|
+
return PowerPointTransform(bounds, rotation, flipH, flipV)
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
private fun parseTextBody(p: XmlPullParser): PowerPointTextBody {
|
|
1799
|
+
val paragraphs = ArrayList<RawParagraph>()
|
|
1800
|
+
var anchor = TextVerticalAnchor.TOP
|
|
1801
|
+
var insetLeft = 4f
|
|
1802
|
+
var insetTop = 2f
|
|
1803
|
+
var insetRight = 4f
|
|
1804
|
+
var insetBottom = 2f
|
|
1805
|
+
var autofit = false
|
|
1806
|
+
var fontScale = 1f
|
|
1807
|
+
var lnSpcReduction = 0f
|
|
1808
|
+
var depth = 1
|
|
1809
|
+
while (depth > 0) {
|
|
1810
|
+
when (p.next()) {
|
|
1811
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1812
|
+
"bodyPr" -> {
|
|
1813
|
+
anchor = when (Ox.attr(p, "anchor")) {
|
|
1814
|
+
"ctr" -> TextVerticalAnchor.MIDDLE
|
|
1815
|
+
"b" -> TextVerticalAnchor.BOTTOM
|
|
1816
|
+
else -> TextVerticalAnchor.TOP
|
|
1817
|
+
}
|
|
1818
|
+
Ox.emuToPt(Ox.attr(p, "lIns"))?.let { insetLeft = it }
|
|
1819
|
+
Ox.emuToPt(Ox.attr(p, "tIns"))?.let { insetTop = it }
|
|
1820
|
+
Ox.emuToPt(Ox.attr(p, "rIns"))?.let { insetRight = it }
|
|
1821
|
+
Ox.emuToPt(Ox.attr(p, "bIns"))?.let { insetBottom = it }
|
|
1822
|
+
// <a:normAutofit> is a CHILD of bodyPr - skipping the element outright (as before)
|
|
1823
|
+
// threw away the shrink factor PowerPoint had already computed for this box.
|
|
1824
|
+
var bd = 1
|
|
1825
|
+
while (bd > 0) {
|
|
1826
|
+
when (p.next()) {
|
|
1827
|
+
XmlPullParser.START_TAG -> {
|
|
1828
|
+
if (Ox.local(p) == "normAutofit") {
|
|
1829
|
+
autofit = true
|
|
1830
|
+
Ox.attr(p, "fontScale")?.toFloatOrNull()?.let { fontScale = it / 100000f }
|
|
1831
|
+
Ox.attr(p, "lnSpcReduction")?.toFloatOrNull()
|
|
1832
|
+
?.let { lnSpcReduction = it / 100000f }
|
|
1833
|
+
}
|
|
1834
|
+
Ox.skip(p)
|
|
1835
|
+
}
|
|
1836
|
+
XmlPullParser.END_TAG -> bd--
|
|
1837
|
+
XmlPullParser.END_DOCUMENT -> bd = 0
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
"p" -> parseTextParagraph(p)?.let { paragraphs.add(it) }
|
|
1842
|
+
else -> Ox.skip(p)
|
|
1843
|
+
}
|
|
1844
|
+
XmlPullParser.END_TAG -> depth--
|
|
1845
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
return PowerPointTextBody(
|
|
1849
|
+
paragraphs, anchor, insetLeft, insetTop, insetRight, insetBottom,
|
|
1850
|
+
autofit, fontScale, lnSpcReduction
|
|
1851
|
+
)
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
private fun parseTextParagraph(p: XmlPullParser): RawParagraph? {
|
|
1855
|
+
val runs = ArrayList<PowerPointTextRun>()
|
|
1856
|
+
var align: TextAlign? = null
|
|
1857
|
+
var bullet: String? = null
|
|
1858
|
+
var bulletExplicit = false
|
|
1859
|
+
var level = 0
|
|
1860
|
+
var marL: Float? = null
|
|
1861
|
+
var indent: Float? = null
|
|
1862
|
+
var lineSpacePct: Float? = null
|
|
1863
|
+
var spaceBefore: Float? = null
|
|
1864
|
+
var spaceAfter: Float? = null
|
|
1865
|
+
var depth = 1
|
|
1866
|
+
while (depth > 0) {
|
|
1867
|
+
when (p.next()) {
|
|
1868
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1869
|
+
"pPr" -> {
|
|
1870
|
+
val props = parseParagraphProperties(p)
|
|
1871
|
+
align = props.align
|
|
1872
|
+
bullet = props.bullet
|
|
1873
|
+
bulletExplicit = props.bulletExplicit
|
|
1874
|
+
level = props.level
|
|
1875
|
+
marL = props.marLPt
|
|
1876
|
+
indent = props.indentPt
|
|
1877
|
+
lineSpacePct = props.lineSpacePct
|
|
1878
|
+
spaceBefore = props.spaceBeforePt
|
|
1879
|
+
spaceAfter = props.spaceAfterPt
|
|
1880
|
+
}
|
|
1881
|
+
"r" -> parseTextRun(p)?.let { runs.add(it) }
|
|
1882
|
+
"br" -> {
|
|
1883
|
+
runs.add(PowerPointTextRun("\n"))
|
|
1884
|
+
Ox.skip(p)
|
|
1885
|
+
}
|
|
1886
|
+
"fld" -> parseFieldText(p)?.let { if (it.isNotBlank()) runs.add(PowerPointTextRun(it)) }
|
|
1887
|
+
"t" -> {
|
|
1888
|
+
val text = Ox.elementText(p)
|
|
1889
|
+
if (text.isNotBlank()) runs.add(PowerPointTextRun(text))
|
|
1890
|
+
}
|
|
1891
|
+
else -> Ox.skip(p)
|
|
1892
|
+
}
|
|
1893
|
+
XmlPullParser.END_TAG -> depth--
|
|
1894
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
if (runs.isEmpty() && !bulletExplicit) return null
|
|
1898
|
+
return RawParagraph(
|
|
1899
|
+
runs, align, bullet, bulletExplicit, level,
|
|
1900
|
+
marL, indent, lineSpacePct, spaceBefore, spaceAfter
|
|
1901
|
+
)
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
private fun parseParagraphProperties(p: XmlPullParser): PowerPointParagraphProperties {
|
|
1905
|
+
val align = when (Ox.attr(p, "algn")) {
|
|
1906
|
+
"ctr" -> TextAlign.CENTER
|
|
1907
|
+
"r" -> TextAlign.RIGHT
|
|
1908
|
+
"just" -> TextAlign.JUSTIFY
|
|
1909
|
+
"l" -> TextAlign.LEFT
|
|
1910
|
+
else -> null
|
|
1911
|
+
}
|
|
1912
|
+
val level = Ox.attr(p, "lvl")?.toIntOrNull() ?: 0
|
|
1913
|
+
val marL = Ox.emuToPt(Ox.attr(p, "marL"))
|
|
1914
|
+
val indent = Ox.emuToPt(Ox.attr(p, "indent"))
|
|
1915
|
+
var bullet: String? = null
|
|
1916
|
+
var bulletExplicit = false
|
|
1917
|
+
var lineSpacePct: Float? = null
|
|
1918
|
+
var spaceBefore: Float? = null
|
|
1919
|
+
var spaceAfter: Float? = null
|
|
1920
|
+
var depth = 1
|
|
1921
|
+
while (depth > 0) {
|
|
1922
|
+
when (p.next()) {
|
|
1923
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
1924
|
+
"buChar" -> {
|
|
1925
|
+
bullet = normalizeBullet(Ox.attr(p, "char"))
|
|
1926
|
+
bulletExplicit = true
|
|
1927
|
+
Ox.skip(p)
|
|
1928
|
+
}
|
|
1929
|
+
"buAutoNum" -> {
|
|
1930
|
+
bullet = autoNumberMarker(Ox.attr(p, "type"), Ox.attr(p, "startAt")?.toIntOrNull() ?: 1)
|
|
1931
|
+
bulletExplicit = true
|
|
1932
|
+
Ox.skip(p)
|
|
1933
|
+
}
|
|
1934
|
+
"buNone" -> {
|
|
1935
|
+
bullet = null
|
|
1936
|
+
bulletExplicit = true
|
|
1937
|
+
Ox.skip(p)
|
|
1938
|
+
}
|
|
1939
|
+
"lnSpc" -> lineSpacePct = parseLineSpacing(p)
|
|
1940
|
+
"spcBef" -> spaceBefore = parseSpacing(p)
|
|
1941
|
+
"spcAft" -> spaceAfter = parseSpacing(p)
|
|
1942
|
+
else -> Ox.skip(p)
|
|
1943
|
+
}
|
|
1944
|
+
XmlPullParser.END_TAG -> depth--
|
|
1945
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
return PowerPointParagraphProperties(
|
|
1949
|
+
align, bullet, bulletExplicit, level, marL, indent, lineSpacePct, spaceBefore, spaceAfter
|
|
1950
|
+
)
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* `<a:lnSpc>` -> a line-height multiple. An exact `spcPts` height is carried as a NEGATIVE
|
|
1955
|
+
* value so the renderer can tell the two forms apart without a second field.
|
|
1956
|
+
*/
|
|
1957
|
+
private fun parseLineSpacing(p: XmlPullParser): Float? {
|
|
1958
|
+
var pct: Float? = null
|
|
1959
|
+
var depth = 1
|
|
1960
|
+
while (depth > 0) {
|
|
1961
|
+
when (p.next()) {
|
|
1962
|
+
XmlPullParser.START_TAG -> {
|
|
1963
|
+
val v = Ox.attr(p, "val")?.toFloatOrNull()
|
|
1964
|
+
when (Ox.local(p)) {
|
|
1965
|
+
"spcPct" -> if (v != null) pct = v / 100000f
|
|
1966
|
+
"spcPts" -> if (v != null) pct = -(v / 100f)
|
|
1967
|
+
}
|
|
1968
|
+
Ox.skip(p)
|
|
1969
|
+
}
|
|
1970
|
+
XmlPullParser.END_TAG -> depth--
|
|
1971
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
return pct
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* `<a:spcBef>`/`<a:spcAft>` -> points. A percentage form is carried NEGATIVE (as a fraction of
|
|
1979
|
+
* the font size) and resolved against the paragraph's base size at layout time.
|
|
1980
|
+
*/
|
|
1981
|
+
private fun parseSpacing(p: XmlPullParser): Float? {
|
|
1982
|
+
var pts: Float? = null
|
|
1983
|
+
var depth = 1
|
|
1984
|
+
while (depth > 0) {
|
|
1985
|
+
when (p.next()) {
|
|
1986
|
+
XmlPullParser.START_TAG -> {
|
|
1987
|
+
val v = Ox.attr(p, "val")?.toFloatOrNull()
|
|
1988
|
+
when (Ox.local(p)) {
|
|
1989
|
+
"spcPts" -> if (v != null) pts = v / 100f
|
|
1990
|
+
"spcPct" -> if (v != null) pts = -(v / 100000f)
|
|
1991
|
+
}
|
|
1992
|
+
Ox.skip(p)
|
|
1993
|
+
}
|
|
1994
|
+
XmlPullParser.END_TAG -> depth--
|
|
1995
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
return pts
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
private fun parseTextRun(p: XmlPullParser): PowerPointTextRun? {
|
|
2002
|
+
var props = PowerPointRunProperties()
|
|
2003
|
+
var text = ""
|
|
2004
|
+
var depth = 1
|
|
2005
|
+
while (depth > 0) {
|
|
2006
|
+
when (p.next()) {
|
|
2007
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2008
|
+
"rPr" -> props = parseRunProperties(p)
|
|
2009
|
+
"t" -> text = Ox.elementText(p)
|
|
2010
|
+
else -> Ox.skip(p)
|
|
2011
|
+
}
|
|
2012
|
+
XmlPullParser.END_TAG -> depth--
|
|
2013
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
if (text.isEmpty()) return null
|
|
2017
|
+
return PowerPointTextRun(
|
|
2018
|
+
text = text,
|
|
2019
|
+
fontSizePt = props.fontSizePt,
|
|
2020
|
+
color = props.color,
|
|
2021
|
+
fontFamily = props.fontFamily,
|
|
2022
|
+
bold = props.bold,
|
|
2023
|
+
italic = props.italic,
|
|
2024
|
+
underline = props.underline,
|
|
2025
|
+
strike = props.strike,
|
|
2026
|
+
allCaps = props.allCaps
|
|
2027
|
+
)
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
private fun parseRunProperties(p: XmlPullParser): PowerPointRunProperties {
|
|
2031
|
+
val props = PowerPointRunProperties(
|
|
2032
|
+
fontSizePt = Ox.attr(p, "sz")?.toFloatOrNull()?.let { it / 100f },
|
|
2033
|
+
bold = Ox.attr(p, "b")?.let { it == "1" },
|
|
2034
|
+
italic = Ox.attr(p, "i")?.let { it == "1" },
|
|
2035
|
+
underline = Ox.attr(p, "u")?.let { it != "none" },
|
|
2036
|
+
strike = Ox.attr(p, "strike")?.let { it != "noStrike" },
|
|
2037
|
+
allCaps = Ox.attr(p, "cap")?.let { it == "all" }
|
|
2038
|
+
)
|
|
2039
|
+
var out = props
|
|
2040
|
+
var depth = 1
|
|
2041
|
+
while (depth > 0) {
|
|
2042
|
+
when (p.next()) {
|
|
2043
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2044
|
+
"solidFill" -> out = out.copy(color = resolveColor(parseColorContainer(p)))
|
|
2045
|
+
// `+mn-lt` / `+mj-lt` are theme font TOKENS, not literal faces. They used to be
|
|
2046
|
+
// discarded outright, which threw away the deck's typography; resolve them against
|
|
2047
|
+
// the theme's font scheme instead.
|
|
2048
|
+
"latin", "ea", "cs" -> {
|
|
2049
|
+
resolveFontToken(Ox.attr(p, "typeface")?.takeIf { it.isNotBlank() })?.let {
|
|
2050
|
+
out = out.copy(fontFamily = it)
|
|
2051
|
+
}
|
|
2052
|
+
Ox.skip(p)
|
|
2053
|
+
}
|
|
2054
|
+
else -> Ox.skip(p)
|
|
2055
|
+
}
|
|
2056
|
+
XmlPullParser.END_TAG -> depth--
|
|
2057
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
return out
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
private fun parseFieldText(p: XmlPullParser): String? {
|
|
2064
|
+
var text: String? = null
|
|
2065
|
+
var depth = 1
|
|
2066
|
+
while (depth > 0) {
|
|
2067
|
+
when (p.next()) {
|
|
2068
|
+
XmlPullParser.START_TAG -> if (Ox.local(p) == "t") text = Ox.elementText(p) else Ox.skip(p)
|
|
2069
|
+
XmlPullParser.END_TAG -> depth--
|
|
2070
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
return text
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
private fun parseTable(p: XmlPullParser, transform: PowerPointTransform): PowerPointElement.Table {
|
|
2077
|
+
val columns = ArrayList<Float>()
|
|
2078
|
+
val rows = ArrayList<PowerPointTableRow>()
|
|
2079
|
+
var depth = 1
|
|
2080
|
+
while (depth > 0) {
|
|
2081
|
+
when (p.next()) {
|
|
2082
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2083
|
+
"gridCol" -> {
|
|
2084
|
+
Ox.emuToPt(Ox.attr(p, "w"))?.let { columns.add(it) }
|
|
2085
|
+
Ox.skip(p)
|
|
2086
|
+
}
|
|
2087
|
+
"tr" -> rows.add(parseTableRow(p))
|
|
2088
|
+
// Named descent: <a:gridCol> entries live inside <a:tblGrid>.
|
|
2089
|
+
"tblGrid" -> depth++
|
|
2090
|
+
else -> Ox.skip(p)
|
|
2091
|
+
}
|
|
2092
|
+
XmlPullParser.END_TAG -> depth--
|
|
2093
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
return PowerPointElement.Table(
|
|
2097
|
+
bounds = transform.bounds ?: RectF(0f, 0f, DEFAULT_SLIDE_WIDTH_PT, DEFAULT_SLIDE_HEIGHT_PT),
|
|
2098
|
+
rotation = transform.rotation,
|
|
2099
|
+
columnsPt = columns,
|
|
2100
|
+
rows = rows
|
|
2101
|
+
)
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
private fun parseTableRow(p: XmlPullParser): PowerPointTableRow {
|
|
2105
|
+
val height = Ox.emuToPt(Ox.attr(p, "h"))
|
|
2106
|
+
val cells = ArrayList<PowerPointTableCell>()
|
|
2107
|
+
var depth = 1
|
|
2108
|
+
while (depth > 0) {
|
|
2109
|
+
when (p.next()) {
|
|
2110
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2111
|
+
"tc" -> cells.add(parseTableCell(p))
|
|
2112
|
+
else -> Ox.skip(p)
|
|
2113
|
+
}
|
|
2114
|
+
XmlPullParser.END_TAG -> depth--
|
|
2115
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
return PowerPointTableRow(height, cells)
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
private fun parseTableCell(p: XmlPullParser): PowerPointTableCell {
|
|
2122
|
+
val paragraphs = ArrayList<PowerPointParagraph>()
|
|
2123
|
+
var props = TableCellProps()
|
|
2124
|
+
// gridSpan / hMerge / vMerge are attributes of <a:tc> ITSELF, not of <a:tcPr> — reading them
|
|
2125
|
+
// off tcPr (as this did) meant every merged cell silently reverted to a span of one.
|
|
2126
|
+
val gridSpan = Ox.attr(p, "gridSpan")?.toIntOrNull()?.coerceAtLeast(1) ?: 1
|
|
2127
|
+
val merged = Ox.attr(p, "hMerge") == "1" || Ox.attr(p, "vMerge") == "1"
|
|
2128
|
+
var depth = 1
|
|
2129
|
+
while (depth > 0) {
|
|
2130
|
+
when (p.next()) {
|
|
2131
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2132
|
+
"tcPr" -> props = parseTableCellProperties(p)
|
|
2133
|
+
"p" -> parseTextParagraph(p)?.let { paragraphs.add(resolveParagraph(it, null)) }
|
|
2134
|
+
// Named descent: cell paragraphs live inside <a:txBody>.
|
|
2135
|
+
"txBody" -> depth++
|
|
2136
|
+
else -> Ox.skip(p)
|
|
2137
|
+
}
|
|
2138
|
+
XmlPullParser.END_TAG -> depth--
|
|
2139
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
return PowerPointTableCell(
|
|
2143
|
+
paragraphs = paragraphs,
|
|
2144
|
+
fillColor = props.fillColor,
|
|
2145
|
+
gridSpan = gridSpan,
|
|
2146
|
+
merged = merged,
|
|
2147
|
+
borderLeft = props.borderLeft,
|
|
2148
|
+
borderRight = props.borderRight,
|
|
2149
|
+
borderTop = props.borderTop,
|
|
2150
|
+
borderBottom = props.borderBottom,
|
|
2151
|
+
marginLeftPt = props.marginLeftPt,
|
|
2152
|
+
marginRightPt = props.marginRightPt,
|
|
2153
|
+
marginTopPt = props.marginTopPt,
|
|
2154
|
+
marginBottomPt = props.marginBottomPt,
|
|
2155
|
+
anchor = props.anchor
|
|
2156
|
+
)
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/**
|
|
2160
|
+
* `<a:tcPr>`: cell fill, per-edge borders, insets and vertical anchor.
|
|
2161
|
+
*
|
|
2162
|
+
* Only the fill was read before, so every table drew the same hardcoded grey grid regardless of
|
|
2163
|
+
* what the deck specified, with fixed insets and top-anchored text.
|
|
2164
|
+
*/
|
|
2165
|
+
private fun parseTableCellProperties(p: XmlPullParser): TableCellProps {
|
|
2166
|
+
var fill: Int? = null
|
|
2167
|
+
var left: PowerPointStroke? = null
|
|
2168
|
+
var right: PowerPointStroke? = null
|
|
2169
|
+
var top: PowerPointStroke? = null
|
|
2170
|
+
var bottom: PowerPointStroke? = null
|
|
2171
|
+
val marL = Ox.emuToPt(Ox.attr(p, "marL")) ?: 7.2f
|
|
2172
|
+
val marR = Ox.emuToPt(Ox.attr(p, "marR")) ?: 7.2f
|
|
2173
|
+
val marT = Ox.emuToPt(Ox.attr(p, "marT")) ?: 3.6f
|
|
2174
|
+
val marB = Ox.emuToPt(Ox.attr(p, "marB")) ?: 3.6f
|
|
2175
|
+
val anchor = when (Ox.attr(p, "anchor")) {
|
|
2176
|
+
"ctr" -> TextVerticalAnchor.MIDDLE
|
|
2177
|
+
"b" -> TextVerticalAnchor.BOTTOM
|
|
2178
|
+
else -> TextVerticalAnchor.TOP
|
|
2179
|
+
}
|
|
2180
|
+
var depth = 1
|
|
2181
|
+
while (depth > 0) {
|
|
2182
|
+
when (p.next()) {
|
|
2183
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2184
|
+
// Edge borders are consumed by parseLine, so the remaining top-level solidFill below
|
|
2185
|
+
// is unambiguously the CELL fill rather than a border colour.
|
|
2186
|
+
"lnL" -> left = parseLine(p)
|
|
2187
|
+
"lnR" -> right = parseLine(p)
|
|
2188
|
+
"lnT" -> top = parseLine(p)
|
|
2189
|
+
"lnB" -> bottom = parseLine(p)
|
|
2190
|
+
"solidFill" -> fill = resolveColor(parseColorContainer(p))
|
|
2191
|
+
else -> Ox.skip(p)
|
|
2192
|
+
}
|
|
2193
|
+
XmlPullParser.END_TAG -> depth--
|
|
2194
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
return TableCellProps(fill, left, right, top, bottom, marL, marR, marT, marB, anchor)
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
private fun parseLine(p: XmlPullParser): PowerPointStroke? {
|
|
2201
|
+
var color: Int? = null
|
|
2202
|
+
val widthPt = Ox.emuToPt(Ox.attr(p, "w")) ?: 1f
|
|
2203
|
+
var noFill = false
|
|
2204
|
+
var depth = 1
|
|
2205
|
+
while (depth > 0) {
|
|
2206
|
+
when (p.next()) {
|
|
2207
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2208
|
+
"solidFill" -> color = resolveColor(parseColorContainer(p))
|
|
2209
|
+
"noFill" -> {
|
|
2210
|
+
noFill = true
|
|
2211
|
+
Ox.skip(p)
|
|
2212
|
+
}
|
|
2213
|
+
else -> Ox.skip(p)
|
|
2214
|
+
}
|
|
2215
|
+
XmlPullParser.END_TAG -> depth--
|
|
2216
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
if (noFill) return null
|
|
2220
|
+
// An <a:ln> that only sets a width or a dash pattern takes its COLOUR from the shape's
|
|
2221
|
+
// <a:lnRef>. Defaulting to black here drew a hard black box around such shapes; returning
|
|
2222
|
+
// null lets parseShape supply the theme colour instead.
|
|
2223
|
+
val c = color ?: return null
|
|
2224
|
+
return PowerPointStroke(c, widthPt.coerceAtLeast(0.5f))
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
private fun parseBlipFill(p: XmlPullParser, rels: Map<String, String>): Bitmap? {
|
|
2228
|
+
var bitmap: Bitmap? = null
|
|
2229
|
+
var depth = 1
|
|
2230
|
+
while (depth > 0) {
|
|
2231
|
+
when (p.next()) {
|
|
2232
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2233
|
+
"blip" -> {
|
|
2234
|
+
Ox.attr(p, "embed")?.let { relId -> bitmap = loadBitmap(rels[relId] ?: "") }
|
|
2235
|
+
Ox.skip(p)
|
|
2236
|
+
}
|
|
2237
|
+
else -> Ox.skip(p)
|
|
2238
|
+
}
|
|
2239
|
+
XmlPullParser.END_TAG -> depth--
|
|
2240
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
return bitmap
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* The first colour choice nested anywhere inside the current element. Shared by every wrapper
|
|
2248
|
+
* that holds exactly one colour: `<a:solidFill>`, `<a:buClr>`, `<a:fgClr>`, a `<p:style>`
|
|
2249
|
+
* reference, and a theme slot such as `<a:dk1>`.
|
|
2250
|
+
*/
|
|
2251
|
+
private fun parseColorContainer(p: XmlPullParser): ColorSpec? {
|
|
2252
|
+
var color: ColorSpec? = null
|
|
2253
|
+
var depth = 1
|
|
2254
|
+
while (depth > 0) {
|
|
2255
|
+
when (p.next()) {
|
|
2256
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2257
|
+
"srgbClr", "schemeClr", "sysClr", "prstClr" -> color = parseColorChoice(p)
|
|
2258
|
+
else -> Ox.skip(p)
|
|
2259
|
+
}
|
|
2260
|
+
XmlPullParser.END_TAG -> depth--
|
|
2261
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
return color
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* Parses one colour-choice element into an UNRESOLVED [ColorSpec], preserving its child
|
|
2269
|
+
* transforms in document order (they are not commutative). Resolution against the theme /
|
|
2270
|
+
* clrMap / phClr happens later, via [resolveColor].
|
|
2271
|
+
*/
|
|
2272
|
+
private fun parseColorChoice(p: XmlPullParser): ColorSpec? {
|
|
2273
|
+
val base: ColorBase? = when (Ox.local(p)) {
|
|
2274
|
+
"srgbClr" -> colorFromHex(Ox.attr(p, "val"))?.let { ColorBase.Literal(it) }
|
|
2275
|
+
"schemeClr" -> Ox.attr(p, "val")?.let {
|
|
2276
|
+
if (it == "phClr") ColorBase.Placeholder else ColorBase.Scheme(it)
|
|
2277
|
+
}
|
|
2278
|
+
"sysClr" -> colorFromHex(Ox.attr(p, "lastClr"))?.let { ColorBase.Literal(it) }
|
|
2279
|
+
"prstClr" -> presetColor(Ox.attr(p, "val"))?.let { ColorBase.Literal(it) }
|
|
2280
|
+
else -> null
|
|
2281
|
+
}
|
|
2282
|
+
val transforms = ArrayList<ColorXf>(4)
|
|
2283
|
+
var depth = 1
|
|
2284
|
+
while (depth > 0) {
|
|
2285
|
+
when (p.next()) {
|
|
2286
|
+
XmlPullParser.START_TAG -> {
|
|
2287
|
+
// All of these carry a percentage in thousandths of a percent.
|
|
2288
|
+
val pct = Ox.attr(p, "val")?.toFloatOrNull()?.div(100000f)
|
|
2289
|
+
if (pct != null) {
|
|
2290
|
+
when (Ox.local(p)) {
|
|
2291
|
+
"tint" -> transforms.add(ColorXf.Tint(pct))
|
|
2292
|
+
"shade" -> transforms.add(ColorXf.Shade(pct))
|
|
2293
|
+
"lumMod" -> transforms.add(ColorXf.LumMod(pct))
|
|
2294
|
+
"lumOff" -> transforms.add(ColorXf.LumOff(pct))
|
|
2295
|
+
"satMod" -> transforms.add(ColorXf.SatMod(pct))
|
|
2296
|
+
"alpha" -> transforms.add(ColorXf.Alpha(pct))
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
Ox.skip(p)
|
|
2300
|
+
}
|
|
2301
|
+
XmlPullParser.END_TAG -> depth--
|
|
2302
|
+
XmlPullParser.END_DOCUMENT -> break
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
return base?.let { ColorSpec(it, transforms) }
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/** Flattens a parsed spec against this deck's theme palette and colour map. */
|
|
2309
|
+
private fun resolveColor(spec: ColorSpec?): Int? =
|
|
2310
|
+
spec?.let { PptColor.resolve(it, colorContext) }
|
|
2311
|
+
|
|
2312
|
+
/**
|
|
2313
|
+
* Decodes an image part, memoised by path. Without the cache a logo on the slide master was
|
|
2314
|
+
* decoded once per slide — 40x the memory and 40x the image budget on a 40-slide deck, which
|
|
2315
|
+
* matters now that layout/master graphics are drawn at all.
|
|
2316
|
+
*/
|
|
2317
|
+
private fun loadBitmap(path: String): Bitmap? {
|
|
2318
|
+
if (path.isBlank()) return null
|
|
2319
|
+
imageCache[path]?.let { return it }
|
|
2320
|
+
val mime = Ox.mimeForName(path)
|
|
2321
|
+
if (mime == "image/x-emf" || mime == "image/svg+xml" || mime == "application/octet-stream") return null
|
|
2322
|
+
if (imageBudget <= 0) return null
|
|
2323
|
+
val bytes = Ox.zipBytes(zip, path, minOf(Ox.MAX_IMAGE_BYTES, imageBudget)) ?: return null
|
|
2324
|
+
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
|
2325
|
+
imageBudget -= bytes.size
|
|
2326
|
+
imageCache[path] = bitmap
|
|
2327
|
+
return bitmap
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
/**
|
|
2331
|
+
* Reads the theme part's colour scheme and major/minor latin typefaces into [colorContext],
|
|
2332
|
+
* [majorLatinFont] and [minorLatinFont].
|
|
2333
|
+
*
|
|
2334
|
+
* Bounded to the FIRST `<a:clrScheme>` deliberately: many Office themes append an
|
|
2335
|
+
* `<a:extraClrSchemeLst>` holding further complete schemes, and the previous flat scan ran to
|
|
2336
|
+
* the end of the document and let those silently overwrite the real palette.
|
|
2337
|
+
*/
|
|
2338
|
+
private fun parseTheme(z: ZipFile, themePart: String) {
|
|
2339
|
+
val colors = HashMap(DEFAULT_SCHEME_COLORS)
|
|
2340
|
+
var seenScheme = false
|
|
2341
|
+
try {
|
|
2342
|
+
Ox.zipStream(z, themePart)?.use { stream ->
|
|
2343
|
+
val p = Ox.parser(stream)
|
|
2344
|
+
while (p.next() != XmlPullParser.END_DOCUMENT) {
|
|
2345
|
+
if (p.eventType != XmlPullParser.START_TAG) continue
|
|
2346
|
+
when (Ox.local(p)) {
|
|
2347
|
+
"clrScheme" -> if (seenScheme) Ox.skip(p) else {
|
|
2348
|
+
parseClrScheme(p, colors)
|
|
2349
|
+
seenScheme = true
|
|
2350
|
+
}
|
|
2351
|
+
"fontScheme" -> parseFontScheme(p)
|
|
2352
|
+
// Everything after themeElements can restate a full scheme — never let it through.
|
|
2353
|
+
"extraClrSchemeLst", "objectDefaults", "custClrLst" -> Ox.skip(p)
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
} catch (_: Throwable) { /* Keep whatever resolved before the failure. */ }
|
|
2358
|
+
schemeColors = colors
|
|
2359
|
+
refreshColorContext()
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/** `<a:clrScheme>`: one child element per slot, each wrapping a single colour choice. */
|
|
2363
|
+
private fun parseClrScheme(p: XmlPullParser, out: MutableMap<String, Int>) {
|
|
2364
|
+
// Slots hold literal srgbClr/sysClr values, so a literals-only context is enough here.
|
|
2365
|
+
val literals = ColorContext(emptyMap(), emptyMap())
|
|
2366
|
+
var depth = 1
|
|
2367
|
+
while (depth > 0) {
|
|
2368
|
+
when (p.next()) {
|
|
2369
|
+
XmlPullParser.START_TAG -> {
|
|
2370
|
+
val slot = Ox.local(p)
|
|
2371
|
+
if (slot in THEME_COLOR_KEYS) {
|
|
2372
|
+
parseColorContainer(p)?.let { out[slot] = PptColor.resolve(it, literals) }
|
|
2373
|
+
} else {
|
|
2374
|
+
Ox.skip(p)
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
XmlPullParser.END_TAG -> depth--
|
|
2378
|
+
XmlPullParser.END_DOCUMENT -> return
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/** `<a:fontScheme>` → the typefaces that `+mj-lt` / `+mn-lt` run tokens resolve to. */
|
|
2384
|
+
private fun parseFontScheme(p: XmlPullParser) {
|
|
2385
|
+
var inMajor = false
|
|
2386
|
+
var depth = 1
|
|
2387
|
+
while (depth > 0) {
|
|
2388
|
+
when (p.next()) {
|
|
2389
|
+
XmlPullParser.START_TAG -> when (Ox.local(p)) {
|
|
2390
|
+
"majorFont" -> inMajor = true
|
|
2391
|
+
"minorFont" -> inMajor = false
|
|
2392
|
+
"latin" -> {
|
|
2393
|
+
val face = Ox.attr(p, "typeface")?.takeIf { it.isNotBlank() }
|
|
2394
|
+
if (face != null) {
|
|
2395
|
+
if (inMajor) majorLatinFont = face else minorLatinFont = face
|
|
2396
|
+
}
|
|
2397
|
+
Ox.skip(p)
|
|
2398
|
+
}
|
|
2399
|
+
else -> Ox.skip(p)
|
|
2400
|
+
}
|
|
2401
|
+
XmlPullParser.END_TAG -> depth--
|
|
2402
|
+
XmlPullParser.END_DOCUMENT -> return
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/** Resolves a run's authored typeface, expanding the theme font tokens. */
|
|
2408
|
+
private fun resolveFontToken(name: String?): String? = when (name) {
|
|
2409
|
+
null -> null
|
|
2410
|
+
"+mj-lt", "+mj-ea", "+mj-cs" -> majorLatinFont
|
|
2411
|
+
"+mn-lt", "+mn-ea", "+mn-cs" -> minorLatinFont
|
|
2412
|
+
else -> name
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
private class PptPresentationExtractor : PowerPointPresentationExtractor {
|
|
2417
|
+
override fun extract(file: File): RawPowerPointPresentation {
|
|
2418
|
+
val bytes = file.readBytes()
|
|
2419
|
+
val pptStream = try {
|
|
2420
|
+
CompoundBinaryFile(bytes).stream("PowerPoint Document") ?: bytes
|
|
2421
|
+
} catch (_: Throwable) {
|
|
2422
|
+
bytes
|
|
2423
|
+
}
|
|
2424
|
+
val slides = parseSlideTextRecords(pptStream)
|
|
2425
|
+
val images = extractEmbeddedBitmaps(bytes)
|
|
2426
|
+
val rawSlides = if (slides.isNotEmpty()) {
|
|
2427
|
+
slides.mapIndexed { index, text ->
|
|
2428
|
+
RawPowerPointSlide(
|
|
2429
|
+
textRuns = text,
|
|
2430
|
+
images = images.getOrNull(index)?.let { listOf(it) } ?: emptyList()
|
|
2431
|
+
)
|
|
2432
|
+
}
|
|
2433
|
+
} else {
|
|
2434
|
+
val text = extractBinaryTextRuns(pptStream).take(12)
|
|
2435
|
+
listOf(RawPowerPointSlide(textRuns = text, images = images.take(3)))
|
|
2436
|
+
}
|
|
2437
|
+
return RawPowerPointPresentation(
|
|
2438
|
+
sourceName = file.name,
|
|
2439
|
+
widthPt = DEFAULT_SLIDE_WIDTH_PT,
|
|
2440
|
+
heightPt = DEFAULT_SLIDE_HEIGHT_PT,
|
|
2441
|
+
slides = rawSlides,
|
|
2442
|
+
// ALL decoded bitmaps, not just the few the formatter positions — the rest used to leak.
|
|
2443
|
+
ownedBitmaps = images
|
|
2444
|
+
)
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
private fun parseSlideTextRecords(stream: ByteArray): List<List<String>> {
|
|
2448
|
+
val out = ArrayList<List<String>>()
|
|
2449
|
+
fun scan(start: Int, end: Int, inSlide: Boolean, bucket: MutableList<String>?) {
|
|
2450
|
+
var pos = start
|
|
2451
|
+
while (pos + 8 <= end && pos + 8 <= stream.size) {
|
|
2452
|
+
val verInstance = leUShort(stream, pos)
|
|
2453
|
+
val recVer = verInstance and 0x000F
|
|
2454
|
+
val recType = leUShort(stream, pos + 2)
|
|
2455
|
+
val len = leInt(stream, pos + 4)
|
|
2456
|
+
if (len < 0) return
|
|
2457
|
+
val dataStart = pos + 8
|
|
2458
|
+
val dataEnd = dataStart + len
|
|
2459
|
+
if (dataEnd > end || dataEnd > stream.size) return
|
|
2460
|
+
if (recType == PPT_RECORD_SLIDE_CONTAINER) {
|
|
2461
|
+
val slideTexts = ArrayList<String>()
|
|
2462
|
+
scan(dataStart, dataEnd, true, slideTexts)
|
|
2463
|
+
val cleaned = slideTexts.map { cleanText(it) }.filter { it.length > 1 }.distinct()
|
|
2464
|
+
if (cleaned.isNotEmpty()) out.add(cleaned)
|
|
2465
|
+
} else if (recType == PPT_RECORD_TEXT_CHARS || recType == PPT_RECORD_CSTRING) {
|
|
2466
|
+
if (inSlide) bucket?.add(decodeUtf16Le(stream, dataStart, len))
|
|
2467
|
+
} else if (recType == PPT_RECORD_TEXT_BYTES) {
|
|
2468
|
+
if (inSlide) bucket?.add(decodeCp1252(stream, dataStart, len))
|
|
2469
|
+
} else if (recVer == 0x0F) {
|
|
2470
|
+
scan(dataStart, dataEnd, inSlide, bucket)
|
|
2471
|
+
}
|
|
2472
|
+
pos = dataEnd
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
scan(0, stream.size, false, null)
|
|
2476
|
+
return out
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
private fun extractEmbeddedBitmaps(bytes: ByteArray): List<Bitmap> {
|
|
2480
|
+
val out = ArrayList<Bitmap>()
|
|
2481
|
+
val seen = HashSet<Int>()
|
|
2482
|
+
fun addBitmap(start: Int, end: Int) {
|
|
2483
|
+
if (out.size >= 12 || start < 0 || end <= start || end > bytes.size) return
|
|
2484
|
+
val size = end - start
|
|
2485
|
+
if (size < 64 || size > Ox.MAX_IMAGE_BYTES) return
|
|
2486
|
+
val hash = size * 31 + start
|
|
2487
|
+
if (!seen.add(hash)) return
|
|
2488
|
+
val bitmap = BitmapFactory.decodeByteArray(bytes, start, size)
|
|
2489
|
+
if (bitmap != null && bitmap.width > 8 && bitmap.height > 8) out.add(bitmap)
|
|
2490
|
+
}
|
|
2491
|
+
var i = 0
|
|
2492
|
+
while (i < bytes.size - 16) {
|
|
2493
|
+
if (matches(bytes, i, PNG_SIG)) {
|
|
2494
|
+
val end = findPngEnd(bytes, i)
|
|
2495
|
+
if (end > i) addBitmap(i, end)
|
|
2496
|
+
i = max(i + 1, end)
|
|
2497
|
+
} else if ((bytes[i].toInt() and 0xFF) == 0xFF && (bytes[i + 1].toInt() and 0xFF) == 0xD8) {
|
|
2498
|
+
val end = findJpegEnd(bytes, i)
|
|
2499
|
+
if (end > i) addBitmap(i, end)
|
|
2500
|
+
i = max(i + 1, end)
|
|
2501
|
+
} else if (matches(bytes, i, GIF87_SIG) || matches(bytes, i, GIF89_SIG)) {
|
|
2502
|
+
val end = findGifEnd(bytes, i)
|
|
2503
|
+
if (end > i) addBitmap(i, end)
|
|
2504
|
+
i = max(i + 1, end)
|
|
2505
|
+
} else if (bytes[i] == 'B'.code.toByte() && bytes[i + 1] == 'M'.code.toByte()) {
|
|
2506
|
+
val size = leInt(bytes, i + 2)
|
|
2507
|
+
if (size > 0) addBitmap(i, min(bytes.size, i + size))
|
|
2508
|
+
i++
|
|
2509
|
+
} else {
|
|
2510
|
+
i++
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
return out
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
internal class PowerPointPreviewAdapter(
|
|
2518
|
+
private val context: Context,
|
|
2519
|
+
private val presentation: PowerPointPresentation,
|
|
2520
|
+
private val renderWidthProvider: () -> Int
|
|
2521
|
+
) : RecyclerView.Adapter<PowerPointPreviewAdapter.Holder>() {
|
|
2522
|
+
inner class Holder(val slideView: PowerPointSlideView) : RecyclerView.ViewHolder(slideView)
|
|
2523
|
+
|
|
2524
|
+
override fun getItemCount(): Int = presentation.slides.size
|
|
2525
|
+
|
|
2526
|
+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
|
2527
|
+
val view = PowerPointSlideView(parent.context)
|
|
2528
|
+
view.setPresentation(presentation)
|
|
2529
|
+
return Holder(view)
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
override fun onBindViewHolder(holder: Holder, position: Int) {
|
|
2533
|
+
val width = renderWidthProvider().coerceAtLeast(240)
|
|
2534
|
+
val height = ceil(width * presentation.heightPt / presentation.widthPt).toInt().coerceAtLeast(1)
|
|
2535
|
+
val lp = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)
|
|
2536
|
+
lp.bottomMargin = if (position == presentation.slides.size - 1) 0 else dp(6)
|
|
2537
|
+
holder.slideView.layoutParams = lp
|
|
2538
|
+
holder.slideView.setSlide(presentation.slides[position])
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
private fun dp(value: Int): Int = (value * context.resources.displayMetrics.density).toInt()
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
internal class PowerPointSlideView(context: Context) : View(context) {
|
|
2545
|
+
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
|
|
2546
|
+
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
|
2547
|
+
private val imagePaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
|
2548
|
+
// Reused across draws: allocating a Matrix per element per frame would be a per-frame cost in
|
|
2549
|
+
// a scrolling RecyclerView.
|
|
2550
|
+
private val elementMatrix = android.graphics.Matrix()
|
|
2551
|
+
private val gradientMatrix = android.graphics.Matrix()
|
|
2552
|
+
/** Reused for table-cell bullet glyphs; free text boxes build theirs during measurement. */
|
|
2553
|
+
private val bulletPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
|
2554
|
+
private val shapePath = android.graphics.Path()
|
|
2555
|
+
private val customMatrix = android.graphics.Matrix()
|
|
2556
|
+
private val scratchOval = RectF()
|
|
2557
|
+
private val matrixValues = FloatArray(9)
|
|
2558
|
+
private val srcRectPx = android.graphics.Rect()
|
|
2559
|
+
private val slideRect = RectF()
|
|
2560
|
+
private var presentation: PowerPointPresentation? = null
|
|
2561
|
+
private var slide: PowerPointSlide? = null
|
|
2562
|
+
|
|
2563
|
+
init {
|
|
2564
|
+
setBackgroundColor(Color.TRANSPARENT)
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
fun setPresentation(value: PowerPointPresentation) {
|
|
2568
|
+
presentation = value
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
fun setSlide(value: PowerPointSlide) {
|
|
2572
|
+
slide = value
|
|
2573
|
+
invalidate()
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
override fun onDraw(canvas: Canvas) {
|
|
2577
|
+
super.onDraw(canvas)
|
|
2578
|
+
val deck = presentation ?: return
|
|
2579
|
+
val currentSlide = slide ?: return
|
|
2580
|
+
val scale = min(width / deck.widthPt, height / deck.heightPt)
|
|
2581
|
+
val slideW = deck.widthPt * scale
|
|
2582
|
+
val slideH = deck.heightPt * scale
|
|
2583
|
+
val left = (width - slideW) / 2f
|
|
2584
|
+
val top = (height - slideH) / 2f
|
|
2585
|
+
canvas.save()
|
|
2586
|
+
canvas.translate(left, top)
|
|
2587
|
+
canvas.clipRect(0f, 0f, slideW, slideH)
|
|
2588
|
+
drawBackground(canvas, currentSlide.background, slideW, slideH)
|
|
2589
|
+
for (element in currentSlide.elements) drawElement(canvas, element, scale)
|
|
2590
|
+
canvas.restore()
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
private fun drawBackground(canvas: Canvas, background: PptFill, slideW: Float, slideH: Float) {
|
|
2594
|
+
slideRect.set(0f, 0f, slideW, slideH)
|
|
2595
|
+
if (applyFill(background, slideRect)) {
|
|
2596
|
+
canvas.drawRect(slideRect, fillPaint)
|
|
2597
|
+
clearFill()
|
|
2598
|
+
}
|
|
2599
|
+
if (background is PptFill.Image && !background.bitmap.isRecycled) {
|
|
2600
|
+
canvas.drawBitmap(background.bitmap, null, slideRect, imagePaint)
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
private fun drawElement(canvas: Canvas, element: PowerPointElement, scale: Float) {
|
|
2605
|
+
canvas.save()
|
|
2606
|
+
// Group transform, applied in POINT space between the slide scale and the element's own
|
|
2607
|
+
// geometry. Without this, shapes inside a <p:grpSp> were drawn at their raw child-space
|
|
2608
|
+
// coordinates, which is what put them on top of unrelated content.
|
|
2609
|
+
val parent = element.parent
|
|
2610
|
+
if (!parent.isIdentity()) {
|
|
2611
|
+
canvas.scale(scale, scale)
|
|
2612
|
+
parent.toMatrixValues(matrixValues)
|
|
2613
|
+
elementMatrix.setValues(matrixValues)
|
|
2614
|
+
canvas.concat(elementMatrix)
|
|
2615
|
+
canvas.scale(1f / scale, 1f / scale)
|
|
2616
|
+
}
|
|
2617
|
+
val rect = scaleRect(element.bounds, scale)
|
|
2618
|
+
if (element.rotation != 0f) canvas.rotate(element.rotation, rect.centerX(), rect.centerY())
|
|
2619
|
+
if (element.flipH || element.flipV) {
|
|
2620
|
+
canvas.scale(
|
|
2621
|
+
if (element.flipH) -1f else 1f,
|
|
2622
|
+
if (element.flipV) -1f else 1f,
|
|
2623
|
+
rect.centerX(),
|
|
2624
|
+
rect.centerY()
|
|
2625
|
+
)
|
|
2626
|
+
}
|
|
2627
|
+
when (element) {
|
|
2628
|
+
is PowerPointElement.Shape -> drawShape(canvas, element, rect, scale)
|
|
2629
|
+
is PowerPointElement.Picture -> drawPicture(canvas, element, rect)
|
|
2630
|
+
is PowerPointElement.TextBox -> drawTextBox(canvas, element, rect, scale)
|
|
2631
|
+
is PowerPointElement.Table -> drawTable(canvas, element, rect, scale)
|
|
2632
|
+
}
|
|
2633
|
+
canvas.restore()
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
private fun drawShape(canvas: Canvas, shape: PowerPointElement.Shape, rect: RectF, scale: Float) {
|
|
2637
|
+
if (applyFill(shape.fill, rect)) {
|
|
2638
|
+
drawShapePath(canvas, shape.type, rect, fillPaint, shape.customPath)
|
|
2639
|
+
clearFill()
|
|
2640
|
+
}
|
|
2641
|
+
shape.stroke?.let {
|
|
2642
|
+
strokePaint.color = it.color
|
|
2643
|
+
strokePaint.strokeWidth = max(1f, it.widthPt * scale)
|
|
2644
|
+
strokePaint.style = Paint.Style.STROKE
|
|
2645
|
+
drawShapePath(canvas, shape.type, rect, strokePaint, shape.customPath)
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
/**
|
|
2650
|
+
* Configures [fillPaint] for [fill] over [rect], returning false when there is nothing to draw.
|
|
2651
|
+
* Gradient shaders are cached on the fill and only rebuilt when the box size changes, so a
|
|
2652
|
+
* scrolling deck doesn't allocate one per frame.
|
|
2653
|
+
*/
|
|
2654
|
+
private fun applyFill(fill: PptFill?, rect: RectF): Boolean {
|
|
2655
|
+
fillPaint.style = Paint.Style.FILL
|
|
2656
|
+
fillPaint.shader = null
|
|
2657
|
+
when (fill) {
|
|
2658
|
+
null -> return false
|
|
2659
|
+
is PptFill.Solid -> {
|
|
2660
|
+
if (Color.alpha(fill.color) == 0) return false
|
|
2661
|
+
fillPaint.color = fill.color
|
|
2662
|
+
}
|
|
2663
|
+
is PptFill.Image -> {
|
|
2664
|
+
fillPaint.color = fill.fallbackColor
|
|
2665
|
+
}
|
|
2666
|
+
is PptFill.Gradient -> {
|
|
2667
|
+
val w = rect.width()
|
|
2668
|
+
val h = rect.height()
|
|
2669
|
+
if (w <= 0f || h <= 0f) return false
|
|
2670
|
+
var shader = fill.cachedShader
|
|
2671
|
+
if (shader == null || fill.cachedW != w || fill.cachedH != h) {
|
|
2672
|
+
shader = buildGradientShader(fill, w, h)
|
|
2673
|
+
fill.cachedShader = shader
|
|
2674
|
+
fill.cachedW = w
|
|
2675
|
+
fill.cachedH = h
|
|
2676
|
+
}
|
|
2677
|
+
// The shader is built in the fill's own 0..w/0..h space, so shift it onto the element.
|
|
2678
|
+
gradientMatrix.setTranslate(rect.left, rect.top)
|
|
2679
|
+
shader.setLocalMatrix(gradientMatrix)
|
|
2680
|
+
fillPaint.color = Color.BLACK
|
|
2681
|
+
fillPaint.shader = shader
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
return true
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
private fun clearFill() {
|
|
2688
|
+
fillPaint.shader = null
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
private fun buildGradientShader(
|
|
2692
|
+
fill: PptFill.Gradient,
|
|
2693
|
+
w: Float,
|
|
2694
|
+
h: Float
|
|
2695
|
+
): android.graphics.Shader {
|
|
2696
|
+
if (fill.radial) {
|
|
2697
|
+
return android.graphics.RadialGradient(
|
|
2698
|
+
w / 2f, h / 2f, max(w, h) / 2f,
|
|
2699
|
+
fill.colors, fill.stops, android.graphics.Shader.TileMode.CLAMP
|
|
2700
|
+
)
|
|
2701
|
+
}
|
|
2702
|
+
// <a:lin ang> is clockwise from the +x axis in a y-down space.
|
|
2703
|
+
val rad = Math.toRadians(fill.angleDeg.toDouble())
|
|
2704
|
+
val dx = kotlin.math.cos(rad).toFloat()
|
|
2705
|
+
val dy = kotlin.math.sin(rad).toFloat()
|
|
2706
|
+
// Project the box onto the gradient axis so the ramp spans the whole shape.
|
|
2707
|
+
val half = (kotlin.math.abs(dx) * w + kotlin.math.abs(dy) * h) / 2f
|
|
2708
|
+
val cx = w / 2f
|
|
2709
|
+
val cy = h / 2f
|
|
2710
|
+
return android.graphics.LinearGradient(
|
|
2711
|
+
cx - dx * half, cy - dy * half,
|
|
2712
|
+
cx + dx * half, cy + dy * half,
|
|
2713
|
+
fill.colors, fill.stops, android.graphics.Shader.TileMode.CLAMP
|
|
2714
|
+
)
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
/**
|
|
2718
|
+
* Draws [type] (or [custom], when the shape carried a `<a:custGeom>`) filling [rect].
|
|
2719
|
+
*
|
|
2720
|
+
* Uses one reusable [shapePath] rather than allocating per call: this runs for every shape of
|
|
2721
|
+
* every visible slide, on every frame of a scroll.
|
|
2722
|
+
*/
|
|
2723
|
+
private fun drawShapePath(
|
|
2724
|
+
canvas: Canvas,
|
|
2725
|
+
type: PowerPointShapeType,
|
|
2726
|
+
rect: RectF,
|
|
2727
|
+
paint: Paint,
|
|
2728
|
+
custom: android.graphics.Path? = null
|
|
2729
|
+
) {
|
|
2730
|
+
// A custom geometry is stored normalised to 0..1 so one Path serves every size it is drawn at.
|
|
2731
|
+
if (custom != null) {
|
|
2732
|
+
shapePath.reset()
|
|
2733
|
+
customMatrix.setScale(rect.width(), rect.height())
|
|
2734
|
+
customMatrix.postTranslate(rect.left, rect.top)
|
|
2735
|
+
custom.transform(customMatrix, shapePath)
|
|
2736
|
+
canvas.drawPath(shapePath, paint)
|
|
2737
|
+
return
|
|
2738
|
+
}
|
|
2739
|
+
when (type) {
|
|
2740
|
+
PowerPointShapeType.RECT -> canvas.drawRect(rect, paint)
|
|
2741
|
+
PowerPointShapeType.ELLIPSE -> canvas.drawOval(rect, paint)
|
|
2742
|
+
PowerPointShapeType.LINE ->
|
|
2743
|
+
canvas.drawLine(rect.left, rect.top, rect.right, rect.bottom, paint)
|
|
2744
|
+
PowerPointShapeType.ROUND_RECT ->
|
|
2745
|
+
canvas.drawRoundRect(rect, rect.width() * 0.08f, rect.height() * 0.08f, paint)
|
|
2746
|
+
else -> {
|
|
2747
|
+
buildPresetPath(type, rect, shapePath)
|
|
2748
|
+
canvas.drawPath(shapePath, paint)
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
private fun buildPresetPath(type: PowerPointShapeType, r: RectF, out: android.graphics.Path) {
|
|
2754
|
+
out.reset()
|
|
2755
|
+
out.fillType = android.graphics.Path.FillType.WINDING
|
|
2756
|
+
val w = r.width()
|
|
2757
|
+
val h = r.height()
|
|
2758
|
+
val cx = r.centerX()
|
|
2759
|
+
val cy = r.centerY()
|
|
2760
|
+
when (type) {
|
|
2761
|
+
PowerPointShapeType.TRIANGLE -> poly(out, cx, r.top, r.right, r.bottom, r.left, r.bottom)
|
|
2762
|
+
PowerPointShapeType.RIGHT_TRIANGLE ->
|
|
2763
|
+
poly(out, r.left, r.top, r.right, r.bottom, r.left, r.bottom)
|
|
2764
|
+
PowerPointShapeType.DIAMOND ->
|
|
2765
|
+
poly(out, cx, r.top, r.right, cy, cx, r.bottom, r.left, cy)
|
|
2766
|
+
PowerPointShapeType.PARALLELOGRAM -> {
|
|
2767
|
+
val d = w * 0.25f
|
|
2768
|
+
poly(out, r.left + d, r.top, r.right, r.top, r.right - d, r.bottom, r.left, r.bottom)
|
|
2769
|
+
}
|
|
2770
|
+
PowerPointShapeType.TRAPEZOID -> {
|
|
2771
|
+
val d = w * 0.25f
|
|
2772
|
+
poly(out, r.left + d, r.top, r.right - d, r.top, r.right, r.bottom, r.left, r.bottom)
|
|
2773
|
+
}
|
|
2774
|
+
PowerPointShapeType.PENTAGON -> regularPolygon(out, r, 5)
|
|
2775
|
+
PowerPointShapeType.HEXAGON -> regularPolygon(out, r, 6)
|
|
2776
|
+
PowerPointShapeType.OCTAGON -> regularPolygon(out, r, 8)
|
|
2777
|
+
PowerPointShapeType.STAR4 -> star(out, r, 4, 0.36f)
|
|
2778
|
+
PowerPointShapeType.STAR5 -> star(out, r, 5, 0.382f)
|
|
2779
|
+
PowerPointShapeType.STAR6 -> star(out, r, 6, 0.577f)
|
|
2780
|
+
PowerPointShapeType.STAR8 -> star(out, r, 8, 0.707f)
|
|
2781
|
+
PowerPointShapeType.STAR12 -> star(out, r, 12, 0.79f)
|
|
2782
|
+
PowerPointShapeType.STAR16 -> star(out, r, 16, 0.84f)
|
|
2783
|
+
PowerPointShapeType.STAR24 -> star(out, r, 24, 0.89f)
|
|
2784
|
+
PowerPointShapeType.STAR32 -> star(out, r, 32, 0.92f)
|
|
2785
|
+
PowerPointShapeType.ARROW_RIGHT -> {
|
|
2786
|
+
val head = min(w, h).coerceAtMost(w) * 0.5f
|
|
2787
|
+
val bh = h * 0.25f
|
|
2788
|
+
val x = r.right - head
|
|
2789
|
+
poly(
|
|
2790
|
+
out, r.left, cy - bh, x, cy - bh, x, r.top, r.right, cy,
|
|
2791
|
+
x, r.bottom, x, cy + bh, r.left, cy + bh
|
|
2792
|
+
)
|
|
2793
|
+
}
|
|
2794
|
+
PowerPointShapeType.ARROW_LEFT -> {
|
|
2795
|
+
val head = min(w, h).coerceAtMost(w) * 0.5f
|
|
2796
|
+
val bh = h * 0.25f
|
|
2797
|
+
val x = r.left + head
|
|
2798
|
+
poly(
|
|
2799
|
+
out, r.right, cy - bh, x, cy - bh, x, r.top, r.left, cy,
|
|
2800
|
+
x, r.bottom, x, cy + bh, r.right, cy + bh
|
|
2801
|
+
)
|
|
2802
|
+
}
|
|
2803
|
+
PowerPointShapeType.ARROW_UP -> {
|
|
2804
|
+
val head = min(w, h).coerceAtMost(h) * 0.5f
|
|
2805
|
+
val bw = w * 0.25f
|
|
2806
|
+
val y = r.top + head
|
|
2807
|
+
poly(
|
|
2808
|
+
out, cx - bw, r.bottom, cx - bw, y, r.left, y, cx, r.top,
|
|
2809
|
+
r.right, y, cx + bw, y, cx + bw, r.bottom
|
|
2810
|
+
)
|
|
2811
|
+
}
|
|
2812
|
+
PowerPointShapeType.ARROW_DOWN -> {
|
|
2813
|
+
val head = min(w, h).coerceAtMost(h) * 0.5f
|
|
2814
|
+
val bw = w * 0.25f
|
|
2815
|
+
val y = r.bottom - head
|
|
2816
|
+
poly(
|
|
2817
|
+
out, cx - bw, r.top, cx - bw, y, r.left, y, cx, r.bottom,
|
|
2818
|
+
r.right, y, cx + bw, y, cx + bw, r.top
|
|
2819
|
+
)
|
|
2820
|
+
}
|
|
2821
|
+
PowerPointShapeType.ARROW_LEFT_RIGHT -> {
|
|
2822
|
+
val head = (w * 0.25f).coerceAtMost(min(w, h) * 0.5f)
|
|
2823
|
+
val bh = h * 0.25f
|
|
2824
|
+
val x0 = r.left + head
|
|
2825
|
+
val x1 = r.right - head
|
|
2826
|
+
poly(
|
|
2827
|
+
out, r.left, cy, x0, r.top, x0, cy - bh, x1, cy - bh, x1, r.top,
|
|
2828
|
+
r.right, cy, x1, r.bottom, x1, cy + bh, x0, cy + bh, x0, r.bottom
|
|
2829
|
+
)
|
|
2830
|
+
}
|
|
2831
|
+
PowerPointShapeType.ARROW_UP_DOWN -> {
|
|
2832
|
+
val head = (h * 0.25f).coerceAtMost(min(w, h) * 0.5f)
|
|
2833
|
+
val bw = w * 0.25f
|
|
2834
|
+
val y0 = r.top + head
|
|
2835
|
+
val y1 = r.bottom - head
|
|
2836
|
+
poly(
|
|
2837
|
+
out, cx, r.top, r.right, y0, cx + bw, y0, cx + bw, y1, r.right, y1,
|
|
2838
|
+
cx, r.bottom, r.left, y1, cx - bw, y1, cx - bw, y0, r.left, y0
|
|
2839
|
+
)
|
|
2840
|
+
}
|
|
2841
|
+
PowerPointShapeType.HOME_PLATE -> {
|
|
2842
|
+
val d = min(w, h) * 0.5f
|
|
2843
|
+
poly(out, r.left, r.top, r.right - d, r.top, r.right, cy, r.right - d, r.bottom, r.left, r.bottom)
|
|
2844
|
+
}
|
|
2845
|
+
PowerPointShapeType.CHEVRON -> {
|
|
2846
|
+
val d = min(w, h) * 0.5f
|
|
2847
|
+
poly(
|
|
2848
|
+
out, r.left, r.top, r.right - d, r.top, r.right, cy,
|
|
2849
|
+
r.right - d, r.bottom, r.left, r.bottom, r.left + d, cy
|
|
2850
|
+
)
|
|
2851
|
+
}
|
|
2852
|
+
PowerPointShapeType.PLUS -> {
|
|
2853
|
+
val ax = w * 0.25f
|
|
2854
|
+
val ay = h * 0.25f
|
|
2855
|
+
poly(
|
|
2856
|
+
out, r.left + ax, r.top, r.right - ax, r.top, r.right - ax, r.top + ay,
|
|
2857
|
+
r.right, r.top + ay, r.right, r.bottom - ay, r.right - ax, r.bottom - ay,
|
|
2858
|
+
r.right - ax, r.bottom, r.left + ax, r.bottom, r.left + ax, r.bottom - ay,
|
|
2859
|
+
r.left, r.bottom - ay, r.left, r.top + ay, r.left + ax, r.top + ay
|
|
2860
|
+
)
|
|
2861
|
+
}
|
|
2862
|
+
PowerPointShapeType.CAN -> {
|
|
2863
|
+
val ry = h * 0.12f
|
|
2864
|
+
scratchOval.set(r.left, r.top, r.right, r.top + 2f * ry)
|
|
2865
|
+
out.moveTo(r.left, r.top + ry)
|
|
2866
|
+
out.arcTo(scratchOval, 180f, 180f)
|
|
2867
|
+
out.lineTo(r.right, r.bottom - ry)
|
|
2868
|
+
scratchOval.set(r.left, r.bottom - 2f * ry, r.right, r.bottom)
|
|
2869
|
+
out.arcTo(scratchOval, 0f, 180f)
|
|
2870
|
+
out.close()
|
|
2871
|
+
}
|
|
2872
|
+
PowerPointShapeType.DONUT -> {
|
|
2873
|
+
// EVEN_ODD punches the inner oval out of the outer one.
|
|
2874
|
+
out.fillType = android.graphics.Path.FillType.EVEN_ODD
|
|
2875
|
+
out.addOval(r, android.graphics.Path.Direction.CW)
|
|
2876
|
+
scratchOval.set(r.left + w * 0.2f, r.top + h * 0.2f, r.right - w * 0.2f, r.bottom - h * 0.2f)
|
|
2877
|
+
out.addOval(scratchOval, android.graphics.Path.Direction.CW)
|
|
2878
|
+
}
|
|
2879
|
+
PowerPointShapeType.FRAME -> {
|
|
2880
|
+
out.fillType = android.graphics.Path.FillType.EVEN_ODD
|
|
2881
|
+
out.addRect(r, android.graphics.Path.Direction.CW)
|
|
2882
|
+
scratchOval.set(r.left + w * 0.125f, r.top + h * 0.125f, r.right - w * 0.125f, r.bottom - h * 0.125f)
|
|
2883
|
+
out.addRect(scratchOval, android.graphics.Path.Direction.CW)
|
|
2884
|
+
}
|
|
2885
|
+
else -> out.addRect(r, android.graphics.Path.Direction.CW)
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
/** Closed polygon through the supplied x,y pairs. */
|
|
2890
|
+
private fun poly(out: android.graphics.Path, vararg xy: Float) {
|
|
2891
|
+
if (xy.size < 4) return
|
|
2892
|
+
out.moveTo(xy[0], xy[1])
|
|
2893
|
+
var i = 2
|
|
2894
|
+
while (i + 1 < xy.size) {
|
|
2895
|
+
out.lineTo(xy[i], xy[i + 1])
|
|
2896
|
+
i += 2
|
|
2897
|
+
}
|
|
2898
|
+
out.close()
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
/** Regular n-gon inscribed in [r], first vertex at the top. */
|
|
2902
|
+
private fun regularPolygon(out: android.graphics.Path, r: RectF, sides: Int) {
|
|
2903
|
+
val rx = r.width() / 2f
|
|
2904
|
+
val ry = r.height() / 2f
|
|
2905
|
+
for (i in 0 until sides) {
|
|
2906
|
+
val a = -Math.PI / 2.0 + 2.0 * Math.PI * i / sides
|
|
2907
|
+
val x = r.centerX() + rx * kotlin.math.cos(a).toFloat()
|
|
2908
|
+
val y = r.centerY() + ry * kotlin.math.sin(a).toFloat()
|
|
2909
|
+
if (i == 0) out.moveTo(x, y) else out.lineTo(x, y)
|
|
2910
|
+
}
|
|
2911
|
+
out.close()
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
/** n-pointed star; [innerRatio] is the valley radius as a fraction of the point radius. */
|
|
2915
|
+
private fun star(out: android.graphics.Path, r: RectF, points: Int, innerRatio: Float) {
|
|
2916
|
+
val rx = r.width() / 2f
|
|
2917
|
+
val ry = r.height() / 2f
|
|
2918
|
+
for (i in 0 until points * 2) {
|
|
2919
|
+
val ratio = if (i % 2 == 0) 1f else innerRatio
|
|
2920
|
+
val a = -Math.PI / 2.0 + Math.PI * i / points
|
|
2921
|
+
val x = r.centerX() + rx * ratio * kotlin.math.cos(a).toFloat()
|
|
2922
|
+
val y = r.centerY() + ry * ratio * kotlin.math.sin(a).toFloat()
|
|
2923
|
+
if (i == 0) out.moveTo(x, y) else out.lineTo(x, y)
|
|
2924
|
+
}
|
|
2925
|
+
out.close()
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
private fun drawPicture(canvas: Canvas, picture: PowerPointElement.Picture, rect: RectF) {
|
|
2929
|
+
val bitmap = picture.bitmap
|
|
2930
|
+
if (bitmap.isRecycled) return
|
|
2931
|
+
val crop = picture.srcRect
|
|
2932
|
+
if (crop == null) {
|
|
2933
|
+
canvas.drawBitmap(bitmap, null, rect, imagePaint)
|
|
2934
|
+
return
|
|
2935
|
+
}
|
|
2936
|
+
// <a:srcRect> insets are fractions of the source, so the visible window is what remains
|
|
2937
|
+
// after trimming each edge.
|
|
2938
|
+
val left = (bitmap.width * crop.left).toInt().coerceIn(0, bitmap.width)
|
|
2939
|
+
val top = (bitmap.height * crop.top).toInt().coerceIn(0, bitmap.height)
|
|
2940
|
+
val right = (bitmap.width * (1f - crop.right)).toInt().coerceIn(left + 1, bitmap.width)
|
|
2941
|
+
val bottom = (bitmap.height * (1f - crop.bottom)).toInt().coerceIn(top + 1, bitmap.height)
|
|
2942
|
+
srcRectPx.set(left, top, right, bottom)
|
|
2943
|
+
canvas.drawBitmap(bitmap, srcRectPx, rect, imagePaint)
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
private fun drawTextBox(canvas: Canvas, box: PowerPointElement.TextBox, rect: RectF, scale: Float) {
|
|
2947
|
+
// Use the shape's real geometry, not an unconditional rectangle: a rounded callout or an
|
|
2948
|
+
// ellipse used to get a hard rectangular fill behind its text.
|
|
2949
|
+
if (applyFill(box.fill, rect)) {
|
|
2950
|
+
drawShapePath(canvas, box.shapeType, rect, fillPaint, box.customPath)
|
|
2951
|
+
clearFill()
|
|
2952
|
+
}
|
|
2953
|
+
box.stroke?.let {
|
|
2954
|
+
strokePaint.color = it.color
|
|
2955
|
+
strokePaint.strokeWidth = max(1f, it.widthPt * scale)
|
|
2956
|
+
canvas.drawRect(rect, strokePaint)
|
|
2957
|
+
}
|
|
2958
|
+
val content = RectF(
|
|
2959
|
+
rect.left + box.insetLeftPt * scale,
|
|
2960
|
+
rect.top + box.insetTopPt * scale,
|
|
2961
|
+
rect.right - box.insetRightPt * scale,
|
|
2962
|
+
rect.bottom - box.insetBottomPt * scale
|
|
2963
|
+
)
|
|
2964
|
+
if (content.width() <= 1f || content.height() <= 1f) return
|
|
2965
|
+
|
|
2966
|
+
// Autofit. PowerPoint records the shrink it computed, so honour that rather than recomputing.
|
|
2967
|
+
// When <a:normAutofit> is present but carries no fontScale (hand-written XML, some
|
|
2968
|
+
// third-party producers), fall back to a bounded search so the text still fits.
|
|
2969
|
+
var shrink = if (box.autofit) box.fontScale.coerceIn(0.1f, 1f) else 1f
|
|
2970
|
+
var measured = measureParagraphs(box, content, scale, shrink)
|
|
2971
|
+
if (box.autofit) {
|
|
2972
|
+
var guard = 0
|
|
2973
|
+
while (measured.second > content.height() && shrink > 0.25f && guard < 12) {
|
|
2974
|
+
shrink *= 0.92f
|
|
2975
|
+
measured = measureParagraphs(box, content, scale, shrink)
|
|
2976
|
+
guard++
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
val (blocks, totalHeight) = measured
|
|
2980
|
+
|
|
2981
|
+
var y = when (box.verticalAnchor) {
|
|
2982
|
+
TextVerticalAnchor.MIDDLE -> content.top + (content.height() - totalHeight) / 2f
|
|
2983
|
+
TextVerticalAnchor.BOTTOM -> content.bottom - totalHeight
|
|
2984
|
+
else -> content.top
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// NO clip and no early break: PowerPoint lets text overflow its box, and clipping silently
|
|
2988
|
+
// hid content. Overflow is still bounded by the slide-level clip in onDraw.
|
|
2989
|
+
for (block in blocks) {
|
|
2990
|
+
y += block.spaceBeforePx
|
|
2991
|
+
canvas.save()
|
|
2992
|
+
canvas.translate(content.left + block.textLeftPx, y)
|
|
2993
|
+
block.layout.draw(canvas)
|
|
2994
|
+
canvas.restore()
|
|
2995
|
+
// The bullet is drawn separately at the hanging-indent position so that every WRAPPED
|
|
2996
|
+
// line of the paragraph still aligns at marL - which prepending the glyph to the text
|
|
2997
|
+
// (the old approach) could never achieve.
|
|
2998
|
+
block.bullet?.let { glyph ->
|
|
2999
|
+
canvas.drawText(
|
|
3000
|
+
glyph.text,
|
|
3001
|
+
content.left + glyph.xPx,
|
|
3002
|
+
y + block.layout.getLineBaseline(0),
|
|
3003
|
+
glyph.paint
|
|
3004
|
+
)
|
|
3005
|
+
}
|
|
3006
|
+
y += block.layout.height + block.spaceAfterPx
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
/** One laid-out paragraph plus everything the draw pass needs to place it. */
|
|
3011
|
+
private class TextBlock(
|
|
3012
|
+
val layout: StaticLayout,
|
|
3013
|
+
val textLeftPx: Float,
|
|
3014
|
+
val spaceBeforePx: Float,
|
|
3015
|
+
val spaceAfterPx: Float,
|
|
3016
|
+
val bullet: BulletGlyph?
|
|
3017
|
+
)
|
|
3018
|
+
|
|
3019
|
+
private class BulletGlyph(val text: String, val xPx: Float, val paint: TextPaint)
|
|
3020
|
+
|
|
3021
|
+
/**
|
|
3022
|
+
* Lays out every paragraph of [box] at [shrink] and returns the blocks plus their total height.
|
|
3023
|
+
*/
|
|
3024
|
+
private fun measureParagraphs(
|
|
3025
|
+
box: PowerPointElement.TextBox,
|
|
3026
|
+
content: RectF,
|
|
3027
|
+
scale: Float,
|
|
3028
|
+
shrink: Float
|
|
3029
|
+
): Pair<List<TextBlock>, Float> {
|
|
3030
|
+
val blocks = ArrayList<TextBlock>(box.paragraphs.size)
|
|
3031
|
+
var total = 0f
|
|
3032
|
+
for (paragraph in box.paragraphs) {
|
|
3033
|
+
val marLPx = paragraph.marLPt * scale
|
|
3034
|
+
val indentPx = paragraph.indentPt * scale
|
|
3035
|
+
val width = (content.width() - marLPx).coerceAtLeast(1f)
|
|
3036
|
+
val layout = buildParagraphLayout(paragraph, width.toInt(), scale, shrink, box.lnSpcReduction)
|
|
3037
|
+
val bullet = paragraph.bullet?.let { glyph ->
|
|
3038
|
+
val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
3039
|
+
color = paragraph.baseColor
|
|
3040
|
+
textSize = max(1f, paragraph.baseSizePt * scale * shrink)
|
|
3041
|
+
typeface = typefaceFor(paragraph.baseFontFamily, paragraph.baseBold, paragraph.baseItalic)
|
|
3042
|
+
}
|
|
3043
|
+
BulletGlyph(glyph, marLPx + indentPx, paint)
|
|
3044
|
+
}
|
|
3045
|
+
val before = paragraph.spaceBeforePt * scale
|
|
3046
|
+
val after = paragraph.spaceAfterPt * scale
|
|
3047
|
+
blocks.add(TextBlock(layout, marLPx, before, after, bullet))
|
|
3048
|
+
total += before + layout.height + after
|
|
3049
|
+
}
|
|
3050
|
+
return blocks to total
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
private fun drawTable(canvas: Canvas, table: PowerPointElement.Table, rect: RectF, scale: Float) {
|
|
3054
|
+
val columnCount = max(
|
|
3055
|
+
table.columnsPt.size,
|
|
3056
|
+
table.rows.maxOfOrNull { it.cells.sumOf { cell -> cell.gridSpan } } ?: 1
|
|
3057
|
+
).coerceAtLeast(1)
|
|
3058
|
+
val columns = if (table.columnsPt.isNotEmpty()) {
|
|
3059
|
+
table.columnsPt
|
|
3060
|
+
} else {
|
|
3061
|
+
List(columnCount) { table.bounds.width() / columnCount }
|
|
3062
|
+
}
|
|
3063
|
+
val totalColPt = columns.sum().takeIf { it > 0f } ?: table.bounds.width()
|
|
3064
|
+
val colScale = rect.width() / totalColPt
|
|
3065
|
+
val defaultRowHeight = rect.height() / table.rows.size.coerceAtLeast(1)
|
|
3066
|
+
|
|
3067
|
+
fun widthOf(colIndex: Int, span: Int): Float =
|
|
3068
|
+
(colIndex until min(columns.size, colIndex + span))
|
|
3069
|
+
.sumOf { columns[it].toDouble() }.toFloat() * colScale
|
|
3070
|
+
|
|
3071
|
+
var y = rect.top
|
|
3072
|
+
for (row in table.rows) {
|
|
3073
|
+
// <a:tr h> is a MINIMUM in PowerPoint, not a fixed height: a row grows to fit its content.
|
|
3074
|
+
// Treating it as fixed (and clamping to the table's declared bottom) is what made table
|
|
3075
|
+
// text collide with the grid lines and get cut off.
|
|
3076
|
+
var rowHeight = (row.heightPt?.times(scale) ?: defaultRowHeight).coerceAtLeast(12f)
|
|
3077
|
+
var probeIndex = 0
|
|
3078
|
+
for (cell in row.cells) {
|
|
3079
|
+
val span = cell.gridSpan.coerceAtLeast(1)
|
|
3080
|
+
if (!cell.merged) {
|
|
3081
|
+
val inner = widthOf(probeIndex, span) -
|
|
3082
|
+
(cell.marginLeftPt + cell.marginRightPt) * scale
|
|
3083
|
+
if (inner > 1f) {
|
|
3084
|
+
val needed = measureCellText(cell.paragraphs, inner, scale) +
|
|
3085
|
+
(cell.marginTopPt + cell.marginBottomPt) * scale
|
|
3086
|
+
if (needed > rowHeight) rowHeight = needed
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
probeIndex += span
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
var x = rect.left
|
|
3093
|
+
var colIndex = 0
|
|
3094
|
+
for (cell in row.cells) {
|
|
3095
|
+
val span = cell.gridSpan.coerceAtLeast(1)
|
|
3096
|
+
val cellWidth = widthOf(colIndex, span)
|
|
3097
|
+
// A continuation cell of a horizontal/vertical merge occupies grid space but must not
|
|
3098
|
+
// paint its own fill, border or text over the cell it was merged into.
|
|
3099
|
+
if (cell.merged) {
|
|
3100
|
+
x += cellWidth
|
|
3101
|
+
colIndex += span
|
|
3102
|
+
continue
|
|
3103
|
+
}
|
|
3104
|
+
val cellRect = RectF(x, y, x + cellWidth, y + rowHeight)
|
|
3105
|
+
fillPaint.shader = null
|
|
3106
|
+
fillPaint.color = cell.fillColor ?: Color.TRANSPARENT
|
|
3107
|
+
if (Color.alpha(fillPaint.color) > 0) canvas.drawRect(cellRect, fillPaint)
|
|
3108
|
+
drawCellBorders(canvas, cell, cellRect, scale)
|
|
3109
|
+
val textRect = RectF(
|
|
3110
|
+
cellRect.left + cell.marginLeftPt * scale,
|
|
3111
|
+
cellRect.top + cell.marginTopPt * scale,
|
|
3112
|
+
cellRect.right - cell.marginRightPt * scale,
|
|
3113
|
+
cellRect.bottom - cell.marginBottomPt * scale
|
|
3114
|
+
)
|
|
3115
|
+
drawCellText(canvas, cell.paragraphs, textRect, scale, cell.anchor)
|
|
3116
|
+
x += cellWidth
|
|
3117
|
+
colIndex += span
|
|
3118
|
+
}
|
|
3119
|
+
y += rowHeight
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
/**
|
|
3124
|
+
* Per-edge cell borders. An edge the deck left unspecified falls back to a neutral hairline so
|
|
3125
|
+
* the grid remains legible rather than vanishing.
|
|
3126
|
+
*/
|
|
3127
|
+
private fun drawCellBorders(
|
|
3128
|
+
canvas: Canvas,
|
|
3129
|
+
cell: PowerPointTableCell,
|
|
3130
|
+
r: RectF,
|
|
3131
|
+
scale: Float
|
|
3132
|
+
) {
|
|
3133
|
+
strokePaint.style = Paint.Style.STROKE
|
|
3134
|
+
fun edge(stroke: PowerPointStroke?, x0: Float, y0: Float, x1: Float, y1: Float) {
|
|
3135
|
+
if (stroke != null) {
|
|
3136
|
+
strokePaint.color = stroke.color
|
|
3137
|
+
strokePaint.strokeWidth = max(1f, stroke.widthPt * scale)
|
|
3138
|
+
} else {
|
|
3139
|
+
strokePaint.color = TABLE_DEFAULT_BORDER
|
|
3140
|
+
strokePaint.strokeWidth = max(1f, 0.75f * scale)
|
|
3141
|
+
}
|
|
3142
|
+
canvas.drawLine(x0, y0, x1, y1, strokePaint)
|
|
3143
|
+
}
|
|
3144
|
+
edge(cell.borderTop, r.left, r.top, r.right, r.top)
|
|
3145
|
+
edge(cell.borderBottom, r.left, r.bottom, r.right, r.bottom)
|
|
3146
|
+
edge(cell.borderLeft, r.left, r.top, r.left, r.bottom)
|
|
3147
|
+
edge(cell.borderRight, r.right, r.top, r.right, r.bottom)
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
/** Total laid-out height of a cell's paragraphs at [width], used for row auto-growth. */
|
|
3151
|
+
private fun measureCellText(
|
|
3152
|
+
paragraphs: List<PowerPointParagraph>,
|
|
3153
|
+
width: Float,
|
|
3154
|
+
scale: Float
|
|
3155
|
+
): Float {
|
|
3156
|
+
var total = 0f
|
|
3157
|
+
for (paragraph in paragraphs) {
|
|
3158
|
+
val inner = (width - paragraph.marLPt * scale).coerceAtLeast(1f)
|
|
3159
|
+
total += buildParagraphLayout(paragraph, inner.toInt(), scale).height +
|
|
3160
|
+
paragraph.spaceBeforePt * scale + paragraph.spaceAfterPt * scale
|
|
3161
|
+
}
|
|
3162
|
+
return total
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
private fun drawCellText(
|
|
3166
|
+
canvas: Canvas,
|
|
3167
|
+
paragraphs: List<PowerPointParagraph>,
|
|
3168
|
+
rect: RectF,
|
|
3169
|
+
scale: Float,
|
|
3170
|
+
anchor: TextVerticalAnchor = TextVerticalAnchor.TOP
|
|
3171
|
+
) {
|
|
3172
|
+
if (rect.width() <= 1f || rect.height() <= 1f) return
|
|
3173
|
+
// Rows have already grown to fit, so anchoring is about placing content within any slack.
|
|
3174
|
+
val contentHeight = measureCellText(paragraphs, rect.width(), scale)
|
|
3175
|
+
var y = when (anchor) {
|
|
3176
|
+
TextVerticalAnchor.MIDDLE -> rect.top + (rect.height() - contentHeight) / 2f
|
|
3177
|
+
TextVerticalAnchor.BOTTOM -> rect.bottom - contentHeight
|
|
3178
|
+
else -> rect.top
|
|
3179
|
+
}.coerceAtLeast(rect.top)
|
|
3180
|
+
canvas.save()
|
|
3181
|
+
// Cells DO keep a clip (unlike free text boxes) so text can't bleed across the grid.
|
|
3182
|
+
canvas.clipRect(rect)
|
|
3183
|
+
for (paragraph in paragraphs) {
|
|
3184
|
+
val marLPx = paragraph.marLPt * scale
|
|
3185
|
+
val width = (rect.width() - marLPx).coerceAtLeast(1f)
|
|
3186
|
+
val layout = buildParagraphLayout(paragraph, width.toInt(), scale)
|
|
3187
|
+
y += paragraph.spaceBeforePt * scale
|
|
3188
|
+
canvas.save()
|
|
3189
|
+
canvas.translate(rect.left + marLPx, y)
|
|
3190
|
+
layout.draw(canvas)
|
|
3191
|
+
canvas.restore()
|
|
3192
|
+
// Bullets are drawn separately (see measureParagraphs) so they hang correctly; cells go
|
|
3193
|
+
// through the same path or they would silently lose their markers.
|
|
3194
|
+
paragraph.bullet?.let { glyph ->
|
|
3195
|
+
bulletPaint.color = paragraph.baseColor
|
|
3196
|
+
bulletPaint.textSize = max(1f, paragraph.baseSizePt * scale)
|
|
3197
|
+
bulletPaint.typeface =
|
|
3198
|
+
typefaceFor(paragraph.baseFontFamily, paragraph.baseBold, paragraph.baseItalic)
|
|
3199
|
+
canvas.drawText(
|
|
3200
|
+
glyph,
|
|
3201
|
+
rect.left + marLPx + paragraph.indentPt * scale,
|
|
3202
|
+
y + layout.getLineBaseline(0),
|
|
3203
|
+
bulletPaint
|
|
3204
|
+
)
|
|
3205
|
+
}
|
|
3206
|
+
y += layout.height + paragraph.spaceAfterPt * scale
|
|
3207
|
+
}
|
|
3208
|
+
canvas.restore()
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
private fun buildParagraphLayout(
|
|
3212
|
+
paragraph: PowerPointParagraph,
|
|
3213
|
+
width: Int,
|
|
3214
|
+
scale: Float,
|
|
3215
|
+
shrink: Float = 1f,
|
|
3216
|
+
lnSpcReduction: Float = 0f
|
|
3217
|
+
): StaticLayout {
|
|
3218
|
+
val text = SpannableStringBuilder()
|
|
3219
|
+
// The bullet is NOT prepended to the text any more - it is drawn separately so wrapped
|
|
3220
|
+
// lines hang correctly. See measureParagraphs.
|
|
3221
|
+
if (paragraph.runs.isEmpty()) text.append(" ")
|
|
3222
|
+
for (run in paragraph.runs) appendRun(text, run, scale * shrink, paragraph)
|
|
3223
|
+
// A paint PER PARAGRAPH, seeded from the paragraph's resolved base appearance. The old code
|
|
3224
|
+
// shared one mutable TextPaint across every StaticLayout (which keeps it by reference) and
|
|
3225
|
+
// reset it to a hardcoded black 14pt — so nothing could carry the deck's real typography.
|
|
3226
|
+
val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
3227
|
+
color = paragraph.baseColor
|
|
3228
|
+
textSize = max(1f, paragraph.baseSizePt * scale * shrink)
|
|
3229
|
+
typeface = typefaceFor(paragraph.baseFontFamily, paragraph.baseBold, paragraph.baseItalic)
|
|
3230
|
+
}
|
|
3231
|
+
// A negative authored value carries an exact spcPts height; convert it against this
|
|
3232
|
+
// paragraph's font size so both forms end up as one multiplier.
|
|
3233
|
+
val authored = paragraph.lineSpacePct
|
|
3234
|
+
val multiplier = when {
|
|
3235
|
+
authored == null -> PPT_SINGLE_LINE_FACTOR
|
|
3236
|
+
authored >= 0f -> authored * PPT_SINGLE_LINE_FACTOR
|
|
3237
|
+
else -> {
|
|
3238
|
+
val sizePt = (paragraph.baseSizePt * shrink).coerceAtLeast(1f)
|
|
3239
|
+
(-authored / sizePt).coerceIn(0.5f, 4f)
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
val effective = (multiplier - lnSpcReduction).coerceAtLeast(0.2f)
|
|
3243
|
+
return StaticLayout.Builder
|
|
3244
|
+
.obtain(text, 0, text.length, paint, width.coerceAtLeast(1))
|
|
3245
|
+
.setAlignment(paragraph.align.toLayoutAlignment())
|
|
3246
|
+
.setLineSpacing(0f, effective)
|
|
3247
|
+
.setIncludePad(false)
|
|
3248
|
+
.build()
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
private fun appendRun(
|
|
3252
|
+
builder: SpannableStringBuilder,
|
|
3253
|
+
run: PowerPointTextRun,
|
|
3254
|
+
scale: Float,
|
|
3255
|
+
paragraph: PowerPointParagraph
|
|
3256
|
+
) {
|
|
3257
|
+
val start = builder.length
|
|
3258
|
+
builder.append(if (run.allCaps == true) run.text.uppercase() else run.text)
|
|
3259
|
+
val end = builder.length
|
|
3260
|
+
if (start == end) return
|
|
3261
|
+
fun span(what: Any) = builder.setSpan(what, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
3262
|
+
|
|
3263
|
+
run.color?.let { span(ForegroundColorSpan(it)) }
|
|
3264
|
+
// AbsoluteSizeSpan takes an INT px and truncates — 10.5pt silently became 10pt. Keeping the
|
|
3265
|
+
// float matters once sizes are scaled by autofit and by the view scale.
|
|
3266
|
+
run.fontSizePt?.let { span(RunSizeSpan(max(1f, it * scale))) }
|
|
3267
|
+
// One span carries family + bold/italic together: Typeface.create picks a real face for the
|
|
3268
|
+
// mapped family and synthesises the style, whereas TypefaceSpan("Calibri") silently
|
|
3269
|
+
// resolved to the default face and lost every serif deck's character.
|
|
3270
|
+
val bold = run.bold ?: paragraph.baseBold
|
|
3271
|
+
val italic = run.italic ?: paragraph.baseItalic
|
|
3272
|
+
val family = run.fontFamily ?: paragraph.baseFontFamily
|
|
3273
|
+
if (family != null || bold || italic) span(RunTypefaceSpan(typefaceFor(family, bold, italic)))
|
|
3274
|
+
if (run.underline == true) span(UnderlineSpan())
|
|
3275
|
+
if (run.strike == true) span(StrikethroughSpan())
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
private fun scaleRect(rect: RectF, scale: Float): RectF =
|
|
3279
|
+
RectF(rect.left * scale, rect.top * scale, rect.right * scale, rect.bottom * scale)
|
|
3280
|
+
|
|
3281
|
+
private fun TextAlign.toLayoutAlignment(): Layout.Alignment = when (this) {
|
|
3282
|
+
TextAlign.CENTER -> Layout.Alignment.ALIGN_CENTER
|
|
3283
|
+
TextAlign.RIGHT -> Layout.Alignment.ALIGN_OPPOSITE
|
|
3284
|
+
else -> Layout.Alignment.ALIGN_NORMAL
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
private data class PowerPointTransform(
|
|
3289
|
+
val bounds: RectF? = null,
|
|
3290
|
+
val rotation: Float = 0f,
|
|
3291
|
+
val flipH: Boolean = false,
|
|
3292
|
+
val flipV: Boolean = false
|
|
3293
|
+
)
|
|
3294
|
+
|
|
3295
|
+
private data class PowerPointShapeProperties(
|
|
3296
|
+
val transform: PowerPointTransform?,
|
|
3297
|
+
val fill: PptFill?,
|
|
3298
|
+
val stroke: PowerPointStroke?,
|
|
3299
|
+
val shapeType: PowerPointShapeType,
|
|
3300
|
+
/** True when the shape said `<a:noFill/>`, which must beat any `<p:style>` fill reference. */
|
|
3301
|
+
val explicitNoFill: Boolean = false,
|
|
3302
|
+
val customPath: android.graphics.Path? = null
|
|
3303
|
+
)
|
|
3304
|
+
|
|
3305
|
+
/** Theme format-scheme colours referenced by a shape's `<p:style>`. */
|
|
3306
|
+
private data class ShapeStyleRefs(
|
|
3307
|
+
val fillColor: Int? = null,
|
|
3308
|
+
val lineColor: Int? = null,
|
|
3309
|
+
val fontColor: Int? = null
|
|
3310
|
+
)
|
|
3311
|
+
|
|
3312
|
+
private data class PowerPointTextBody(
|
|
3313
|
+
val paragraphs: List<RawParagraph> = emptyList(),
|
|
3314
|
+
val anchor: TextVerticalAnchor = TextVerticalAnchor.TOP,
|
|
3315
|
+
val insetLeftPt: Float = 4f,
|
|
3316
|
+
val insetTopPt: Float = 2f,
|
|
3317
|
+
val insetRightPt: Float = 4f,
|
|
3318
|
+
val insetBottomPt: Float = 2f,
|
|
3319
|
+
val autofit: Boolean = false,
|
|
3320
|
+
val fontScale: Float = 1f,
|
|
3321
|
+
val lnSpcReduction: Float = 0f
|
|
3322
|
+
)
|
|
3323
|
+
|
|
3324
|
+
private data class PowerPointParagraphProperties(
|
|
3325
|
+
// Null align/unset bullet means the paragraph did not specify one, so the placeholder's
|
|
3326
|
+
// inherited list style (layout → master → txStyles) supplies it.
|
|
3327
|
+
val align: TextAlign?,
|
|
3328
|
+
val bullet: String?,
|
|
3329
|
+
val bulletExplicit: Boolean,
|
|
3330
|
+
val level: Int,
|
|
3331
|
+
val marLPt: Float? = null,
|
|
3332
|
+
val indentPt: Float? = null,
|
|
3333
|
+
val lineSpacePct: Float? = null,
|
|
3334
|
+
val spaceBeforePt: Float? = null,
|
|
3335
|
+
val spaceAfterPt: Float? = null
|
|
3336
|
+
)
|
|
3337
|
+
|
|
3338
|
+
/**
|
|
3339
|
+
* A paragraph as read from a slide's own XML, BEFORE layout/master defaults are folded in.
|
|
3340
|
+
* `align`/`bullet` are null/absent when the slide didn't set them; [resolveParagraph] later
|
|
3341
|
+
* fills the gaps from the inherited placeholder style.
|
|
3342
|
+
*/
|
|
3343
|
+
private data class RawParagraph(
|
|
3344
|
+
val runs: List<PowerPointTextRun>,
|
|
3345
|
+
val align: TextAlign?,
|
|
3346
|
+
val bullet: String?,
|
|
3347
|
+
val bulletExplicit: Boolean,
|
|
3348
|
+
val level: Int,
|
|
3349
|
+
val marLPt: Float? = null,
|
|
3350
|
+
val indentPt: Float? = null,
|
|
3351
|
+
val lineSpacePct: Float? = null,
|
|
3352
|
+
val spaceBeforePt: Float? = null,
|
|
3353
|
+
val spaceAfterPt: Float? = null
|
|
3354
|
+
)
|
|
3355
|
+
|
|
3356
|
+
internal data class PowerPointRunProperties(
|
|
3357
|
+
val fontSizePt: Float? = null,
|
|
3358
|
+
val color: Int? = null,
|
|
3359
|
+
val fontFamily: String? = null,
|
|
3360
|
+
val bold: Boolean? = null,
|
|
3361
|
+
val italic: Boolean? = null,
|
|
3362
|
+
val underline: Boolean? = null,
|
|
3363
|
+
val strike: Boolean? = null,
|
|
3364
|
+
val allCaps: Boolean? = null
|
|
3365
|
+
)
|
|
3366
|
+
|
|
3367
|
+
// ── Layout / master inheritance model ──────────────────────────────
|
|
3368
|
+
|
|
3369
|
+
/** Placeholder identity from `<p:ph type=".." idx="..">` — the key for inheritance matching. */
|
|
3370
|
+
private data class PowerPointPlaceholder(val type: String?, val idx: String?)
|
|
3371
|
+
|
|
3372
|
+
/** Which master text-style table a placeholder draws its defaults from. */
|
|
3373
|
+
private enum class PlaceholderCategory { TITLE, BODY, OTHER }
|
|
3374
|
+
|
|
3375
|
+
/** Default run + paragraph formatting for one outline level (from a list/text style). */
|
|
3376
|
+
internal data class ParaRunDefaults(
|
|
3377
|
+
val run: PowerPointRunProperties = PowerPointRunProperties(),
|
|
3378
|
+
val align: TextAlign? = null,
|
|
3379
|
+
val bullet: String? = null,
|
|
3380
|
+
/** True when this level SAID something about bullets, including `<a:buNone/>`. */
|
|
3381
|
+
val bulletExplicit: Boolean = false,
|
|
3382
|
+
val marLPt: Float? = null,
|
|
3383
|
+
val indentPt: Float? = null,
|
|
3384
|
+
val lineSpacePct: Float? = null,
|
|
3385
|
+
val spaceBeforePt: Float? = null,
|
|
3386
|
+
val spaceAfterPt: Float? = null
|
|
3387
|
+
)
|
|
3388
|
+
|
|
3389
|
+
/** Inheritable data for one placeholder on a layout/master: its geometry and per-level styles. */
|
|
3390
|
+
private data class PlaceholderInfo(
|
|
3391
|
+
val bounds: RectF? = null,
|
|
3392
|
+
val lstStyle: Map<Int, ParaRunDefaults> = emptyMap()
|
|
3393
|
+
)
|
|
3394
|
+
|
|
3395
|
+
private data class StoredPlaceholder(val ph: PowerPointPlaceholder, val info: PlaceholderInfo)
|
|
3396
|
+
|
|
3397
|
+
/**
|
|
3398
|
+
* Field-wise inheritance: `this` wins, gaps come from [base]. Null strictly means "inherit".
|
|
3399
|
+
* Same idiom as `DocxConverter.RunProps.overlayOn` so both preview paths resolve styles alike.
|
|
3400
|
+
*/
|
|
3401
|
+
internal fun PowerPointRunProperties.overlayOn(
|
|
3402
|
+
base: PowerPointRunProperties?
|
|
3403
|
+
): PowerPointRunProperties {
|
|
3404
|
+
if (base == null) return this
|
|
3405
|
+
return PowerPointRunProperties(
|
|
3406
|
+
fontSizePt = fontSizePt ?: base.fontSizePt,
|
|
3407
|
+
color = color ?: base.color,
|
|
3408
|
+
fontFamily = fontFamily ?: base.fontFamily,
|
|
3409
|
+
bold = bold ?: base.bold,
|
|
3410
|
+
italic = italic ?: base.italic,
|
|
3411
|
+
underline = underline ?: base.underline,
|
|
3412
|
+
strike = strike ?: base.strike,
|
|
3413
|
+
allCaps = allCaps ?: base.allCaps
|
|
3414
|
+
)
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
internal fun ParaRunDefaults.overlayOn(base: ParaRunDefaults?): ParaRunDefaults {
|
|
3418
|
+
if (base == null) return this
|
|
3419
|
+
return ParaRunDefaults(
|
|
3420
|
+
run = run.overlayOn(base.run),
|
|
3421
|
+
align = align ?: base.align,
|
|
3422
|
+
// An explicit <a:buNone/> must survive: coalescing on null alone would let an inherited
|
|
3423
|
+
// bullet reappear on a level that deliberately turned bullets off.
|
|
3424
|
+
bullet = if (bulletExplicit) bullet else base.bullet,
|
|
3425
|
+
bulletExplicit = bulletExplicit || base.bulletExplicit,
|
|
3426
|
+
marLPt = marLPt ?: base.marLPt,
|
|
3427
|
+
indentPt = indentPt ?: base.indentPt,
|
|
3428
|
+
lineSpacePct = lineSpacePct ?: base.lineSpacePct,
|
|
3429
|
+
spaceBeforePt = spaceBeforePt ?: base.spaceBeforePt,
|
|
3430
|
+
spaceAfterPt = spaceAfterPt ?: base.spaceAfterPt
|
|
3431
|
+
)
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
/** A master's `<p:txStyles>`: default run/paragraph styling per outline level, per category. */
|
|
3435
|
+
private class TextStyles(
|
|
3436
|
+
val title: Map<Int, ParaRunDefaults>,
|
|
3437
|
+
val body: Map<Int, ParaRunDefaults>,
|
|
3438
|
+
val other: Map<Int, ParaRunDefaults>
|
|
3439
|
+
) {
|
|
3440
|
+
fun forCategory(cat: PlaceholderCategory): Map<Int, ParaRunDefaults> = when (cat) {
|
|
3441
|
+
PlaceholderCategory.TITLE -> title
|
|
3442
|
+
PlaceholderCategory.BODY -> body
|
|
3443
|
+
PlaceholderCategory.OTHER -> other
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
companion object {
|
|
3447
|
+
val EMPTY = TextStyles(emptyMap(), emptyMap(), emptyMap())
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
/** A parsed slide layout or master. `textStyles` is populated for masters only. */
|
|
3452
|
+
private data class ParsedSlidePart(
|
|
3453
|
+
val placeholders: List<StoredPlaceholder>,
|
|
3454
|
+
val background: PptFill?,
|
|
3455
|
+
val textStyles: TextStyles,
|
|
3456
|
+
/**
|
|
3457
|
+
* Non-placeholder shapes/pictures on the layout or master - logos, colour bars, framing
|
|
3458
|
+
* graphics. Previously discarded outright, which is why themed decks rendered as bare text on
|
|
3459
|
+
* white.
|
|
3460
|
+
*/
|
|
3461
|
+
val decorations: List<PowerPointElement> = emptyList()
|
|
3462
|
+
)
|
|
3463
|
+
|
|
3464
|
+
/** The resolved inheritance sources for one slide (layout + master). */
|
|
3465
|
+
private class SlideInheritanceContext(
|
|
3466
|
+
val layoutPlaceholders: List<StoredPlaceholder>,
|
|
3467
|
+
val masterPlaceholders: List<StoredPlaceholder>,
|
|
3468
|
+
val textStyles: TextStyles,
|
|
3469
|
+
val background: PptFill?,
|
|
3470
|
+
/** Master decoration first, then layout decoration - both behind the slide's own shapes. */
|
|
3471
|
+
val decorations: List<PowerPointElement> = emptyList()
|
|
3472
|
+
) {
|
|
3473
|
+
companion object {
|
|
3474
|
+
val EMPTY = SlideInheritanceContext(emptyList(), emptyList(), TextStyles.EMPTY, null)
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
/** Match type used for placeholder lookup — center title is equivalent to title; a missing type
|
|
3479
|
+
* defaults to body (per the OOXML placeholder rules). */
|
|
3480
|
+
private fun phMatchType(type: String?): String = when (type) {
|
|
3481
|
+
null -> "body"
|
|
3482
|
+
"ctrTitle" -> "title"
|
|
3483
|
+
else -> type
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
private fun phCategory(type: String?): PlaceholderCategory = when (type) {
|
|
3487
|
+
"title", "ctrTitle" -> PlaceholderCategory.TITLE
|
|
3488
|
+
"body", "subTitle", "obj", null -> PlaceholderCategory.BODY
|
|
3489
|
+
else -> PlaceholderCategory.OTHER
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
/** Finds the layout/master placeholder a slide placeholder inherits from: same `idx` first
|
|
3493
|
+
* (the strongest signal), then same match-type, then same category. */
|
|
3494
|
+
private fun matchPlaceholder(list: List<StoredPlaceholder>, ph: PowerPointPlaceholder): PlaceholderInfo? {
|
|
3495
|
+
if (list.isEmpty()) return null
|
|
3496
|
+
if (ph.idx != null) list.firstOrNull { it.ph.idx == ph.idx }?.let { return it.info }
|
|
3497
|
+
val matchType = phMatchType(ph.type)
|
|
3498
|
+
list.firstOrNull { phMatchType(it.ph.type) == matchType }?.let { return it.info }
|
|
3499
|
+
val category = phCategory(ph.type)
|
|
3500
|
+
list.firstOrNull { phCategory(it.ph.type) == category }?.let { return it.info }
|
|
3501
|
+
return null
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
private class CompoundBinaryFile(private val bytes: ByteArray) {
|
|
3505
|
+
private val sectorShift: Int
|
|
3506
|
+
private val miniSectorShift: Int
|
|
3507
|
+
private val sectorSize: Int
|
|
3508
|
+
private val miniSectorSize: Int
|
|
3509
|
+
private val firstDirSector: Int
|
|
3510
|
+
private val firstMiniFatSector: Int
|
|
3511
|
+
private val numMiniFatSectors: Int
|
|
3512
|
+
private val miniCutoff: Int
|
|
3513
|
+
private val fat: IntArray
|
|
3514
|
+
private val directories: List<DirectoryEntry>
|
|
3515
|
+
private val root: DirectoryEntry?
|
|
3516
|
+
private val miniFat: IntArray
|
|
3517
|
+
private val miniStream: ByteArray
|
|
3518
|
+
|
|
3519
|
+
init {
|
|
3520
|
+
if (!matches(bytes, 0, OLE_SIG)) throw java.io.IOException("Not an OLE compound file")
|
|
3521
|
+
sectorShift = leUShort(bytes, 30)
|
|
3522
|
+
miniSectorShift = leUShort(bytes, 32)
|
|
3523
|
+
sectorSize = 1 shl sectorShift
|
|
3524
|
+
miniSectorSize = 1 shl miniSectorShift
|
|
3525
|
+
miniCutoff = leInt(bytes, 56).takeIf { it > 0 } ?: 4096
|
|
3526
|
+
firstDirSector = leInt(bytes, 48)
|
|
3527
|
+
firstMiniFatSector = leInt(bytes, 60)
|
|
3528
|
+
numMiniFatSectors = leInt(bytes, 64).coerceAtLeast(0)
|
|
3529
|
+
fat = readFat()
|
|
3530
|
+
val dirBytes = readRegularStream(firstDirSector, Int.MAX_VALUE)
|
|
3531
|
+
directories = parseDirectories(dirBytes)
|
|
3532
|
+
root = directories.firstOrNull { it.type == 5 }
|
|
3533
|
+
miniFat = readMiniFat()
|
|
3534
|
+
miniStream = root?.let { readRegularStream(it.startSector, it.size.toIntSafe()) } ?: ByteArray(0)
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
fun stream(name: String): ByteArray? {
|
|
3538
|
+
val entry = directories.firstOrNull { it.name.equals(name, ignoreCase = true) && it.type == 2 } ?: return null
|
|
3539
|
+
return if (entry.size < miniCutoff && entry.startSector >= 0 && miniStream.isNotEmpty()) {
|
|
3540
|
+
readMiniStream(entry.startSector, entry.size.toIntSafe())
|
|
3541
|
+
} else {
|
|
3542
|
+
readRegularStream(entry.startSector, entry.size.toIntSafe())
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
private fun readFat(): IntArray {
|
|
3547
|
+
val difat = ArrayList<Int>()
|
|
3548
|
+
for (i in 0 until 109) {
|
|
3549
|
+
val sector = leInt(bytes, 76 + i * 4)
|
|
3550
|
+
if (sector >= 0) difat.add(sector)
|
|
3551
|
+
}
|
|
3552
|
+
val out = ArrayList<Int>()
|
|
3553
|
+
for (sector in difat) {
|
|
3554
|
+
val offset = sectorOffset(sector)
|
|
3555
|
+
if (offset < 0 || offset + sectorSize > bytes.size) continue
|
|
3556
|
+
var pos = offset
|
|
3557
|
+
repeat(sectorSize / 4) {
|
|
3558
|
+
out.add(leInt(bytes, pos))
|
|
3559
|
+
pos += 4
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
return out.toIntArray()
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
private fun readMiniFat(): IntArray {
|
|
3566
|
+
if (firstMiniFatSector < 0 || numMiniFatSectors <= 0) return IntArray(0)
|
|
3567
|
+
val data = readRegularStream(firstMiniFatSector, numMiniFatSectors * sectorSize)
|
|
3568
|
+
val out = IntArray(data.size / 4)
|
|
3569
|
+
for (i in out.indices) out[i] = leInt(data, i * 4)
|
|
3570
|
+
return out
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
private fun readRegularStream(startSector: Int, expectedSize: Int): ByteArray {
|
|
3574
|
+
if (startSector < 0) return ByteArray(0)
|
|
3575
|
+
val out = java.io.ByteArrayOutputStream(if (expectedSize in 1..(64 * 1024 * 1024)) expectedSize else 4096)
|
|
3576
|
+
var sector = startSector
|
|
3577
|
+
val visited = HashSet<Int>()
|
|
3578
|
+
while (sector >= 0 && sector < fat.size && visited.add(sector)) {
|
|
3579
|
+
val offset = sectorOffset(sector)
|
|
3580
|
+
if (offset < 0 || offset >= bytes.size) break
|
|
3581
|
+
out.write(bytes, offset, min(sectorSize, bytes.size - offset))
|
|
3582
|
+
if (expectedSize != Int.MAX_VALUE && out.size() >= expectedSize) break
|
|
3583
|
+
sector = fat[sector]
|
|
3584
|
+
if (sector == END_OF_CHAIN) break
|
|
3585
|
+
}
|
|
3586
|
+
val data = out.toByteArray()
|
|
3587
|
+
return if (expectedSize != Int.MAX_VALUE && expectedSize in 0 until data.size) data.copyOf(expectedSize) else data
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
private fun readMiniStream(startSector: Int, expectedSize: Int): ByteArray {
|
|
3591
|
+
val out = java.io.ByteArrayOutputStream(if (expectedSize > 0) expectedSize else 256)
|
|
3592
|
+
var sector = startSector
|
|
3593
|
+
val visited = HashSet<Int>()
|
|
3594
|
+
while (sector >= 0 && sector < miniFat.size && visited.add(sector)) {
|
|
3595
|
+
val offset = sector * miniSectorSize
|
|
3596
|
+
if (offset < 0 || offset >= miniStream.size) break
|
|
3597
|
+
out.write(miniStream, offset, min(miniSectorSize, miniStream.size - offset))
|
|
3598
|
+
if (out.size() >= expectedSize) break
|
|
3599
|
+
sector = miniFat[sector]
|
|
3600
|
+
if (sector == END_OF_CHAIN) break
|
|
3601
|
+
}
|
|
3602
|
+
val data = out.toByteArray()
|
|
3603
|
+
return if (expectedSize in 0 until data.size) data.copyOf(expectedSize) else data
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
private fun parseDirectories(data: ByteArray): List<DirectoryEntry> {
|
|
3607
|
+
val out = ArrayList<DirectoryEntry>()
|
|
3608
|
+
var pos = 0
|
|
3609
|
+
while (pos + 128 <= data.size) {
|
|
3610
|
+
val nameLen = leUShort(data, pos + 64)
|
|
3611
|
+
val rawNameBytes = (nameLen - 2).coerceIn(0, 64)
|
|
3612
|
+
val name = if (rawNameBytes > 0) {
|
|
3613
|
+
String(data, pos, rawNameBytes, Charsets.UTF_16LE).trimEnd('\u0000')
|
|
3614
|
+
} else ""
|
|
3615
|
+
val type = data[pos + 66].toInt() and 0xFF
|
|
3616
|
+
val startSector = leInt(data, pos + 116)
|
|
3617
|
+
val sizeLow = leUInt(data, pos + 120)
|
|
3618
|
+
val sizeHigh = leUInt(data, pos + 124)
|
|
3619
|
+
val size = (sizeHigh shl 32) or sizeLow
|
|
3620
|
+
out.add(DirectoryEntry(name, type, startSector, size))
|
|
3621
|
+
pos += 128
|
|
3622
|
+
}
|
|
3623
|
+
return out
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
private fun sectorOffset(sector: Int): Int {
|
|
3627
|
+
val offset = (sector + 1L) * sectorSize
|
|
3628
|
+
return if (offset > Int.MAX_VALUE) -1 else offset.toInt()
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
private data class DirectoryEntry(
|
|
3633
|
+
val name: String,
|
|
3634
|
+
val type: Int,
|
|
3635
|
+
val startSector: Int,
|
|
3636
|
+
val size: Long
|
|
3637
|
+
)
|
|
3638
|
+
|
|
3639
|
+
private fun cleanText(text: String): String =
|
|
3640
|
+
text.replace('\u0000', ' ')
|
|
3641
|
+
.replace(Regex("[\\u0001-\\u0008\\u000B\\u000C\\u000E-\\u001F]"), " ")
|
|
3642
|
+
.replace(Regex("\\s+"), " ")
|
|
3643
|
+
.trim()
|
|
3644
|
+
|
|
3645
|
+
private fun extractBinaryTextRuns(bytes: ByteArray): List<String> {
|
|
3646
|
+
val out = ArrayList<String>()
|
|
3647
|
+
var i = 0
|
|
3648
|
+
while (i < bytes.size - 4 && out.size < 80) {
|
|
3649
|
+
if (isUtf16Run(bytes, i)) {
|
|
3650
|
+
val text = extractUtf16Run(bytes, i)
|
|
3651
|
+
val cleaned = cleanText(text)
|
|
3652
|
+
if (cleaned.length >= 3) out.add(cleaned)
|
|
3653
|
+
i += max(2, text.length * 2)
|
|
3654
|
+
} else if (isAsciiRun(bytes, i)) {
|
|
3655
|
+
val text = extractAsciiRun(bytes, i)
|
|
3656
|
+
val cleaned = cleanText(text)
|
|
3657
|
+
if (cleaned.length >= 4 && cleaned.any { it.isLetter() }) out.add(cleaned)
|
|
3658
|
+
i += max(1, text.length)
|
|
3659
|
+
} else {
|
|
3660
|
+
i++
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
return out.distinct()
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
private fun isUtf16Run(bytes: ByteArray, pos: Int): Boolean =
|
|
3667
|
+
pos + 5 < bytes.size &&
|
|
3668
|
+
bytes[pos + 1] == 0.toByte() &&
|
|
3669
|
+
bytes[pos + 3] == 0.toByte() &&
|
|
3670
|
+
printableByte(bytes[pos]) &&
|
|
3671
|
+
printableByte(bytes[pos + 2])
|
|
3672
|
+
|
|
3673
|
+
private fun extractUtf16Run(bytes: ByteArray, start: Int): String {
|
|
3674
|
+
val sb = StringBuilder()
|
|
3675
|
+
var i = start
|
|
3676
|
+
while (i + 1 < bytes.size && bytes[i + 1] == 0.toByte() && printableByte(bytes[i])) {
|
|
3677
|
+
sb.append((bytes[i].toInt() and 0xFF).toChar())
|
|
3678
|
+
i += 2
|
|
3679
|
+
}
|
|
3680
|
+
return sb.toString()
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
private fun isAsciiRun(bytes: ByteArray, pos: Int): Boolean =
|
|
3684
|
+
pos < bytes.size && (bytes[pos].toInt() and 0xFF) in 0x20..0x7E
|
|
3685
|
+
|
|
3686
|
+
private fun extractAsciiRun(bytes: ByteArray, start: Int): String {
|
|
3687
|
+
val sb = StringBuilder()
|
|
3688
|
+
var i = start
|
|
3689
|
+
while (i < bytes.size && (bytes[i].toInt() and 0xFF) in 0x20..0x7E) {
|
|
3690
|
+
sb.append((bytes[i].toInt() and 0xFF).toChar())
|
|
3691
|
+
i++
|
|
3692
|
+
}
|
|
3693
|
+
return sb.toString()
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
private fun printableByte(byte: Byte): Boolean {
|
|
3697
|
+
val value = byte.toInt() and 0xFF
|
|
3698
|
+
return value in 0x20..0x7E || value == 0x09 || value == 0x0A || value == 0x0D
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
private fun decodeUtf16Le(bytes: ByteArray, start: Int, len: Int): String {
|
|
3702
|
+
if (len <= 0 || start + len > bytes.size) return ""
|
|
3703
|
+
return String(bytes, start, len - (len % 2), Charsets.UTF_16LE)
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
private fun decodeCp1252(bytes: ByteArray, start: Int, len: Int): String {
|
|
3707
|
+
if (len <= 0 || start + len > bytes.size) return ""
|
|
3708
|
+
return String(bytes, start, len, Charset.forName("windows-1252"))
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
private fun leUShort(bytes: ByteArray, offset: Int): Int {
|
|
3712
|
+
if (offset + 1 >= bytes.size) return 0
|
|
3713
|
+
return (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8)
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
private fun leInt(bytes: ByteArray, offset: Int): Int {
|
|
3717
|
+
if (offset + 3 >= bytes.size) return 0
|
|
3718
|
+
return (bytes[offset].toInt() and 0xFF) or
|
|
3719
|
+
((bytes[offset + 1].toInt() and 0xFF) shl 8) or
|
|
3720
|
+
((bytes[offset + 2].toInt() and 0xFF) shl 16) or
|
|
3721
|
+
((bytes[offset + 3].toInt() and 0xFF) shl 24)
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
private fun leUInt(bytes: ByteArray, offset: Int): Long = leInt(bytes, offset).toLong() and 0xFFFF_FFFFL
|
|
3725
|
+
|
|
3726
|
+
private fun matches(bytes: ByteArray, offset: Int, signature: ByteArray): Boolean {
|
|
3727
|
+
if (offset < 0 || offset + signature.size > bytes.size) return false
|
|
3728
|
+
for (i in signature.indices) if (bytes[offset + i] != signature[i]) return false
|
|
3729
|
+
return true
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
private fun findPngEnd(bytes: ByteArray, start: Int): Int {
|
|
3733
|
+
var pos = start + PNG_SIG.size
|
|
3734
|
+
while (pos + 12 <= bytes.size) {
|
|
3735
|
+
val len = leIntBigEndian(bytes, pos)
|
|
3736
|
+
if (len < 0 || pos + 12 + len > bytes.size) return -1
|
|
3737
|
+
val typeStart = pos + 4
|
|
3738
|
+
val isEnd = bytes[typeStart] == 'I'.code.toByte() &&
|
|
3739
|
+
bytes[typeStart + 1] == 'E'.code.toByte() &&
|
|
3740
|
+
bytes[typeStart + 2] == 'N'.code.toByte() &&
|
|
3741
|
+
bytes[typeStart + 3] == 'D'.code.toByte()
|
|
3742
|
+
pos += 12 + len
|
|
3743
|
+
if (isEnd) return pos
|
|
3744
|
+
}
|
|
3745
|
+
return -1
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
private fun findJpegEnd(bytes: ByteArray, start: Int): Int {
|
|
3749
|
+
var pos = start + 2
|
|
3750
|
+
while (pos + 1 < bytes.size) {
|
|
3751
|
+
if ((bytes[pos].toInt() and 0xFF) == 0xFF && (bytes[pos + 1].toInt() and 0xFF) == 0xD9) return pos + 2
|
|
3752
|
+
pos++
|
|
3753
|
+
}
|
|
3754
|
+
return -1
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
private fun findGifEnd(bytes: ByteArray, start: Int): Int {
|
|
3758
|
+
var pos = start + 13
|
|
3759
|
+
if (pos >= bytes.size) return -1
|
|
3760
|
+
val packed = bytes[start + 10].toInt() and 0xFF
|
|
3761
|
+
if ((packed and 0x80) != 0) pos += 3 * (1 shl ((packed and 0x07) + 1))
|
|
3762
|
+
while (pos < bytes.size) {
|
|
3763
|
+
when (bytes[pos].toInt() and 0xFF) {
|
|
3764
|
+
0x3B -> return pos + 1
|
|
3765
|
+
0x21 -> {
|
|
3766
|
+
pos += 2
|
|
3767
|
+
while (pos < bytes.size) {
|
|
3768
|
+
val block = bytes[pos].toInt() and 0xFF
|
|
3769
|
+
pos++
|
|
3770
|
+
if (block == 0) break
|
|
3771
|
+
pos += block
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
0x2C -> {
|
|
3775
|
+
pos += 9
|
|
3776
|
+
if (pos >= bytes.size) return -1
|
|
3777
|
+
val imagePacked = bytes[pos].toInt() and 0xFF
|
|
3778
|
+
pos++
|
|
3779
|
+
if ((imagePacked and 0x80) != 0) pos += 3 * (1 shl ((imagePacked and 0x07) + 1))
|
|
3780
|
+
if (pos >= bytes.size) return -1
|
|
3781
|
+
pos++
|
|
3782
|
+
while (pos < bytes.size) {
|
|
3783
|
+
val block = bytes[pos].toInt() and 0xFF
|
|
3784
|
+
pos++
|
|
3785
|
+
if (block == 0) break
|
|
3786
|
+
pos += block
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
else -> return -1
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
return -1
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
private fun leIntBigEndian(bytes: ByteArray, offset: Int): Int {
|
|
3796
|
+
if (offset + 3 >= bytes.size) return -1
|
|
3797
|
+
return ((bytes[offset].toInt() and 0xFF) shl 24) or
|
|
3798
|
+
((bytes[offset + 1].toInt() and 0xFF) shl 16) or
|
|
3799
|
+
((bytes[offset + 2].toInt() and 0xFF) shl 8) or
|
|
3800
|
+
(bytes[offset + 3].toInt() and 0xFF)
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
private fun colorFromHex(value: String?): Int? {
|
|
3804
|
+
if (value.isNullOrBlank()) return null
|
|
3805
|
+
val clean = value.trim().removePrefix("#")
|
|
3806
|
+
return try {
|
|
3807
|
+
when (clean.length) {
|
|
3808
|
+
6 -> Color.rgb(clean.substring(0, 2).toInt(16), clean.substring(2, 4).toInt(16), clean.substring(4, 6).toInt(16))
|
|
3809
|
+
8 -> Color.argb(clean.substring(0, 2).toInt(16), clean.substring(2, 4).toInt(16), clean.substring(4, 6).toInt(16), clean.substring(6, 8).toInt(16))
|
|
3810
|
+
else -> null
|
|
3811
|
+
}
|
|
3812
|
+
} catch (_: Throwable) {
|
|
3813
|
+
null
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
// ── Fonts ───────────────────────────────────────────────────────────
|
|
3818
|
+
//
|
|
3819
|
+
// No font a PPTX names ships with Android, so the authored family has to be mapped onto an
|
|
3820
|
+
// installed one. `TypefaceSpan("Calibri")` looks like it works but silently resolves to the
|
|
3821
|
+
// default sans face, which is why serif decks previously rendered sans throughout. Mapping to
|
|
3822
|
+
// buckets keeps the right SHAPE class (serif vs sans vs mono) even though the exact face differs.
|
|
3823
|
+
|
|
3824
|
+
private val SERIF_FONTS = listOf(
|
|
3825
|
+
"Times", "Cambria", "Georgia", "Garamond", "Book Antiqua", "Palatino", "Constantia",
|
|
3826
|
+
"Bookman", "Baskerville", "Century Schoolbook", "Century Old", "Merriweather", "Playfair",
|
|
3827
|
+
"Minion", "Caslon", "Didot", "Rockwell", "Bodoni", "PT Serif", "Noto Serif", "Source Serif"
|
|
3828
|
+
)
|
|
3829
|
+
|
|
3830
|
+
private val MONO_FONTS = listOf(
|
|
3831
|
+
"Consolas", "Courier", "Lucida Console", "Lucida Sans Typewriter", "Monaco", "Menlo",
|
|
3832
|
+
"Cascadia", "Andale Mono", "DejaVu Sans Mono", "Roboto Mono", "Source Code", "Fira Code",
|
|
3833
|
+
"IBM Plex Mono", "Inconsolata", "SF Mono"
|
|
3834
|
+
)
|
|
3835
|
+
|
|
3836
|
+
private val CURSIVE_FONTS = listOf(
|
|
3837
|
+
"Brush Script", "Segoe Script", "Lucida Handwriting", "Comic Sans", "Bradley Hand",
|
|
3838
|
+
"Freestyle Script", "Edwardian Script", "Pacifico", "Dancing Script"
|
|
3839
|
+
)
|
|
3840
|
+
|
|
3841
|
+
private fun androidFamilyFor(name: String?): String = when {
|
|
3842
|
+
name.isNullOrBlank() -> "sans-serif"
|
|
3843
|
+
MONO_FONTS.any { name.startsWith(it, ignoreCase = true) } -> "monospace"
|
|
3844
|
+
SERIF_FONTS.any { name.startsWith(it, ignoreCase = true) } -> "serif"
|
|
3845
|
+
CURSIVE_FONTS.any { name.startsWith(it, ignoreCase = true) } -> "cursive"
|
|
3846
|
+
name.contains("Narrow", true) || name.contains("Condensed", true) -> "sans-serif-condensed"
|
|
3847
|
+
// Calibri, Aptos, Arial, Helvetica, Segoe UI, Verdana, Tahoma, Wingdings, … all land here.
|
|
3848
|
+
else -> "sans-serif"
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
/** Typefaces are immutable and cheap to share, but `Typeface.create` is not free — cache them. */
|
|
3852
|
+
private val typefaceCache = HashMap<String, Typeface>()
|
|
3853
|
+
|
|
3854
|
+
@Synchronized
|
|
3855
|
+
private fun typefaceFor(family: String?, bold: Boolean, italic: Boolean): Typeface {
|
|
3856
|
+
val resolved = androidFamilyFor(family)
|
|
3857
|
+
val style = when {
|
|
3858
|
+
bold && italic -> Typeface.BOLD_ITALIC
|
|
3859
|
+
bold -> Typeface.BOLD
|
|
3860
|
+
italic -> Typeface.ITALIC
|
|
3861
|
+
else -> Typeface.NORMAL
|
|
3862
|
+
}
|
|
3863
|
+
return typefaceCache.getOrPut("$resolved#$style") { Typeface.create(resolved, style) }
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3866
|
+
/**
|
|
3867
|
+
* Float-precise replacement for `AbsoluteSizeSpan`, which takes an int px and truncates — a
|
|
3868
|
+
* 10.5pt run silently rendered at 10pt, and the error compounds once autofit scaling is applied.
|
|
3869
|
+
*/
|
|
3870
|
+
private class RunSizeSpan(private val px: Float) : MetricAffectingSpan() {
|
|
3871
|
+
override fun updateMeasureState(p: TextPaint) { p.textSize = px }
|
|
3872
|
+
override fun updateDrawState(p: TextPaint) { p.textSize = px }
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
/**
|
|
3876
|
+
* Applies a concrete [Typeface] to a run. `TypefaceSpan(Typeface)` would do this but is API 28
|
|
3877
|
+
* and this module's minSdk is 24.
|
|
3878
|
+
*/
|
|
3879
|
+
private class RunTypefaceSpan(private val typeface: Typeface) : MetricAffectingSpan() {
|
|
3880
|
+
override fun updateMeasureState(p: TextPaint) { p.typeface = typeface }
|
|
3881
|
+
override fun updateDrawState(p: TextPaint) { p.typeface = typeface }
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
private fun presetColor(name: String?): Int? = when (name) {
|
|
3885
|
+
"black" -> Color.BLACK
|
|
3886
|
+
"white" -> Color.WHITE
|
|
3887
|
+
"red" -> Color.RED
|
|
3888
|
+
"green" -> Color.GREEN
|
|
3889
|
+
"blue" -> Color.BLUE
|
|
3890
|
+
"yellow" -> Color.YELLOW
|
|
3891
|
+
"cyan" -> Color.CYAN
|
|
3892
|
+
"magenta" -> Color.MAGENTA
|
|
3893
|
+
else -> null
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
/**
|
|
3897
|
+
* Office 2007 theme palette, used when a deck's theme part is missing or unreadable.
|
|
3898
|
+
*
|
|
3899
|
+
* Deliberately contains ONLY the twelve real theme slots. The `tx1`/`bg1`/`tx2`/`bg2` aliases
|
|
3900
|
+
* are absent by design: they are clrMap indirections resolved by [ColorContext.scheme], and
|
|
3901
|
+
* seeding them here is exactly what used to make `schemeClr val="tx1"` short-circuit to
|
|
3902
|
+
* hardcoded black (rendering dark-themed decks as black-on-dark).
|
|
3903
|
+
*/
|
|
3904
|
+
private val DEFAULT_SCHEME_COLORS: Map<String, Int> = mapOf(
|
|
3905
|
+
"dk1" to 0xFF000000.toInt(),
|
|
3906
|
+
"lt1" to 0xFFFFFFFF.toInt(),
|
|
3907
|
+
"dk2" to 0xFF1F497D.toInt(),
|
|
3908
|
+
"lt2" to 0xFFEEECE1.toInt(),
|
|
3909
|
+
"accent1" to 0xFF4F81BD.toInt(),
|
|
3910
|
+
"accent2" to 0xFFC0504D.toInt(),
|
|
3911
|
+
"accent3" to 0xFF9BBB59.toInt(),
|
|
3912
|
+
"accent4" to 0xFF8064A2.toInt(),
|
|
3913
|
+
"accent5" to 0xFF4BACC6.toInt(),
|
|
3914
|
+
"accent6" to 0xFFF79646.toInt(),
|
|
3915
|
+
"hlink" to 0xFF0000FF.toInt(),
|
|
3916
|
+
"folHlink" to 0xFF800080.toInt()
|
|
3917
|
+
)
|
|
3918
|
+
|
|
3919
|
+
private const val BLACK_ARGB = 0xFF000000.toInt()
|
|
3920
|
+
|
|
3921
|
+
/**
|
|
3922
|
+
* Maps a `prstGeom` preset onto a drawable primitive.
|
|
3923
|
+
*
|
|
3924
|
+
* The important case is the LINE family. Only `straightConnector1` was recognised before, so every
|
|
3925
|
+
* other connector, arrow and callout leader was drawn as a fully filled RECTANGLE spanning its
|
|
3926
|
+
* bounding box — which is one of the ways decorative geometry ended up covering slide text.
|
|
3927
|
+
* Anything genuinely unknown still falls back to a rectangle, matching how PowerPoint fills a
|
|
3928
|
+
* closed shape.
|
|
3929
|
+
*/
|
|
3930
|
+
internal fun mapShapeType(value: String?): PowerPointShapeType = when {
|
|
3931
|
+
value == null -> PowerPointShapeType.RECT
|
|
3932
|
+
|
|
3933
|
+
value == "ellipse" || value == "oval" || value == "circle" || value == "teardrop" ||
|
|
3934
|
+
value == "cloud" || value == "flowChartConnector" || value == "flowChartOnlineStorage" ->
|
|
3935
|
+
PowerPointShapeType.ELLIPSE
|
|
3936
|
+
|
|
3937
|
+
value == "roundRect" || value == "round1Rect" || value == "round2SameRect" ||
|
|
3938
|
+
value == "round2DiagRect" || value == "snip1Rect" || value == "snip2SameRect" ||
|
|
3939
|
+
value == "snip2DiagRect" || value == "snipRoundRect" || value == "bevel" ||
|
|
3940
|
+
value == "flowChartTerminator" || value == "flowChartAlternateProcess" ||
|
|
3941
|
+
value.startsWith("wedgeRoundRectCallout") || value.startsWith("roundRectCallout") ->
|
|
3942
|
+
PowerPointShapeType.ROUND_RECT
|
|
3943
|
+
|
|
3944
|
+
value == "triangle" || value == "flowChartExtract" || value == "flowChartMerge" ->
|
|
3945
|
+
PowerPointShapeType.TRIANGLE
|
|
3946
|
+
value == "rtTriangle" -> PowerPointShapeType.RIGHT_TRIANGLE
|
|
3947
|
+
value == "diamond" || value == "flowChartDecision" -> PowerPointShapeType.DIAMOND
|
|
3948
|
+
value == "parallelogram" || value == "flowChartInputOutput" ->
|
|
3949
|
+
PowerPointShapeType.PARALLELOGRAM
|
|
3950
|
+
value == "trapezoid" || value == "flowChartManualOperation" -> PowerPointShapeType.TRAPEZOID
|
|
3951
|
+
|
|
3952
|
+
value == "pentagon" -> PowerPointShapeType.PENTAGON
|
|
3953
|
+
value == "hexagon" || value == "flowChartPreparation" -> PowerPointShapeType.HEXAGON
|
|
3954
|
+
value == "octagon" -> PowerPointShapeType.OCTAGON
|
|
3955
|
+
|
|
3956
|
+
value == "star4" -> PowerPointShapeType.STAR4
|
|
3957
|
+
value == "star5" -> PowerPointShapeType.STAR5
|
|
3958
|
+
value == "star6" -> PowerPointShapeType.STAR6
|
|
3959
|
+
value == "star7" || value == "star8" -> PowerPointShapeType.STAR8
|
|
3960
|
+
value == "star10" || value == "star12" -> PowerPointShapeType.STAR12
|
|
3961
|
+
value == "star16" -> PowerPointShapeType.STAR16
|
|
3962
|
+
value == "star24" -> PowerPointShapeType.STAR24
|
|
3963
|
+
value == "star32" -> PowerPointShapeType.STAR32
|
|
3964
|
+
|
|
3965
|
+
value == "rightArrow" || value == "stripedRightArrow" || value == "notchedRightArrow" ->
|
|
3966
|
+
PowerPointShapeType.ARROW_RIGHT
|
|
3967
|
+
value == "leftArrow" -> PowerPointShapeType.ARROW_LEFT
|
|
3968
|
+
value == "upArrow" -> PowerPointShapeType.ARROW_UP
|
|
3969
|
+
value == "downArrow" -> PowerPointShapeType.ARROW_DOWN
|
|
3970
|
+
value == "leftRightArrow" -> PowerPointShapeType.ARROW_LEFT_RIGHT
|
|
3971
|
+
value == "upDownArrow" -> PowerPointShapeType.ARROW_UP_DOWN
|
|
3972
|
+
|
|
3973
|
+
value == "chevron" -> PowerPointShapeType.CHEVRON
|
|
3974
|
+
value == "homePlate" -> PowerPointShapeType.HOME_PLATE
|
|
3975
|
+
value == "plus" || value == "mathPlus" -> PowerPointShapeType.PLUS
|
|
3976
|
+
value == "can" || value == "flowChartMagneticDrum" -> PowerPointShapeType.CAN
|
|
3977
|
+
value == "donut" -> PowerPointShapeType.DONUT
|
|
3978
|
+
value == "frame" || value == "halfFrame" -> PowerPointShapeType.FRAME
|
|
3979
|
+
|
|
3980
|
+
// Every connector/line/arrow variant: straight, bent, curved.
|
|
3981
|
+
value == "line" || value.startsWith("straightConnector") ||
|
|
3982
|
+
value.startsWith("bentConnector") || value.startsWith("curvedConnector") ||
|
|
3983
|
+
value.startsWith("lineInv") || value == "bentLine" -> PowerPointShapeType.LINE
|
|
3984
|
+
|
|
3985
|
+
else -> PowerPointShapeType.RECT
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
/**
|
|
3989
|
+
* `<a:buAutoNum>` -> the literal marker for the paragraph.
|
|
3990
|
+
*
|
|
3991
|
+
* Every numbered list previously rendered as a hardcoded "1.", ignoring the format and the start
|
|
3992
|
+
* value. Reuses [Ox.formatOrdinal], already shared with the DOCX/ODF numbering path.
|
|
3993
|
+
*/
|
|
3994
|
+
internal fun autoNumberMarker(type: String?, startAt: Int): String {
|
|
3995
|
+
val n = startAt.coerceAtLeast(1)
|
|
3996
|
+
val fmt = when {
|
|
3997
|
+
type == null -> "arabic"
|
|
3998
|
+
type.startsWith("alphaLc") -> "lowerLetter"
|
|
3999
|
+
type.startsWith("alphaUc") -> "upperLetter"
|
|
4000
|
+
type.startsWith("romanLc") -> "lowerRoman"
|
|
4001
|
+
type.startsWith("romanUc") -> "upperRoman"
|
|
4002
|
+
else -> "arabic"
|
|
4003
|
+
}
|
|
4004
|
+
val body = Ox.formatOrdinal(n, fmt)
|
|
4005
|
+
return when {
|
|
4006
|
+
type == null -> "$body."
|
|
4007
|
+
type.endsWith("ParenBoth") -> "($body)"
|
|
4008
|
+
type.endsWith("ParenR") -> "$body)"
|
|
4009
|
+
type.endsWith("Period") -> "$body."
|
|
4010
|
+
type.endsWith("Plain") -> body
|
|
4011
|
+
else -> "$body."
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
private fun normalizeBullet(value: String?): String =
|
|
4016
|
+
when {
|
|
4017
|
+
value.isNullOrBlank() -> BULLET
|
|
4018
|
+
value.first().code >= 0xE000 || value.first().code == 0xF0B7 -> BULLET
|
|
4019
|
+
else -> value
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
private fun Long.toIntSafe(): Int =
|
|
4023
|
+
when {
|
|
4024
|
+
this < 0L -> 0
|
|
4025
|
+
this > Int.MAX_VALUE -> Int.MAX_VALUE
|
|
4026
|
+
else -> toInt()
|
|
4027
|
+
}
|
|
4028
|
+
|
|
4029
|
+
private const val DEFAULT_SLIDE_WIDTH_PT = 720f
|
|
4030
|
+
private const val DEFAULT_SLIDE_HEIGHT_PT = 540f
|
|
4031
|
+
private const val BULLET = "\u2022"
|
|
4032
|
+
|
|
4033
|
+
/** Fallback theme part, used only when the presentation \u2192 master \u2192 theme chain can't be walked. */
|
|
4034
|
+
private const val DEFAULT_THEME_PART = "ppt/theme/theme1.xml"
|
|
4035
|
+
|
|
4036
|
+
/** Backstop against pathological (or hostile) `<p:grpSp>` nesting; real decks are 2-3 deep. */
|
|
4037
|
+
private const val MAX_GROUP_NESTING = 16
|
|
4038
|
+
|
|
4039
|
+
/**
|
|
4040
|
+
* `<mc:Choice Requires="...">` namespaces whose content is ordinary DrawingML plus cosmetic
|
|
4041
|
+
* extensions, so taking the Choice loses nothing. Anything else (SmartArt's `dsp`, Word shapes'
|
|
4042
|
+
* `wps`/`wpg`, ink, \u2026) falls through to the `<mc:Fallback>`, which is plain `p:`/`a:` markup.
|
|
4043
|
+
*/
|
|
4044
|
+
private val RENDERABLE_CHOICE_NS = setOf("a14", "p14", "a15", "p15", "p16", "pm", "com", "v")
|
|
4045
|
+
|
|
4046
|
+
// OOXML's own defaults for text that specifies no size anywhere in the inheritance chain. The
|
|
4047
|
+
// previous renderer used a flat 14pt for everything, which made every deck look uniformly small.
|
|
4048
|
+
private const val DEFAULT_BODY_SIZE_PT = 18f
|
|
4049
|
+
private const val DEFAULT_TITLE_SIZE_PT = 44f
|
|
4050
|
+
|
|
4051
|
+
/**
|
|
4052
|
+
* PowerPoint's "single" line spacing is 1.2x the font size, whereas Android's default line height
|
|
4053
|
+
* is the font's own ascent+descent+leading (~1.17em for Roboto). One tunable constant rather than
|
|
4054
|
+
* a fudge factor scattered through the layout code.
|
|
4055
|
+
*/
|
|
4056
|
+
private const val PPT_SINGLE_LINE_FACTOR = 1.2f
|
|
4057
|
+
|
|
4058
|
+
/**
|
|
4059
|
+
* Hanging indent used when neither the paragraph nor its inherited list style supplies `marL`.
|
|
4060
|
+
* Matches the DOCX preview's list indent so both previews look like the same product.
|
|
4061
|
+
*/
|
|
4062
|
+
private const val DEFAULT_BULLET_INDENT_PT = 18f
|
|
4063
|
+
|
|
4064
|
+
/** Neutral hairline for table edges the deck didn't specify, so the grid stays legible. */
|
|
4065
|
+
private val TABLE_DEFAULT_BORDER = 0xFFB4BECD.toInt()
|
|
4066
|
+
private const val PPT_RECORD_SLIDE_CONTAINER = 1006
|
|
4067
|
+
private const val PPT_RECORD_TEXT_CHARS = 4000
|
|
4068
|
+
private const val PPT_RECORD_TEXT_BYTES = 4008
|
|
4069
|
+
private const val PPT_RECORD_CSTRING = 4026
|
|
4070
|
+
private const val END_OF_CHAIN = -2
|
|
4071
|
+
|
|
4072
|
+
private val OLE_SIG = byteArrayOf(0xD0.toByte(), 0xCF.toByte(), 0x11, 0xE0.toByte(), 0xA1.toByte(), 0xB1.toByte(), 0x1A, 0xE1.toByte())
|
|
4073
|
+
private val PNG_SIG = byteArrayOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte(), 0x0D, 0x0A, 0x1A, 0x0A)
|
|
4074
|
+
private val GIF87_SIG = "GIF87a".toByteArray(Charsets.US_ASCII)
|
|
4075
|
+
private val GIF89_SIG = "GIF89a".toByteArray(Charsets.US_ASCII)
|
|
4076
|
+
private val THEME_COLOR_KEYS = setOf("dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink")
|
|
4077
|
+
private val LVL_STYLE_REGEX = Regex("lvl(\\d)pPr")
|