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.
Files changed (506) hide show
  1. package/CustomCamera.podspec +20 -0
  2. package/LICENSE +20 -0
  3. package/README.md +833 -0
  4. package/android/build.gradle +135 -0
  5. package/android/consumer-rules.pro +47 -0
  6. package/android/src/main/AndroidManifest.xml +72 -0
  7. package/android/src/main/java/android/print/PdfPrint.java +51 -0
  8. package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
  9. package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
  10. package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
  11. package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
  12. package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
  13. package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
  14. package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
  15. package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
  16. package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
  17. package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
  18. package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
  19. package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
  20. package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
  21. package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
  22. package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
  23. package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
  24. package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
  25. package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
  26. package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
  27. package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
  28. package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
  29. package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
  30. package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
  31. package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
  32. package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
  33. package/android/src/main/java/com/customcamera/Localization.kt +206 -0
  34. package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
  35. package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
  36. package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
  37. package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
  38. package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
  39. package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
  40. package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
  41. package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
  42. package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
  43. package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
  44. package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
  45. package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
  46. package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
  47. package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
  48. package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
  49. package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
  50. package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
  51. package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
  52. package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
  53. package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
  54. package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
  55. package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
  56. package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
  57. package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
  58. package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
  59. package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
  60. package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
  61. package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
  62. package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
  63. package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
  64. package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
  65. package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
  66. package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
  67. package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
  68. package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
  69. package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
  70. package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
  71. package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
  72. package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
  73. package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
  74. package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
  75. package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
  76. package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
  77. package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
  78. package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
  79. package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
  80. package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
  81. package/android/src/main/res/drawable/bg_pill.xml +6 -0
  82. package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
  83. package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
  84. package/android/src/main/res/drawable/bg_send_button.xml +5 -0
  85. package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
  86. package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
  87. package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
  88. package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
  89. package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
  90. package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
  91. package/android/src/main/res/drawable/ic_audio.xml +10 -0
  92. package/android/src/main/res/drawable/ic_back.xml +10 -0
  93. package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
  94. package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
  95. package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
  96. package/android/src/main/res/drawable/ic_check.xml +17 -0
  97. package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
  98. package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
  99. package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
  100. package/android/src/main/res/drawable/ic_close.xml +10 -0
  101. package/android/src/main/res/drawable/ic_cover.xml +9 -0
  102. package/android/src/main/res/drawable/ic_crop.xml +10 -0
  103. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  104. package/android/src/main/res/drawable/ic_document.xml +10 -0
  105. package/android/src/main/res/drawable/ic_done.xml +10 -0
  106. package/android/src/main/res/drawable/ic_download.xml +10 -0
  107. package/android/src/main/res/drawable/ic_emoji.xml +10 -0
  108. package/android/src/main/res/drawable/ic_filter.xml +10 -0
  109. package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
  110. package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
  111. package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
  112. package/android/src/main/res/drawable/ic_flip.xml +15 -0
  113. package/android/src/main/res/drawable/ic_gallery.xml +10 -0
  114. package/android/src/main/res/drawable/ic_gif.xml +24 -0
  115. package/android/src/main/res/drawable/ic_lock.xml +19 -0
  116. package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
  117. package/android/src/main/res/drawable/ic_marker.xml +10 -0
  118. package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
  119. package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
  120. package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
  121. package/android/src/main/res/drawable/ic_pause.xml +10 -0
  122. package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
  123. package/android/src/main/res/drawable/ic_play.xml +10 -0
  124. package/android/src/main/res/drawable/ic_redo.xml +10 -0
  125. package/android/src/main/res/drawable/ic_rotate.xml +9 -0
  126. package/android/src/main/res/drawable/ic_send.xml +10 -0
  127. package/android/src/main/res/drawable/ic_share.xml +10 -0
  128. package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
  129. package/android/src/main/res/drawable/ic_sticker.xml +24 -0
  130. package/android/src/main/res/drawable/ic_text.xml +10 -0
  131. package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
  132. package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
  133. package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
  134. package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
  135. package/android/src/main/res/drawable/ic_trim.xml +23 -0
  136. package/android/src/main/res/drawable/ic_undo.xml +10 -0
  137. package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
  138. package/android/src/main/res/drawable/ic_video_note.xml +13 -0
  139. package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
  140. package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
  141. package/android/src/main/res/drawable/sticker_heart.xml +9 -0
  142. package/android/src/main/res/drawable/sticker_smile.xml +9 -0
  143. package/android/src/main/res/drawable/sticker_star.xml +9 -0
  144. package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
  145. package/android/src/main/res/values/colors.xml +34 -0
  146. package/android/src/main/res/values/ids.xml +15 -0
  147. package/android/src/main/res/values/strings.xml +15 -0
  148. package/android/src/main/res/values/styles.xml +20 -0
  149. package/android/src/main/res/xml/file_paths.xml +34 -0
  150. package/ios/CustomCamera.h +5 -0
  151. package/ios/CustomCamera.mm +214 -0
  152. package/lib/module/AudioPlayerContext.js +233 -0
  153. package/lib/module/AudioPlayerContext.js.map +1 -0
  154. package/lib/module/CameraPreview.js +304 -0
  155. package/lib/module/CameraPreview.js.map +1 -0
  156. package/lib/module/NativeAnimatedImageView.js +41 -0
  157. package/lib/module/NativeAnimatedImageView.js.map +1 -0
  158. package/lib/module/NativeAudioPlayerView.js +33 -0
  159. package/lib/module/NativeAudioPlayerView.js.map +1 -0
  160. package/lib/module/NativeBlurView.js +35 -0
  161. package/lib/module/NativeBlurView.js.map +1 -0
  162. package/lib/module/NativeCustomCamera.js +128 -0
  163. package/lib/module/NativeCustomCamera.js.map +1 -0
  164. package/lib/module/NativeCustomCamera.web.js +143 -0
  165. package/lib/module/NativeCustomCamera.web.js.map +1 -0
  166. package/lib/module/NativeCustomVideoView.js +5 -0
  167. package/lib/module/NativeCustomVideoView.js.map +1 -0
  168. package/lib/module/NativeDocumentPreviewView.js +156 -0
  169. package/lib/module/NativeDocumentPreviewView.js.map +1 -0
  170. package/lib/module/NativeMediaThumbnailView.js +110 -0
  171. package/lib/module/NativeMediaThumbnailView.js.map +1 -0
  172. package/lib/module/NativeSvgIcon.js +32 -0
  173. package/lib/module/NativeSvgIcon.js.map +1 -0
  174. package/lib/module/ResponsivePreviewContainer.js +171 -0
  175. package/lib/module/ResponsivePreviewContainer.js.map +1 -0
  176. package/lib/module/accessibility.js +61 -0
  177. package/lib/module/accessibility.js.map +1 -0
  178. package/lib/module/config.js +352 -0
  179. package/lib/module/config.js.map +1 -0
  180. package/lib/module/cpuOptimization.js +296 -0
  181. package/lib/module/cpuOptimization.js.map +1 -0
  182. package/lib/module/db/DatabaseManager.js +191 -0
  183. package/lib/module/db/DatabaseManager.js.map +1 -0
  184. package/lib/module/db/MediaMapper.js +284 -0
  185. package/lib/module/db/MediaMapper.js.map +1 -0
  186. package/lib/module/db/MediaRepository.js +242 -0
  187. package/lib/module/db/MediaRepository.js.map +1 -0
  188. package/lib/module/db/index.js +41 -0
  189. package/lib/module/db/index.js.map +1 -0
  190. package/lib/module/db/migrations.js +180 -0
  191. package/lib/module/db/migrations.js.map +1 -0
  192. package/lib/module/db/react-native-sqlcipher.d.js +2 -0
  193. package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
  194. package/lib/module/db/secureKey.js +87 -0
  195. package/lib/module/db/secureKey.js.map +1 -0
  196. package/lib/module/db/types.js +57 -0
  197. package/lib/module/db/types.js.map +1 -0
  198. package/lib/module/db/useMediaDatabase.js +123 -0
  199. package/lib/module/db/useMediaDatabase.js.map +1 -0
  200. package/lib/module/defaultIcons.js +2259 -0
  201. package/lib/module/defaultIcons.js.map +1 -0
  202. package/lib/module/deviceCapabilities.js +408 -0
  203. package/lib/module/deviceCapabilities.js.map +1 -0
  204. package/lib/module/errors.js +267 -0
  205. package/lib/module/errors.js.map +1 -0
  206. package/lib/module/iconConfig.js +796 -0
  207. package/lib/module/iconConfig.js.map +1 -0
  208. package/lib/module/icons.js +514 -0
  209. package/lib/module/icons.js.map +1 -0
  210. package/lib/module/index.js +251 -0
  211. package/lib/module/index.js.map +1 -0
  212. package/lib/module/jest-globals.d.js +2 -0
  213. package/lib/module/jest-globals.d.js.map +1 -0
  214. package/lib/module/library/MediaLibrary.js +107 -0
  215. package/lib/module/library/MediaLibrary.js.map +1 -0
  216. package/lib/module/library/MediaLibraryScreen.js +2597 -0
  217. package/lib/module/library/MediaLibraryScreen.js.map +1 -0
  218. package/lib/module/library/components/AudioPreview.js +74 -0
  219. package/lib/module/library/components/AudioPreview.js.map +1 -0
  220. package/lib/module/library/components/DocumentPreview.js +331 -0
  221. package/lib/module/library/components/DocumentPreview.js.map +1 -0
  222. package/lib/module/library/components/ExpandableFab.js +343 -0
  223. package/lib/module/library/components/ExpandableFab.js.map +1 -0
  224. package/lib/module/library/components/FileTiles.js +251 -0
  225. package/lib/module/library/components/FileTiles.js.map +1 -0
  226. package/lib/module/library/components/ImagePreview.js +70 -0
  227. package/lib/module/library/components/ImagePreview.js.map +1 -0
  228. package/lib/module/library/components/MediaMetadataHeader.js +175 -0
  229. package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
  230. package/lib/module/library/components/RecordAudioScreen.js +816 -0
  231. package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
  232. package/lib/module/library/components/VideoPreview.js +453 -0
  233. package/lib/module/library/components/VideoPreview.js.map +1 -0
  234. package/lib/module/library/components/ViewerCounter.js +55 -0
  235. package/lib/module/library/components/ViewerCounter.js.map +1 -0
  236. package/lib/module/library/compressionEstimate.js +507 -0
  237. package/lib/module/library/compressionEstimate.js.map +1 -0
  238. package/lib/module/library/fileMetadata.js +196 -0
  239. package/lib/module/library/fileMetadata.js.map +1 -0
  240. package/lib/module/library/index.js +42 -0
  241. package/lib/module/library/index.js.map +1 -0
  242. package/lib/module/library/libraryIconRegistry.js +69 -0
  243. package/lib/module/library/libraryIconRegistry.js.map +1 -0
  244. package/lib/module/library/libraryIcons.js +858 -0
  245. package/lib/module/library/libraryIcons.js.map +1 -0
  246. package/lib/module/library/localizedFormat.js +181 -0
  247. package/lib/module/library/localizedFormat.js.map +1 -0
  248. package/lib/module/library/palette.js +225 -0
  249. package/lib/module/library/palette.js.map +1 -0
  250. package/lib/module/library/responsive.js +53 -0
  251. package/lib/module/library/responsive.js.map +1 -0
  252. package/lib/module/locales.js +416 -0
  253. package/lib/module/locales.js.map +1 -0
  254. package/lib/module/localization.js +1064 -0
  255. package/lib/module/localization.js.map +1 -0
  256. package/lib/module/logger.js +20 -0
  257. package/lib/module/logger.js.map +1 -0
  258. package/lib/module/lowMemoryCache.js +206 -0
  259. package/lib/module/lowMemoryCache.js.map +1 -0
  260. package/lib/module/mediaHistory.js +33 -0
  261. package/lib/module/mediaHistory.js.map +1 -0
  262. package/lib/module/mediaStorageService.js +402 -0
  263. package/lib/module/mediaStorageService.js.map +1 -0
  264. package/lib/module/memory.js +79 -0
  265. package/lib/module/memory.js.map +1 -0
  266. package/lib/module/nativeActions.js +307 -0
  267. package/lib/module/nativeActions.js.map +1 -0
  268. package/lib/module/nativeComponentSupport.js +61 -0
  269. package/lib/module/nativeComponentSupport.js.map +1 -0
  270. package/lib/module/networkPerformance.js +24 -0
  271. package/lib/module/networkPerformance.js.map +1 -0
  272. package/lib/module/package.json +1 -0
  273. package/lib/module/performance.js +104 -0
  274. package/lib/module/performance.js.map +1 -0
  275. package/lib/module/performanceMonitor.js +155 -0
  276. package/lib/module/performanceMonitor.js.map +1 -0
  277. package/lib/module/permissions.js +370 -0
  278. package/lib/module/permissions.js.map +1 -0
  279. package/lib/module/rendering.js +40 -0
  280. package/lib/module/rendering.js.map +1 -0
  281. package/lib/module/security.js +18 -0
  282. package/lib/module/security.js.map +1 -0
  283. package/lib/module/state.js +266 -0
  284. package/lib/module/state.js.map +1 -0
  285. package/lib/module/storage.js +400 -0
  286. package/lib/module/storage.js.map +1 -0
  287. package/lib/module/theme.js +390 -0
  288. package/lib/module/theme.js.map +1 -0
  289. package/lib/module/types.js +4 -0
  290. package/lib/module/types.js.map +1 -0
  291. package/lib/module/utils.js +115 -0
  292. package/lib/module/utils.js.map +1 -0
  293. package/lib/module/validation.js +288 -0
  294. package/lib/module/validation.js.map +1 -0
  295. package/lib/typescript/package.json +1 -0
  296. package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
  297. package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
  298. package/lib/typescript/src/CameraPreview.d.ts +13 -0
  299. package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
  300. package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
  301. package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
  302. package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
  303. package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
  304. package/lib/typescript/src/NativeBlurView.d.ts +12 -0
  305. package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
  306. package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
  307. package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
  308. package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
  309. package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
  310. package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
  311. package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
  312. package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
  313. package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
  314. package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
  315. package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
  316. package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
  317. package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
  318. package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
  319. package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
  320. package/lib/typescript/src/accessibility.d.ts +20 -0
  321. package/lib/typescript/src/accessibility.d.ts.map +1 -0
  322. package/lib/typescript/src/config.d.ts +145 -0
  323. package/lib/typescript/src/config.d.ts.map +1 -0
  324. package/lib/typescript/src/cpuOptimization.d.ts +63 -0
  325. package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
  326. package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
  327. package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
  328. package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
  329. package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
  330. package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
  331. package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
  332. package/lib/typescript/src/db/index.d.ts +23 -0
  333. package/lib/typescript/src/db/index.d.ts.map +1 -0
  334. package/lib/typescript/src/db/migrations.d.ts +31 -0
  335. package/lib/typescript/src/db/migrations.d.ts.map +1 -0
  336. package/lib/typescript/src/db/secureKey.d.ts +10 -0
  337. package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
  338. package/lib/typescript/src/db/types.d.ts +115 -0
  339. package/lib/typescript/src/db/types.d.ts.map +1 -0
  340. package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
  341. package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
  342. package/lib/typescript/src/defaultIcons.d.ts +139 -0
  343. package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
  344. package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
  345. package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
  346. package/lib/typescript/src/errors.d.ts +95 -0
  347. package/lib/typescript/src/errors.d.ts.map +1 -0
  348. package/lib/typescript/src/iconConfig.d.ts +176 -0
  349. package/lib/typescript/src/iconConfig.d.ts.map +1 -0
  350. package/lib/typescript/src/icons.d.ts +217 -0
  351. package/lib/typescript/src/icons.d.ts.map +1 -0
  352. package/lib/typescript/src/index.d.ts +104 -0
  353. package/lib/typescript/src/index.d.ts.map +1 -0
  354. package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
  355. package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
  356. package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
  357. package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
  358. package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
  359. package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
  360. package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
  361. package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
  362. package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
  363. package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
  364. package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
  365. package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
  366. package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
  367. package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
  368. package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
  369. package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
  370. package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
  371. package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
  372. package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
  373. package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
  374. package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
  375. package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
  376. package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
  377. package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
  378. package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
  379. package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
  380. package/lib/typescript/src/library/index.d.ts +40 -0
  381. package/lib/typescript/src/library/index.d.ts.map +1 -0
  382. package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
  383. package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
  384. package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
  385. package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
  386. package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
  387. package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
  388. package/lib/typescript/src/library/palette.d.ts +104 -0
  389. package/lib/typescript/src/library/palette.d.ts.map +1 -0
  390. package/lib/typescript/src/library/responsive.d.ts +15 -0
  391. package/lib/typescript/src/library/responsive.d.ts.map +1 -0
  392. package/lib/typescript/src/locales.d.ts +103 -0
  393. package/lib/typescript/src/locales.d.ts.map +1 -0
  394. package/lib/typescript/src/localization.d.ts +1051 -0
  395. package/lib/typescript/src/localization.d.ts.map +1 -0
  396. package/lib/typescript/src/logger.d.ts +4 -0
  397. package/lib/typescript/src/logger.d.ts.map +1 -0
  398. package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
  399. package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
  400. package/lib/typescript/src/mediaHistory.d.ts +3 -0
  401. package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
  402. package/lib/typescript/src/mediaStorageService.d.ts +155 -0
  403. package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
  404. package/lib/typescript/src/memory.d.ts +21 -0
  405. package/lib/typescript/src/memory.d.ts.map +1 -0
  406. package/lib/typescript/src/nativeActions.d.ts +174 -0
  407. package/lib/typescript/src/nativeActions.d.ts.map +1 -0
  408. package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
  409. package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
  410. package/lib/typescript/src/networkPerformance.d.ts +12 -0
  411. package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
  412. package/lib/typescript/src/performance.d.ts +32 -0
  413. package/lib/typescript/src/performance.d.ts.map +1 -0
  414. package/lib/typescript/src/performanceMonitor.d.ts +45 -0
  415. package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
  416. package/lib/typescript/src/permissions.d.ts +132 -0
  417. package/lib/typescript/src/permissions.d.ts.map +1 -0
  418. package/lib/typescript/src/rendering.d.ts +9 -0
  419. package/lib/typescript/src/rendering.d.ts.map +1 -0
  420. package/lib/typescript/src/security.d.ts +6 -0
  421. package/lib/typescript/src/security.d.ts.map +1 -0
  422. package/lib/typescript/src/state.d.ts +102 -0
  423. package/lib/typescript/src/state.d.ts.map +1 -0
  424. package/lib/typescript/src/storage.d.ts +117 -0
  425. package/lib/typescript/src/storage.d.ts.map +1 -0
  426. package/lib/typescript/src/theme.d.ts +15 -0
  427. package/lib/typescript/src/theme.d.ts.map +1 -0
  428. package/lib/typescript/src/types.d.ts +802 -0
  429. package/lib/typescript/src/types.d.ts.map +1 -0
  430. package/lib/typescript/src/utils.d.ts +50 -0
  431. package/lib/typescript/src/utils.d.ts.map +1 -0
  432. package/lib/typescript/src/validation.d.ts +52 -0
  433. package/lib/typescript/src/validation.d.ts.map +1 -0
  434. package/package.json +204 -0
  435. package/react-native.config.js +17 -0
  436. package/src/AudioPlayerContext.tsx +220 -0
  437. package/src/CameraPreview.tsx +352 -0
  438. package/src/NativeAnimatedImageView.tsx +71 -0
  439. package/src/NativeAudioPlayerView.tsx +39 -0
  440. package/src/NativeBlurView.tsx +40 -0
  441. package/src/NativeCustomCamera.ts +305 -0
  442. package/src/NativeCustomCamera.web.ts +192 -0
  443. package/src/NativeCustomVideoView.ts +30 -0
  444. package/src/NativeDocumentPreviewView.tsx +196 -0
  445. package/src/NativeMediaThumbnailView.tsx +167 -0
  446. package/src/NativeSvgIcon.tsx +46 -0
  447. package/src/ResponsivePreviewContainer.tsx +227 -0
  448. package/src/accessibility.ts +80 -0
  449. package/src/config.ts +406 -0
  450. package/src/cpuOptimization.ts +365 -0
  451. package/src/db/DatabaseManager.ts +212 -0
  452. package/src/db/MediaMapper.ts +377 -0
  453. package/src/db/MediaRepository.ts +313 -0
  454. package/src/db/index.ts +73 -0
  455. package/src/db/migrations.ts +222 -0
  456. package/src/db/react-native-sqlcipher.d.ts +76 -0
  457. package/src/db/secureKey.ts +100 -0
  458. package/src/db/types.ts +123 -0
  459. package/src/db/useMediaDatabase.ts +191 -0
  460. package/src/defaultIcons.tsx +2390 -0
  461. package/src/deviceCapabilities.ts +438 -0
  462. package/src/errors.ts +350 -0
  463. package/src/iconConfig.tsx +962 -0
  464. package/src/icons.tsx +788 -0
  465. package/src/index.tsx +763 -0
  466. package/src/jest-globals.d.ts +9 -0
  467. package/src/library/MediaLibrary.tsx +141 -0
  468. package/src/library/MediaLibraryScreen.tsx +3003 -0
  469. package/src/library/components/AudioPreview.tsx +73 -0
  470. package/src/library/components/DocumentPreview.tsx +361 -0
  471. package/src/library/components/ExpandableFab.tsx +342 -0
  472. package/src/library/components/FileTiles.tsx +252 -0
  473. package/src/library/components/ImagePreview.tsx +72 -0
  474. package/src/library/components/MediaMetadataHeader.tsx +208 -0
  475. package/src/library/components/RecordAudioScreen.tsx +845 -0
  476. package/src/library/components/VideoPreview.tsx +478 -0
  477. package/src/library/components/ViewerCounter.tsx +71 -0
  478. package/src/library/compressionEstimate.ts +662 -0
  479. package/src/library/fileMetadata.ts +212 -0
  480. package/src/library/index.ts +66 -0
  481. package/src/library/libraryIconRegistry.tsx +86 -0
  482. package/src/library/libraryIcons.tsx +928 -0
  483. package/src/library/localizedFormat.ts +207 -0
  484. package/src/library/palette.ts +281 -0
  485. package/src/library/responsive.ts +49 -0
  486. package/src/locales.ts +381 -0
  487. package/src/localization.ts +1947 -0
  488. package/src/logger.ts +20 -0
  489. package/src/lowMemoryCache.ts +218 -0
  490. package/src/mediaHistory.ts +42 -0
  491. package/src/mediaStorageService.ts +546 -0
  492. package/src/memory.ts +102 -0
  493. package/src/nativeActions.ts +422 -0
  494. package/src/nativeComponentSupport.ts +60 -0
  495. package/src/networkPerformance.ts +30 -0
  496. package/src/performance.ts +137 -0
  497. package/src/performanceMonitor.ts +219 -0
  498. package/src/permissions.ts +403 -0
  499. package/src/rendering.ts +47 -0
  500. package/src/security.ts +19 -0
  501. package/src/state.ts +333 -0
  502. package/src/storage.ts +453 -0
  503. package/src/theme.tsx +467 -0
  504. package/src/types.ts +946 -0
  505. package/src/utils.ts +177 -0
  506. 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")