react-native-ensys-camera-x 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CustomCamera.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +833 -0
- package/android/build.gradle +135 -0
- package/android/consumer-rules.pro +47 -0
- package/android/src/main/AndroidManifest.xml +72 -0
- package/android/src/main/java/android/print/PdfPrint.java +51 -0
- package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
- package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
- package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
- package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
- package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
- package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
- package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
- package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
- package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
- package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
- package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
- package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
- package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
- package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
- package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
- package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
- package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
- package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
- package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
- package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
- package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
- package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
- package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
- package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
- package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
- package/android/src/main/java/com/customcamera/Localization.kt +206 -0
- package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
- package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
- package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
- package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
- package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
- package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
- package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
- package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
- package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
- package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
- package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
- package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
- package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
- package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
- package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
- package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
- package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
- package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
- package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
- package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
- package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
- package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
- package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
- package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
- package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
- package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
- package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
- package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
- package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
- package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
- package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
- package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
- package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
- package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
- package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
- package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
- package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
- package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
- package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
- package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
- package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
- package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
- package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
- package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
- package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
- package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
- package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
- package/android/src/main/res/drawable/bg_pill.xml +6 -0
- package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
- package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
- package/android/src/main/res/drawable/bg_send_button.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
- package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
- package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
- package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
- package/android/src/main/res/drawable/ic_audio.xml +10 -0
- package/android/src/main/res/drawable/ic_back.xml +10 -0
- package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
- package/android/src/main/res/drawable/ic_check.xml +17 -0
- package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
- package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
- package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
- package/android/src/main/res/drawable/ic_close.xml +10 -0
- package/android/src/main/res/drawable/ic_cover.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +10 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_document.xml +10 -0
- package/android/src/main/res/drawable/ic_done.xml +10 -0
- package/android/src/main/res/drawable/ic_download.xml +10 -0
- package/android/src/main/res/drawable/ic_emoji.xml +10 -0
- package/android/src/main/res/drawable/ic_filter.xml +10 -0
- package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
- package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
- package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
- package/android/src/main/res/drawable/ic_flip.xml +15 -0
- package/android/src/main/res/drawable/ic_gallery.xml +10 -0
- package/android/src/main/res/drawable/ic_gif.xml +24 -0
- package/android/src/main/res/drawable/ic_lock.xml +19 -0
- package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
- package/android/src/main/res/drawable/ic_marker.xml +10 -0
- package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
- package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
- package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
- package/android/src/main/res/drawable/ic_pause.xml +10 -0
- package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_play.xml +10 -0
- package/android/src/main/res/drawable/ic_redo.xml +10 -0
- package/android/src/main/res/drawable/ic_rotate.xml +9 -0
- package/android/src/main/res/drawable/ic_send.xml +10 -0
- package/android/src/main/res/drawable/ic_share.xml +10 -0
- package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
- package/android/src/main/res/drawable/ic_sticker.xml +24 -0
- package/android/src/main/res/drawable/ic_text.xml +10 -0
- package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
- package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
- package/android/src/main/res/drawable/ic_trim.xml +23 -0
- package/android/src/main/res/drawable/ic_undo.xml +10 -0
- package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_video_note.xml +13 -0
- package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
- package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
- package/android/src/main/res/drawable/sticker_heart.xml +9 -0
- package/android/src/main/res/drawable/sticker_smile.xml +9 -0
- package/android/src/main/res/drawable/sticker_star.xml +9 -0
- package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
- package/android/src/main/res/values/colors.xml +34 -0
- package/android/src/main/res/values/ids.xml +15 -0
- package/android/src/main/res/values/strings.xml +15 -0
- package/android/src/main/res/values/styles.xml +20 -0
- package/android/src/main/res/xml/file_paths.xml +34 -0
- package/ios/CustomCamera.h +5 -0
- package/ios/CustomCamera.mm +214 -0
- package/lib/module/AudioPlayerContext.js +233 -0
- package/lib/module/AudioPlayerContext.js.map +1 -0
- package/lib/module/CameraPreview.js +304 -0
- package/lib/module/CameraPreview.js.map +1 -0
- package/lib/module/NativeAnimatedImageView.js +41 -0
- package/lib/module/NativeAnimatedImageView.js.map +1 -0
- package/lib/module/NativeAudioPlayerView.js +33 -0
- package/lib/module/NativeAudioPlayerView.js.map +1 -0
- package/lib/module/NativeBlurView.js +35 -0
- package/lib/module/NativeBlurView.js.map +1 -0
- package/lib/module/NativeCustomCamera.js +128 -0
- package/lib/module/NativeCustomCamera.js.map +1 -0
- package/lib/module/NativeCustomCamera.web.js +143 -0
- package/lib/module/NativeCustomCamera.web.js.map +1 -0
- package/lib/module/NativeCustomVideoView.js +5 -0
- package/lib/module/NativeCustomVideoView.js.map +1 -0
- package/lib/module/NativeDocumentPreviewView.js +156 -0
- package/lib/module/NativeDocumentPreviewView.js.map +1 -0
- package/lib/module/NativeMediaThumbnailView.js +110 -0
- package/lib/module/NativeMediaThumbnailView.js.map +1 -0
- package/lib/module/NativeSvgIcon.js +32 -0
- package/lib/module/NativeSvgIcon.js.map +1 -0
- package/lib/module/ResponsivePreviewContainer.js +171 -0
- package/lib/module/ResponsivePreviewContainer.js.map +1 -0
- package/lib/module/accessibility.js +61 -0
- package/lib/module/accessibility.js.map +1 -0
- package/lib/module/config.js +352 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/cpuOptimization.js +296 -0
- package/lib/module/cpuOptimization.js.map +1 -0
- package/lib/module/db/DatabaseManager.js +191 -0
- package/lib/module/db/DatabaseManager.js.map +1 -0
- package/lib/module/db/MediaMapper.js +284 -0
- package/lib/module/db/MediaMapper.js.map +1 -0
- package/lib/module/db/MediaRepository.js +242 -0
- package/lib/module/db/MediaRepository.js.map +1 -0
- package/lib/module/db/index.js +41 -0
- package/lib/module/db/index.js.map +1 -0
- package/lib/module/db/migrations.js +180 -0
- package/lib/module/db/migrations.js.map +1 -0
- package/lib/module/db/react-native-sqlcipher.d.js +2 -0
- package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
- package/lib/module/db/secureKey.js +87 -0
- package/lib/module/db/secureKey.js.map +1 -0
- package/lib/module/db/types.js +57 -0
- package/lib/module/db/types.js.map +1 -0
- package/lib/module/db/useMediaDatabase.js +123 -0
- package/lib/module/db/useMediaDatabase.js.map +1 -0
- package/lib/module/defaultIcons.js +2259 -0
- package/lib/module/defaultIcons.js.map +1 -0
- package/lib/module/deviceCapabilities.js +408 -0
- package/lib/module/deviceCapabilities.js.map +1 -0
- package/lib/module/errors.js +267 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/iconConfig.js +796 -0
- package/lib/module/iconConfig.js.map +1 -0
- package/lib/module/icons.js +514 -0
- package/lib/module/icons.js.map +1 -0
- package/lib/module/index.js +251 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/jest-globals.d.js +2 -0
- package/lib/module/jest-globals.d.js.map +1 -0
- package/lib/module/library/MediaLibrary.js +107 -0
- package/lib/module/library/MediaLibrary.js.map +1 -0
- package/lib/module/library/MediaLibraryScreen.js +2597 -0
- package/lib/module/library/MediaLibraryScreen.js.map +1 -0
- package/lib/module/library/components/AudioPreview.js +74 -0
- package/lib/module/library/components/AudioPreview.js.map +1 -0
- package/lib/module/library/components/DocumentPreview.js +331 -0
- package/lib/module/library/components/DocumentPreview.js.map +1 -0
- package/lib/module/library/components/ExpandableFab.js +343 -0
- package/lib/module/library/components/ExpandableFab.js.map +1 -0
- package/lib/module/library/components/FileTiles.js +251 -0
- package/lib/module/library/components/FileTiles.js.map +1 -0
- package/lib/module/library/components/ImagePreview.js +70 -0
- package/lib/module/library/components/ImagePreview.js.map +1 -0
- package/lib/module/library/components/MediaMetadataHeader.js +175 -0
- package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
- package/lib/module/library/components/RecordAudioScreen.js +816 -0
- package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
- package/lib/module/library/components/VideoPreview.js +453 -0
- package/lib/module/library/components/VideoPreview.js.map +1 -0
- package/lib/module/library/components/ViewerCounter.js +55 -0
- package/lib/module/library/components/ViewerCounter.js.map +1 -0
- package/lib/module/library/compressionEstimate.js +507 -0
- package/lib/module/library/compressionEstimate.js.map +1 -0
- package/lib/module/library/fileMetadata.js +196 -0
- package/lib/module/library/fileMetadata.js.map +1 -0
- package/lib/module/library/index.js +42 -0
- package/lib/module/library/index.js.map +1 -0
- package/lib/module/library/libraryIconRegistry.js +69 -0
- package/lib/module/library/libraryIconRegistry.js.map +1 -0
- package/lib/module/library/libraryIcons.js +858 -0
- package/lib/module/library/libraryIcons.js.map +1 -0
- package/lib/module/library/localizedFormat.js +181 -0
- package/lib/module/library/localizedFormat.js.map +1 -0
- package/lib/module/library/palette.js +225 -0
- package/lib/module/library/palette.js.map +1 -0
- package/lib/module/library/responsive.js +53 -0
- package/lib/module/library/responsive.js.map +1 -0
- package/lib/module/locales.js +416 -0
- package/lib/module/locales.js.map +1 -0
- package/lib/module/localization.js +1064 -0
- package/lib/module/localization.js.map +1 -0
- package/lib/module/logger.js +20 -0
- package/lib/module/logger.js.map +1 -0
- package/lib/module/lowMemoryCache.js +206 -0
- package/lib/module/lowMemoryCache.js.map +1 -0
- package/lib/module/mediaHistory.js +33 -0
- package/lib/module/mediaHistory.js.map +1 -0
- package/lib/module/mediaStorageService.js +402 -0
- package/lib/module/mediaStorageService.js.map +1 -0
- package/lib/module/memory.js +79 -0
- package/lib/module/memory.js.map +1 -0
- package/lib/module/nativeActions.js +307 -0
- package/lib/module/nativeActions.js.map +1 -0
- package/lib/module/nativeComponentSupport.js +61 -0
- package/lib/module/nativeComponentSupport.js.map +1 -0
- package/lib/module/networkPerformance.js +24 -0
- package/lib/module/networkPerformance.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/performance.js +104 -0
- package/lib/module/performance.js.map +1 -0
- package/lib/module/performanceMonitor.js +155 -0
- package/lib/module/performanceMonitor.js.map +1 -0
- package/lib/module/permissions.js +370 -0
- package/lib/module/permissions.js.map +1 -0
- package/lib/module/rendering.js +40 -0
- package/lib/module/rendering.js.map +1 -0
- package/lib/module/security.js +18 -0
- package/lib/module/security.js.map +1 -0
- package/lib/module/state.js +266 -0
- package/lib/module/state.js.map +1 -0
- package/lib/module/storage.js +400 -0
- package/lib/module/storage.js.map +1 -0
- package/lib/module/theme.js +390 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils.js +115 -0
- package/lib/module/utils.js.map +1 -0
- package/lib/module/validation.js +288 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
- package/lib/typescript/src/CameraPreview.d.ts +13 -0
- package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
- package/lib/typescript/src/NativeBlurView.d.ts +12 -0
- package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
- package/lib/typescript/src/accessibility.d.ts +20 -0
- package/lib/typescript/src/accessibility.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +145 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/cpuOptimization.d.ts +63 -0
- package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
- package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
- package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
- package/lib/typescript/src/db/index.d.ts +23 -0
- package/lib/typescript/src/db/index.d.ts.map +1 -0
- package/lib/typescript/src/db/migrations.d.ts +31 -0
- package/lib/typescript/src/db/migrations.d.ts.map +1 -0
- package/lib/typescript/src/db/secureKey.d.ts +10 -0
- package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
- package/lib/typescript/src/db/types.d.ts +115 -0
- package/lib/typescript/src/db/types.d.ts.map +1 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
- package/lib/typescript/src/defaultIcons.d.ts +139 -0
- package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
- package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
- package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +95 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/iconConfig.d.ts +176 -0
- package/lib/typescript/src/iconConfig.d.ts.map +1 -0
- package/lib/typescript/src/icons.d.ts +217 -0
- package/lib/typescript/src/icons.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +104 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
- package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
- package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
- package/lib/typescript/src/library/index.d.ts +40 -0
- package/lib/typescript/src/library/index.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
- package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
- package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
- package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
- package/lib/typescript/src/library/palette.d.ts +104 -0
- package/lib/typescript/src/library/palette.d.ts.map +1 -0
- package/lib/typescript/src/library/responsive.d.ts +15 -0
- package/lib/typescript/src/library/responsive.d.ts.map +1 -0
- package/lib/typescript/src/locales.d.ts +103 -0
- package/lib/typescript/src/locales.d.ts.map +1 -0
- package/lib/typescript/src/localization.d.ts +1051 -0
- package/lib/typescript/src/localization.d.ts.map +1 -0
- package/lib/typescript/src/logger.d.ts +4 -0
- package/lib/typescript/src/logger.d.ts.map +1 -0
- package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
- package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
- package/lib/typescript/src/mediaHistory.d.ts +3 -0
- package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
- package/lib/typescript/src/mediaStorageService.d.ts +155 -0
- package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
- package/lib/typescript/src/memory.d.ts +21 -0
- package/lib/typescript/src/memory.d.ts.map +1 -0
- package/lib/typescript/src/nativeActions.d.ts +174 -0
- package/lib/typescript/src/nativeActions.d.ts.map +1 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
- package/lib/typescript/src/networkPerformance.d.ts +12 -0
- package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
- package/lib/typescript/src/performance.d.ts +32 -0
- package/lib/typescript/src/performance.d.ts.map +1 -0
- package/lib/typescript/src/performanceMonitor.d.ts +45 -0
- package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
- package/lib/typescript/src/permissions.d.ts +132 -0
- package/lib/typescript/src/permissions.d.ts.map +1 -0
- package/lib/typescript/src/rendering.d.ts +9 -0
- package/lib/typescript/src/rendering.d.ts.map +1 -0
- package/lib/typescript/src/security.d.ts +6 -0
- package/lib/typescript/src/security.d.ts.map +1 -0
- package/lib/typescript/src/state.d.ts +102 -0
- package/lib/typescript/src/state.d.ts.map +1 -0
- package/lib/typescript/src/storage.d.ts +117 -0
- package/lib/typescript/src/storage.d.ts.map +1 -0
- package/lib/typescript/src/theme.d.ts +15 -0
- package/lib/typescript/src/theme.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +802 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utils.d.ts +50 -0
- package/lib/typescript/src/utils.d.ts.map +1 -0
- package/lib/typescript/src/validation.d.ts +52 -0
- package/lib/typescript/src/validation.d.ts.map +1 -0
- package/package.json +204 -0
- package/react-native.config.js +17 -0
- package/src/AudioPlayerContext.tsx +220 -0
- package/src/CameraPreview.tsx +352 -0
- package/src/NativeAnimatedImageView.tsx +71 -0
- package/src/NativeAudioPlayerView.tsx +39 -0
- package/src/NativeBlurView.tsx +40 -0
- package/src/NativeCustomCamera.ts +305 -0
- package/src/NativeCustomCamera.web.ts +192 -0
- package/src/NativeCustomVideoView.ts +30 -0
- package/src/NativeDocumentPreviewView.tsx +196 -0
- package/src/NativeMediaThumbnailView.tsx +167 -0
- package/src/NativeSvgIcon.tsx +46 -0
- package/src/ResponsivePreviewContainer.tsx +227 -0
- package/src/accessibility.ts +80 -0
- package/src/config.ts +406 -0
- package/src/cpuOptimization.ts +365 -0
- package/src/db/DatabaseManager.ts +212 -0
- package/src/db/MediaMapper.ts +377 -0
- package/src/db/MediaRepository.ts +313 -0
- package/src/db/index.ts +73 -0
- package/src/db/migrations.ts +222 -0
- package/src/db/react-native-sqlcipher.d.ts +76 -0
- package/src/db/secureKey.ts +100 -0
- package/src/db/types.ts +123 -0
- package/src/db/useMediaDatabase.ts +191 -0
- package/src/defaultIcons.tsx +2390 -0
- package/src/deviceCapabilities.ts +438 -0
- package/src/errors.ts +350 -0
- package/src/iconConfig.tsx +962 -0
- package/src/icons.tsx +788 -0
- package/src/index.tsx +763 -0
- package/src/jest-globals.d.ts +9 -0
- package/src/library/MediaLibrary.tsx +141 -0
- package/src/library/MediaLibraryScreen.tsx +3003 -0
- package/src/library/components/AudioPreview.tsx +73 -0
- package/src/library/components/DocumentPreview.tsx +361 -0
- package/src/library/components/ExpandableFab.tsx +342 -0
- package/src/library/components/FileTiles.tsx +252 -0
- package/src/library/components/ImagePreview.tsx +72 -0
- package/src/library/components/MediaMetadataHeader.tsx +208 -0
- package/src/library/components/RecordAudioScreen.tsx +845 -0
- package/src/library/components/VideoPreview.tsx +478 -0
- package/src/library/components/ViewerCounter.tsx +71 -0
- package/src/library/compressionEstimate.ts +662 -0
- package/src/library/fileMetadata.ts +212 -0
- package/src/library/index.ts +66 -0
- package/src/library/libraryIconRegistry.tsx +86 -0
- package/src/library/libraryIcons.tsx +928 -0
- package/src/library/localizedFormat.ts +207 -0
- package/src/library/palette.ts +281 -0
- package/src/library/responsive.ts +49 -0
- package/src/locales.ts +381 -0
- package/src/localization.ts +1947 -0
- package/src/logger.ts +20 -0
- package/src/lowMemoryCache.ts +218 -0
- package/src/mediaHistory.ts +42 -0
- package/src/mediaStorageService.ts +546 -0
- package/src/memory.ts +102 -0
- package/src/nativeActions.ts +422 -0
- package/src/nativeComponentSupport.ts +60 -0
- package/src/networkPerformance.ts +30 -0
- package/src/performance.ts +137 -0
- package/src/performanceMonitor.ts +219 -0
- package/src/permissions.ts +403 -0
- package/src/rendering.ts +47 -0
- package/src/security.ts +19 -0
- package/src/state.ts +333 -0
- package/src/storage.ts +453 -0
- package/src/theme.tsx +467 -0
- package/src/types.ts +946 -0
- package/src/utils.ts +177 -0
- package/src/validation.ts +372 -0
|
@@ -0,0 +1,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("<")
|
|
68
|
+
'>' -> append(">")
|
|
69
|
+
'"' -> append(""")
|
|
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()) " " 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)} </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("‑"); 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()) " " 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()) " " 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(" ") }
|
|
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()) " " 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 { " " }).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()) " " 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(" ")
|
|
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
|
+
}
|