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,2791 @@
1
+ package com.customcamera
2
+
3
+ import android.util.Base64
4
+ import android.util.Xml
5
+ import org.xmlpull.v1.XmlPullParser
6
+ import java.io.File
7
+ import java.io.InputStream
8
+ import java.util.zip.ZipFile
9
+
10
+ /**
11
+ * Native, dependency-free office-document → HTML conversion pipeline (the "reader/extractor/
12
+ * formatter" of the in-app preview). Each converter parses the format's REAL internal structure
13
+ * (OOXML / ODF XML inside the zip container, RTF control words, CSV records) with only Android
14
+ * SDK primitives — java.util.zip + XmlPullParser + Base64 — and emits fidelity-focused HTML/CSS
15
+ * that the locked preview WebView (JavaScript off, network blocked, navigation blocked) lays out
16
+ * with its Chromium engine.
17
+ *
18
+ * Fidelity model: the ORIGINAL geometry travels through — page width/height/margins (DOCX/ODT,
19
+ * from the section properties, in true points), slide dimensions and absolute shape positions
20
+ * (PPTX, from EMUs), column widths / row heights / merged regions (XLSX/ODS), font names/sizes/
21
+ * colors, alignment, indents, line/paragraph spacing, table grids, borders, shading, and images
22
+ * at their authored display size (inlined as data URIs so the locked WebView needs no file
23
+ * access). Where the source font isn't installed on the device, the ORIGINAL family name is
24
+ * emitted first so an equivalent installed face is used before falling back per CSS.
25
+ *
26
+ * Honest limits (engine-level, not fixable by parsing): automatic pagination (where Word breaks
27
+ * a page mid-flow) and glyph-exact line breaking depend on the original layout engine + exact
28
+ * font metrics, so continuous content renders as one flowing sheet per EXPLICIT page break with
29
+ * the true page width and margins. Binary-era DOC/PPT/XLS and Apple PAGES are proprietary binary
30
+ * formats that require a full engine (LibreOffice-class) to read — they are not handled here and
31
+ * keep the informational-card fallback.
32
+ */
33
+ internal object OfficeDocumentConverter {
34
+
35
+ class Conversion(val html: String, val pageCount: Int)
36
+
37
+ /** [kind] is the preview kind routed from JS (docx/xlsx/odt/ods/rtf/csv/epub). */
38
+ fun convert(file: File, kind: String): Conversion = when (kind) {
39
+ "docx" -> DocxConverter(file).convert()
40
+ "xlsx" -> XlsxConverter(file).convert()
41
+ "odt" -> OdtConverter(file).convert()
42
+ "ods" -> OdsConverter(file).convert()
43
+ "rtf" -> RtfConverter(file).convert()
44
+ "csv" -> CsvConverter(file).convert()
45
+ "epub" -> EpubConverter(file).convert()
46
+ else -> throw IllegalArgumentException("Unsupported office kind: $kind")
47
+ }
48
+ }
49
+
50
+ /** Shared parsing/formatting helpers for every office converter. */
51
+ internal object Ox {
52
+ /** Per-image and total caps keep data-URI inlining bounded on low-RAM devices. */
53
+ const val MAX_IMAGE_BYTES = 4 * 1024 * 1024
54
+ const val MAX_TOTAL_IMAGE_BYTES = 24 * 1024 * 1024
55
+
56
+ /**
57
+ * Cap on the DISPLAYED page margins (DOCX/ODT/RTF). Authored one-inch print margins read
58
+ * as huge blank bands around a phone-scaled page. Converters shrink the sheet by exactly
59
+ * the amount the margins shrink, so the CONTENT box keeps its authored width — identical
60
+ * line lengths and wrapping — while the whole layout scales larger on screen.
61
+ */
62
+ const val MAX_DISPLAY_MARGIN_PT = 20f
63
+
64
+ fun esc(s: String): String = buildString(s.length + 16) {
65
+ for (c in s) when (c) {
66
+ '&' -> append("&")
67
+ '<' -> append("&lt;")
68
+ '>' -> append("&gt;")
69
+ '"' -> append("&quot;")
70
+ else -> append(c)
71
+ }
72
+ }
73
+
74
+ fun parser(input: InputStream): XmlPullParser =
75
+ Xml.newPullParser().apply {
76
+ // Namespace prefixes are matched by local name (see [local]) — cheaper and resilient to
77
+ // producers that bind non-standard prefixes.
78
+ setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
79
+ setInput(input, null)
80
+ }
81
+
82
+ fun local(p: XmlPullParser): String = p.name?.substringAfterLast(':') ?: ""
83
+
84
+ fun attr(p: XmlPullParser, name: String): String? {
85
+ for (i in 0 until p.attributeCount) {
86
+ val an = p.getAttributeName(i)
87
+ if (an == name || an.substringAfterLast(':') == name) return p.getAttributeValue(i)
88
+ }
89
+ return null
90
+ }
91
+
92
+ /** Skips the current element including all children (parser positioned ON its START_TAG). */
93
+ fun skip(p: XmlPullParser) {
94
+ var depth = 1
95
+ while (depth > 0) {
96
+ when (p.next()) {
97
+ XmlPullParser.START_TAG -> depth++
98
+ XmlPullParser.END_TAG -> depth--
99
+ XmlPullParser.END_DOCUMENT -> return
100
+ }
101
+ }
102
+ }
103
+
104
+ /** All character data inside the current element (which may contain child tags to flatten). */
105
+ fun elementText(p: XmlPullParser): String {
106
+ val sb = StringBuilder()
107
+ var depth = 1
108
+ while (depth > 0) {
109
+ when (p.next()) {
110
+ XmlPullParser.START_TAG -> depth++
111
+ XmlPullParser.END_TAG -> depth--
112
+ XmlPullParser.TEXT -> sb.append(p.text)
113
+ XmlPullParser.END_DOCUMENT -> break
114
+ }
115
+ }
116
+ return sb.toString()
117
+ }
118
+
119
+ // ── Units ─────────────────────────────────────────────────────────
120
+ fun twipsToPt(v: String?): Float? = v?.toFloatOrNull()?.let { it / 20f }
121
+ fun halfPtToPt(v: String?): Float? = v?.toFloatOrNull()?.let { it / 2f }
122
+ fun emuToPt(v: String?): Float? = v?.toLongOrNull()?.let { it / 12700f }
123
+
124
+ /** Compact CSS number: 12 → "12", 12.5 → "12.5". */
125
+ fun n(v: Float): String =
126
+ if (v == v.toLong().toFloat()) v.toLong().toString() else String.format("%.2f", v)
127
+
128
+ // ── Zip access ────────────────────────────────────────────────────
129
+ fun zipBytes(zip: ZipFile, path: String, cap: Int = Int.MAX_VALUE): ByteArray? {
130
+ val entry = zip.getEntry(path) ?: return null
131
+ if (entry.size in 1..Long.MAX_VALUE && entry.size > cap) return null
132
+ zip.getInputStream(entry).use { stream ->
133
+ val out = java.io.ByteArrayOutputStream(minOf(entry.size.toInt().coerceAtLeast(1024), 1 shl 20))
134
+ val buf = ByteArray(64 * 1024)
135
+ var total = 0
136
+ while (true) {
137
+ val read = stream.read(buf)
138
+ if (read == -1) break
139
+ total += read
140
+ if (total > cap) return null
141
+ out.write(buf, 0, read)
142
+ }
143
+ return out.toByteArray()
144
+ }
145
+ }
146
+
147
+ fun zipStream(zip: ZipFile, path: String): InputStream? =
148
+ zip.getEntry(path)?.let { zip.getInputStream(it) }
149
+
150
+ /** Resolves [target] relative to the directory of [basePart], normalizing `..` segments. */
151
+ fun resolveZipPath(basePart: String, target: String): String {
152
+ if (target.startsWith("/")) return target.trimStart('/')
153
+ val stack = ArrayDeque<String>()
154
+ val baseDir = basePart.substringBeforeLast('/', "")
155
+ if (baseDir.isNotEmpty()) baseDir.split('/').forEach { stack.addLast(it) }
156
+ for (seg in target.split('/')) {
157
+ when (seg) {
158
+ "", "." -> {}
159
+ ".." -> stack.removeLastOrNull()
160
+ else -> stack.addLast(seg)
161
+ }
162
+ }
163
+ return stack.joinToString("/")
164
+ }
165
+
166
+ /** OPC relationships: rId → target path resolved against [partPath]'s directory. */
167
+ fun parseRels(zip: ZipFile, partPath: String): Map<String, String> {
168
+ val relsPath = resolveZipPath(partPath, "_rels/${partPath.substringAfterLast('/')}.rels")
169
+ val stream = zipStream(zip, relsPath) ?: return emptyMap()
170
+ val out = HashMap<String, String>()
171
+ stream.use { s ->
172
+ val p = parser(s)
173
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
174
+ if (p.eventType == XmlPullParser.START_TAG && local(p) == "Relationship") {
175
+ val id = attr(p, "Id")
176
+ val target = attr(p, "Target")
177
+ if (id != null && target != null) out[id] = resolveZipPath(partPath, target)
178
+ }
179
+ }
180
+ }
181
+ return out
182
+ }
183
+
184
+ // ── Images ────────────────────────────────────────────────────────
185
+ fun mimeForName(name: String): String = when (name.substringAfterLast('.', "").lowercase()) {
186
+ "png" -> "image/png"
187
+ "jpg", "jpeg" -> "image/jpeg"
188
+ "gif" -> "image/gif"
189
+ "bmp" -> "image/bmp"
190
+ "webp" -> "image/webp"
191
+ "svg" -> "image/svg+xml"
192
+ "emf", "wmf" -> "image/x-emf" // WebView can't decode these; caller may skip
193
+ else -> "application/octet-stream"
194
+ }
195
+
196
+ fun dataUri(bytes: ByteArray, mime: String): String =
197
+ "data:$mime;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
198
+
199
+ // ── Document shell ────────────────────────────────────────────────
200
+ /**
201
+ * Wraps converted content in the standard preview shell. [contentWidthPt] drives the
202
+ * viewport width so the WebView's overview mode scales the TRUE-dimension layout to fit the
203
+ * screen exactly like a PDF page (pinch-zoom stays available for reading at 100%).
204
+ */
205
+ fun shell(contentWidthPt: Float, extraCss: String, body: String): String {
206
+ val viewportPx = (contentWidthPt * 4f / 3f).toInt().coerceAtLeast(320)
207
+ return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"/>" +
208
+ "<meta name=\"viewport\" content=\"width=$viewportPx\"/>" +
209
+ "<style>" +
210
+ "html,body{margin:0;padding:0;background:#3A3D45;}" +
211
+ "*{box-sizing:border-box;}" +
212
+ // Edge-to-edge: the viewport is exactly the sheet width, so the page fills the preview
213
+ // with no dark bands around it. Sheets keep only a thin separator BETWEEN each other.
214
+ ".sheet{background:#FFFFFF;margin:0 auto;overflow:hidden;}" +
215
+ ".sheet+.sheet{margin-top:6pt;}" +
216
+ "p{margin:0;padding:0;}" +
217
+ "table{border-spacing:0;}" +
218
+ "img{max-width:100%;}" +
219
+ extraCss +
220
+ "</style></head><body>$body</body></html>"
221
+ }
222
+
223
+ /** Roman numeral / letter list markers shared by DOCX + ODF numbering. */
224
+ fun formatOrdinal(value: Int, fmt: String): String = when (fmt) {
225
+ "lowerLetter", "a" -> letter(value).lowercase()
226
+ "upperLetter", "A" -> letter(value)
227
+ "lowerRoman", "i" -> roman(value).lowercase()
228
+ "upperRoman", "I" -> roman(value)
229
+ else -> value.toString()
230
+ }
231
+
232
+ private fun letter(value: Int): String {
233
+ var v = value
234
+ val sb = StringBuilder()
235
+ while (v > 0) {
236
+ v--
237
+ sb.insert(0, ('A' + (v % 26)))
238
+ v /= 26
239
+ }
240
+ return sb.toString()
241
+ }
242
+
243
+ private fun roman(value: Int): String {
244
+ if (value <= 0 || value > 3999) return value.toString()
245
+ val symbols = listOf(
246
+ 1000 to "M", 900 to "CM", 500 to "D", 400 to "CD", 100 to "C", 90 to "XC",
247
+ 50 to "L", 40 to "XL", 10 to "X", 9 to "IX", 5 to "V", 4 to "IV", 1 to "I"
248
+ )
249
+ var v = value
250
+ val sb = StringBuilder()
251
+ for ((num, sym) in symbols) {
252
+ while (v >= num) {
253
+ sb.append(sym)
254
+ v -= num
255
+ }
256
+ }
257
+ return sb.toString()
258
+ }
259
+ }
260
+
261
+ // ═══════════════════════════════════════════════════════════════════
262
+ // DOCX (WordprocessingML)
263
+ // ═══════════════════════════════════════════════════════════════════
264
+
265
+ internal class DocxConverter(private val file: File) {
266
+
267
+ // Effective run formatting after style-chain resolution. Null = inherit.
268
+ private class RunProps {
269
+ var bold: Boolean? = null
270
+ var italic: Boolean? = null
271
+ var underline: Boolean? = null
272
+ var strike: Boolean? = null
273
+ var caps: Boolean? = null
274
+ var smallCaps: Boolean? = null
275
+ var vertAlign: String? = null
276
+ var sizePt: Float? = null
277
+ var color: String? = null
278
+ var highlight: String? = null
279
+ var shading: String? = null
280
+ var font: String? = null
281
+
282
+ fun overlayOn(base: RunProps?): RunProps {
283
+ val out = RunProps()
284
+ out.bold = bold ?: base?.bold
285
+ out.italic = italic ?: base?.italic
286
+ out.underline = underline ?: base?.underline
287
+ out.strike = strike ?: base?.strike
288
+ out.caps = caps ?: base?.caps
289
+ out.smallCaps = smallCaps ?: base?.smallCaps
290
+ out.vertAlign = vertAlign ?: base?.vertAlign
291
+ out.sizePt = sizePt ?: base?.sizePt
292
+ out.color = color ?: base?.color
293
+ out.highlight = highlight ?: base?.highlight
294
+ out.shading = shading ?: base?.shading
295
+ out.font = font ?: base?.font
296
+ return out
297
+ }
298
+ }
299
+
300
+ private class ParaProps {
301
+ var styleId: String? = null
302
+ var align: String? = null
303
+ var leftPt: Float? = null
304
+ var rightPt: Float? = null
305
+ var firstLinePt: Float? = null
306
+ var hangingPt: Float? = null
307
+ var beforePt: Float? = null
308
+ var afterPt: Float? = null
309
+ var lineCss: String? = null
310
+ var shading: String? = null
311
+ var pageBreakBefore = false
312
+ var numId: String? = null
313
+ var ilvl = 0
314
+ var runStyleId: String? = null
315
+
316
+ fun overlayOn(base: ParaProps?): ParaProps {
317
+ val out = ParaProps()
318
+ out.styleId = styleId ?: base?.styleId
319
+ out.align = align ?: base?.align
320
+ out.leftPt = leftPt ?: base?.leftPt
321
+ out.rightPt = rightPt ?: base?.rightPt
322
+ out.firstLinePt = firstLinePt ?: base?.firstLinePt
323
+ out.hangingPt = hangingPt ?: base?.hangingPt
324
+ out.beforePt = beforePt ?: base?.beforePt
325
+ out.afterPt = afterPt ?: base?.afterPt
326
+ out.lineCss = lineCss ?: base?.lineCss
327
+ out.shading = shading ?: base?.shading
328
+ out.pageBreakBefore = pageBreakBefore || (base?.pageBreakBefore ?: false)
329
+ out.numId = numId ?: base?.numId
330
+ out.ilvl = if (numId != null) ilvl else (base?.takeIf { it.numId != null }?.ilvl ?: ilvl)
331
+ out.runStyleId = runStyleId ?: base?.runStyleId
332
+ return out
333
+ }
334
+ }
335
+
336
+ private class Style(val id: String) {
337
+ var basedOn: String? = null
338
+ var pPr = ParaProps()
339
+ var rPr = RunProps()
340
+ /** Table styles: tblPr > tblBorders resolved to CSS, for tables referencing this style. */
341
+ var tblBorderCss: String? = null
342
+ }
343
+
344
+ private class NumLevel(val numFmt: String, val lvlText: String, val start: Int, val indentPt: Float?)
345
+
346
+ private class Section {
347
+ var pageWPt = 612f // US Letter defaults
348
+ var pageHPt = 792f
349
+ var marTopPt = 72f
350
+ var marBottomPt = 72f
351
+ var marLeftPt = 72f
352
+ var marRightPt = 72f
353
+ var columns = 1
354
+ var columnGapPt = 36f
355
+ var headerPart: String? = null
356
+ var footerPart: String? = null
357
+ }
358
+
359
+ private lateinit var zip: ZipFile
360
+ private var rels: Map<String, String> = emptyMap()
361
+ private val styles = HashMap<String, Style>()
362
+ private var docDefaultsR = RunProps()
363
+ private var docDefaultsP = ParaProps()
364
+ private var defaultParaStyleId: String? = null
365
+ private val numToAbstract = HashMap<String, String>()
366
+ private val abstractLevels = HashMap<String, HashMap<Int, NumLevel>>()
367
+ private val listCounters = HashMap<String, IntArray>()
368
+ private val section = Section()
369
+ private var imageBudget = Ox.MAX_TOTAL_IMAGE_BYTES
370
+ private var sheetCount = 0
371
+
372
+ fun convert(): OfficeDocumentConverter.Conversion {
373
+ ZipFile(file).use { z ->
374
+ zip = z
375
+ rels = Ox.parseRels(z, "word/document.xml")
376
+ parseStyles()
377
+ parseNumbering()
378
+ scanSection()
379
+ val fontFaceCss = extractEmbeddedFonts()
380
+
381
+ val headerHtml = section.headerPart?.let { renderAuxPart(it) } ?: ""
382
+ val footerHtml = section.footerPart?.let { renderAuxPart(it) } ?: ""
383
+
384
+ val sheets = ArrayList<StringBuilder>()
385
+ sheets.add(StringBuilder())
386
+ Ox.zipStream(z, "word/document.xml")?.use { stream ->
387
+ val p = Ox.parser(stream)
388
+ // Advance into <w:body>, then stream its block-level children.
389
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
390
+ if (p.eventType == XmlPullParser.START_TAG && Ox.local(p) == "body") {
391
+ renderBlocks(p, "body") { pageBreak, html ->
392
+ if (pageBreak && sheets.last().isNotEmpty()) sheets.add(StringBuilder())
393
+ sheets.last().append(html)
394
+ }
395
+ break
396
+ }
397
+ }
398
+ } ?: throw java.io.IOException("word/document.xml missing")
399
+
400
+ sheetCount = sheets.size
401
+ // Cap the displayed margins: the sheet shrinks by the same amount the margins do, so
402
+ // the content box (and therefore line wrapping) keeps its exact authored width while
403
+ // wasting far less of the phone screen on blank print margins.
404
+ val dispMarT = minOf(section.marTopPt, Ox.MAX_DISPLAY_MARGIN_PT)
405
+ val dispMarB = minOf(section.marBottomPt, Ox.MAX_DISPLAY_MARGIN_PT)
406
+ val dispMarL = minOf(section.marLeftPt, Ox.MAX_DISPLAY_MARGIN_PT)
407
+ val dispMarR = minOf(section.marRightPt, Ox.MAX_DISPLAY_MARGIN_PT)
408
+ val dispPageW = section.pageWPt - (section.marLeftPt - dispMarL) - (section.marRightPt - dispMarR)
409
+ val dispPageH = section.pageHPt - (section.marTopPt - dispMarT) - (section.marBottomPt - dispMarB)
410
+ val body = StringBuilder()
411
+ for ((index, sheet) in sheets.withIndex()) {
412
+ body.append("<div class=\"sheet docx\" style=\"width:")
413
+ .append(Ox.n(dispPageW)).append("pt;")
414
+ // Sheets size to their content: automatic pagination is unknowable here, so padding
415
+ // them to a full authored page height would append a large blank band under the
416
+ // final line of a short page.
417
+ body.append("padding:").append(Ox.n(dispMarT)).append("pt ")
418
+ .append(Ox.n(dispMarR)).append("pt ").append(Ox.n(dispMarB))
419
+ .append("pt ").append(Ox.n(dispMarL)).append("pt;\">")
420
+ if (headerHtml.isNotEmpty()) body.append("<div class=\"hdr\">").append(headerHtml).append("</div>")
421
+ if (section.columns > 1) {
422
+ body.append("<div style=\"column-count:").append(section.columns)
423
+ .append(";column-gap:").append(Ox.n(section.columnGapPt)).append("pt;\">")
424
+ }
425
+ body.append(sheet)
426
+ if (section.columns > 1) body.append("</div>")
427
+ if (footerHtml.isNotEmpty()) body.append("<div class=\"ftr\">").append(footerHtml).append("</div>")
428
+ body.append("</div>")
429
+ }
430
+
431
+ val defaultFont = docDefaultsR.font ?: "Calibri"
432
+ val defaultSize = docDefaultsR.sizePt ?: 11f
433
+ val css =
434
+ fontFaceCss +
435
+ ".docx{font-family:'${defaultFont.replace("'", "")}',sans-serif;" +
436
+ "font-size:${Ox.n(defaultSize)}pt;color:#000;line-height:1.15;" +
437
+ "display:flex;flex-direction:column;}" +
438
+ ".docx .ftr{margin-top:auto;}" +
439
+ ".docx td,.docx th{vertical-align:top;}"
440
+ return OfficeDocumentConverter.Conversion(
441
+ Ox.shell(dispPageW, css, body.toString()),
442
+ sheetCount
443
+ )
444
+ }
445
+ }
446
+
447
+ // ── styles.xml ───────────────────────────────────────────────────
448
+ private fun parseStyles() {
449
+ Ox.zipStream(zip, "word/styles.xml")?.use { stream ->
450
+ val p = Ox.parser(stream)
451
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
452
+ if (p.eventType != XmlPullParser.START_TAG) continue
453
+ when (Ox.local(p)) {
454
+ "rPrDefault" -> {
455
+ // Contains one rPr with the document-wide run defaults.
456
+ var depth = 1
457
+ while (depth > 0) {
458
+ when (p.next()) {
459
+ XmlPullParser.START_TAG -> if (Ox.local(p) == "rPr") { parseRunProps(p, docDefaultsR) } else depth++
460
+ XmlPullParser.END_TAG -> depth--
461
+ }
462
+ }
463
+ }
464
+ "pPrDefault" -> {
465
+ var depth = 1
466
+ while (depth > 0) {
467
+ when (p.next()) {
468
+ XmlPullParser.START_TAG -> if (Ox.local(p) == "pPr") { parseParaProps(p, docDefaultsP) } else depth++
469
+ XmlPullParser.END_TAG -> depth--
470
+ }
471
+ }
472
+ }
473
+ "style" -> {
474
+ val type = Ox.attr(p, "type")
475
+ val id = Ox.attr(p, "styleId")
476
+ val isDefault = Ox.attr(p, "default") == "1" || Ox.attr(p, "default") == "true"
477
+ if (id == null) { Ox.skip(p); continue }
478
+ if (type == "paragraph" && isDefault) defaultParaStyleId = id
479
+ val style = Style(id)
480
+ var depth = 1
481
+ while (depth > 0) {
482
+ when (p.next()) {
483
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
484
+ "basedOn" -> { style.basedOn = Ox.attr(p, "val"); Ox.skip(p) }
485
+ "pPr" -> parseParaProps(p, style.pPr)
486
+ "rPr" -> parseRunProps(p, style.rPr)
487
+ "tblPr" -> {
488
+ var tDepth = 1
489
+ while (tDepth > 0) {
490
+ when (p.next()) {
491
+ XmlPullParser.START_TAG ->
492
+ if (Ox.local(p) == "tblBorders") style.tblBorderCss = parseBordersToCss(p)
493
+ else Ox.skip(p)
494
+ XmlPullParser.END_TAG -> tDepth--
495
+ XmlPullParser.END_DOCUMENT -> return
496
+ }
497
+ }
498
+ }
499
+ else -> Ox.skip(p)
500
+ }
501
+ XmlPullParser.END_TAG -> depth--
502
+ }
503
+ }
504
+ styles[id] = style
505
+ }
506
+ }
507
+ }
508
+ }
509
+ }
510
+
511
+ /** Chain-resolved paragraph props for a style id (basedOn ancestors underneath). */
512
+ private fun resolveStyleP(id: String?, guard: Int = 0): ParaProps? {
513
+ if (id == null || guard > 8) return null
514
+ val style = styles[id] ?: return null
515
+ return style.pPr.overlayOn(resolveStyleP(style.basedOn, guard + 1))
516
+ }
517
+
518
+ private fun resolveStyleR(id: String?, guard: Int = 0): RunProps? {
519
+ if (id == null || guard > 8) return null
520
+ val style = styles[id] ?: return null
521
+ return style.rPr.overlayOn(resolveStyleR(style.basedOn, guard + 1))
522
+ }
523
+
524
+ /** Chain-resolved tblBorders CSS for a table style id (nearest declaration wins). */
525
+ private fun resolveTableStyleBorders(id: String?, guard: Int = 0): String? {
526
+ if (id == null || guard > 8) return null
527
+ val style = styles[id] ?: return null
528
+ return style.tblBorderCss ?: resolveTableStyleBorders(style.basedOn, guard + 1)
529
+ }
530
+
531
+ // ── numbering.xml ────────────────────────────────────────────────
532
+ private fun parseNumbering() {
533
+ Ox.zipStream(zip, "word/numbering.xml")?.use { stream ->
534
+ val p = Ox.parser(stream)
535
+ var currentAbstract: String? = null
536
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
537
+ if (p.eventType != XmlPullParser.START_TAG) continue
538
+ when (Ox.local(p)) {
539
+ "abstractNum" -> currentAbstract = Ox.attr(p, "abstractNumId")
540
+ "lvl" -> {
541
+ val abs = currentAbstract ?: continue
542
+ val ilvl = Ox.attr(p, "ilvl")?.toIntOrNull() ?: continue
543
+ var numFmt = "decimal"
544
+ var lvlText = "%1."
545
+ var start = 1
546
+ var indentPt: Float? = null
547
+ var depth = 1
548
+ while (depth > 0) {
549
+ when (p.next()) {
550
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
551
+ "numFmt" -> { Ox.attr(p, "val")?.let { numFmt = it }; Ox.skip(p) }
552
+ "lvlText" -> { Ox.attr(p, "val")?.let { lvlText = it }; Ox.skip(p) }
553
+ "start" -> { Ox.attr(p, "val")?.toIntOrNull()?.let { start = it }; Ox.skip(p) }
554
+ "ind" -> { indentPt = Ox.twipsToPt(Ox.attr(p, "left")); Ox.skip(p) }
555
+ else -> Ox.skip(p)
556
+ }
557
+ XmlPullParser.END_TAG -> depth--
558
+ }
559
+ }
560
+ abstractLevels.getOrPut(abs) { HashMap() }[ilvl] = NumLevel(numFmt, lvlText, start, indentPt)
561
+ }
562
+ "num" -> {
563
+ val numId = Ox.attr(p, "numId")
564
+ var abstractId: String? = null
565
+ var depth = 1
566
+ while (depth > 0) {
567
+ when (p.next()) {
568
+ XmlPullParser.START_TAG ->
569
+ if (Ox.local(p) == "abstractNumId") { abstractId = Ox.attr(p, "val"); Ox.skip(p) } else Ox.skip(p)
570
+ XmlPullParser.END_TAG -> depth--
571
+ }
572
+ }
573
+ if (numId != null && abstractId != null) numToAbstract[numId] = abstractId
574
+ }
575
+ }
576
+ }
577
+ }
578
+ }
579
+
580
+ // ── Embedded fonts (word/fonts/*.odttf) ─────────────────────────
581
+ /**
582
+ * DOCX embeds fonts as obfuscated TTFs: the first 32 bytes are XORed with the GUID from the
583
+ * fontTable relationship key. De-obfuscating restores a normal TTF, which is emitted as an
584
+ * inline @font-face so text renders in the AUTHOR'S actual face — the strongest fidelity
585
+ * lever available when the device lacks the font. Size-capped; failures fall back to the
586
+ * family-name match already emitted per run.
587
+ */
588
+ private fun extractEmbeddedFonts(): String {
589
+ val MAX_FONT_BYTES = 2 * 1024 * 1024
590
+ var budget = 6 * 1024 * 1024
591
+ val css = StringBuilder()
592
+ Ox.zipStream(zip, "word/fontTable.xml")?.use { stream ->
593
+ val fontRels = Ox.parseRels(zip, "word/fontTable.xml")
594
+ val p = Ox.parser(stream)
595
+ var family: String? = null
596
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
597
+ if (p.eventType != XmlPullParser.START_TAG) continue
598
+ when (Ox.local(p)) {
599
+ "font" -> family = Ox.attr(p, "name")
600
+ "embedRegular" -> {
601
+ val rid = Ox.attr(p, "id")
602
+ val key = Ox.attr(p, "fontKey")
603
+ val fam = family
604
+ if (rid != null && fam != null && budget > 0) {
605
+ val target = fontRels[rid]
606
+ val bytes = target?.let { Ox.zipBytes(zip, it, minOf(MAX_FONT_BYTES, budget)) }
607
+ if (bytes != null && bytes.size > 32) {
608
+ deobfuscateFont(bytes, key)
609
+ budget -= bytes.size
610
+ css.append("@font-face{font-family:'").append(fam.replace("'", ""))
611
+ .append("';src:url(data:font/ttf;base64,")
612
+ .append(Base64.encodeToString(bytes, Base64.NO_WRAP))
613
+ .append(");}")
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+ }
620
+ return css.toString()
621
+ }
622
+
623
+ /** XORs the first 32 bytes with the reversed GUID bytes per the ODTTF obfuscation spec. */
624
+ private fun deobfuscateFont(bytes: ByteArray, fontKey: String?) {
625
+ val hex = fontKey?.replace("{", "")?.replace("}", "")?.replace("-", "") ?: return
626
+ if (hex.length != 32) return
627
+ val guid = ByteArray(16)
628
+ for (i in 0 until 16) {
629
+ guid[i] = ((hex[i * 2].digitToIntOrNull(16) ?: return) * 16 +
630
+ (hex[i * 2 + 1].digitToIntOrNull(16) ?: return)).toByte()
631
+ }
632
+ for (i in 0 until 32) {
633
+ bytes[i] = (bytes[i].toInt() xor guid[15 - (i % 16)].toInt()).toByte()
634
+ }
635
+ }
636
+
637
+ // ── sectPr pre-scan (page geometry + header/footer refs) ────────
638
+ private fun scanSection() {
639
+ Ox.zipStream(zip, "word/document.xml")?.use { stream ->
640
+ val p = Ox.parser(stream)
641
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
642
+ if (p.eventType != XmlPullParser.START_TAG) continue
643
+ when (Ox.local(p)) {
644
+ "pgSz" -> {
645
+ Ox.twipsToPt(Ox.attr(p, "w"))?.let { section.pageWPt = it }
646
+ Ox.twipsToPt(Ox.attr(p, "h"))?.let { section.pageHPt = it }
647
+ }
648
+ "pgMar" -> {
649
+ Ox.twipsToPt(Ox.attr(p, "top"))?.let { section.marTopPt = it }
650
+ Ox.twipsToPt(Ox.attr(p, "bottom"))?.let { section.marBottomPt = it }
651
+ Ox.twipsToPt(Ox.attr(p, "left"))?.let { section.marLeftPt = it }
652
+ Ox.twipsToPt(Ox.attr(p, "right"))?.let { section.marRightPt = it }
653
+ }
654
+ "cols" -> {
655
+ Ox.attr(p, "num")?.toIntOrNull()?.let { section.columns = it.coerceIn(1, 8) }
656
+ Ox.twipsToPt(Ox.attr(p, "space"))?.let { section.columnGapPt = it }
657
+ }
658
+ "headerReference" ->
659
+ if (Ox.attr(p, "type") == "default") section.headerPart = rels[Ox.attr(p, "id")]
660
+ "footerReference" ->
661
+ if (Ox.attr(p, "type") == "default") section.footerPart = rels[Ox.attr(p, "id")]
662
+ }
663
+ }
664
+ }
665
+ }
666
+
667
+ /** Renders a header/footer part with the same paragraph pipeline (its own rels for images). */
668
+ private fun renderAuxPart(part: String): String {
669
+ val partRels = Ox.parseRels(zip, part)
670
+ val sb = StringBuilder()
671
+ Ox.zipStream(zip, part)?.use { stream ->
672
+ val p = Ox.parser(stream)
673
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
674
+ if (p.eventType == XmlPullParser.START_TAG && (Ox.local(p) == "hdr" || Ox.local(p) == "ftr")) {
675
+ renderBlocks(p, Ox.local(p), overrideRels = partRels) { _, html -> sb.append(html) }
676
+ break
677
+ }
678
+ }
679
+ }
680
+ return sb.toString()
681
+ }
682
+
683
+ // ── Block-level content ─────────────────────────────────────────
684
+ /**
685
+ * Streams the children of the container element the parser is positioned ON, emitting one
686
+ * HTML chunk per block. [emit]'s pageBreak flag is true when the chunk must start a new sheet.
687
+ */
688
+ private fun renderBlocks(
689
+ p: XmlPullParser,
690
+ containerLocal: String,
691
+ overrideRels: Map<String, String>? = null,
692
+ emit: (pageBreak: Boolean, html: String) -> Unit
693
+ ) {
694
+ var depth = 1
695
+ while (depth > 0) {
696
+ when (p.next()) {
697
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
698
+ "p" -> {
699
+ val (html, breakBefore) = renderParagraph(p, overrideRels)
700
+ emit(breakBefore, html)
701
+ }
702
+ "tbl" -> emit(false, renderTable(p, overrideRels))
703
+ "sdt" -> { /* structured document tag: descend transparently */ }
704
+ "sdtContent" -> { /* children stream through this loop */ }
705
+ "sectPr" -> Ox.skip(p)
706
+ else -> Ox.skip(p)
707
+ }
708
+ XmlPullParser.END_TAG -> if (Ox.local(p) == containerLocal || Ox.local(p) == "sdt" || Ox.local(p) == "sdtContent") {
709
+ if (Ox.local(p) == containerLocal) depth--
710
+ }
711
+ XmlPullParser.END_DOCUMENT -> return
712
+ }
713
+ }
714
+ }
715
+
716
+ // ── Paragraphs & runs ────────────────────────────────────────────
717
+ /** Returns the paragraph HTML and whether a page break must occur BEFORE it. */
718
+ private fun renderParagraph(p: XmlPullParser, overrideRels: Map<String, String>?): Pair<String, Boolean> {
719
+ val direct = ParaProps()
720
+ val runs = StringBuilder()
721
+ var sawPageBreak = false
722
+ var depth = 1
723
+ while (depth > 0) {
724
+ when (p.next()) {
725
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
726
+ "pPr" -> parseParaProps(p, direct)
727
+ "r" -> {
728
+ val (html, pageBreak) = renderRun(p, direct, overrideRels)
729
+ runs.append(html)
730
+ sawPageBreak = sawPageBreak || pageBreak
731
+ }
732
+ "hyperlink", "smartTag", "sdt", "sdtContent", "ins" -> { /* stream children */ }
733
+ "del" -> Ox.skip(p) // deleted (tracked-changes) content is not part of the document
734
+ else -> Ox.skip(p)
735
+ }
736
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "p") depth--
737
+ XmlPullParser.END_DOCUMENT -> return "" to false
738
+ }
739
+ }
740
+
741
+ val styleP = resolveStyleP(direct.styleId ?: defaultParaStyleId)
742
+ val eff = direct.overlayOn(styleP?.overlayOn(docDefaultsP))
743
+
744
+ val css = StringBuilder()
745
+ eff.align?.let {
746
+ val a = when (it) { "both", "distribute" -> "justify"; "start" -> "left"; "end" -> "right"; else -> it }
747
+ css.append("text-align:").append(a).append(";")
748
+ }
749
+ var leftPt = eff.leftPt ?: 0f
750
+ // A numbered/bulleted paragraph inherits its level indent when it has no direct indent.
751
+ val marker = listMarker(eff)
752
+ if (marker != null && eff.leftPt == null) leftPt = marker.second
753
+ if (leftPt != 0f) css.append("margin-left:").append(Ox.n(leftPt)).append("pt;")
754
+ eff.rightPt?.takeIf { it != 0f }?.let { css.append("margin-right:").append(Ox.n(it)).append("pt;") }
755
+ eff.beforePt?.takeIf { it != 0f }?.let { css.append("margin-top:").append(Ox.n(it)).append("pt;") }
756
+ eff.afterPt?.takeIf { it != 0f }?.let { css.append("margin-bottom:").append(Ox.n(it)).append("pt;") }
757
+ eff.firstLinePt?.let { css.append("text-indent:").append(Ox.n(it)).append("pt;") }
758
+ eff.hangingPt?.let { css.append("text-indent:-").append(Ox.n(it)).append("pt;padding-left:").append(Ox.n(it)).append("pt;") }
759
+ eff.lineCss?.let { css.append(it) }
760
+ eff.shading?.let { css.append("background-color:#").append(it).append(";") }
761
+
762
+ val markerHtml = marker?.first ?: ""
763
+ // An empty paragraph still occupies one line, exactly like Word.
764
+ val content = if (runs.isEmpty() && markerHtml.isEmpty()) "&nbsp;" else markerHtml + runs
765
+ return "<p style=\"$css\">$content</p>" to (eff.pageBreakBefore || sawPageBreak)
766
+ }
767
+
768
+ /** Marker html + level indent (pt) for a numbered paragraph, advancing this list's counter. */
769
+ private fun listMarker(eff: ParaProps): Pair<String, Float>? {
770
+ val numId = eff.numId ?: return null
771
+ if (numId == "0") return null
772
+ val abs = numToAbstract[numId] ?: return null
773
+ val level = abstractLevels[abs]?.get(eff.ilvl) ?: return null
774
+ val counters = listCounters.getOrPut(numId) { IntArray(9) { -1 } }
775
+ val text: String
776
+ if (level.numFmt == "bullet") {
777
+ text = when {
778
+ level.lvlText.isEmpty() -> "•"
779
+ // Wingdings/Symbol private-use glyphs render as tofu — normalize to standard bullets.
780
+ level.lvlText[0].code >= 0xE000 || level.lvlText[0].code == 0xF0B7 -> "•"
781
+ level.lvlText == "o" -> "◦"
782
+ else -> level.lvlText
783
+ }
784
+ } else {
785
+ if (counters[eff.ilvl] < 0) counters[eff.ilvl] = level.start - 1
786
+ counters[eff.ilvl]++
787
+ for (deeper in eff.ilvl + 1 until 9) counters[deeper] = -1
788
+ var t = level.lvlText
789
+ for (i in 1..9) {
790
+ val counterValue = if (counters[i - 1] >= 0) counters[i - 1] else 1
791
+ t = t.replace("%$i", Ox.formatOrdinal(counterValue, level.numFmt))
792
+ }
793
+ text = t
794
+ }
795
+ val indent = level.indentPt ?: (18f + eff.ilvl * 18f)
796
+ val html = "<span style=\"display:inline-block;min-width:18pt;margin-left:-18pt;\">${Ox.esc(text)}&nbsp;</span>"
797
+ return html to indent
798
+ }
799
+
800
+ /** Returns run HTML and whether the run contained an explicit page break. */
801
+ private fun renderRun(
802
+ p: XmlPullParser,
803
+ paraDirect: ParaProps,
804
+ overrideRels: Map<String, String>?
805
+ ): Pair<String, Boolean> {
806
+ val direct = RunProps()
807
+ var directStyleId: String? = null
808
+ val text = StringBuilder()
809
+ var pageBreak = false
810
+ var depth = 1
811
+ while (depth > 0) {
812
+ when (p.next()) {
813
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
814
+ "rPr" -> directStyleId = parseRunProps(p, direct)
815
+ "t" -> text.append(Ox.esc(Ox.elementText(p)))
816
+ "tab" -> { text.append("<span style=\"display:inline-block;width:36pt;\"></span>"); Ox.skip(p) }
817
+ "br" -> {
818
+ if (Ox.attr(p, "type") == "page") pageBreak = true else text.append("<br/>")
819
+ Ox.skip(p)
820
+ }
821
+ "drawing", "pict", "object" -> {
822
+ text.append(renderImage(p, overrideRels))
823
+ // renderImage consumes to the END of the element it was called on.
824
+ }
825
+ "noBreakHyphen" -> { text.append("&#8209;"); Ox.skip(p) }
826
+ "sym" -> {
827
+ val ch = Ox.attr(p, "char")?.toIntOrNull(16)
828
+ if (ch != null) text.append("&#${if (ch >= 0xF000) ch - 0xF000 else ch};")
829
+ Ox.skip(p)
830
+ }
831
+ else -> Ox.skip(p)
832
+ }
833
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "r") depth--
834
+ XmlPullParser.END_DOCUMENT -> return "" to pageBreak
835
+ }
836
+ }
837
+ if (text.isEmpty()) return "" to pageBreak
838
+ // Effective chain: doc defaults ← paragraph style rPr ← character style ← direct rPr.
839
+ val paraStyleR = resolveStyleR(paraDirect.styleId ?: defaultParaStyleId)
840
+ val charStyleR = resolveStyleR(directStyleId)
841
+ val eff = direct.overlayOn(charStyleR?.overlayOn(paraStyleR?.overlayOn(docDefaultsR)) ?: paraStyleR ?: docDefaultsR)
842
+
843
+ val css = StringBuilder()
844
+ eff.font?.let { css.append("font-family:'").append(it.replace("'", "")).append("';") }
845
+ eff.sizePt?.let { css.append("font-size:").append(Ox.n(it)).append("pt;") }
846
+ if (eff.bold == true) css.append("font-weight:bold;")
847
+ if (eff.italic == true) css.append("font-style:italic;")
848
+ val deco = ArrayList<String>()
849
+ if (eff.underline == true) deco.add("underline")
850
+ if (eff.strike == true) deco.add("line-through")
851
+ if (deco.isNotEmpty()) css.append("text-decoration:").append(deco.joinToString(" ")).append(";")
852
+ eff.color?.takeIf { it != "auto" }?.let { css.append("color:#").append(it).append(";") }
853
+ eff.highlight?.let { css.append("background-color:").append(highlightColor(it)).append(";") }
854
+ eff.shading?.let { css.append("background-color:#").append(it).append(";") }
855
+ if (eff.caps == true) css.append("text-transform:uppercase;")
856
+ if (eff.smallCaps == true) css.append("font-variant:small-caps;")
857
+ when (eff.vertAlign) {
858
+ "superscript" -> css.append("vertical-align:super;font-size:smaller;")
859
+ "subscript" -> css.append("vertical-align:sub;font-size:smaller;")
860
+ }
861
+ val html = if (css.isEmpty()) text.toString() else "<span style=\"$css\">$text</span>"
862
+ return html to pageBreak
863
+ }
864
+
865
+ /** DrawingML/VML image: consumes the element, emits an <img> at its authored size. */
866
+ private fun renderImage(p: XmlPullParser, overrideRels: Map<String, String>?): String {
867
+ var relId: String? = null
868
+ var widthPt: Float? = null
869
+ var heightPt: Float? = null
870
+ var depth = 1
871
+ while (depth > 0) {
872
+ when (p.next()) {
873
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
874
+ "extent" -> {
875
+ widthPt = Ox.emuToPt(Ox.attr(p, "cx"))
876
+ heightPt = Ox.emuToPt(Ox.attr(p, "cy"))
877
+ }
878
+ "blip" -> Ox.attr(p, "embed")?.let { relId = it }
879
+ "imagedata" -> Ox.attr(p, "id")?.let { relId = it } // VML legacy
880
+ }
881
+ XmlPullParser.END_TAG -> depth--
882
+ XmlPullParser.END_DOCUMENT -> return ""
883
+ }
884
+ }
885
+ val target = (overrideRels ?: rels)[relId ?: return ""] ?: return ""
886
+ val mime = Ox.mimeForName(target)
887
+ if (mime == "image/x-emf" || mime == "application/octet-stream") return ""
888
+ if (imageBudget <= 0) return ""
889
+ val bytes = Ox.zipBytes(zip, target, minOf(Ox.MAX_IMAGE_BYTES, imageBudget)) ?: return ""
890
+ imageBudget -= bytes.size
891
+ val size = StringBuilder()
892
+ widthPt?.let { size.append("width:").append(Ox.n(it)).append("pt;") }
893
+ heightPt?.let { size.append("height:").append(Ox.n(it)).append("pt;") }
894
+ return "<img style=\"$size\" src=\"${Ox.dataUri(bytes, mime)}\"/>"
895
+ }
896
+
897
+ // ── Tables ───────────────────────────────────────────────────────
898
+ private class DocxCell {
899
+ var html = StringBuilder()
900
+ var colspan = 1
901
+ var rowspan = 1
902
+ var vMergeContinue = false
903
+ var css = StringBuilder()
904
+ }
905
+
906
+ private fun renderTable(p: XmlPullParser, overrideRels: Map<String, String>?): String {
907
+ val colWidthsPt = ArrayList<Float>()
908
+ val rows = ArrayList<ArrayList<DocxCell>>()
909
+ val rowHeights = ArrayList<Float?>()
910
+ // Borders come ONLY from the document: direct tblBorders, else the referenced table
911
+ // style's tblBorders (chain-resolved). A table that declares neither is genuinely
912
+ // borderless in Word (layout tables!) and must not be painted with an artificial grid —
913
+ // that was the "unwanted rectangular boxes" artifact.
914
+ var tableBorderCss: String? = null
915
+ var tableStyleBorderCss: String? = null
916
+ var tableShading: String? = null
917
+ var depth = 1
918
+ while (depth > 0) {
919
+ when (p.next()) {
920
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
921
+ "tblStyle" -> { tableStyleBorderCss = resolveTableStyleBorders(Ox.attr(p, "val")); Ox.skip(p) }
922
+ "gridCol" -> { Ox.twipsToPt(Ox.attr(p, "w"))?.let { colWidthsPt.add(it) }; Ox.skip(p) }
923
+ "tblBorders" -> { tableBorderCss = parseBordersToCss(p) }
924
+ "shd" -> { Ox.attr(p, "fill")?.takeIf { it != "auto" }?.let { tableShading = it }; Ox.skip(p) }
925
+ "tr" -> {
926
+ val row = ArrayList<DocxCell>()
927
+ var rowHeight: Float? = null
928
+ var trDepth = 1
929
+ while (trDepth > 0) {
930
+ when (p.next()) {
931
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
932
+ "trHeight" -> { rowHeight = Ox.twipsToPt(Ox.attr(p, "val")); Ox.skip(p) }
933
+ "tc" -> row.add(parseTableCell(p, tableBorderCss ?: tableStyleBorderCss ?: "", overrideRels))
934
+ "tblPrEx" -> Ox.skip(p)
935
+ }
936
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "tr") trDepth--
937
+ XmlPullParser.END_DOCUMENT -> return ""
938
+ }
939
+ }
940
+ rows.add(row)
941
+ rowHeights.add(rowHeight)
942
+ }
943
+ }
944
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "tbl") depth--
945
+ XmlPullParser.END_DOCUMENT -> return ""
946
+ }
947
+ }
948
+
949
+ // Resolve vertical merges into rowspans (vMerge "restart" starts, bare vMerge continues).
950
+ for (rowIndex in rows.indices) {
951
+ var gridCol = 0
952
+ for (cell in rows[rowIndex]) {
953
+ if (!cell.vMergeContinue) {
954
+ var span = 1
955
+ var below = rowIndex + 1
956
+ while (below < rows.size) {
957
+ val target = cellAtGridColumn(rows[below], gridCol)
958
+ if (target != null && target.vMergeContinue) { span++; below++ } else break
959
+ }
960
+ cell.rowspan = span
961
+ }
962
+ gridCol += cell.colspan
963
+ }
964
+ }
965
+
966
+ val totalW = colWidthsPt.sum().takeIf { it > 0f }
967
+ val sb = StringBuilder("<table style=\"table-layout:fixed;border-collapse:collapse;")
968
+ totalW?.let { sb.append("width:").append(Ox.n(it)).append("pt;") }
969
+ tableShading?.let { sb.append("background-color:#").append(it).append(";") }
970
+ sb.append("\">")
971
+ if (colWidthsPt.isNotEmpty()) {
972
+ sb.append("<colgroup>")
973
+ for (w in colWidthsPt) sb.append("<col style=\"width:").append(Ox.n(w)).append("pt;\"/>")
974
+ sb.append("</colgroup>")
975
+ }
976
+ for ((rowIndex, row) in rows.withIndex()) {
977
+ sb.append("<tr")
978
+ rowHeights[rowIndex]?.let { sb.append(" style=\"height:").append(Ox.n(it)).append("pt;\"") }
979
+ sb.append(">")
980
+ for (cell in row) {
981
+ if (cell.vMergeContinue) continue
982
+ sb.append("<td")
983
+ if (cell.colspan > 1) sb.append(" colspan=\"").append(cell.colspan).append("\"")
984
+ if (cell.rowspan > 1) sb.append(" rowspan=\"").append(cell.rowspan).append("\"")
985
+ sb.append(" style=\"").append(cell.css).append("\">")
986
+ sb.append(if (cell.html.isEmpty()) "&nbsp;" else cell.html)
987
+ sb.append("</td>")
988
+ }
989
+ sb.append("</tr>")
990
+ }
991
+ sb.append("</table>")
992
+ return sb.toString()
993
+ }
994
+
995
+ private fun cellAtGridColumn(row: List<DocxCell>, gridCol: Int): DocxCell? {
996
+ var col = 0
997
+ for (cell in row) {
998
+ if (col == gridCol) return cell
999
+ col += cell.colspan
1000
+ if (col > gridCol) return null
1001
+ }
1002
+ return null
1003
+ }
1004
+
1005
+ private fun parseTableCell(
1006
+ p: XmlPullParser,
1007
+ inheritedBorderCss: String,
1008
+ overrideRels: Map<String, String>?
1009
+ ): DocxCell {
1010
+ val cell = DocxCell()
1011
+ var borderCss: String? = null
1012
+ var padCss = "padding:2pt 5.4pt;" // Word default cell margins (108 twips sides)
1013
+ var depth = 1
1014
+ while (depth > 0) {
1015
+ when (p.next()) {
1016
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1017
+ "tcPr" -> {
1018
+ var prDepth = 1
1019
+ while (prDepth > 0) {
1020
+ when (p.next()) {
1021
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1022
+ "gridSpan" -> { Ox.attr(p, "val")?.toIntOrNull()?.let { cell.colspan = it.coerceAtLeast(1) }; Ox.skip(p) }
1023
+ "vMerge" -> { cell.vMergeContinue = Ox.attr(p, "val")?.equals("restart") != true; Ox.skip(p) }
1024
+ "shd" -> { Ox.attr(p, "fill")?.takeIf { it != "auto" }?.let { cell.css.append("background-color:#$it;") }; Ox.skip(p) }
1025
+ "vAlign" -> { Ox.attr(p, "val")?.let { cell.css.append("vertical-align:${if (it == "center") "middle" else it};") }; Ox.skip(p) }
1026
+ "tcBorders" -> borderCss = parseBordersToCss(p)
1027
+ "tcMar" -> { padCss = parseCellMargins(p); }
1028
+ "tcW" -> Ox.skip(p) // colgroup already carries the grid widths
1029
+ else -> Ox.skip(p)
1030
+ }
1031
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "tcPr") prDepth--
1032
+ XmlPullParser.END_DOCUMENT -> return cell
1033
+ }
1034
+ }
1035
+ }
1036
+ "p" -> cell.html.append(renderParagraph(p, overrideRels).first)
1037
+ "tbl" -> cell.html.append(renderTable(p, overrideRels))
1038
+ else -> Ox.skip(p)
1039
+ }
1040
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "tc") depth--
1041
+ XmlPullParser.END_DOCUMENT -> return cell
1042
+ }
1043
+ }
1044
+ cell.css.append(borderCss ?: inheritedBorderCss).append(padCss)
1045
+ return cell
1046
+ }
1047
+
1048
+ /** w:tblBorders / w:tcBorders → per-side CSS. Parser positioned ON the borders element. */
1049
+ private fun parseBordersToCss(p: XmlPullParser): String {
1050
+ val css = StringBuilder()
1051
+ var depth = 1
1052
+ while (depth > 0) {
1053
+ when (p.next()) {
1054
+ XmlPullParser.START_TAG -> {
1055
+ val side = when (Ox.local(p)) {
1056
+ "top" -> "border-top"
1057
+ "bottom" -> "border-bottom"
1058
+ "left", "start", "insideV" -> "border-left"
1059
+ "right", "end" -> "border-right"
1060
+ else -> null
1061
+ }
1062
+ if (side != null) {
1063
+ val style = when (Ox.attr(p, "val")) {
1064
+ "nil", "none" -> "none"
1065
+ "dashed", "dashSmallGap" -> "dashed"
1066
+ "dotted" -> "dotted"
1067
+ "double" -> "double"
1068
+ else -> "solid"
1069
+ }
1070
+ // w:sz is in eighths of a point.
1071
+ val sz = (Ox.attr(p, "sz")?.toFloatOrNull() ?: 4f) / 8f
1072
+ val color = Ox.attr(p, "color")?.takeIf { it != "auto" } ?: "000000"
1073
+ if (style == "none") css.append("$side:none;")
1074
+ else css.append("$side:${Ox.n(sz.coerceAtLeast(0.5f))}pt $style #$color;")
1075
+ }
1076
+ Ox.skip(p)
1077
+ }
1078
+ XmlPullParser.END_TAG -> depth--
1079
+ XmlPullParser.END_DOCUMENT -> break
1080
+ }
1081
+ }
1082
+ return css.toString()
1083
+ }
1084
+
1085
+ private fun parseCellMargins(p: XmlPullParser): String {
1086
+ var top = 2f; var bottom = 2f; var left = 5.4f; var right = 5.4f
1087
+ var depth = 1
1088
+ while (depth > 0) {
1089
+ when (p.next()) {
1090
+ XmlPullParser.START_TAG -> {
1091
+ val v = Ox.twipsToPt(Ox.attr(p, "w"))
1092
+ when (Ox.local(p)) {
1093
+ "top" -> v?.let { top = it }
1094
+ "bottom" -> v?.let { bottom = it }
1095
+ "left", "start" -> v?.let { left = it }
1096
+ "right", "end" -> v?.let { right = it }
1097
+ }
1098
+ Ox.skip(p)
1099
+ }
1100
+ XmlPullParser.END_TAG -> depth--
1101
+ XmlPullParser.END_DOCUMENT -> break
1102
+ }
1103
+ }
1104
+ return "padding:${Ox.n(top)}pt ${Ox.n(right)}pt ${Ox.n(bottom)}pt ${Ox.n(left)}pt;"
1105
+ }
1106
+
1107
+ // ── Property parsers ─────────────────────────────────────────────
1108
+ /** Parser positioned ON <w:pPr>; consumes it. */
1109
+ private fun parseParaProps(p: XmlPullParser, out: ParaProps) {
1110
+ var depth = 1
1111
+ while (depth > 0) {
1112
+ when (p.next()) {
1113
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1114
+ "pStyle" -> { out.styleId = Ox.attr(p, "val"); Ox.skip(p) }
1115
+ "jc" -> { out.align = Ox.attr(p, "val"); Ox.skip(p) }
1116
+ "ind" -> {
1117
+ Ox.twipsToPt(Ox.attr(p, "left") ?: Ox.attr(p, "start"))?.let { out.leftPt = it }
1118
+ Ox.twipsToPt(Ox.attr(p, "right") ?: Ox.attr(p, "end"))?.let { out.rightPt = it }
1119
+ Ox.twipsToPt(Ox.attr(p, "firstLine"))?.let { out.firstLinePt = it }
1120
+ Ox.twipsToPt(Ox.attr(p, "hanging"))?.let { out.hangingPt = it }
1121
+ Ox.skip(p)
1122
+ }
1123
+ "spacing" -> {
1124
+ Ox.twipsToPt(Ox.attr(p, "before"))?.let { out.beforePt = it }
1125
+ Ox.twipsToPt(Ox.attr(p, "after"))?.let { out.afterPt = it }
1126
+ val line = Ox.attr(p, "line")?.toFloatOrNull()
1127
+ if (line != null) {
1128
+ out.lineCss = when (Ox.attr(p, "lineRule")) {
1129
+ "exact" -> "line-height:${Ox.n(line / 20f)}pt;"
1130
+ "atLeast" -> "line-height:${Ox.n(line / 20f)}pt;"
1131
+ else -> "line-height:${Ox.n(line / 240f)};" // "auto": 240 twips = single
1132
+ }
1133
+ }
1134
+ Ox.skip(p)
1135
+ }
1136
+ "shd" -> { Ox.attr(p, "fill")?.takeIf { it != "auto" }?.let { out.shading = it }; Ox.skip(p) }
1137
+ "pageBreakBefore" -> { out.pageBreakBefore = Ox.attr(p, "val") != "0"; Ox.skip(p) }
1138
+ "numPr" -> {
1139
+ var numDepth = 1
1140
+ while (numDepth > 0) {
1141
+ when (p.next()) {
1142
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1143
+ "ilvl" -> { Ox.attr(p, "val")?.toIntOrNull()?.let { out.ilvl = it.coerceIn(0, 8) }; Ox.skip(p) }
1144
+ "numId" -> { out.numId = Ox.attr(p, "val"); Ox.skip(p) }
1145
+ else -> Ox.skip(p)
1146
+ }
1147
+ XmlPullParser.END_TAG -> numDepth--
1148
+ XmlPullParser.END_DOCUMENT -> return
1149
+ }
1150
+ }
1151
+ }
1152
+ "rPr" -> Ox.skip(p) // paragraph-mark run props — not content formatting
1153
+ else -> Ox.skip(p)
1154
+ }
1155
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "pPr") depth--
1156
+ XmlPullParser.END_DOCUMENT -> return
1157
+ }
1158
+ }
1159
+ }
1160
+
1161
+ /** Parser positioned ON <w:rPr>; consumes it. Returns the character style id if present. */
1162
+ private fun parseRunProps(p: XmlPullParser, out: RunProps): String? {
1163
+ var styleId: String? = null
1164
+ var depth = 1
1165
+ while (depth > 0) {
1166
+ when (p.next()) {
1167
+ XmlPullParser.START_TAG -> {
1168
+ val toggleOn = Ox.attr(p, "val").let { it == null || it == "1" || it == "true" || it == "on" }
1169
+ when (Ox.local(p)) {
1170
+ "rStyle" -> styleId = Ox.attr(p, "val")
1171
+ "b" -> out.bold = toggleOn
1172
+ "i" -> out.italic = toggleOn
1173
+ "strike" -> out.strike = toggleOn
1174
+ "caps" -> out.caps = toggleOn
1175
+ "smallCaps" -> out.smallCaps = toggleOn
1176
+ "u" -> out.underline = Ox.attr(p, "val") != "none"
1177
+ "vertAlign" -> out.vertAlign = Ox.attr(p, "val")
1178
+ "sz" -> Ox.halfPtToPt(Ox.attr(p, "val"))?.let { out.sizePt = it }
1179
+ "color" -> Ox.attr(p, "val")?.let { out.color = it }
1180
+ "highlight" -> out.highlight = Ox.attr(p, "val")
1181
+ "shd" -> Ox.attr(p, "fill")?.takeIf { it != "auto" }?.let { out.shading = it }
1182
+ "rFonts" -> out.font = Ox.attr(p, "ascii") ?: Ox.attr(p, "hAnsi") ?: Ox.attr(p, "cs")
1183
+ }
1184
+ Ox.skip(p)
1185
+ }
1186
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "rPr") depth--
1187
+ XmlPullParser.END_DOCUMENT -> return styleId
1188
+ }
1189
+ }
1190
+ return styleId
1191
+ }
1192
+
1193
+ private fun highlightColor(name: String): String = when (name) {
1194
+ "yellow" -> "#FFFF00"; "green" -> "#00FF00"; "cyan" -> "#00FFFF"; "magenta" -> "#FF00FF"
1195
+ "blue" -> "#0000FF"; "red" -> "#FF0000"; "darkBlue" -> "#00008B"; "darkCyan" -> "#008B8B"
1196
+ "darkGreen" -> "#006400"; "darkMagenta" -> "#8B008B"; "darkRed" -> "#8B0000"
1197
+ "darkYellow" -> "#808000"; "darkGray" -> "#A9A9A9"; "lightGray" -> "#D3D3D3"; "black" -> "#000000"
1198
+ else -> "transparent"
1199
+ }
1200
+ }
1201
+
1202
+ // ═══════════════════════════════════════════════════════════════════
1203
+ // XLSX (SpreadsheetML)
1204
+ // ═══════════════════════════════════════════════════════════════════
1205
+
1206
+ /**
1207
+ * Renders every worksheet as a paper sheet with the TRUE grid geometry: authored column widths
1208
+ * (Excel "character" units → pt), row heights (already pt), merged regions as col/rowspans, and
1209
+ * per-cell font/fill/border/alignment resolved through styles.xml cellXfs. Values are shown as
1210
+ * stored (with builtin date formats converted from serials), matching what Excel displays for
1211
+ * everything but custom number-format masks.
1212
+ */
1213
+ internal class XlsxConverter(private val file: File) {
1214
+
1215
+ private class CellStyle {
1216
+ var fontCss: String = ""
1217
+ var fillCss: String = ""
1218
+ var borderCss: String = ""
1219
+ var alignCss: String = ""
1220
+ var numFmtId: Int = 0
1221
+ var wrap = false
1222
+ }
1223
+
1224
+ private lateinit var zip: ZipFile
1225
+ private val sharedStrings = ArrayList<String>()
1226
+ private val cellStyles = ArrayList<CellStyle>()
1227
+
1228
+ companion object {
1229
+ /** Excel column-width unit ≈ one '0' glyph ≈ 7px @96dpi = 5.25pt. */
1230
+ private const val CHAR_TO_PT = 5.25f
1231
+ private const val DEFAULT_COL_PT = 8.43f * CHAR_TO_PT
1232
+ private const val DEFAULT_ROW_PT = 15f
1233
+ private const val MAX_ROWS = 2000
1234
+ private const val MAX_COLS = 256
1235
+ }
1236
+
1237
+ fun convert(): OfficeDocumentConverter.Conversion {
1238
+ ZipFile(file).use { z ->
1239
+ zip = z
1240
+ parseSharedStrings()
1241
+ parseStyles()
1242
+ val sheets = sheetParts()
1243
+ if (sheets.isEmpty()) throw java.io.IOException("No worksheets")
1244
+ val body = StringBuilder()
1245
+ var maxSheetWidthPt = 0f
1246
+ for ((name, part) in sheets) {
1247
+ val (html, widthPt) = renderSheet(name, part, showTitle = sheets.size > 1)
1248
+ body.append(html)
1249
+ if (widthPt > maxSheetWidthPt) maxSheetWidthPt = widthPt
1250
+ }
1251
+ val css =
1252
+ ".xlsx{font-family:Calibri,sans-serif;font-size:11pt;color:#000;padding:6pt;}" +
1253
+ ".xlsx table{table-layout:fixed;border-collapse:collapse;}" +
1254
+ ".xlsx td{border:0.5pt solid #D0D0D0;padding:0 2.4pt;overflow:hidden;" +
1255
+ "white-space:nowrap;vertical-align:bottom;}" +
1256
+ ".xlsx td.wrap{white-space:normal;word-wrap:break-word;}" +
1257
+ ".sheetName{font-weight:bold;font-size:10pt;color:#444;padding:2pt 0 4pt 0;}" +
1258
+ ".truncNote{font-size:9pt;color:#888;padding:4pt 0;}"
1259
+ return OfficeDocumentConverter.Conversion(
1260
+ Ox.shell(maxSheetWidthPt.coerceIn(360f, 1600f) + 24f, css, body.toString()),
1261
+ sheets.size
1262
+ )
1263
+ }
1264
+ }
1265
+
1266
+ /** workbook.xml sheet order → (visible name, worksheet part path). */
1267
+ private fun sheetParts(): List<Pair<String, String>> {
1268
+ val rels = Ox.parseRels(zip, "xl/workbook.xml")
1269
+ val out = ArrayList<Pair<String, String>>()
1270
+ Ox.zipStream(zip, "xl/workbook.xml")?.use { stream ->
1271
+ val p = Ox.parser(stream)
1272
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
1273
+ if (p.eventType == XmlPullParser.START_TAG && Ox.local(p) == "sheet") {
1274
+ val name = Ox.attr(p, "name") ?: "Sheet"
1275
+ val target = rels[Ox.attr(p, "id")]
1276
+ // Hidden sheets are not part of what the user sees in Excel.
1277
+ if (target != null && Ox.attr(p, "state") != "hidden") out.add(name to target)
1278
+ }
1279
+ }
1280
+ }
1281
+ return out
1282
+ }
1283
+
1284
+ private fun parseSharedStrings() {
1285
+ Ox.zipStream(zip, "xl/sharedStrings.xml")?.use { stream ->
1286
+ val p = Ox.parser(stream)
1287
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
1288
+ if (p.eventType == XmlPullParser.START_TAG && Ox.local(p) == "si") {
1289
+ // Rich-text runs flatten to their concatenated text; formatting inside a shared
1290
+ // string is per-run and rare in previews.
1291
+ val sb = StringBuilder()
1292
+ var depth = 1
1293
+ while (depth > 0) {
1294
+ when (p.next()) {
1295
+ XmlPullParser.START_TAG -> if (Ox.local(p) == "t") sb.append(Ox.elementText(p)) else depth++
1296
+ XmlPullParser.END_TAG -> depth--
1297
+ XmlPullParser.END_DOCUMENT -> break
1298
+ }
1299
+ }
1300
+ sharedStrings.add(sb.toString())
1301
+ }
1302
+ }
1303
+ }
1304
+ }
1305
+
1306
+ // ── styles.xml → resolved per-xf CSS ─────────────────────────────
1307
+ private fun parseStyles() {
1308
+ val fonts = ArrayList<String>()
1309
+ val fills = ArrayList<String>()
1310
+ val borders = ArrayList<String>()
1311
+ Ox.zipStream(zip, "xl/styles.xml")?.use { stream ->
1312
+ val p = Ox.parser(stream)
1313
+ var inCellXfs = false
1314
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
1315
+ when (p.eventType) {
1316
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1317
+ "font" -> fonts.add(parseFont(p))
1318
+ "fill" -> fills.add(parseFill(p))
1319
+ "border" -> borders.add(parseBorder(p))
1320
+ "cellXfs" -> inCellXfs = true
1321
+ "xf" -> if (inCellXfs) {
1322
+ val style = CellStyle()
1323
+ style.numFmtId = Ox.attr(p, "numFmtId")?.toIntOrNull() ?: 0
1324
+ fonts.getOrNull(Ox.attr(p, "fontId")?.toIntOrNull() ?: -1)?.let { style.fontCss = it }
1325
+ fills.getOrNull(Ox.attr(p, "fillId")?.toIntOrNull() ?: -1)?.let { style.fillCss = it }
1326
+ borders.getOrNull(Ox.attr(p, "borderId")?.toIntOrNull() ?: -1)?.let { style.borderCss = it }
1327
+ var depth = 1
1328
+ while (depth > 0) {
1329
+ when (p.next()) {
1330
+ XmlPullParser.START_TAG -> if (Ox.local(p) == "alignment") {
1331
+ Ox.attr(p, "horizontal")?.let { style.alignCss += "text-align:$it;" }
1332
+ Ox.attr(p, "vertical")?.let {
1333
+ style.alignCss += "vertical-align:${if (it == "center") "middle" else it};"
1334
+ }
1335
+ if (Ox.attr(p, "wrapText") == "1" || Ox.attr(p, "wrapText") == "true") style.wrap = true
1336
+ Ox.skip(p)
1337
+ } else Ox.skip(p)
1338
+ XmlPullParser.END_TAG -> depth--
1339
+ XmlPullParser.END_DOCUMENT -> break
1340
+ }
1341
+ }
1342
+ cellStyles.add(style)
1343
+ } else Ox.skip(p)
1344
+ }
1345
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "cellXfs") inCellXfs = false
1346
+ }
1347
+ }
1348
+ }
1349
+ }
1350
+
1351
+ /** ARGB "FFRRGGBB" → #RRGGBB (theme/indexed colors have no static value here → null). */
1352
+ private fun argb(p: XmlPullParser): String? =
1353
+ Ox.attr(p, "rgb")?.takeIf { it.length == 8 }?.substring(2)?.let { "#$it" }
1354
+
1355
+ private fun parseFont(p: XmlPullParser): String {
1356
+ val css = StringBuilder()
1357
+ var depth = 1
1358
+ while (depth > 0) {
1359
+ when (p.next()) {
1360
+ XmlPullParser.START_TAG -> {
1361
+ when (Ox.local(p)) {
1362
+ "b" -> css.append("font-weight:bold;")
1363
+ "i" -> css.append("font-style:italic;")
1364
+ "u" -> css.append("text-decoration:underline;")
1365
+ "strike" -> css.append("text-decoration:line-through;")
1366
+ "sz" -> Ox.attr(p, "val")?.toFloatOrNull()?.let { css.append("font-size:${Ox.n(it)}pt;") }
1367
+ "name" -> Ox.attr(p, "val")?.let { css.append("font-family:'${it.replace("'", "")}';") }
1368
+ "color" -> argb(p)?.let { css.append("color:$it;") }
1369
+ }
1370
+ Ox.skip(p)
1371
+ }
1372
+ XmlPullParser.END_TAG -> depth--
1373
+ XmlPullParser.END_DOCUMENT -> break
1374
+ }
1375
+ }
1376
+ return css.toString()
1377
+ }
1378
+
1379
+ private fun parseFill(p: XmlPullParser): String {
1380
+ var css = ""
1381
+ var depth = 1
1382
+ while (depth > 0) {
1383
+ when (p.next()) {
1384
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1385
+ "patternFill" -> if (Ox.attr(p, "patternType") == "none") { Ox.skip(p) } // stay transparent
1386
+ "fgColor" -> { argb(p)?.let { css = "background-color:$it;" }; Ox.skip(p) }
1387
+ else -> Ox.skip(p)
1388
+ }
1389
+ XmlPullParser.END_TAG -> depth--
1390
+ XmlPullParser.END_DOCUMENT -> break
1391
+ }
1392
+ }
1393
+ return css
1394
+ }
1395
+
1396
+ private fun parseBorder(p: XmlPullParser): String {
1397
+ val css = StringBuilder()
1398
+ var depth = 1
1399
+ while (depth > 0) {
1400
+ when (p.next()) {
1401
+ XmlPullParser.START_TAG -> {
1402
+ val side = when (Ox.local(p)) {
1403
+ "left" -> "border-left"; "right" -> "border-right"
1404
+ "top" -> "border-top"; "bottom" -> "border-bottom"
1405
+ else -> null
1406
+ }
1407
+ if (side != null && Ox.attr(p, "style") != null) {
1408
+ val bs = when (Ox.attr(p, "style")) {
1409
+ "dashed", "mediumDashed" -> "dashed"; "dotted" -> "dotted"; "double" -> "double"
1410
+ "medium" -> "solid"; "thick" -> "solid"; else -> "solid"
1411
+ }
1412
+ val w = when (Ox.attr(p, "style")) { "medium" -> "1.5pt"; "thick" -> "2pt"; else -> "0.5pt" }
1413
+ // A border element's color child (if any) is inside — cheap scan.
1414
+ var color = "#000000"
1415
+ var sideDepth = 1
1416
+ while (sideDepth > 0) {
1417
+ when (p.next()) {
1418
+ XmlPullParser.START_TAG -> { if (Ox.local(p) == "color") argb(p)?.let { color = it }; Ox.skip(p) }
1419
+ XmlPullParser.END_TAG -> sideDepth--
1420
+ XmlPullParser.END_DOCUMENT -> break
1421
+ }
1422
+ }
1423
+ css.append("$side:$w $bs $color;")
1424
+ } else Ox.skip(p)
1425
+ }
1426
+ XmlPullParser.END_TAG -> depth--
1427
+ XmlPullParser.END_DOCUMENT -> break
1428
+ }
1429
+ }
1430
+ return css.toString()
1431
+ }
1432
+
1433
+ // ── Worksheet ────────────────────────────────────────────────────
1434
+ private class Cell(val html: String, val css: String, val wrap: Boolean) {
1435
+ var colspan = 1
1436
+ var rowspan = 1
1437
+ var covered = false
1438
+ }
1439
+
1440
+ private fun renderSheet(name: String, part: String, showTitle: Boolean): Pair<String, Float> {
1441
+ val colWidthPt = HashMap<Int, Float>() // 0-based col → pt
1442
+ val rows = LinkedHashMap<Int, HashMap<Int, Cell>>() // 0-based row → col → cell
1443
+ val rowHeightPt = HashMap<Int, Float>()
1444
+ val merges = ArrayList<IntArray>() // [r1, c1, r2, c2]
1445
+ var maxCol = 0
1446
+ var maxRow = -1
1447
+ var truncated = false
1448
+
1449
+ Ox.zipStream(zip, part)?.use { stream ->
1450
+ val p = Ox.parser(stream)
1451
+ var currentRow = -1
1452
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
1453
+ if (p.eventType != XmlPullParser.START_TAG) continue
1454
+ when (Ox.local(p)) {
1455
+ "col" -> {
1456
+ val min = Ox.attr(p, "min")?.toIntOrNull() ?: continue
1457
+ val max = Ox.attr(p, "max")?.toIntOrNull() ?: min
1458
+ val w = Ox.attr(p, "width")?.toFloatOrNull() ?: continue
1459
+ for (c in (min - 1) until max.coerceAtMost(MAX_COLS)) colWidthPt[c] = w * CHAR_TO_PT
1460
+ }
1461
+ "row" -> {
1462
+ currentRow = (Ox.attr(p, "r")?.toIntOrNull() ?: (currentRow + 2)) - 1
1463
+ if (currentRow >= MAX_ROWS) { truncated = true; Ox.skip(p); continue }
1464
+ Ox.attr(p, "ht")?.toFloatOrNull()?.let { rowHeightPt[currentRow] = it }
1465
+ if (currentRow > maxRow) maxRow = currentRow
1466
+ }
1467
+ "c" -> {
1468
+ if (currentRow < 0 || currentRow >= MAX_ROWS) { Ox.skip(p); continue }
1469
+ val ref = Ox.attr(p, "r")
1470
+ val col = ref?.let { colOfRef(it) } ?: ((rows[currentRow]?.size ?: 0))
1471
+ if (col >= MAX_COLS) { truncated = true; Ox.skip(p); continue }
1472
+ val type = Ox.attr(p, "t") ?: "n"
1473
+ val styleIndex = Ox.attr(p, "s")?.toIntOrNull() ?: -1
1474
+ var value: String? = null
1475
+ var inlineStr: String? = null
1476
+ var depth = 1
1477
+ while (depth > 0) {
1478
+ when (p.next()) {
1479
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1480
+ "v" -> value = Ox.elementText(p)
1481
+ "is" -> inlineStr = Ox.elementText(p)
1482
+ "f" -> Ox.skip(p) // formula text — the cached <v> is what Excel shows
1483
+ else -> Ox.skip(p)
1484
+ }
1485
+ XmlPullParser.END_TAG -> depth--
1486
+ XmlPullParser.END_DOCUMENT -> break
1487
+ }
1488
+ }
1489
+ val style = cellStyles.getOrNull(styleIndex)
1490
+ val display = displayValue(type, value, inlineStr, style?.numFmtId ?: 0)
1491
+ if (display != null || style != null) {
1492
+ val css = StringBuilder()
1493
+ style?.let {
1494
+ css.append(it.fontCss).append(it.fillCss).append(it.borderCss).append(it.alignCss)
1495
+ }
1496
+ // Numbers right-align by default in Excel; text left-aligns.
1497
+ if (style?.alignCss?.contains("text-align") != true && type == "n" && display != null) {
1498
+ css.append("text-align:right;")
1499
+ }
1500
+ rows.getOrPut(currentRow) { HashMap() }[col] =
1501
+ Cell(display?.let { Ox.esc(it) } ?: "", css.toString(), style?.wrap == true)
1502
+ if (col > maxCol) maxCol = col
1503
+ if (currentRow > maxRow) maxRow = currentRow
1504
+ }
1505
+ }
1506
+ "mergeCell" -> {
1507
+ Ox.attr(p, "ref")?.let { ref ->
1508
+ val parts = ref.split(':')
1509
+ if (parts.size == 2) {
1510
+ val r1 = rowOfRef(parts[0]); val c1 = colOfRef(parts[0])
1511
+ val r2 = rowOfRef(parts[1]); val c2 = colOfRef(parts[1])
1512
+ if (r1 >= 0 && c1 >= 0 && r2 >= r1 && c2 >= c1) merges.add(intArrayOf(r1, c1, r2, c2))
1513
+ }
1514
+ }
1515
+ }
1516
+ }
1517
+ }
1518
+ } ?: throw java.io.IOException("$part missing")
1519
+
1520
+ // Apply merged regions: anchor gets the span, covered cells drop out of the row.
1521
+ for (m in merges) {
1522
+ val (r1, c1, r2, c2) = m
1523
+ if (r1 >= MAX_ROWS || c1 >= MAX_COLS) continue
1524
+ val anchorRow = rows.getOrPut(r1) { HashMap() }
1525
+ val anchor = anchorRow[c1] ?: Cell("", "", false).also { anchorRow[c1] = it }
1526
+ anchor.colspan = (c2 - c1 + 1).coerceAtMost(MAX_COLS - c1)
1527
+ anchor.rowspan = (r2 - r1 + 1).coerceAtMost(MAX_ROWS - r1)
1528
+ for (r in r1..r2.coerceAtMost(MAX_ROWS - 1)) for (c in c1..c2.coerceAtMost(MAX_COLS - 1)) {
1529
+ if (r == r1 && c == c1) continue
1530
+ rows.getOrPut(r) { HashMap() }.getOrPut(c) { Cell("", "", false) }.covered = true
1531
+ }
1532
+ if (c2 > maxCol) maxCol = c2.coerceAtMost(MAX_COLS - 1)
1533
+ if (r2 > maxRow) maxRow = r2.coerceAtMost(MAX_ROWS - 1)
1534
+ }
1535
+
1536
+ var totalWidthPt = 0f
1537
+ for (c in 0..maxCol) totalWidthPt += colWidthPt[c] ?: DEFAULT_COL_PT
1538
+
1539
+ val sb = StringBuilder("<div class=\"sheet xlsx\" style=\"width:")
1540
+ .append(Ox.n(totalWidthPt + 12f)).append("pt;\">")
1541
+ if (showTitle) sb.append("<div class=\"sheetName\">").append(Ox.esc(name)).append("</div>")
1542
+ sb.append("<table><colgroup>")
1543
+ for (c in 0..maxCol) {
1544
+ sb.append("<col style=\"width:").append(Ox.n(colWidthPt[c] ?: DEFAULT_COL_PT)).append("pt;\"/>")
1545
+ }
1546
+ sb.append("</colgroup>")
1547
+ for (r in 0..maxRow) {
1548
+ val height = rowHeightPt[r] ?: DEFAULT_ROW_PT
1549
+ sb.append("<tr style=\"height:").append(Ox.n(height)).append("pt;\">")
1550
+ val rowCells = rows[r]
1551
+ for (c in 0..maxCol) {
1552
+ val cell = rowCells?.get(c)
1553
+ if (cell?.covered == true) continue
1554
+ sb.append("<td")
1555
+ if (cell != null) {
1556
+ if (cell.colspan > 1) sb.append(" colspan=\"").append(cell.colspan).append("\"")
1557
+ if (cell.rowspan > 1) sb.append(" rowspan=\"").append(cell.rowspan).append("\"")
1558
+ if (cell.wrap) sb.append(" class=\"wrap\"")
1559
+ if (cell.css.isNotEmpty()) sb.append(" style=\"").append(cell.css).append("\"")
1560
+ }
1561
+ sb.append(">").append(cell?.html?.ifEmpty { "" } ?: "").append("</td>")
1562
+ }
1563
+ sb.append("</tr>")
1564
+ }
1565
+ sb.append("</table>")
1566
+ if (truncated) {
1567
+ sb.append("<div class=\"truncNote\">" + Localization.getString("previewTruncatedRowsCols", "Preview truncated — showing first {0} rows / {1} columns", MAX_ROWS, MAX_COLS) + "</div>")
1568
+ }
1569
+ sb.append("</div>")
1570
+ return sb.toString() to (totalWidthPt + 12f)
1571
+ }
1572
+
1573
+ /** "BC12" → 0-based column 54. */
1574
+ private fun colOfRef(ref: String): Int {
1575
+ var col = 0
1576
+ for (ch in ref) {
1577
+ if (ch in 'A'..'Z') col = col * 26 + (ch - 'A' + 1)
1578
+ else if (ch in 'a'..'z') col = col * 26 + (ch - 'a' + 1)
1579
+ else break
1580
+ }
1581
+ return col - 1
1582
+ }
1583
+
1584
+ private fun rowOfRef(ref: String): Int {
1585
+ val digits = ref.dropWhile { it.isLetter() }
1586
+ return (digits.toIntOrNull() ?: 0) - 1
1587
+ }
1588
+
1589
+ private fun displayValue(type: String, v: String?, inlineStr: String?, numFmtId: Int): String? {
1590
+ if (inlineStr != null) return inlineStr
1591
+ if (v == null) return null
1592
+ return when (type) {
1593
+ "s" -> sharedStrings.getOrNull(v.toIntOrNull() ?: -1) ?: ""
1594
+ "str", "inlineStr" -> v
1595
+ "b" -> if (v == "1") "TRUE" else "FALSE"
1596
+ "e" -> v // error literal (#DIV/0! etc.) is stored as shown
1597
+ else -> formatNumber(v, numFmtId)
1598
+ }
1599
+ }
1600
+
1601
+ /** Builtin date/time formats convert the Excel serial; other numbers trim float noise. */
1602
+ private fun formatNumber(v: String, numFmtId: Int): String {
1603
+ val serial = v.toDoubleOrNull() ?: return v
1604
+ val isDate = numFmtId in 14..17 || numFmtId == 22
1605
+ val isTime = numFmtId in 18..21 || numFmtId in 45..47
1606
+ if (isDate || isTime) {
1607
+ // Excel epoch 1899-12-30 (with the fictional 1900 leap day already absorbed).
1608
+ val millis = ((serial - 25569.0) * 86400000.0).toLong()
1609
+ val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
1610
+ cal.timeInMillis = millis
1611
+ return if (isDate) {
1612
+ String.format(
1613
+ "%02d/%02d/%04d",
1614
+ cal.get(java.util.Calendar.MONTH) + 1,
1615
+ cal.get(java.util.Calendar.DAY_OF_MONTH),
1616
+ cal.get(java.util.Calendar.YEAR)
1617
+ )
1618
+ } else {
1619
+ String.format(
1620
+ "%02d:%02d:%02d",
1621
+ cal.get(java.util.Calendar.HOUR_OF_DAY),
1622
+ cal.get(java.util.Calendar.MINUTE),
1623
+ cal.get(java.util.Calendar.SECOND)
1624
+ )
1625
+ }
1626
+ }
1627
+ // Strip trailing ".0" and float noise the way the Excel grid does.
1628
+ return if (serial == serial.toLong().toDouble()) serial.toLong().toString()
1629
+ else String.format("%.10g", serial).trimEnd('0').trimEnd('.')
1630
+ }
1631
+ }
1632
+
1633
+ // ═══════════════════════════════════════════════════════════════════
1634
+ // PPTX (PresentationML)
1635
+ // ═══════════════════════════════════════════════════════════════════
1636
+
1637
+ // ═══════════════════════════════════════════════════════════════════
1638
+ // ODF shared (ODT + ODS) — automatic-style resolution
1639
+ // ═══════════════════════════════════════════════════════════════════
1640
+
1641
+ /**
1642
+ * ODF keeps formatting in named "automatic styles" inside content.xml (and page geometry in
1643
+ * styles.xml master pages). This base collects style-name → CSS maps that both the text and
1644
+ * spreadsheet converters resolve at render time, honoring parent-style-name chains.
1645
+ */
1646
+ internal abstract class OdfConverterBase(protected val file: File) {
1647
+
1648
+ protected class OdfStyle {
1649
+ var parent: String? = null
1650
+ var family: String? = null
1651
+ var textCss = "" // fo:* text properties
1652
+ var paraCss = "" // fo:* paragraph properties
1653
+ var cellCss = "" // table-cell properties
1654
+ var colWidthPt: Float? = null
1655
+ var rowHeightPt: Float? = null
1656
+ var pageBreakBefore = false
1657
+ }
1658
+
1659
+ protected lateinit var zip: ZipFile
1660
+ protected val odfStyles = HashMap<String, OdfStyle>()
1661
+ protected var imageBudget = Ox.MAX_TOTAL_IMAGE_BYTES
1662
+
1663
+ // Page geometry from styles.xml master page layout (defaults: A4 with 2cm margins).
1664
+ protected var pageWPt = 595f
1665
+ protected var pageHPt = 842f
1666
+ protected var marTopPt = 56.7f
1667
+ protected var marBottomPt = 56.7f
1668
+ protected var marLeftPt = 56.7f
1669
+ protected var marRightPt = 56.7f
1670
+
1671
+ /** "1.25cm" / "12pt" / "0.5in" / "3mm" → pt. */
1672
+ protected fun lenToPt(v: String?): Float? {
1673
+ if (v == null) return null
1674
+ val num = v.takeWhile { it.isDigit() || it == '.' || it == '-' }.toFloatOrNull() ?: return null
1675
+ return when {
1676
+ v.endsWith("cm") -> num * 28.3465f
1677
+ v.endsWith("mm") -> num * 2.83465f
1678
+ v.endsWith("in") -> num * 72f
1679
+ v.endsWith("pt") -> num
1680
+ v.endsWith("pc") -> num * 12f
1681
+ v.endsWith("px") -> num * 0.75f
1682
+ else -> num
1683
+ }
1684
+ }
1685
+
1686
+ /** Parses <style:style>/<style:page-layout> children of the element the parser is ON. */
1687
+ protected fun collectStyles(p: XmlPullParser, containerLocal: String) {
1688
+ var depth = 1
1689
+ while (depth > 0) {
1690
+ when (p.next()) {
1691
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1692
+ "style" -> {
1693
+ val name = Ox.attr(p, "name")
1694
+ if (name == null) { Ox.skip(p); continue }
1695
+ val style = OdfStyle()
1696
+ style.parent = Ox.attr(p, "parent-style-name")
1697
+ style.family = Ox.attr(p, "family")
1698
+ var sDepth = 1
1699
+ while (sDepth > 0) {
1700
+ when (p.next()) {
1701
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1702
+ "text-properties" -> { style.textCss = odfTextCss(p); Ox.skip(p) }
1703
+ "paragraph-properties" -> { style.paraCss = odfParaCss(p, style); Ox.skip(p) }
1704
+ "table-cell-properties" -> { style.cellCss = odfCellCss(p); Ox.skip(p) }
1705
+ "table-column-properties" -> { style.colWidthPt = lenToPt(Ox.attr(p, "column-width")); Ox.skip(p) }
1706
+ "table-row-properties" -> { style.rowHeightPt = lenToPt(Ox.attr(p, "row-height")); Ox.skip(p) }
1707
+ else -> Ox.skip(p)
1708
+ }
1709
+ XmlPullParser.END_TAG -> sDepth--
1710
+ XmlPullParser.END_DOCUMENT -> return
1711
+ }
1712
+ }
1713
+ odfStyles[name] = style
1714
+ }
1715
+ "page-layout" -> {
1716
+ var plDepth = 1
1717
+ while (plDepth > 0) {
1718
+ when (p.next()) {
1719
+ XmlPullParser.START_TAG -> if (Ox.local(p) == "page-layout-properties") {
1720
+ lenToPt(Ox.attr(p, "page-width"))?.let { pageWPt = it }
1721
+ lenToPt(Ox.attr(p, "page-height"))?.let { pageHPt = it }
1722
+ lenToPt(Ox.attr(p, "margin-top"))?.let { marTopPt = it }
1723
+ lenToPt(Ox.attr(p, "margin-bottom"))?.let { marBottomPt = it }
1724
+ lenToPt(Ox.attr(p, "margin-left"))?.let { marLeftPt = it }
1725
+ lenToPt(Ox.attr(p, "margin-right"))?.let { marRightPt = it }
1726
+ Ox.skip(p)
1727
+ } else Ox.skip(p)
1728
+ XmlPullParser.END_TAG -> plDepth--
1729
+ XmlPullParser.END_DOCUMENT -> return
1730
+ }
1731
+ }
1732
+ }
1733
+ else -> Ox.skip(p)
1734
+ }
1735
+ XmlPullParser.END_TAG -> if (Ox.local(p) == containerLocal) depth--
1736
+ XmlPullParser.END_DOCUMENT -> return
1737
+ }
1738
+ }
1739
+ }
1740
+
1741
+ /** Reads automatic styles + page layout from both styles.xml and content.xml. */
1742
+ protected fun parseAllStyles() {
1743
+ for (part in listOf("styles.xml", "content.xml")) {
1744
+ Ox.zipStream(zip, part)?.use { stream ->
1745
+ val p = Ox.parser(stream)
1746
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
1747
+ if (p.eventType != XmlPullParser.START_TAG) continue
1748
+ when (Ox.local(p)) {
1749
+ "automatic-styles", "styles" -> collectStyles(p, Ox.local(p))
1750
+ }
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+
1756
+ private fun odfTextCss(p: XmlPullParser): String {
1757
+ val css = StringBuilder()
1758
+ Ox.attr(p, "font-name")?.let { css.append("font-family:'${it.replace("'", "")}';") }
1759
+ Ox.attr(p, "font-family")?.let { css.append("font-family:'${it.replace("'", "")}';") }
1760
+ Ox.attr(p, "font-size")?.let { fs -> lenToPt(fs)?.let { css.append("font-size:${Ox.n(it)}pt;") } }
1761
+ Ox.attr(p, "font-weight")?.takeIf { it == "bold" || (it.toIntOrNull() ?: 0) >= 600 }
1762
+ ?.let { css.append("font-weight:bold;") }
1763
+ Ox.attr(p, "font-style")?.takeIf { it == "italic" || it == "oblique" }
1764
+ ?.let { css.append("font-style:italic;") }
1765
+ if (Ox.attr(p, "text-underline-style")?.let { it != "none" } == true) css.append("text-decoration:underline;")
1766
+ if (Ox.attr(p, "text-line-through-style")?.let { it != "none" } == true) css.append("text-decoration:line-through;")
1767
+ Ox.attr(p, "color")?.let { css.append("color:$it;") }
1768
+ Ox.attr(p, "background-color")?.takeIf { it != "transparent" }?.let { css.append("background-color:$it;") }
1769
+ when (Ox.attr(p, "text-position")?.substringBefore(' ')) {
1770
+ "super" -> css.append("vertical-align:super;font-size:smaller;")
1771
+ "sub" -> css.append("vertical-align:sub;font-size:smaller;")
1772
+ }
1773
+ return css.toString()
1774
+ }
1775
+
1776
+ private fun odfParaCss(p: XmlPullParser, style: OdfStyle): String {
1777
+ val css = StringBuilder()
1778
+ Ox.attr(p, "text-align")?.let {
1779
+ css.append("text-align:${when (it) { "end" -> "right"; "start" -> "left"; else -> it }};")
1780
+ }
1781
+ lenToPt(Ox.attr(p, "margin-left"))?.let { css.append("margin-left:${Ox.n(it)}pt;") }
1782
+ lenToPt(Ox.attr(p, "margin-right"))?.let { css.append("margin-right:${Ox.n(it)}pt;") }
1783
+ lenToPt(Ox.attr(p, "margin-top"))?.let { css.append("margin-top:${Ox.n(it)}pt;") }
1784
+ lenToPt(Ox.attr(p, "margin-bottom"))?.let { css.append("margin-bottom:${Ox.n(it)}pt;") }
1785
+ lenToPt(Ox.attr(p, "text-indent"))?.let { css.append("text-indent:${Ox.n(it)}pt;") }
1786
+ Ox.attr(p, "line-height")?.let { lh ->
1787
+ if (lh.endsWith("%")) lh.dropLast(1).toFloatOrNull()?.let { css.append("line-height:${Ox.n(it / 100f)};") }
1788
+ else lenToPt(lh)?.let { css.append("line-height:${Ox.n(it)}pt;") }
1789
+ }
1790
+ Ox.attr(p, "background-color")?.takeIf { it != "transparent" }?.let { css.append("background-color:$it;") }
1791
+ if (Ox.attr(p, "break-before") == "page") style.pageBreakBefore = true
1792
+ return css.toString()
1793
+ }
1794
+
1795
+ private fun odfCellCss(p: XmlPullParser): String {
1796
+ val css = StringBuilder()
1797
+ Ox.attr(p, "background-color")?.takeIf { it != "transparent" }?.let { css.append("background-color:$it;") }
1798
+ Ox.attr(p, "border")?.let { css.append("border:${odfBorder(it)};") }
1799
+ Ox.attr(p, "border-top")?.let { css.append("border-top:${odfBorder(it)};") }
1800
+ Ox.attr(p, "border-bottom")?.let { css.append("border-bottom:${odfBorder(it)};") }
1801
+ Ox.attr(p, "border-left")?.let { css.append("border-left:${odfBorder(it)};") }
1802
+ Ox.attr(p, "border-right")?.let { css.append("border-right:${odfBorder(it)};") }
1803
+ Ox.attr(p, "vertical-align")?.let {
1804
+ css.append("vertical-align:${if (it == "middle") "middle" else it};")
1805
+ }
1806
+ lenToPt(Ox.attr(p, "padding"))?.let { css.append("padding:${Ox.n(it)}pt;") }
1807
+ return css.toString()
1808
+ }
1809
+
1810
+ /** "0.5pt solid #000000" stays; "none" → none. */
1811
+ private fun odfBorder(v: String): String = if (v == "none") "none" else v
1812
+
1813
+ /** Chain-resolved CSS for a style name: parents first, own properties override. */
1814
+ protected fun styleCss(name: String?, pick: (OdfStyle) -> String, guard: Int = 0): String {
1815
+ if (name == null || guard > 8) return ""
1816
+ val style = odfStyles[name] ?: return ""
1817
+ return styleCss(style.parent, pick, guard + 1) + pick(style)
1818
+ }
1819
+
1820
+ protected fun styleOf(name: String?): OdfStyle? = name?.let { odfStyles[it] }
1821
+
1822
+ /** draw:image inside a frame — inlines the picture at the frame's authored size. */
1823
+ protected fun renderOdfImage(href: String?, wPt: Float?, hPt: Float?): String {
1824
+ if (href == null || href.startsWith("http")) return ""
1825
+ val path = href.trimStart('.', '/')
1826
+ val mime = Ox.mimeForName(path)
1827
+ if (mime == "image/x-emf" || mime == "application/octet-stream") return ""
1828
+ if (imageBudget <= 0) return ""
1829
+ val bytes = Ox.zipBytes(zip, path, minOf(Ox.MAX_IMAGE_BYTES, imageBudget)) ?: return ""
1830
+ imageBudget -= bytes.size
1831
+ val size = StringBuilder()
1832
+ wPt?.let { size.append("width:${Ox.n(it)}pt;") }
1833
+ hPt?.let { size.append("height:${Ox.n(it)}pt;") }
1834
+ return "<img style=\"$size\" src=\"${Ox.dataUri(bytes, mime)}\"/>"
1835
+ }
1836
+ }
1837
+
1838
+ // ═══════════════════════════════════════════════════════════════════
1839
+ // ODT (OpenDocument Text)
1840
+ // ═══════════════════════════════════════════════════════════════════
1841
+
1842
+ /**
1843
+ * True page geometry from the master page layout; paragraphs/spans resolve their automatic
1844
+ * styles (alignment, indents, spacing, fonts, colors), tables carry column widths and cell
1845
+ * styles, lists render with their level markers, images inline at authored frame size, and
1846
+ * hard page breaks (fo:break-before) start a new sheet.
1847
+ */
1848
+ internal class OdtConverter(file: File) : OdfConverterBase(file) {
1849
+
1850
+ fun convert(): OfficeDocumentConverter.Conversion {
1851
+ ZipFile(file).use { z ->
1852
+ zip = z
1853
+ parseAllStyles()
1854
+ val sheets = ArrayList<StringBuilder>()
1855
+ sheets.add(StringBuilder())
1856
+ Ox.zipStream(z, "content.xml")?.use { stream ->
1857
+ val p = Ox.parser(stream)
1858
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
1859
+ if (p.eventType == XmlPullParser.START_TAG && Ox.local(p) == "text" ) {
1860
+ // office:text — the document body container.
1861
+ renderBody(p) { pageBreak, html ->
1862
+ if (pageBreak && sheets.last().isNotEmpty()) sheets.add(StringBuilder())
1863
+ sheets.last().append(html)
1864
+ }
1865
+ break
1866
+ }
1867
+ }
1868
+ } ?: throw java.io.IOException("content.xml missing")
1869
+
1870
+ val body = StringBuilder()
1871
+ // Same display-margin cap as DOCX: content box keeps its authored width.
1872
+ val dMarT = minOf(marTopPt, Ox.MAX_DISPLAY_MARGIN_PT)
1873
+ val dMarB = minOf(marBottomPt, Ox.MAX_DISPLAY_MARGIN_PT)
1874
+ val dMarL = minOf(marLeftPt, Ox.MAX_DISPLAY_MARGIN_PT)
1875
+ val dMarR = minOf(marRightPt, Ox.MAX_DISPLAY_MARGIN_PT)
1876
+ val dPageW = pageWPt - (marLeftPt - dMarL) - (marRightPt - dMarR)
1877
+ val dPageH = pageHPt - (marTopPt - dMarT) - (marBottomPt - dMarB)
1878
+ for ((index, sheet) in sheets.withIndex()) {
1879
+ body.append("<div class=\"sheet odt\" style=\"width:").append(Ox.n(dPageW)).append("pt;")
1880
+ // All sheets size to content — automatic pagination is unknowable, and padding the
1881
+ // interior sheets to a full authored page height only adds blank bands on short pages.
1882
+ body.append("padding:")
1883
+ .append(Ox.n(dMarT)).append("pt ").append(Ox.n(dMarR)).append("pt ")
1884
+ .append(Ox.n(dMarB)).append("pt ").append(Ox.n(dMarL)).append("pt;\">")
1885
+ .append(sheet).append("</div>")
1886
+ }
1887
+ val css =
1888
+ ".odt{font-family:'Liberation Serif',serif;font-size:12pt;color:#000;line-height:1.15;}" +
1889
+ ".odt table{border-collapse:collapse;table-layout:fixed;}" +
1890
+ ".odt td{border:0.5pt solid #999;padding:1pt 2pt;vertical-align:top;}"
1891
+ return OfficeDocumentConverter.Conversion(
1892
+ Ox.shell(dPageW, css, body.toString()),
1893
+ sheets.size
1894
+ )
1895
+ }
1896
+ }
1897
+
1898
+ private fun renderBody(p: XmlPullParser, emit: (Boolean, String) -> Unit) {
1899
+ var depth = 1
1900
+ while (depth > 0) {
1901
+ when (p.next()) {
1902
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1903
+ "p", "h" -> {
1904
+ val styleName = Ox.attr(p, "style-name")
1905
+ val breakBefore = styleOf(styleName)?.pageBreakBefore == true ||
1906
+ generateSequence(styleOf(styleName)?.parent) { styleOf(it)?.parent }
1907
+ .take(8).any { styleOf(it)?.pageBreakBefore == true }
1908
+ emit(breakBefore, renderParagraph(p, styleName))
1909
+ }
1910
+ "list" -> emit(false, renderList(p, level = 0))
1911
+ "table" -> emit(false, renderTable(p))
1912
+ "section" -> { /* transparent container — children stream through */ }
1913
+ "soft-page-break" -> Ox.skip(p) // layout hint from the producing app, not authored
1914
+ else -> Ox.skip(p)
1915
+ }
1916
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "text" || Ox.local(p) == "section") {
1917
+ if (Ox.local(p) == "text") depth--
1918
+ }
1919
+ XmlPullParser.END_DOCUMENT -> return
1920
+ }
1921
+ }
1922
+ }
1923
+
1924
+ /** text:p / text:h — the parser is ON the element; consumes it. */
1925
+ private fun renderParagraph(p: XmlPullParser, styleName: String?): String {
1926
+ val content = renderInline(p, Ox.local(p))
1927
+ val css = styleCss(styleName, { it.paraCss }) + styleCss(styleName, { it.textCss })
1928
+ val body = if (content.isEmpty()) "&nbsp;" else content
1929
+ return "<p style=\"$css\">$body</p>"
1930
+ }
1931
+
1932
+ /** Inline content of a paragraph-like element until its END_TAG. */
1933
+ private fun renderInline(p: XmlPullParser, containerLocal: String): String {
1934
+ val sb = StringBuilder()
1935
+ var depth = 1
1936
+ while (depth > 0) {
1937
+ when (p.next()) {
1938
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1939
+ "span" -> {
1940
+ val css = styleCss(Ox.attr(p, "style-name"), { it.textCss })
1941
+ val inner = renderInline(p, "span")
1942
+ sb.append(if (css.isEmpty()) inner else "<span style=\"$css\">$inner</span>")
1943
+ }
1944
+ "a" -> sb.append(renderInline(p, "a")) // keep link text, drop navigation
1945
+ "s" -> {
1946
+ val count = Ox.attr(p, "c")?.toIntOrNull() ?: 1
1947
+ repeat(count.coerceIn(1, 100)) { sb.append("&nbsp;") }
1948
+ Ox.skip(p)
1949
+ }
1950
+ "tab" -> { sb.append("<span style=\"display:inline-block;width:36pt;\"></span>"); Ox.skip(p) }
1951
+ "line-break" -> { sb.append("<br/>"); Ox.skip(p) }
1952
+ "frame" -> sb.append(renderFrame(p))
1953
+ "note" -> Ox.skip(p) // footnote body would inline mid-text — drop like a print preview margin note
1954
+ else -> { /* descend transparently to catch nested text */ }
1955
+ }
1956
+ XmlPullParser.END_TAG -> if (Ox.local(p) == containerLocal) depth--
1957
+ XmlPullParser.TEXT -> sb.append(Ox.esc(p.text))
1958
+ XmlPullParser.END_DOCUMENT -> break
1959
+ }
1960
+ }
1961
+ return sb.toString()
1962
+ }
1963
+
1964
+ /** draw:frame → its draw:image at the frame's svg:width/height. */
1965
+ private fun renderFrame(p: XmlPullParser): String {
1966
+ val wPt = lenToPt(Ox.attr(p, "width"))
1967
+ val hPt = lenToPt(Ox.attr(p, "height"))
1968
+ var html = ""
1969
+ var depth = 1
1970
+ while (depth > 0) {
1971
+ when (p.next()) {
1972
+ XmlPullParser.START_TAG -> if (Ox.local(p) == "image") {
1973
+ html = renderOdfImage(Ox.attr(p, "href"), wPt, hPt)
1974
+ Ox.skip(p)
1975
+ } else Ox.skip(p)
1976
+ XmlPullParser.END_TAG -> depth--
1977
+ XmlPullParser.END_DOCUMENT -> break
1978
+ }
1979
+ }
1980
+ return html
1981
+ }
1982
+
1983
+ private fun renderList(p: XmlPullParser, level: Int): String {
1984
+ val sb = StringBuilder()
1985
+ var depth = 1
1986
+ var itemIndex = 0
1987
+ while (depth > 0) {
1988
+ when (p.next()) {
1989
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
1990
+ "list-item" -> {
1991
+ itemIndex++
1992
+ sb.append(renderListItem(p, level, itemIndex))
1993
+ }
1994
+ else -> Ox.skip(p)
1995
+ }
1996
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "list") depth--
1997
+ XmlPullParser.END_DOCUMENT -> break
1998
+ }
1999
+ }
2000
+ return sb.toString()
2001
+ }
2002
+
2003
+ private fun renderListItem(p: XmlPullParser, level: Int, index: Int): String {
2004
+ val sb = StringBuilder()
2005
+ val indentPt = 18f + level * 18f
2006
+ var depth = 1
2007
+ while (depth > 0) {
2008
+ when (p.next()) {
2009
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
2010
+ "p", "h" -> {
2011
+ val styleName = Ox.attr(p, "style-name")
2012
+ val content = renderInline(p, Ox.local(p))
2013
+ val css = styleCss(styleName, { it.paraCss }) + styleCss(styleName, { it.textCss })
2014
+ sb.append("<p style=\"margin-left:${Ox.n(indentPt)}pt;$css\">")
2015
+ // Without the list-style definition chain, bullets are the faithful default —
2016
+ // LibreOffice's own default list style is bullet at every level.
2017
+ .append("<span style=\"display:inline-block;min-width:14pt;\">•</span>")
2018
+ .append(if (content.isEmpty()) "&nbsp;" else content)
2019
+ .append("</p>")
2020
+ }
2021
+ "list" -> sb.append(renderList(p, level + 1))
2022
+ else -> Ox.skip(p)
2023
+ }
2024
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "list-item") depth--
2025
+ XmlPullParser.END_DOCUMENT -> break
2026
+ }
2027
+ }
2028
+ return sb.toString()
2029
+ }
2030
+
2031
+ private fun renderTable(p: XmlPullParser): String {
2032
+ val colWidths = ArrayList<Float?>()
2033
+ val rowsHtml = StringBuilder()
2034
+ var depth = 1
2035
+ while (depth > 0) {
2036
+ when (p.next()) {
2037
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
2038
+ "table-column" -> {
2039
+ val repeat = Ox.attr(p, "number-columns-repeated")?.toIntOrNull() ?: 1
2040
+ val w = styleOf(Ox.attr(p, "style-name"))?.colWidthPt
2041
+ repeat(repeat.coerceIn(1, 64)) { colWidths.add(w) }
2042
+ Ox.skip(p)
2043
+ }
2044
+ "table-row" -> rowsHtml.append(renderTableRow(p))
2045
+ "table-header-rows" -> { /* descend — rows inside stream through this loop */ }
2046
+ else -> Ox.skip(p)
2047
+ }
2048
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "table") depth--
2049
+ XmlPullParser.END_DOCUMENT -> break
2050
+ }
2051
+ }
2052
+ val total = colWidths.filterNotNull().sum().takeIf { it > 0 }
2053
+ val sb = StringBuilder("<table style=\"")
2054
+ total?.let { sb.append("width:${Ox.n(it)}pt;") }
2055
+ sb.append("\">")
2056
+ if (colWidths.any { it != null }) {
2057
+ sb.append("<colgroup>")
2058
+ for (w in colWidths) {
2059
+ sb.append(if (w != null) "<col style=\"width:${Ox.n(w)}pt;\"/>" else "<col/>")
2060
+ }
2061
+ sb.append("</colgroup>")
2062
+ }
2063
+ sb.append(rowsHtml).append("</table>")
2064
+ return sb.toString()
2065
+ }
2066
+
2067
+ private fun renderTableRow(p: XmlPullParser): String {
2068
+ val sb = StringBuilder("<tr")
2069
+ styleOf(Ox.attr(p, "style-name"))?.rowHeightPt?.let {
2070
+ sb.append(" style=\"height:${Ox.n(it)}pt;\"")
2071
+ }
2072
+ sb.append(">")
2073
+ var depth = 1
2074
+ while (depth > 0) {
2075
+ when (p.next()) {
2076
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
2077
+ "table-cell" -> {
2078
+ val spanCols = Ox.attr(p, "number-columns-spanned")?.toIntOrNull() ?: 1
2079
+ val spanRows = Ox.attr(p, "number-rows-spanned")?.toIntOrNull() ?: 1
2080
+ val css = styleCss(Ox.attr(p, "style-name"), { it.cellCss })
2081
+ val inner = StringBuilder()
2082
+ var cDepth = 1
2083
+ while (cDepth > 0) {
2084
+ when (p.next()) {
2085
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
2086
+ "p", "h" -> inner.append(renderParagraph(p, Ox.attr(p, "style-name")))
2087
+ "table" -> inner.append(renderTable(p))
2088
+ "list" -> inner.append(renderList(p, 0))
2089
+ else -> Ox.skip(p)
2090
+ }
2091
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "table-cell") cDepth--
2092
+ XmlPullParser.END_DOCUMENT -> break
2093
+ }
2094
+ }
2095
+ sb.append("<td")
2096
+ if (spanCols > 1) sb.append(" colspan=\"$spanCols\"")
2097
+ if (spanRows > 1) sb.append(" rowspan=\"$spanRows\"")
2098
+ if (css.isNotEmpty()) sb.append(" style=\"$css\"")
2099
+ sb.append(">").append(inner.ifEmpty { "&nbsp;" }).append("</td>")
2100
+ }
2101
+ "covered-table-cell" -> Ox.skip(p) // consumed by a spanning neighbour
2102
+ else -> Ox.skip(p)
2103
+ }
2104
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "table-row") depth--
2105
+ XmlPullParser.END_DOCUMENT -> break
2106
+ }
2107
+ }
2108
+ sb.append("</tr>")
2109
+ return sb.toString()
2110
+ }
2111
+ }
2112
+
2113
+ // ═══════════════════════════════════════════════════════════════════
2114
+ // ODS (OpenDocument Spreadsheet)
2115
+ // ═══════════════════════════════════════════════════════════════════
2116
+
2117
+ /**
2118
+ * Grid with authored column widths / row heights from automatic styles, cell backgrounds,
2119
+ * borders, fonts and merged regions (number-columns/rows-spanned). Values render from the
2120
+ * calculated office:value attributes exactly as the sheet displays them.
2121
+ */
2122
+ internal class OdsConverter(file: File) : OdfConverterBase(file) {
2123
+
2124
+ companion object {
2125
+ private const val MAX_ROWS = 2000
2126
+ private const val MAX_COLS = 256
2127
+ private const val DEFAULT_COL_PT = 48f
2128
+ private const val DEFAULT_ROW_PT = 14f
2129
+ }
2130
+
2131
+ fun convert(): OfficeDocumentConverter.Conversion {
2132
+ ZipFile(file).use { z ->
2133
+ zip = z
2134
+ parseAllStyles()
2135
+ val body = StringBuilder()
2136
+ var sheetCount = 0
2137
+ var maxWidthPt = 360f
2138
+ Ox.zipStream(z, "content.xml")?.use { stream ->
2139
+ val p = Ox.parser(stream)
2140
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
2141
+ if (p.eventType == XmlPullParser.START_TAG && Ox.local(p) == "table") {
2142
+ sheetCount++
2143
+ val name = Ox.attr(p, "name") ?: "Sheet$sheetCount"
2144
+ val (html, widthPt) = renderSheet(p, name)
2145
+ body.append(html)
2146
+ if (widthPt > maxWidthPt) maxWidthPt = widthPt
2147
+ }
2148
+ }
2149
+ } ?: throw java.io.IOException("content.xml missing")
2150
+ if (sheetCount == 0) throw java.io.IOException("No sheets")
2151
+ val css =
2152
+ ".ods{font-family:'Liberation Sans',sans-serif;font-size:10pt;color:#000;padding:6pt;}" +
2153
+ ".ods table{table-layout:fixed;border-collapse:collapse;}" +
2154
+ ".ods td{border:0.5pt solid #D0D0D0;padding:0 2pt;overflow:hidden;" +
2155
+ "white-space:nowrap;vertical-align:bottom;}" +
2156
+ ".sheetName{font-weight:bold;font-size:10pt;color:#444;padding:2pt 0 4pt 0;}" +
2157
+ ".truncNote{font-size:9pt;color:#888;padding:4pt 0;}"
2158
+ return OfficeDocumentConverter.Conversion(
2159
+ Ox.shell(maxWidthPt.coerceIn(360f, 1600f) + 24f, css, body.toString()),
2160
+ sheetCount
2161
+ )
2162
+ }
2163
+ }
2164
+
2165
+ /** Parser is ON <table:table>; consumes it. */
2166
+ private fun renderSheet(p: XmlPullParser, name: String): Pair<String, Float> {
2167
+ val colWidths = ArrayList<Float>()
2168
+ val rowsHtml = StringBuilder()
2169
+ var rowCount = 0
2170
+ var truncated = false
2171
+ var depth = 1
2172
+ while (depth > 0) {
2173
+ when (p.next()) {
2174
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
2175
+ "table-column" -> {
2176
+ val repeatCols = (Ox.attr(p, "number-columns-repeated")?.toIntOrNull() ?: 1)
2177
+ // A trailing repeated column definition can claim thousands of columns; clamp to
2178
+ // the remaining room (which may already be zero).
2179
+ val room = (MAX_COLS - colWidths.size).coerceAtLeast(0)
2180
+ val w = styleOf(Ox.attr(p, "style-name"))?.colWidthPt ?: DEFAULT_COL_PT
2181
+ repeat(minOf(repeatCols.coerceAtLeast(1), room)) { colWidths.add(w) }
2182
+ Ox.skip(p)
2183
+ }
2184
+ "table-row" -> {
2185
+ if (rowCount >= MAX_ROWS) { truncated = true; Ox.skip(p); continue }
2186
+ val repeatRows = (Ox.attr(p, "number-rows-repeated")?.toIntOrNull() ?: 1).coerceIn(1, 100)
2187
+ val rowHtml = renderRow(p)
2188
+ // Empty repeated filler rows (common trailing artifact) contribute once, not N times.
2189
+ val times = if (rowHtml.second) 1 else repeatRows
2190
+ repeat(times.coerceAtMost(MAX_ROWS - rowCount)) {
2191
+ rowsHtml.append(rowHtml.first)
2192
+ rowCount++
2193
+ }
2194
+ }
2195
+ "table-header-rows" -> { /* descend */ }
2196
+ else -> Ox.skip(p)
2197
+ }
2198
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "table") depth--
2199
+ XmlPullParser.END_DOCUMENT -> break
2200
+ }
2201
+ }
2202
+ val totalWidth = colWidths.sum().coerceAtLeast(DEFAULT_COL_PT)
2203
+ val sb = StringBuilder("<div class=\"sheet ods\" style=\"width:")
2204
+ .append(Ox.n(totalWidth + 12f)).append("pt;\">")
2205
+ .append("<div class=\"sheetName\">").append(Ox.esc(name)).append("</div>")
2206
+ .append("<table><colgroup>")
2207
+ for (w in colWidths) sb.append("<col style=\"width:${Ox.n(w)}pt;\"/>")
2208
+ sb.append("</colgroup>").append(rowsHtml).append("</table>")
2209
+ if (truncated) sb.append("<div class=\"truncNote\">" + Localization.getString("previewTruncatedRows", "Preview truncated — showing first {0} rows", MAX_ROWS) + "</div>")
2210
+ sb.append("</div>")
2211
+ return sb.toString() to (totalWidth + 12f)
2212
+ }
2213
+
2214
+ /** Returns (rowHtml, isEmpty). Parser is ON <table:table-row>; consumes it. */
2215
+ private fun renderRow(p: XmlPullParser): Pair<String, Boolean> {
2216
+ val sb = StringBuilder("<tr")
2217
+ styleOf(Ox.attr(p, "style-name"))?.rowHeightPt?.let { sb.append(" style=\"height:${Ox.n(it)}pt;\"") }
2218
+ ?: sb.append(" style=\"height:${Ox.n(DEFAULT_ROW_PT)}pt;\"")
2219
+ sb.append(">")
2220
+ var isEmpty = true
2221
+ var colCount = 0
2222
+ var depth = 1
2223
+ while (depth > 0) {
2224
+ when (p.next()) {
2225
+ XmlPullParser.START_TAG -> when (Ox.local(p)) {
2226
+ "table-cell" -> {
2227
+ if (colCount >= MAX_COLS) { Ox.skip(p); continue }
2228
+ val repeatCells = (Ox.attr(p, "number-columns-repeated")?.toIntOrNull() ?: 1)
2229
+ .coerceIn(1, MAX_COLS - colCount)
2230
+ val spanCols = Ox.attr(p, "number-columns-spanned")?.toIntOrNull() ?: 1
2231
+ val spanRows = Ox.attr(p, "number-rows-spanned")?.toIntOrNull() ?: 1
2232
+ val css = styleCss(Ox.attr(p, "style-name"), { it.cellCss }) +
2233
+ styleCss(Ox.attr(p, "style-name"), { it.textCss })
2234
+ val valueType = Ox.attr(p, "value-type")
2235
+ // Calculated/typed value attribute is what the grid displays for non-strings.
2236
+ val typedValue = Ox.attr(p, "value") ?: Ox.attr(p, "date-value") ?: Ox.attr(p, "time-value")
2237
+ val inner = StringBuilder()
2238
+ var cDepth = 1
2239
+ while (cDepth > 0) {
2240
+ when (p.next()) {
2241
+ XmlPullParser.START_TAG -> if (Ox.local(p) == "p") {
2242
+ if (inner.isNotEmpty()) inner.append("<br/>")
2243
+ inner.append(Ox.esc(Ox.elementText(p)))
2244
+ } else Ox.skip(p)
2245
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "table-cell") cDepth--
2246
+ XmlPullParser.END_DOCUMENT -> break
2247
+ }
2248
+ }
2249
+ val display = if (inner.isNotEmpty()) inner.toString()
2250
+ else typedValue?.let { Ox.esc(trimFloat(it)) } ?: ""
2251
+ if (display.isNotEmpty()) isEmpty = false
2252
+ val alignCss = if (css.contains("text-align")) ""
2253
+ else if (valueType == "float" || valueType == "currency" || valueType == "percentage") "text-align:right;"
2254
+ else ""
2255
+ repeat(repeatCells) {
2256
+ sb.append("<td")
2257
+ if (spanCols > 1) sb.append(" colspan=\"$spanCols\"")
2258
+ if (spanRows > 1) sb.append(" rowspan=\"$spanRows\"")
2259
+ val fullCss = css + alignCss
2260
+ if (fullCss.isNotEmpty()) sb.append(" style=\"$fullCss\"")
2261
+ sb.append(">").append(display).append("</td>")
2262
+ colCount++
2263
+ }
2264
+ }
2265
+ "covered-table-cell" -> {
2266
+ // Placeholder under a merged anchor — no <td> emitted.
2267
+ Ox.skip(p)
2268
+ }
2269
+ else -> Ox.skip(p)
2270
+ }
2271
+ XmlPullParser.END_TAG -> if (Ox.local(p) == "table-row") depth--
2272
+ XmlPullParser.END_DOCUMENT -> break
2273
+ }
2274
+ }
2275
+ sb.append("</tr>")
2276
+ return sb.toString() to isEmpty
2277
+ }
2278
+
2279
+ private fun trimFloat(v: String): String {
2280
+ val d = v.toDoubleOrNull() ?: return v
2281
+ return if (d == d.toLong().toDouble()) d.toLong().toString() else v
2282
+ }
2283
+ }
2284
+
2285
+ // ═══════════════════════════════════════════════════════════════════
2286
+ // RTF
2287
+ // ═══════════════════════════════════════════════════════════════════
2288
+
2289
+ /**
2290
+ * Streaming RTF control-word interpreter with a proper group state stack — \b \i \ul \strike,
2291
+ * \fs (half-points), \cfN against the color table, \fN against the font table, paragraph
2292
+ * alignment (\ql \qc \qr \qj), first-line/left indents (\fiN \liN twips), \page as a hard
2293
+ * sheet break, and \'hh / \uN unicode escapes. Paper geometry honors \paperwN \paperhN and
2294
+ * margin control words when present (US Letter + 1in otherwise, RTF's own defaults).
2295
+ */
2296
+ internal class RtfConverter(private val file: File) {
2297
+
2298
+ private class State {
2299
+ var bold = false
2300
+ var italic = false
2301
+ var underline = false
2302
+ var strike = false
2303
+ var fontSizePt = 12f
2304
+ var colorIndex = -1
2305
+ var fontIndex = -1
2306
+ fun copy(): State = State().also {
2307
+ it.bold = bold; it.italic = italic; it.underline = underline; it.strike = strike
2308
+ it.fontSizePt = fontSizePt; it.colorIndex = colorIndex; it.fontIndex = fontIndex
2309
+ }
2310
+ }
2311
+
2312
+ private val colorTable = ArrayList<String>()
2313
+ private val fontTable = HashMap<Int, String>()
2314
+
2315
+ companion object {
2316
+ private const val MAX_RTF_BYTES = 4 * 1024 * 1024
2317
+ }
2318
+
2319
+ fun convert(): OfficeDocumentConverter.Conversion {
2320
+ val raw = file.readBytes().let { if (it.size > MAX_RTF_BYTES) it.copyOf(MAX_RTF_BYTES) else it }
2321
+ val text = String(raw, Charsets.ISO_8859_1) // RTF is 7-bit with \'hh escapes for high bytes
2322
+ if (!text.startsWith("{\\rtf")) throw java.io.IOException("Not an RTF file")
2323
+
2324
+ var paperWPt = 612f; var paperHPt = 792f
2325
+ var marLPt = 72f; var marRPt = 72f; var marTPt = 72f; var marBPt = 72f
2326
+
2327
+ val sheets = ArrayList<StringBuilder>()
2328
+ sheets.add(StringBuilder())
2329
+ val para = StringBuilder() // runs of the current paragraph
2330
+ val run = StringBuilder() // visible text of the current run
2331
+ var state = State()
2332
+ val stack = ArrayDeque<State>()
2333
+ var paraAlign = "left"
2334
+ var paraFiPt = 0f
2335
+ var paraLiPt = 0f
2336
+
2337
+ /** Closes the current run into the paragraph with the active character CSS. */
2338
+ fun flushRun() {
2339
+ if (run.isEmpty()) return
2340
+ val css = StringBuilder()
2341
+ if (state.bold) css.append("font-weight:bold;")
2342
+ if (state.italic) css.append("font-style:italic;")
2343
+ val deco = ArrayList<String>()
2344
+ if (state.underline) deco.add("underline")
2345
+ if (state.strike) deco.add("line-through")
2346
+ if (deco.isNotEmpty()) css.append("text-decoration:${deco.joinToString(" ")};")
2347
+ css.append("font-size:${Ox.n(state.fontSizePt)}pt;")
2348
+ colorTable.getOrNull(state.colorIndex)?.let { css.append("color:$it;") }
2349
+ fontTable[state.fontIndex]?.let { css.append("font-family:'${it.replace("'", "")}';") }
2350
+ para.append("<span style=\"$css\">").append(run).append("</span>")
2351
+ run.setLength(0)
2352
+ }
2353
+
2354
+ fun flushPara() {
2355
+ flushRun()
2356
+ val css = StringBuilder("text-align:$paraAlign;")
2357
+ if (paraLiPt != 0f) css.append("margin-left:${Ox.n(paraLiPt)}pt;")
2358
+ if (paraFiPt != 0f) css.append("text-indent:${Ox.n(paraFiPt)}pt;")
2359
+ sheets.last().append("<p style=\"$css\">")
2360
+ .append(if (para.isEmpty()) "&nbsp;" else para.toString())
2361
+ .append("</p>")
2362
+ para.setLength(0)
2363
+ }
2364
+
2365
+ var i = 0
2366
+ var skipGroupDepth = 0 // inside \fonttbl/\colortbl/\pict/\*-destination
2367
+ var pendingColorRed = -1; var pendingColorGreen = -1; var pendingColorBlue = -1
2368
+ var inColorTable = false
2369
+ var inFontTable = false
2370
+ var fontTableIndex = -1
2371
+ val fontNameBuf = StringBuilder()
2372
+ var uSkip = 1 // \ucN — ANSI fallback chars to skip after \uN
2373
+
2374
+ while (i < text.length) {
2375
+ val c = text[i]
2376
+ when {
2377
+ c == '{' -> {
2378
+ if (skipGroupDepth > 0) skipGroupDepth++
2379
+ else stack.addLast(state.copy())
2380
+ i++
2381
+ }
2382
+ c == '}' -> {
2383
+ if (skipGroupDepth > 0) {
2384
+ skipGroupDepth--
2385
+ if (skipGroupDepth == 0) { inColorTable = false; inFontTable = false }
2386
+ } else {
2387
+ if (inFontTable && fontTableIndex >= 0 && fontNameBuf.isNotEmpty()) {
2388
+ fontTable[fontTableIndex] = fontNameBuf.toString().trimEnd(';').trim()
2389
+ fontNameBuf.setLength(0)
2390
+ }
2391
+ flushRun()
2392
+ state = stack.removeLastOrNull() ?: State()
2393
+ }
2394
+ i++
2395
+ }
2396
+ c == '\\' -> {
2397
+ i++
2398
+ if (i >= text.length) break
2399
+ val next = text[i]
2400
+ if (next == '\'') {
2401
+ // Hex escape → ISO-8859-1 char.
2402
+ val hex = text.substring(i + 1, minOf(i + 3, text.length))
2403
+ hex.toIntOrNull(16)?.let { code ->
2404
+ val ch = code.toChar()
2405
+ if (inFontTable) fontNameBuf.append(ch)
2406
+ else if (skipGroupDepth == 0) run.append(Ox.esc(ch.toString()))
2407
+ }
2408
+ i += 3
2409
+ } else if (!next.isLetter()) {
2410
+ // Escaped literal / special: \\ \{ \} \~ \- \*
2411
+ when (next) {
2412
+ '\\', '{', '}' -> if (skipGroupDepth == 0) run.append(Ox.esc(next.toString()))
2413
+ '~' -> if (skipGroupDepth == 0) run.append("&nbsp;")
2414
+ '*' -> if (skipGroupDepth == 0 && !inColorTable && !inFontTable) skipGroupDepth = 1
2415
+ }
2416
+ i++
2417
+ } else {
2418
+ // Control word: letters + optional signed numeric parameter + optional space.
2419
+ val wordStart = i
2420
+ while (i < text.length && text[i].isLetter()) i++
2421
+ val word = text.substring(wordStart, i)
2422
+ var param: Int? = null
2423
+ if (i < text.length && (text[i] == '-' || text[i].isDigit())) {
2424
+ val numStart = i
2425
+ if (text[i] == '-') i++
2426
+ while (i < text.length && text[i].isDigit()) i++
2427
+ param = text.substring(numStart, i).toIntOrNull()
2428
+ }
2429
+ if (i < text.length && text[i] == ' ') i++
2430
+ if (skipGroupDepth > 0 && !inColorTable && !inFontTable) continue
2431
+
2432
+ when (word) {
2433
+ "par" -> flushPara()
2434
+ "page" -> { flushPara(); sheets.add(StringBuilder()) }
2435
+ "pard" -> { paraAlign = "left"; paraFiPt = 0f; paraLiPt = 0f }
2436
+ "plain" -> { flushRun(); state = State() }
2437
+ "b" -> { flushRun(); state.bold = param != 0 }
2438
+ "i" -> { flushRun(); state.italic = param != 0 }
2439
+ "ul" -> { flushRun(); state.underline = param != 0 }
2440
+ "ulnone" -> { flushRun(); state.underline = false }
2441
+ "strike" -> { flushRun(); state.strike = param != 0 }
2442
+ "fs" -> { flushRun(); param?.let { state.fontSizePt = it / 2f } }
2443
+ "cf" -> { flushRun(); state.colorIndex = param ?: -1 }
2444
+ "f" -> {
2445
+ if (inFontTable) {
2446
+ if (fontTableIndex >= 0 && fontNameBuf.isNotEmpty()) {
2447
+ fontTable[fontTableIndex] = fontNameBuf.toString().trimEnd(';').trim()
2448
+ fontNameBuf.setLength(0)
2449
+ }
2450
+ fontTableIndex = param ?: -1
2451
+ } else { flushRun(); state.fontIndex = param ?: -1 }
2452
+ }
2453
+ "ql" -> paraAlign = "left"
2454
+ "qc" -> paraAlign = "center"
2455
+ "qr" -> paraAlign = "right"
2456
+ "qj" -> paraAlign = "justify"
2457
+ "fi" -> paraFiPt = (param ?: 0) / 20f
2458
+ "li" -> paraLiPt = (param ?: 0) / 20f
2459
+ "paperw" -> param?.let { paperWPt = it / 20f }
2460
+ "paperh" -> param?.let { paperHPt = it / 20f }
2461
+ "margl" -> param?.let { marLPt = it / 20f }
2462
+ "margr" -> param?.let { marRPt = it / 20f }
2463
+ "margt" -> param?.let { marTPt = it / 20f }
2464
+ "margb" -> param?.let { marBPt = it / 20f }
2465
+ "line" -> run.append("<br/>")
2466
+ "tab" -> run.append("<span style=\"display:inline-block;width:36pt;\"></span>")
2467
+ "u" -> {
2468
+ // \uN — signed 16-bit unicode, then skip \uc ANSI fallback chars.
2469
+ param?.let { code ->
2470
+ val cp = if (code < 0) code + 65536 else code
2471
+ run.append("&#$cp;")
2472
+ }
2473
+ var skipped = 0
2474
+ while (skipped < uSkip && i < text.length) {
2475
+ if (text[i] == '\\' && i + 1 < text.length && text[i + 1] == '\'') { i += 4; skipped++ }
2476
+ else if (text[i] != '{' && text[i] != '}' && text[i] != '\\') { i++; skipped++ }
2477
+ else break
2478
+ }
2479
+ }
2480
+ "uc" -> uSkip = param ?: 1
2481
+ "fonttbl" -> { inFontTable = true; skipGroupDepth = 1 }
2482
+ "colortbl" -> { inColorTable = true; skipGroupDepth = 1 }
2483
+ "red" -> pendingColorRed = param ?: 0
2484
+ "green" -> pendingColorGreen = param ?: 0
2485
+ "blue" -> pendingColorBlue = param ?: 0
2486
+ "pict", "object", "header", "footer", "footnote", "info", "stylesheet" ->
2487
+ if (skipGroupDepth == 0) skipGroupDepth = 1
2488
+ "fnil", "froman", "fswiss", "fmodern", "fscript", "fdecor", "ftech" -> {} // font family class markers
2489
+ }
2490
+ }
2491
+ }
2492
+ c == ';' && inColorTable -> {
2493
+ // Color entries terminate with ';'. An entry with no \red… is the auto color.
2494
+ colorTable.add(
2495
+ if (pendingColorRed >= 0)
2496
+ String.format("#%02X%02X%02X", pendingColorRed, pendingColorGreen.coerceAtLeast(0), pendingColorBlue.coerceAtLeast(0))
2497
+ else "#000000"
2498
+ )
2499
+ pendingColorRed = -1; pendingColorGreen = -1; pendingColorBlue = -1
2500
+ i++
2501
+ }
2502
+ c == '\r' || c == '\n' -> i++
2503
+ else -> {
2504
+ if (inFontTable) fontNameBuf.append(c)
2505
+ else if (skipGroupDepth == 0) run.append(Ox.esc(c.toString()))
2506
+ i++
2507
+ }
2508
+ }
2509
+ }
2510
+ flushPara()
2511
+
2512
+ val body = StringBuilder()
2513
+ // Same display-margin cap as DOCX: content box keeps its authored width.
2514
+ val dMarT = minOf(marTPt, Ox.MAX_DISPLAY_MARGIN_PT)
2515
+ val dMarB = minOf(marBPt, Ox.MAX_DISPLAY_MARGIN_PT)
2516
+ val dMarL = minOf(marLPt, Ox.MAX_DISPLAY_MARGIN_PT)
2517
+ val dMarR = minOf(marRPt, Ox.MAX_DISPLAY_MARGIN_PT)
2518
+ val dPaperW = paperWPt - (marLPt - dMarL) - (marRPt - dMarR)
2519
+ val dPaperH = paperHPt - (marTPt - dMarT) - (marBPt - dMarB)
2520
+ for ((index, sheet) in sheets.withIndex()) {
2521
+ if (sheet.isEmpty() && sheets.size > 1) continue
2522
+ body.append("<div class=\"sheet rtf\" style=\"width:${Ox.n(dPaperW)}pt;")
2523
+ // All sheets size to content (see DOCX) — no full-page blank band under the end.
2524
+ body.append("padding:${Ox.n(dMarT)}pt ${Ox.n(dMarR)}pt ${Ox.n(dMarB)}pt ${Ox.n(dMarL)}pt;\">")
2525
+ .append(sheet).append("</div>")
2526
+ }
2527
+ val css = ".rtf{font-family:'Times New Roman',serif;font-size:12pt;color:#000;line-height:1.15;}"
2528
+ return OfficeDocumentConverter.Conversion(
2529
+ Ox.shell(dPaperW, css, body.toString()),
2530
+ sheets.size
2531
+ )
2532
+ }
2533
+ }
2534
+
2535
+ // ═══════════════════════════════════════════════════════════════════
2536
+ // CSV
2537
+ // ═══════════════════════════════════════════════════════════════════
2538
+
2539
+ /**
2540
+ * RFC-4180 state-machine parse (quoted fields, embedded delimiters/newlines, "" escapes) with
2541
+ * , ; and TAB delimiter sniffing. The grid renders like a spreadsheet: per-column widths sized
2542
+ * from the longest content (clamped), header row bolded when it looks like one, numbers
2543
+ * right-aligned — the closest visual to opening the file in Excel/Calc.
2544
+ */
2545
+ internal class CsvConverter(private val file: File) {
2546
+
2547
+ companion object {
2548
+ private const val MAX_CSV_BYTES = 2 * 1024 * 1024
2549
+ private const val MAX_ROWS = 3000
2550
+ private const val MIN_COL_PT = 36f
2551
+ private const val MAX_COL_PT = 240f
2552
+ private const val CHAR_PT = 5.1f // ≈ average glyph advance at 10pt sans
2553
+ }
2554
+
2555
+ fun convert(): OfficeDocumentConverter.Conversion {
2556
+ val raw = file.readBytes().let { if (it.size > MAX_CSV_BYTES) it.copyOf(MAX_CSV_BYTES) else it }
2557
+ val truncatedBytes = file.length() > MAX_CSV_BYTES
2558
+ var text = String(raw, Charsets.UTF_8).removePrefix("")
2559
+ if (truncatedBytes) text = text.substringBeforeLast('\n') // drop the torn last record
2560
+
2561
+ val delimiter = sniffDelimiter(text)
2562
+ val rows = parse(text, delimiter)
2563
+ if (rows.isEmpty()) throw java.io.IOException("Empty CSV")
2564
+ val truncatedRows = rows.size > MAX_ROWS
2565
+ val visible = if (truncatedRows) rows.subList(0, MAX_ROWS) else rows
2566
+
2567
+ val colCount = visible.maxOf { it.size }.coerceAtMost(256)
2568
+ val widths = FloatArray(colCount) { MIN_COL_PT }
2569
+ for (row in visible) {
2570
+ for (ci in 0 until minOf(row.size, colCount)) {
2571
+ val w = (row[ci].length * CHAR_PT + 8f).coerceIn(MIN_COL_PT, MAX_COL_PT)
2572
+ if (w > widths[ci]) widths[ci] = w
2573
+ }
2574
+ }
2575
+
2576
+ // Header heuristic: first row all non-empty, non-numeric, and a later row has a number.
2577
+ val first = visible.first()
2578
+ val headerLikely = first.isNotEmpty() && first.all { it.isNotBlank() && it.toDoubleOrNull() == null } &&
2579
+ visible.drop(1).take(20).any { row -> row.any { it.toDoubleOrNull() != null } }
2580
+
2581
+ val sb = StringBuilder("<div class=\"sheet csv\" style=\"width:${Ox.n(widths.sum() + 12f)}pt;\">")
2582
+ sb.append("<table><colgroup>")
2583
+ for (w in widths) sb.append("<col style=\"width:${Ox.n(w)}pt;\"/>")
2584
+ sb.append("</colgroup>")
2585
+ for ((ri, row) in visible.withIndex()) {
2586
+ sb.append("<tr>")
2587
+ for (ci in 0 until colCount) {
2588
+ val value = row.getOrNull(ci) ?: ""
2589
+ val numeric = value.isNotEmpty() && value.toDoubleOrNull() != null
2590
+ sb.append("<td")
2591
+ val css = StringBuilder()
2592
+ if (ri == 0 && headerLikely) css.append("font-weight:bold;background-color:#EFEFEF;")
2593
+ if (numeric) css.append("text-align:right;")
2594
+ if (css.isNotEmpty()) sb.append(" style=\"$css\"")
2595
+ sb.append(">").append(Ox.esc(value)).append("</td>")
2596
+ }
2597
+ sb.append("</tr>")
2598
+ }
2599
+ sb.append("</table>")
2600
+ if (truncatedRows || truncatedBytes) {
2601
+ sb.append("<div class=\"truncNote\">" + Localization.getString("previewTruncatedNotice", "Preview truncated") + "</div>")
2602
+ }
2603
+ sb.append("</div>")
2604
+
2605
+ val css =
2606
+ ".csv{font-family:'Roboto',sans-serif;font-size:10pt;color:#000;padding:6pt;}" +
2607
+ ".csv table{table-layout:fixed;border-collapse:collapse;}" +
2608
+ ".csv td{border:0.5pt solid #D0D0D0;padding:1pt 3pt;overflow:hidden;white-space:nowrap;}" +
2609
+ ".truncNote{font-size:9pt;color:#888;padding:4pt 0;}"
2610
+ return OfficeDocumentConverter.Conversion(
2611
+ Ox.shell((widths.sum() + 24f).coerceIn(360f, 1600f), css, sb.toString()),
2612
+ 0
2613
+ )
2614
+ }
2615
+
2616
+ /** Counts candidate delimiters outside quotes on the first lines; most frequent wins. */
2617
+ private fun sniffDelimiter(text: String): Char {
2618
+ val head = text.take(4096)
2619
+ var comma = 0; var semi = 0; var tab = 0
2620
+ var inQuotes = false
2621
+ for (c in head) {
2622
+ when {
2623
+ c == '"' -> inQuotes = !inQuotes
2624
+ inQuotes -> {}
2625
+ c == ',' -> comma++
2626
+ c == ';' -> semi++
2627
+ c == '\t' -> tab++
2628
+ }
2629
+ }
2630
+ return when {
2631
+ tab > comma && tab > semi -> '\t'
2632
+ semi > comma -> ';'
2633
+ else -> ','
2634
+ }
2635
+ }
2636
+
2637
+ private fun parse(text: String, delimiter: Char): List<List<String>> {
2638
+ val rows = ArrayList<List<String>>()
2639
+ val row = ArrayList<String>()
2640
+ val field = StringBuilder()
2641
+ var inQuotes = false
2642
+ var i = 0
2643
+ while (i < text.length) {
2644
+ val c = text[i]
2645
+ when {
2646
+ inQuotes -> when {
2647
+ c == '"' && i + 1 < text.length && text[i + 1] == '"' -> { field.append('"'); i++ }
2648
+ c == '"' -> inQuotes = false
2649
+ else -> field.append(c)
2650
+ }
2651
+ c == '"' && field.isEmpty() -> inQuotes = true
2652
+ c == delimiter -> { row.add(field.toString()); field.setLength(0) }
2653
+ c == '\n' || c == '\r' -> {
2654
+ if (c == '\r' && i + 1 < text.length && text[i + 1] == '\n') i++
2655
+ row.add(field.toString()); field.setLength(0)
2656
+ // Skip fully blank records (common trailing newline artifacts).
2657
+ if (row.any { it.isNotEmpty() }) rows.add(ArrayList(row))
2658
+ row.clear()
2659
+ if (rows.size > MAX_ROWS + 1) return rows
2660
+ }
2661
+ else -> field.append(c)
2662
+ }
2663
+ i++
2664
+ }
2665
+ if (field.isNotEmpty() || row.isNotEmpty()) {
2666
+ row.add(field.toString())
2667
+ if (row.any { it.isNotEmpty() }) rows.add(row)
2668
+ }
2669
+ return rows
2670
+ }
2671
+ }
2672
+
2673
+ // ═══════════════════════════════════════════════════════════════════
2674
+ // EPUB
2675
+ // ═══════════════════════════════════════════════════════════════════
2676
+
2677
+ /**
2678
+ * Real EPUB structure walk: META-INF/container.xml → OPF package → spine order → XHTML chapters
2679
+ * concatenated as book "sheets", with the publication's own CSS inlined (author styling is the
2680
+ * fidelity) and images swapped to data URIs so the locked WebView renders them offline.
2681
+ */
2682
+ internal class EpubConverter(private val file: File) {
2683
+
2684
+ private var imageBudget = Ox.MAX_TOTAL_IMAGE_BYTES
2685
+
2686
+ companion object {
2687
+ private const val MAX_CHAPTERS = 60
2688
+ private const val MAX_CHAPTER_BYTES = 1 shl 20
2689
+ private const val MAX_CSS_BYTES = 256 * 1024
2690
+ }
2691
+
2692
+ fun convert(): OfficeDocumentConverter.Conversion {
2693
+ ZipFile(file).use { zip ->
2694
+ val opfPath = findOpfPath(zip) ?: throw java.io.IOException("No OPF package")
2695
+ // Manifest: id → (href, media-type); spine: ordered idrefs.
2696
+ val manifest = HashMap<String, Pair<String, String>>()
2697
+ val spine = ArrayList<String>()
2698
+ Ox.zipStream(zip, opfPath)?.use { stream ->
2699
+ val p = Ox.parser(stream)
2700
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
2701
+ if (p.eventType != XmlPullParser.START_TAG) continue
2702
+ when (Ox.local(p)) {
2703
+ "item" -> {
2704
+ val id = Ox.attr(p, "id"); val href = Ox.attr(p, "href")
2705
+ val type = Ox.attr(p, "media-type") ?: ""
2706
+ if (id != null && href != null) manifest[id] = Ox.resolveZipPath(opfPath, href) to type
2707
+ }
2708
+ "itemref" -> Ox.attr(p, "idref")?.let { spine.add(it) }
2709
+ }
2710
+ }
2711
+ } ?: throw java.io.IOException("OPF unreadable")
2712
+
2713
+ // Publication CSS: all manifest text/css entries, size-capped, @import/url(fonts) left
2714
+ // as-is (the locked WebView simply won't fetch them).
2715
+ val cssBundle = StringBuilder()
2716
+ for ((_, entry) in manifest) {
2717
+ if (entry.second == "text/css" && cssBundle.length < MAX_CSS_BYTES) {
2718
+ Ox.zipBytes(zip, entry.first, MAX_CSS_BYTES)?.let {
2719
+ cssBundle.append(String(it, Charsets.UTF_8)).append('\n')
2720
+ }
2721
+ }
2722
+ }
2723
+
2724
+ val body = StringBuilder()
2725
+ var chapters = 0
2726
+ var truncated = false
2727
+ for (idref in spine) {
2728
+ if (chapters >= MAX_CHAPTERS) { truncated = true; break }
2729
+ val entry = manifest[idref] ?: continue
2730
+ if (!entry.second.contains("html")) continue
2731
+ val bytes = Ox.zipBytes(zip, entry.first, MAX_CHAPTER_BYTES) ?: continue
2732
+ chapters++
2733
+ body.append("<div class=\"chapter\">")
2734
+ .append(inlineImages(extractBody(String(bytes, Charsets.UTF_8)), zip, entry.first))
2735
+ .append("</div>")
2736
+ }
2737
+ if (chapters == 0) throw java.io.IOException("No readable chapters")
2738
+ if (truncated) body.append("<div class=\"truncNote\">" + Localization.getString("previewTruncatedChapters", "Preview truncated — first {0} chapters", MAX_CHAPTERS) + "</div>")
2739
+
2740
+ // 384pt ≈ a paperback text block; the shell scales it to screen width.
2741
+ val css =
2742
+ ".chapter{background:#FFFFFF;color:#000;padding:24pt 28pt;margin:8pt auto;width:384pt;" +
2743
+ "box-shadow:0 1pt 4pt rgba(0,0,0,0.4);font-family:'Georgia',serif;font-size:11pt;line-height:1.5;}" +
2744
+ ".chapter img{max-width:100%;height:auto;}" +
2745
+ ".truncNote{color:#AAA;text-align:center;font-size:9pt;padding:6pt;}" +
2746
+ // Publication CSS last so author rules win over the chapter defaults.
2747
+ cssBundle.toString()
2748
+ return OfficeDocumentConverter.Conversion(
2749
+ Ox.shell(440f, css, body.toString()),
2750
+ chapters
2751
+ )
2752
+ }
2753
+ }
2754
+
2755
+ private fun findOpfPath(zip: ZipFile): String? {
2756
+ Ox.zipStream(zip, "META-INF/container.xml")?.use { stream ->
2757
+ val p = Ox.parser(stream)
2758
+ while (p.next() != XmlPullParser.END_DOCUMENT) {
2759
+ if (p.eventType == XmlPullParser.START_TAG && Ox.local(p) == "rootfile") {
2760
+ return Ox.attr(p, "full-path")
2761
+ }
2762
+ }
2763
+ }
2764
+ return null
2765
+ }
2766
+
2767
+ /** Contents of <body>…</body> with scripts stripped (the WebView blocks JS anyway). */
2768
+ private fun extractBody(xhtml: String): String {
2769
+ val bodyStart = xhtml.indexOf("<body", ignoreCase = true)
2770
+ val contentStart = if (bodyStart >= 0) xhtml.indexOf('>', bodyStart) + 1 else 0
2771
+ val bodyEnd = xhtml.lastIndexOf("</body>", ignoreCase = true)
2772
+ val content = if (contentStart in 1 until bodyEnd) xhtml.substring(contentStart, bodyEnd) else xhtml
2773
+ return content.replace(Regex("<script[\\s\\S]*?</script>", RegexOption.IGNORE_CASE), "")
2774
+ }
2775
+
2776
+ /** Rewrites <img src>/xlink:href relative paths into data URIs from the zip. */
2777
+ private fun inlineImages(html: String, zip: ZipFile, chapterPath: String): String =
2778
+ Regex("(src|xlink:href)\\s*=\\s*\"([^\"]+)\"").replace(html) { match ->
2779
+ val attr = match.groupValues[1]
2780
+ val href = match.groupValues[2]
2781
+ if (href.startsWith("http") || href.startsWith("data:")) return@replace match.value
2782
+ val resolved = Ox.resolveZipPath(chapterPath, href)
2783
+ val mime = Ox.mimeForName(resolved)
2784
+ if (!mime.startsWith("image/") || mime == "image/x-emf") return@replace match.value
2785
+ if (imageBudget <= 0) return@replace match.value
2786
+ val bytes = Ox.zipBytes(zip, resolved, minOf(Ox.MAX_IMAGE_BYTES, imageBudget))
2787
+ ?: return@replace match.value
2788
+ imageBudget -= bytes.size
2789
+ "$attr=\"${Ox.dataUri(bytes, mime)}\""
2790
+ }
2791
+ }