kreuzberg 4.0.0.rc2 → 4.0.0
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.
- checksums.yaml +4 -4
- data/.gitignore +14 -14
- data/.rspec +3 -3
- data/.rubocop.yaml +1 -1
- data/.rubocop.yml +543 -538
- data/Gemfile +8 -8
- data/Gemfile.lock +194 -6
- data/README.md +396 -426
- data/Rakefile +34 -25
- data/Steepfile +51 -47
- data/examples/async_patterns.rb +283 -341
- data/ext/kreuzberg_rb/extconf.rb +65 -45
- data/ext/kreuzberg_rb/native/.cargo/config.toml +23 -0
- data/ext/kreuzberg_rb/native/Cargo.lock +7619 -6535
- data/ext/kreuzberg_rb/native/Cargo.toml +75 -44
- data/ext/kreuzberg_rb/native/README.md +425 -425
- data/ext/kreuzberg_rb/native/build.rs +15 -15
- data/ext/kreuzberg_rb/native/include/ieeefp.h +11 -11
- data/ext/kreuzberg_rb/native/include/msvc_compat/strings.h +14 -14
- data/ext/kreuzberg_rb/native/include/strings.h +20 -20
- data/ext/kreuzberg_rb/native/include/unistd.h +47 -47
- data/ext/kreuzberg_rb/native/src/lib.rs +3802 -2998
- data/extconf.rb +60 -28
- data/kreuzberg.gemspec +199 -148
- data/lib/kreuzberg/api_proxy.rb +126 -142
- data/lib/kreuzberg/cache_api.rb +67 -46
- data/lib/kreuzberg/cli.rb +47 -55
- data/lib/kreuzberg/cli_proxy.rb +117 -127
- data/lib/kreuzberg/config.rb +936 -691
- data/lib/kreuzberg/error_context.rb +136 -32
- data/lib/kreuzberg/errors.rb +116 -118
- data/lib/kreuzberg/extraction_api.rb +313 -85
- data/lib/kreuzberg/mcp_proxy.rb +177 -186
- data/lib/kreuzberg/ocr_backend_protocol.rb +40 -113
- data/lib/kreuzberg/post_processor_protocol.rb +15 -86
- data/lib/kreuzberg/result.rb +334 -216
- data/lib/kreuzberg/setup_lib_path.rb +99 -80
- data/lib/kreuzberg/types.rb +170 -0
- data/lib/kreuzberg/validator_protocol.rb +16 -89
- data/lib/kreuzberg/version.rb +5 -5
- data/lib/kreuzberg.rb +96 -103
- data/lib/libpdfium.so +0 -0
- data/sig/kreuzberg/internal.rbs +184 -184
- data/sig/kreuzberg.rbs +561 -520
- data/spec/binding/async_operations_spec.rb +473 -0
- data/spec/binding/batch_operations_spec.rb +595 -0
- data/spec/binding/batch_spec.rb +359 -0
- data/spec/binding/cache_spec.rb +227 -227
- data/spec/binding/cli_proxy_spec.rb +85 -85
- data/spec/binding/cli_spec.rb +55 -55
- data/spec/binding/config_result_spec.rb +377 -0
- data/spec/binding/config_spec.rb +419 -345
- data/spec/binding/config_validation_spec.rb +377 -283
- data/spec/binding/embeddings_spec.rb +816 -0
- data/spec/binding/error_handling_spec.rb +399 -213
- data/spec/binding/error_recovery_spec.rb +488 -0
- data/spec/binding/errors_spec.rb +66 -66
- data/spec/binding/font_config_spec.rb +220 -0
- data/spec/binding/images_spec.rb +738 -0
- data/spec/binding/keywords_extraction_spec.rb +600 -0
- data/spec/binding/metadata_types_spec.rb +1228 -0
- data/spec/binding/pages_extraction_spec.rb +471 -0
- data/spec/binding/plugins/ocr_backend_spec.rb +307 -307
- data/spec/binding/plugins/postprocessor_spec.rb +269 -269
- data/spec/binding/plugins/validator_spec.rb +273 -274
- data/spec/binding/tables_spec.rb +641 -0
- data/spec/fixtures/config.toml +38 -39
- data/spec/fixtures/config.yaml +41 -41
- data/spec/fixtures/invalid_config.toml +3 -4
- data/spec/smoke/package_spec.rb +177 -178
- data/spec/spec_helper.rb +40 -42
- data/spec/unit/config/chunking_config_spec.rb +213 -0
- data/spec/unit/config/embedding_config_spec.rb +343 -0
- data/spec/unit/config/extraction_config_spec.rb +438 -0
- data/spec/unit/config/font_config_spec.rb +285 -0
- data/spec/unit/config/hierarchy_config_spec.rb +314 -0
- data/spec/unit/config/image_extraction_config_spec.rb +209 -0
- data/spec/unit/config/image_preprocessing_config_spec.rb +249 -0
- data/spec/unit/config/keyword_config_spec.rb +229 -0
- data/spec/unit/config/language_detection_config_spec.rb +258 -0
- data/spec/unit/config/ocr_config_spec.rb +171 -0
- data/spec/unit/config/page_config_spec.rb +221 -0
- data/spec/unit/config/pdf_config_spec.rb +267 -0
- data/spec/unit/config/postprocessor_config_spec.rb +290 -0
- data/spec/unit/config/tesseract_config_spec.rb +181 -0
- data/spec/unit/config/token_reduction_config_spec.rb +251 -0
- data/test/metadata_types_test.rb +959 -0
- data/vendor/Cargo.toml +61 -0
- data/vendor/kreuzberg/Cargo.toml +259 -204
- data/vendor/kreuzberg/README.md +263 -175
- data/vendor/kreuzberg/build.rs +782 -474
- data/vendor/kreuzberg/examples/bench_fixes.rs +71 -0
- data/vendor/kreuzberg/examples/test_pdfium_fork.rs +62 -0
- data/vendor/kreuzberg/src/api/error.rs +81 -81
- data/vendor/kreuzberg/src/api/handlers.rs +320 -199
- data/vendor/kreuzberg/src/api/mod.rs +94 -79
- data/vendor/kreuzberg/src/api/server.rs +518 -353
- data/vendor/kreuzberg/src/api/types.rs +206 -170
- data/vendor/kreuzberg/src/cache/mod.rs +1167 -1167
- data/vendor/kreuzberg/src/chunking/mod.rs +2303 -677
- data/vendor/kreuzberg/src/chunking/processor.rs +219 -0
- data/vendor/kreuzberg/src/core/batch_mode.rs +95 -95
- data/vendor/kreuzberg/src/core/batch_optimizations.rs +385 -0
- data/vendor/kreuzberg/src/core/config.rs +1914 -1032
- data/vendor/kreuzberg/src/core/config_validation.rs +949 -0
- data/vendor/kreuzberg/src/core/extractor.rs +1200 -1024
- data/vendor/kreuzberg/src/core/formats.rs +235 -0
- data/vendor/kreuzberg/src/core/io.rs +329 -329
- data/vendor/kreuzberg/src/core/mime.rs +605 -605
- data/vendor/kreuzberg/src/core/mod.rs +61 -45
- data/vendor/kreuzberg/src/core/pipeline.rs +1223 -984
- data/vendor/kreuzberg/src/core/server_config.rs +1220 -0
- data/vendor/kreuzberg/src/embeddings.rs +471 -432
- data/vendor/kreuzberg/src/error.rs +431 -431
- data/vendor/kreuzberg/src/extraction/archive.rs +959 -954
- data/vendor/kreuzberg/src/extraction/capacity.rs +263 -0
- data/vendor/kreuzberg/src/extraction/docx.rs +404 -40
- data/vendor/kreuzberg/src/extraction/email.rs +855 -854
- data/vendor/kreuzberg/src/extraction/excel.rs +697 -688
- data/vendor/kreuzberg/src/extraction/html.rs +1830 -553
- data/vendor/kreuzberg/src/extraction/image.rs +492 -368
- data/vendor/kreuzberg/src/extraction/libreoffice.rs +574 -563
- data/vendor/kreuzberg/src/extraction/markdown.rs +216 -213
- data/vendor/kreuzberg/src/extraction/mod.rs +93 -81
- data/vendor/kreuzberg/src/extraction/office_metadata/app_properties.rs +398 -398
- data/vendor/kreuzberg/src/extraction/office_metadata/core_properties.rs +247 -247
- data/vendor/kreuzberg/src/extraction/office_metadata/custom_properties.rs +240 -240
- data/vendor/kreuzberg/src/extraction/office_metadata/mod.rs +130 -130
- data/vendor/kreuzberg/src/extraction/office_metadata/odt_properties.rs +284 -287
- data/vendor/kreuzberg/src/extraction/pptx.rs +3102 -3000
- data/vendor/kreuzberg/src/extraction/structured.rs +491 -490
- data/vendor/kreuzberg/src/extraction/table.rs +329 -328
- data/vendor/kreuzberg/src/extraction/text.rs +277 -269
- data/vendor/kreuzberg/src/extraction/xml.rs +333 -333
- data/vendor/kreuzberg/src/extractors/archive.rs +447 -446
- data/vendor/kreuzberg/src/extractors/bibtex.rs +470 -469
- data/vendor/kreuzberg/src/extractors/docbook.rs +504 -502
- data/vendor/kreuzberg/src/extractors/docx.rs +400 -367
- data/vendor/kreuzberg/src/extractors/email.rs +157 -143
- data/vendor/kreuzberg/src/extractors/epub.rs +696 -707
- data/vendor/kreuzberg/src/extractors/excel.rs +385 -343
- data/vendor/kreuzberg/src/extractors/fictionbook.rs +492 -491
- data/vendor/kreuzberg/src/extractors/html.rs +419 -393
- data/vendor/kreuzberg/src/extractors/image.rs +219 -198
- data/vendor/kreuzberg/src/extractors/jats.rs +1054 -1051
- data/vendor/kreuzberg/src/extractors/jupyter.rs +368 -367
- data/vendor/kreuzberg/src/extractors/latex.rs +653 -652
- data/vendor/kreuzberg/src/extractors/markdown.rs +701 -700
- data/vendor/kreuzberg/src/extractors/mod.rs +429 -365
- data/vendor/kreuzberg/src/extractors/odt.rs +628 -628
- data/vendor/kreuzberg/src/extractors/opml.rs +635 -634
- data/vendor/kreuzberg/src/extractors/orgmode.rs +529 -528
- data/vendor/kreuzberg/src/extractors/pdf.rs +761 -493
- data/vendor/kreuzberg/src/extractors/pptx.rs +279 -248
- data/vendor/kreuzberg/src/extractors/rst.rs +577 -576
- data/vendor/kreuzberg/src/extractors/rtf.rs +809 -810
- data/vendor/kreuzberg/src/extractors/security.rs +484 -484
- data/vendor/kreuzberg/src/extractors/security_tests.rs +367 -367
- data/vendor/kreuzberg/src/extractors/structured.rs +142 -140
- data/vendor/kreuzberg/src/extractors/text.rs +265 -260
- data/vendor/kreuzberg/src/extractors/typst.rs +651 -650
- data/vendor/kreuzberg/src/extractors/xml.rs +147 -135
- data/vendor/kreuzberg/src/image/dpi.rs +164 -164
- data/vendor/kreuzberg/src/image/mod.rs +6 -6
- data/vendor/kreuzberg/src/image/preprocessing.rs +417 -417
- data/vendor/kreuzberg/src/image/resize.rs +89 -89
- data/vendor/kreuzberg/src/keywords/config.rs +154 -154
- data/vendor/kreuzberg/src/keywords/mod.rs +237 -237
- data/vendor/kreuzberg/src/keywords/processor.rs +275 -267
- data/vendor/kreuzberg/src/keywords/rake.rs +293 -293
- data/vendor/kreuzberg/src/keywords/types.rs +68 -68
- data/vendor/kreuzberg/src/keywords/yake.rs +163 -163
- data/vendor/kreuzberg/src/language_detection/mod.rs +985 -942
- data/vendor/kreuzberg/src/language_detection/processor.rs +218 -0
- data/vendor/kreuzberg/src/lib.rs +114 -105
- data/vendor/kreuzberg/src/mcp/mod.rs +35 -32
- data/vendor/kreuzberg/src/mcp/server.rs +2090 -1968
- data/vendor/kreuzberg/src/ocr/cache.rs +469 -469
- data/vendor/kreuzberg/src/ocr/error.rs +37 -37
- data/vendor/kreuzberg/src/ocr/hocr.rs +216 -216
- data/vendor/kreuzberg/src/ocr/language_registry.rs +520 -0
- data/vendor/kreuzberg/src/ocr/mod.rs +60 -58
- data/vendor/kreuzberg/src/ocr/processor.rs +858 -863
- data/vendor/kreuzberg/src/ocr/table/mod.rs +4 -4
- data/vendor/kreuzberg/src/ocr/table/tsv_parser.rs +144 -144
- data/vendor/kreuzberg/src/ocr/tesseract_backend.rs +456 -450
- data/vendor/kreuzberg/src/ocr/types.rs +393 -393
- data/vendor/kreuzberg/src/ocr/utils.rs +47 -47
- data/vendor/kreuzberg/src/ocr/validation.rs +206 -206
- data/vendor/kreuzberg/src/panic_context.rs +154 -154
- data/vendor/kreuzberg/src/pdf/bindings.rs +306 -0
- data/vendor/kreuzberg/src/pdf/bundled.rs +408 -0
- data/vendor/kreuzberg/src/pdf/error.rs +214 -122
- data/vendor/kreuzberg/src/pdf/fonts.rs +358 -0
- data/vendor/kreuzberg/src/pdf/hierarchy.rs +903 -0
- data/vendor/kreuzberg/src/pdf/images.rs +139 -139
- data/vendor/kreuzberg/src/pdf/metadata.rs +509 -346
- data/vendor/kreuzberg/src/pdf/mod.rs +81 -50
- data/vendor/kreuzberg/src/pdf/rendering.rs +369 -369
- data/vendor/kreuzberg/src/pdf/table.rs +417 -393
- data/vendor/kreuzberg/src/pdf/text.rs +553 -158
- data/vendor/kreuzberg/src/plugins/extractor.rs +1042 -1013
- data/vendor/kreuzberg/src/plugins/mod.rs +212 -209
- data/vendor/kreuzberg/src/plugins/ocr.rs +637 -620
- data/vendor/kreuzberg/src/plugins/processor.rs +650 -642
- data/vendor/kreuzberg/src/plugins/registry.rs +1339 -1337
- data/vendor/kreuzberg/src/plugins/traits.rs +258 -258
- data/vendor/kreuzberg/src/plugins/validator.rs +967 -956
- data/vendor/kreuzberg/src/stopwords/mod.rs +1470 -1470
- data/vendor/kreuzberg/src/text/mod.rs +27 -19
- data/vendor/kreuzberg/src/text/quality.rs +710 -697
- data/vendor/kreuzberg/src/text/quality_processor.rs +231 -0
- data/vendor/kreuzberg/src/text/string_utils.rs +229 -217
- data/vendor/kreuzberg/src/text/token_reduction/cjk_utils.rs +164 -164
- data/vendor/kreuzberg/src/text/token_reduction/config.rs +100 -100
- data/vendor/kreuzberg/src/text/token_reduction/core.rs +832 -796
- data/vendor/kreuzberg/src/text/token_reduction/filters.rs +923 -902
- data/vendor/kreuzberg/src/text/token_reduction/mod.rs +160 -160
- data/vendor/kreuzberg/src/text/token_reduction/semantic.rs +619 -619
- data/vendor/kreuzberg/src/text/token_reduction/simd_text.rs +148 -147
- data/vendor/kreuzberg/src/text/utf8_validation.rs +193 -0
- data/vendor/kreuzberg/src/types.rs +1713 -903
- data/vendor/kreuzberg/src/utils/mod.rs +31 -17
- data/vendor/kreuzberg/src/utils/pool.rs +503 -0
- data/vendor/kreuzberg/src/utils/pool_sizing.rs +364 -0
- data/vendor/kreuzberg/src/utils/quality.rs +968 -959
- data/vendor/kreuzberg/src/utils/string_pool.rs +761 -0
- data/vendor/kreuzberg/src/utils/string_utils.rs +381 -381
- data/vendor/kreuzberg/stopwords/af_stopwords.json +53 -53
- data/vendor/kreuzberg/stopwords/ar_stopwords.json +482 -482
- data/vendor/kreuzberg/stopwords/bg_stopwords.json +261 -261
- data/vendor/kreuzberg/stopwords/bn_stopwords.json +400 -400
- data/vendor/kreuzberg/stopwords/br_stopwords.json +1205 -1205
- data/vendor/kreuzberg/stopwords/ca_stopwords.json +280 -280
- data/vendor/kreuzberg/stopwords/cs_stopwords.json +425 -425
- data/vendor/kreuzberg/stopwords/da_stopwords.json +172 -172
- data/vendor/kreuzberg/stopwords/de_stopwords.json +622 -622
- data/vendor/kreuzberg/stopwords/el_stopwords.json +849 -849
- data/vendor/kreuzberg/stopwords/en_stopwords.json +1300 -1300
- data/vendor/kreuzberg/stopwords/eo_stopwords.json +175 -175
- data/vendor/kreuzberg/stopwords/es_stopwords.json +734 -734
- data/vendor/kreuzberg/stopwords/et_stopwords.json +37 -37
- data/vendor/kreuzberg/stopwords/eu_stopwords.json +100 -100
- data/vendor/kreuzberg/stopwords/fa_stopwords.json +801 -801
- data/vendor/kreuzberg/stopwords/fi_stopwords.json +849 -849
- data/vendor/kreuzberg/stopwords/fr_stopwords.json +693 -693
- data/vendor/kreuzberg/stopwords/ga_stopwords.json +111 -111
- data/vendor/kreuzberg/stopwords/gl_stopwords.json +162 -162
- data/vendor/kreuzberg/stopwords/gu_stopwords.json +226 -226
- data/vendor/kreuzberg/stopwords/ha_stopwords.json +41 -41
- data/vendor/kreuzberg/stopwords/he_stopwords.json +196 -196
- data/vendor/kreuzberg/stopwords/hi_stopwords.json +227 -227
- data/vendor/kreuzberg/stopwords/hr_stopwords.json +181 -181
- data/vendor/kreuzberg/stopwords/hu_stopwords.json +791 -791
- data/vendor/kreuzberg/stopwords/hy_stopwords.json +47 -47
- data/vendor/kreuzberg/stopwords/id_stopwords.json +760 -760
- data/vendor/kreuzberg/stopwords/it_stopwords.json +634 -634
- data/vendor/kreuzberg/stopwords/ja_stopwords.json +136 -136
- data/vendor/kreuzberg/stopwords/kn_stopwords.json +84 -84
- data/vendor/kreuzberg/stopwords/ko_stopwords.json +681 -681
- data/vendor/kreuzberg/stopwords/ku_stopwords.json +64 -64
- data/vendor/kreuzberg/stopwords/la_stopwords.json +51 -51
- data/vendor/kreuzberg/stopwords/lt_stopwords.json +476 -476
- data/vendor/kreuzberg/stopwords/lv_stopwords.json +163 -163
- data/vendor/kreuzberg/stopwords/ml_stopwords.json +1 -1
- data/vendor/kreuzberg/stopwords/mr_stopwords.json +101 -101
- data/vendor/kreuzberg/stopwords/ms_stopwords.json +477 -477
- data/vendor/kreuzberg/stopwords/ne_stopwords.json +490 -490
- data/vendor/kreuzberg/stopwords/nl_stopwords.json +415 -415
- data/vendor/kreuzberg/stopwords/no_stopwords.json +223 -223
- data/vendor/kreuzberg/stopwords/pl_stopwords.json +331 -331
- data/vendor/kreuzberg/stopwords/pt_stopwords.json +562 -562
- data/vendor/kreuzberg/stopwords/ro_stopwords.json +436 -436
- data/vendor/kreuzberg/stopwords/ru_stopwords.json +561 -561
- data/vendor/kreuzberg/stopwords/si_stopwords.json +193 -193
- data/vendor/kreuzberg/stopwords/sk_stopwords.json +420 -420
- data/vendor/kreuzberg/stopwords/sl_stopwords.json +448 -448
- data/vendor/kreuzberg/stopwords/so_stopwords.json +32 -32
- data/vendor/kreuzberg/stopwords/st_stopwords.json +33 -33
- data/vendor/kreuzberg/stopwords/sv_stopwords.json +420 -420
- data/vendor/kreuzberg/stopwords/sw_stopwords.json +76 -76
- data/vendor/kreuzberg/stopwords/ta_stopwords.json +129 -129
- data/vendor/kreuzberg/stopwords/te_stopwords.json +54 -54
- data/vendor/kreuzberg/stopwords/th_stopwords.json +118 -118
- data/vendor/kreuzberg/stopwords/tl_stopwords.json +149 -149
- data/vendor/kreuzberg/stopwords/tr_stopwords.json +506 -506
- data/vendor/kreuzberg/stopwords/uk_stopwords.json +75 -75
- data/vendor/kreuzberg/stopwords/ur_stopwords.json +519 -519
- data/vendor/kreuzberg/stopwords/vi_stopwords.json +647 -647
- data/vendor/kreuzberg/stopwords/yo_stopwords.json +62 -62
- data/vendor/kreuzberg/stopwords/zh_stopwords.json +796 -796
- data/vendor/kreuzberg/stopwords/zu_stopwords.json +31 -31
- data/vendor/kreuzberg/tests/api_embed.rs +360 -0
- data/vendor/kreuzberg/tests/api_extract_multipart.rs +52 -52
- data/vendor/kreuzberg/tests/api_large_pdf_extraction.rs +471 -0
- data/vendor/kreuzberg/tests/api_large_pdf_extraction_diagnostics.rs +289 -0
- data/vendor/kreuzberg/tests/api_tests.rs +1472 -966
- data/vendor/kreuzberg/tests/archive_integration.rs +545 -543
- data/vendor/kreuzberg/tests/batch_orchestration.rs +587 -556
- data/vendor/kreuzberg/tests/batch_pooling_benchmark.rs +154 -0
- data/vendor/kreuzberg/tests/batch_processing.rs +328 -316
- data/vendor/kreuzberg/tests/bibtex_parity_test.rs +421 -421
- data/vendor/kreuzberg/tests/concurrency_stress.rs +541 -525
- data/vendor/kreuzberg/tests/config_features.rs +612 -598
- data/vendor/kreuzberg/tests/config_integration_test.rs +753 -0
- data/vendor/kreuzberg/tests/config_loading_tests.rs +416 -415
- data/vendor/kreuzberg/tests/core_integration.rs +519 -510
- data/vendor/kreuzberg/tests/csv_integration.rs +414 -414
- data/vendor/kreuzberg/tests/data/hierarchy_ground_truth.json +294 -0
- data/vendor/kreuzberg/tests/docbook_extractor_tests.rs +500 -498
- data/vendor/kreuzberg/tests/docx_metadata_extraction_test.rs +122 -122
- data/vendor/kreuzberg/tests/docx_vs_pandoc_comparison.rs +370 -370
- data/vendor/kreuzberg/tests/email_integration.rs +327 -325
- data/vendor/kreuzberg/tests/epub_native_extractor_tests.rs +275 -275
- data/vendor/kreuzberg/tests/error_handling.rs +402 -393
- data/vendor/kreuzberg/tests/fictionbook_extractor_tests.rs +228 -228
- data/vendor/kreuzberg/tests/format_integration.rs +165 -159
- data/vendor/kreuzberg/tests/helpers/mod.rs +202 -142
- data/vendor/kreuzberg/tests/html_table_test.rs +551 -551
- data/vendor/kreuzberg/tests/image_integration.rs +255 -253
- data/vendor/kreuzberg/tests/instrumentation_test.rs +139 -139
- data/vendor/kreuzberg/tests/jats_extractor_tests.rs +639 -639
- data/vendor/kreuzberg/tests/jupyter_extractor_tests.rs +704 -704
- data/vendor/kreuzberg/tests/keywords_integration.rs +479 -479
- data/vendor/kreuzberg/tests/keywords_quality.rs +509 -509
- data/vendor/kreuzberg/tests/latex_extractor_tests.rs +496 -496
- data/vendor/kreuzberg/tests/markdown_extractor_tests.rs +490 -490
- data/vendor/kreuzberg/tests/mime_detection.rs +429 -428
- data/vendor/kreuzberg/tests/ocr_configuration.rs +514 -510
- data/vendor/kreuzberg/tests/ocr_errors.rs +698 -676
- data/vendor/kreuzberg/tests/ocr_language_registry.rs +191 -0
- data/vendor/kreuzberg/tests/ocr_quality.rs +629 -627
- data/vendor/kreuzberg/tests/ocr_stress.rs +469 -469
- data/vendor/kreuzberg/tests/odt_extractor_tests.rs +674 -695
- data/vendor/kreuzberg/tests/opml_extractor_tests.rs +616 -616
- data/vendor/kreuzberg/tests/orgmode_extractor_tests.rs +822 -822
- data/vendor/kreuzberg/tests/page_markers.rs +297 -0
- data/vendor/kreuzberg/tests/pdf_hierarchy_detection.rs +301 -0
- data/vendor/kreuzberg/tests/pdf_hierarchy_quality.rs +589 -0
- data/vendor/kreuzberg/tests/pdf_integration.rs +45 -43
- data/vendor/kreuzberg/tests/pdf_ocr_triggering.rs +301 -0
- data/vendor/kreuzberg/tests/pdf_text_merging.rs +475 -0
- data/vendor/kreuzberg/tests/pdfium_linking.rs +340 -0
- data/vendor/kreuzberg/tests/pipeline_integration.rs +1446 -1411
- data/vendor/kreuzberg/tests/plugin_ocr_backend_test.rs +776 -771
- data/vendor/kreuzberg/tests/plugin_postprocessor_test.rs +577 -560
- data/vendor/kreuzberg/tests/plugin_system.rs +927 -921
- data/vendor/kreuzberg/tests/plugin_validator_test.rs +783 -783
- data/vendor/kreuzberg/tests/registry_integration_tests.rs +587 -586
- data/vendor/kreuzberg/tests/rst_extractor_tests.rs +694 -692
- data/vendor/kreuzberg/tests/rtf_extractor_tests.rs +775 -776
- data/vendor/kreuzberg/tests/security_validation.rs +416 -415
- data/vendor/kreuzberg/tests/stopwords_integration_test.rs +888 -888
- data/vendor/kreuzberg/tests/test_fastembed.rs +631 -609
- data/vendor/kreuzberg/tests/typst_behavioral_tests.rs +1260 -1259
- data/vendor/kreuzberg/tests/typst_extractor_tests.rs +648 -647
- data/vendor/kreuzberg/tests/xlsx_metadata_extraction_test.rs +87 -87
- data/vendor/kreuzberg-ffi/Cargo.toml +67 -0
- data/vendor/kreuzberg-ffi/README.md +851 -0
- data/vendor/kreuzberg-ffi/benches/result_view_benchmark.rs +227 -0
- data/vendor/kreuzberg-ffi/build.rs +168 -0
- data/vendor/kreuzberg-ffi/cbindgen.toml +37 -0
- data/vendor/kreuzberg-ffi/kreuzberg-ffi.pc.in +12 -0
- data/vendor/kreuzberg-ffi/kreuzberg.h +3012 -0
- data/vendor/kreuzberg-ffi/src/batch_streaming.rs +588 -0
- data/vendor/kreuzberg-ffi/src/config.rs +1341 -0
- data/vendor/kreuzberg-ffi/src/error.rs +901 -0
- data/vendor/kreuzberg-ffi/src/extraction.rs +555 -0
- data/vendor/kreuzberg-ffi/src/helpers.rs +879 -0
- data/vendor/kreuzberg-ffi/src/lib.rs +977 -0
- data/vendor/kreuzberg-ffi/src/memory.rs +493 -0
- data/vendor/kreuzberg-ffi/src/mime.rs +329 -0
- data/vendor/kreuzberg-ffi/src/panic_shield.rs +265 -0
- data/vendor/kreuzberg-ffi/src/plugins/document_extractor.rs +442 -0
- data/vendor/kreuzberg-ffi/src/plugins/mod.rs +14 -0
- data/vendor/kreuzberg-ffi/src/plugins/ocr_backend.rs +628 -0
- data/vendor/kreuzberg-ffi/src/plugins/post_processor.rs +438 -0
- data/vendor/kreuzberg-ffi/src/plugins/validator.rs +329 -0
- data/vendor/kreuzberg-ffi/src/result.rs +510 -0
- data/vendor/kreuzberg-ffi/src/result_pool.rs +639 -0
- data/vendor/kreuzberg-ffi/src/result_view.rs +773 -0
- data/vendor/kreuzberg-ffi/src/string_intern.rs +568 -0
- data/vendor/kreuzberg-ffi/src/types.rs +363 -0
- data/vendor/kreuzberg-ffi/src/util.rs +210 -0
- data/vendor/kreuzberg-ffi/src/validation.rs +848 -0
- data/vendor/kreuzberg-ffi/tests.disabled/README.md +48 -0
- data/vendor/kreuzberg-ffi/tests.disabled/config_loading_tests.rs +299 -0
- data/vendor/kreuzberg-ffi/tests.disabled/config_tests.rs +346 -0
- data/vendor/kreuzberg-ffi/tests.disabled/extractor_tests.rs +232 -0
- data/vendor/kreuzberg-ffi/tests.disabled/plugin_registration_tests.rs +470 -0
- data/vendor/kreuzberg-tesseract/.commitlintrc.json +13 -0
- data/vendor/kreuzberg-tesseract/.crate-ignore +2 -0
- data/vendor/kreuzberg-tesseract/Cargo.lock +2933 -0
- data/vendor/kreuzberg-tesseract/Cargo.toml +57 -0
- data/vendor/{rb-sys/LICENSE-MIT → kreuzberg-tesseract/LICENSE} +22 -21
- data/vendor/kreuzberg-tesseract/README.md +399 -0
- data/vendor/kreuzberg-tesseract/build.rs +1127 -0
- data/vendor/kreuzberg-tesseract/patches/README.md +71 -0
- data/vendor/kreuzberg-tesseract/patches/tesseract.diff +199 -0
- data/vendor/kreuzberg-tesseract/src/api.rs +1371 -0
- data/vendor/kreuzberg-tesseract/src/choice_iterator.rs +77 -0
- data/vendor/kreuzberg-tesseract/src/enums.rs +297 -0
- data/vendor/kreuzberg-tesseract/src/error.rs +81 -0
- data/vendor/kreuzberg-tesseract/src/lib.rs +145 -0
- data/vendor/kreuzberg-tesseract/src/monitor.rs +57 -0
- data/vendor/kreuzberg-tesseract/src/mutable_iterator.rs +197 -0
- data/vendor/kreuzberg-tesseract/src/page_iterator.rs +253 -0
- data/vendor/kreuzberg-tesseract/src/result_iterator.rs +286 -0
- data/vendor/kreuzberg-tesseract/src/result_renderer.rs +183 -0
- data/vendor/kreuzberg-tesseract/tests/integration_test.rs +211 -0
- metadata +196 -45
- data/vendor/kreuzberg/benches/otel_overhead.rs +0 -48
- data/vendor/kreuzberg/src/extractors/fictionbook.rs.backup2 +0 -738
- data/vendor/rb-sys/.cargo-ok +0 -1
- data/vendor/rb-sys/.cargo_vcs_info.json +0 -6
- data/vendor/rb-sys/Cargo.lock +0 -393
- data/vendor/rb-sys/Cargo.toml +0 -70
- data/vendor/rb-sys/Cargo.toml.orig +0 -57
- data/vendor/rb-sys/LICENSE-APACHE +0 -190
- data/vendor/rb-sys/bin/release.sh +0 -21
- data/vendor/rb-sys/build/features.rs +0 -108
- data/vendor/rb-sys/build/main.rs +0 -246
- data/vendor/rb-sys/build/stable_api_config.rs +0 -153
- data/vendor/rb-sys/build/version.rs +0 -48
- data/vendor/rb-sys/readme.md +0 -36
- data/vendor/rb-sys/src/bindings.rs +0 -21
- data/vendor/rb-sys/src/hidden.rs +0 -11
- data/vendor/rb-sys/src/lib.rs +0 -34
- data/vendor/rb-sys/src/macros.rs +0 -371
- data/vendor/rb-sys/src/memory.rs +0 -53
- data/vendor/rb-sys/src/ruby_abi_version.rs +0 -38
- data/vendor/rb-sys/src/special_consts.rs +0 -31
- data/vendor/rb-sys/src/stable_api/compiled.c +0 -179
- data/vendor/rb-sys/src/stable_api/compiled.rs +0 -257
- data/vendor/rb-sys/src/stable_api/ruby_2_6.rs +0 -316
- data/vendor/rb-sys/src/stable_api/ruby_2_7.rs +0 -316
- data/vendor/rb-sys/src/stable_api/ruby_3_0.rs +0 -324
- data/vendor/rb-sys/src/stable_api/ruby_3_1.rs +0 -317
- data/vendor/rb-sys/src/stable_api/ruby_3_2.rs +0 -315
- data/vendor/rb-sys/src/stable_api/ruby_3_3.rs +0 -326
- data/vendor/rb-sys/src/stable_api/ruby_3_4.rs +0 -327
- data/vendor/rb-sys/src/stable_api.rs +0 -261
- data/vendor/rb-sys/src/symbol.rs +0 -31
- data/vendor/rb-sys/src/tracking_allocator.rs +0 -332
- data/vendor/rb-sys/src/utils.rs +0 -89
- data/vendor/rb-sys/src/value_type.rs +0 -7
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
//! PDF text hierarchy extraction using pdfium character positions.
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides functions for extracting character information from PDFs,
|
|
4
|
+
//! preserving font size and position data for text hierarchy analysis.
|
|
5
|
+
//!
|
|
6
|
+
//! Note: Requires the "pdf" feature to be enabled.
|
|
7
|
+
|
|
8
|
+
use super::error::{PdfError, Result};
|
|
9
|
+
use crate::core::config::ExtractionConfig;
|
|
10
|
+
use pdfium_render::prelude::*;
|
|
11
|
+
|
|
12
|
+
// Magic number constants
|
|
13
|
+
const DEFAULT_FONT_SIZE: f32 = 12.0;
|
|
14
|
+
const WEIGHTED_DISTANCE_X_WEIGHT: f32 = 5.0;
|
|
15
|
+
const WEIGHTED_DISTANCE_Y_WEIGHT: f32 = 1.0;
|
|
16
|
+
const KMEANS_MAX_ITERATIONS: usize = 100;
|
|
17
|
+
const KMEANS_CONVERGENCE_THRESHOLD: f32 = 0.01;
|
|
18
|
+
const MERGE_INTERSECTION_THRESHOLD: f32 = 0.05;
|
|
19
|
+
const MERGE_X_THRESHOLD_MULTIPLIER: f32 = 2.0;
|
|
20
|
+
const MERGE_Y_THRESHOLD_MULTIPLIER: f32 = 1.5;
|
|
21
|
+
|
|
22
|
+
/// A bounding box for text or elements.
|
|
23
|
+
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
24
|
+
pub struct BoundingBox {
|
|
25
|
+
/// Left x-coordinate
|
|
26
|
+
pub left: f32,
|
|
27
|
+
/// Top y-coordinate
|
|
28
|
+
pub top: f32,
|
|
29
|
+
/// Right x-coordinate
|
|
30
|
+
pub right: f32,
|
|
31
|
+
/// Bottom y-coordinate
|
|
32
|
+
pub bottom: f32,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl BoundingBox {
|
|
36
|
+
/// Calculate the Intersection over Union (IOU) between this bounding box and another.
|
|
37
|
+
///
|
|
38
|
+
/// IOU = intersection_area / union_area
|
|
39
|
+
///
|
|
40
|
+
/// # Arguments
|
|
41
|
+
///
|
|
42
|
+
/// * `other` - The other bounding box to compare with
|
|
43
|
+
///
|
|
44
|
+
/// # Returns
|
|
45
|
+
///
|
|
46
|
+
/// The IOU value between 0.0 and 1.0
|
|
47
|
+
pub fn iou(&self, other: &BoundingBox) -> f32 {
|
|
48
|
+
let intersection_area = self.calculate_intersection_area(other);
|
|
49
|
+
let self_area = self.calculate_area();
|
|
50
|
+
let other_area = other.calculate_area();
|
|
51
|
+
let union_area = self_area + other_area - intersection_area;
|
|
52
|
+
|
|
53
|
+
if union_area <= 0.0 {
|
|
54
|
+
0.0
|
|
55
|
+
} else {
|
|
56
|
+
intersection_area / union_area
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Calculate the weighted distance between the centers of two bounding boxes.
|
|
61
|
+
///
|
|
62
|
+
/// The distance is weighted with X-axis having weight 5.0 and Y-axis having weight 1.0.
|
|
63
|
+
/// This reflects the greater importance of horizontal distance in text layout.
|
|
64
|
+
///
|
|
65
|
+
/// # Arguments
|
|
66
|
+
///
|
|
67
|
+
/// * `other` - The other bounding box to compare with
|
|
68
|
+
///
|
|
69
|
+
/// # Returns
|
|
70
|
+
///
|
|
71
|
+
/// The weighted distance value
|
|
72
|
+
pub fn weighted_distance(&self, other: &BoundingBox) -> f32 {
|
|
73
|
+
let (self_center_x, self_center_y) = self.center();
|
|
74
|
+
let (other_center_x, other_center_y) = other.center();
|
|
75
|
+
|
|
76
|
+
let dx = (self_center_x - other_center_x).abs();
|
|
77
|
+
let dy = (self_center_y - other_center_y).abs();
|
|
78
|
+
|
|
79
|
+
dx * WEIGHTED_DISTANCE_X_WEIGHT + dy * WEIGHTED_DISTANCE_Y_WEIGHT
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Calculate the intersection ratio relative to this bounding box's area.
|
|
83
|
+
///
|
|
84
|
+
/// intersection_ratio = intersection_area / self_area
|
|
85
|
+
///
|
|
86
|
+
/// # Arguments
|
|
87
|
+
///
|
|
88
|
+
/// * `other` - The other bounding box to compare with
|
|
89
|
+
///
|
|
90
|
+
/// # Returns
|
|
91
|
+
///
|
|
92
|
+
/// The intersection ratio between 0.0 and 1.0
|
|
93
|
+
pub fn intersection_ratio(&self, other: &BoundingBox) -> f32 {
|
|
94
|
+
let intersection_area = self.calculate_intersection_area(other);
|
|
95
|
+
let self_area = self.calculate_area();
|
|
96
|
+
|
|
97
|
+
if self_area <= 0.0 {
|
|
98
|
+
0.0
|
|
99
|
+
} else {
|
|
100
|
+
intersection_area / self_area
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Check if this bounding box contains another bounding box.
|
|
105
|
+
pub fn contains(&self, other: &BoundingBox) -> bool {
|
|
106
|
+
other.left >= self.left && other.right <= self.right && other.top >= self.top && other.bottom <= self.bottom
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Calculate the center coordinates of this bounding box.
|
|
110
|
+
pub fn center(&self) -> (f32, f32) {
|
|
111
|
+
((self.left + self.right) / 2.0, (self.top + self.bottom) / 2.0)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Merge this bounding box with another, creating a box that contains both.
|
|
115
|
+
pub fn merge(&self, other: &BoundingBox) -> BoundingBox {
|
|
116
|
+
BoundingBox {
|
|
117
|
+
left: self.left.min(other.left),
|
|
118
|
+
top: self.top.min(other.top),
|
|
119
|
+
right: self.right.max(other.right),
|
|
120
|
+
bottom: self.bottom.max(other.bottom),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Calculate a relaxed IOU with an expansion factor.
|
|
125
|
+
pub fn relaxed_iou(&self, other: &BoundingBox, relaxation: f32) -> f32 {
|
|
126
|
+
let self_width = self.right - self.left;
|
|
127
|
+
let self_height = self.bottom - self.top;
|
|
128
|
+
let self_expansion = relaxation * self_width.min(self_height).max(0.0);
|
|
129
|
+
|
|
130
|
+
let other_width = other.right - other.left;
|
|
131
|
+
let other_height = other.bottom - other.top;
|
|
132
|
+
let other_expansion = relaxation * other_width.min(other_height).max(0.0);
|
|
133
|
+
|
|
134
|
+
let expanded_self = BoundingBox {
|
|
135
|
+
left: (self.left - self_expansion).max(0.0),
|
|
136
|
+
top: (self.top - self_expansion).max(0.0),
|
|
137
|
+
right: self.right + self_expansion,
|
|
138
|
+
bottom: self.bottom + self_expansion,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
let expanded_other = BoundingBox {
|
|
142
|
+
left: (other.left - other_expansion).max(0.0),
|
|
143
|
+
top: (other.top - other_expansion).max(0.0),
|
|
144
|
+
right: other.right + other_expansion,
|
|
145
|
+
bottom: other.bottom + other_expansion,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
expanded_self.iou(&expanded_other)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Calculate the area of this bounding box.
|
|
152
|
+
fn calculate_area(&self) -> f32 {
|
|
153
|
+
let width = (self.right - self.left).max(0.0);
|
|
154
|
+
let height = (self.bottom - self.top).max(0.0);
|
|
155
|
+
width * height
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Calculate the intersection area between this bounding box and another.
|
|
159
|
+
fn calculate_intersection_area(&self, other: &BoundingBox) -> f32 {
|
|
160
|
+
let left = self.left.max(other.left);
|
|
161
|
+
let top = self.top.max(other.top);
|
|
162
|
+
let right = self.right.min(other.right);
|
|
163
|
+
let bottom = self.bottom.min(other.bottom);
|
|
164
|
+
|
|
165
|
+
let width = (right - left).max(0.0);
|
|
166
|
+
let height = (bottom - top).max(0.0);
|
|
167
|
+
width * height
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Character information extracted from PDF with font metrics.
|
|
172
|
+
#[derive(Debug, Clone)]
|
|
173
|
+
pub struct CharData {
|
|
174
|
+
/// The character text content
|
|
175
|
+
pub text: String,
|
|
176
|
+
/// X position in PDF units
|
|
177
|
+
pub x: f32,
|
|
178
|
+
/// Y position in PDF units
|
|
179
|
+
pub y: f32,
|
|
180
|
+
/// Font size in points
|
|
181
|
+
pub font_size: f32,
|
|
182
|
+
/// Character width in PDF units
|
|
183
|
+
pub width: f32,
|
|
184
|
+
/// Character height in PDF units
|
|
185
|
+
pub height: f32,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// A block of text with spatial and semantic information.
|
|
189
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
190
|
+
pub struct TextBlock {
|
|
191
|
+
/// The text content
|
|
192
|
+
pub text: String,
|
|
193
|
+
/// The bounding box of the block
|
|
194
|
+
pub bbox: BoundingBox,
|
|
195
|
+
/// The font size of the text in this block
|
|
196
|
+
pub font_size: f32,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// A cluster of text blocks with the same font size characteristics.
|
|
200
|
+
#[derive(Debug, Clone)]
|
|
201
|
+
pub struct FontSizeCluster {
|
|
202
|
+
/// The centroid (mean) font size of this cluster
|
|
203
|
+
pub centroid: f32,
|
|
204
|
+
/// The text blocks that belong to this cluster
|
|
205
|
+
pub members: Vec<TextBlock>,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// Result of KMeans clustering on font sizes.
|
|
209
|
+
///
|
|
210
|
+
/// Contains cluster labels for each block, where cluster index indicates
|
|
211
|
+
/// the hierarchy level: 0=H1, 1=H2, ..., 5=H6, 6+=Body.
|
|
212
|
+
#[derive(Debug, Clone)]
|
|
213
|
+
pub struct KMeansResult {
|
|
214
|
+
/// Cluster label for each block (0-indexed)
|
|
215
|
+
pub labels: Vec<u32>,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// Hierarchy level assignment result.
|
|
219
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
220
|
+
pub enum HierarchyLevel {
|
|
221
|
+
/// H1 - Top-level heading
|
|
222
|
+
H1 = 1,
|
|
223
|
+
/// H2 - Secondary heading
|
|
224
|
+
H2 = 2,
|
|
225
|
+
/// H3 - Tertiary heading
|
|
226
|
+
H3 = 3,
|
|
227
|
+
/// H4 - Quaternary heading
|
|
228
|
+
H4 = 4,
|
|
229
|
+
/// H5 - Quinary heading
|
|
230
|
+
H5 = 5,
|
|
231
|
+
/// H6 - Senary heading
|
|
232
|
+
H6 = 6,
|
|
233
|
+
/// Body text
|
|
234
|
+
Body = 0,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// A TextBlock with hierarchy level assignment.
|
|
238
|
+
#[derive(Debug, Clone)]
|
|
239
|
+
pub struct HierarchyBlock {
|
|
240
|
+
/// The text content
|
|
241
|
+
pub text: String,
|
|
242
|
+
/// The bounding box of the block
|
|
243
|
+
pub bbox: BoundingBox,
|
|
244
|
+
/// The font size of the text in this block
|
|
245
|
+
pub font_size: f32,
|
|
246
|
+
/// The hierarchy level of this block (H1-H6 or Body)
|
|
247
|
+
pub hierarchy_level: HierarchyLevel,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
impl HierarchyLevel {
|
|
251
|
+
/// Convert a numeric level to HierarchyLevel.
|
|
252
|
+
pub fn from_level(level: usize) -> Self {
|
|
253
|
+
match level {
|
|
254
|
+
1 => HierarchyLevel::H1,
|
|
255
|
+
2 => HierarchyLevel::H2,
|
|
256
|
+
3 => HierarchyLevel::H3,
|
|
257
|
+
4 => HierarchyLevel::H4,
|
|
258
|
+
5 => HierarchyLevel::H5,
|
|
259
|
+
6 => HierarchyLevel::H6,
|
|
260
|
+
_ => HierarchyLevel::Body,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/// Assign hierarchy levels to text blocks based on KMeans clustering results.
|
|
266
|
+
///
|
|
267
|
+
/// Maps cluster indices to HTML heading levels (H1-H6) and body text:
|
|
268
|
+
/// - Cluster 0 → H1 (top-level heading)
|
|
269
|
+
/// - Cluster 1 → H2 (secondary heading)
|
|
270
|
+
/// - Cluster 2 → H3 (tertiary heading)
|
|
271
|
+
/// - Cluster 3 → H4 (quaternary heading)
|
|
272
|
+
/// - Cluster 4 → H5 (quinary heading)
|
|
273
|
+
/// - Cluster 5 → H6 (senary heading)
|
|
274
|
+
/// - Cluster 6+ → Body (body text)
|
|
275
|
+
///
|
|
276
|
+
/// # Arguments
|
|
277
|
+
///
|
|
278
|
+
/// * `blocks` - Slice of TextBlock objects to assign hierarchy levels to
|
|
279
|
+
/// * `kmeans_result` - KMeansResult containing cluster labels for each block
|
|
280
|
+
///
|
|
281
|
+
/// # Returns
|
|
282
|
+
///
|
|
283
|
+
/// Vector of tuples containing (original block info, hierarchy level)
|
|
284
|
+
///
|
|
285
|
+
/// # Example
|
|
286
|
+
///
|
|
287
|
+
/// ```rust,no_run
|
|
288
|
+
/// # #[cfg(feature = "pdf")]
|
|
289
|
+
/// # {
|
|
290
|
+
/// use kreuzberg::pdf::hierarchy::{TextBlock, BoundingBox, HierarchyLevel, assign_hierarchy_levels, KMeansResult};
|
|
291
|
+
///
|
|
292
|
+
/// let blocks = vec![
|
|
293
|
+
/// TextBlock {
|
|
294
|
+
/// text: "Title".to_string(),
|
|
295
|
+
/// bbox: BoundingBox { left: 0.0, top: 0.0, right: 100.0, bottom: 24.0 },
|
|
296
|
+
/// font_size: 24.0,
|
|
297
|
+
/// },
|
|
298
|
+
/// TextBlock {
|
|
299
|
+
/// text: "Body".to_string(),
|
|
300
|
+
/// bbox: BoundingBox { left: 0.0, top: 30.0, right: 100.0, bottom: 42.0 },
|
|
301
|
+
/// font_size: 12.0,
|
|
302
|
+
/// },
|
|
303
|
+
/// ];
|
|
304
|
+
///
|
|
305
|
+
/// let kmeans_result = KMeansResult {
|
|
306
|
+
/// labels: vec![0, 6],
|
|
307
|
+
/// };
|
|
308
|
+
///
|
|
309
|
+
/// let results = assign_hierarchy_levels(&blocks, &kmeans_result);
|
|
310
|
+
/// assert_eq!(results[0].hierarchy_level, HierarchyLevel::H1);
|
|
311
|
+
/// assert_eq!(results[1].hierarchy_level, HierarchyLevel::Body);
|
|
312
|
+
/// # }
|
|
313
|
+
/// ```
|
|
314
|
+
pub fn assign_hierarchy_levels(blocks: &[TextBlock], kmeans_result: &KMeansResult) -> Vec<HierarchyBlock> {
|
|
315
|
+
if blocks.is_empty() || kmeans_result.labels.is_empty() {
|
|
316
|
+
return Vec::new();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
blocks
|
|
320
|
+
.iter()
|
|
321
|
+
.zip(kmeans_result.labels.iter())
|
|
322
|
+
.map(|(block, &cluster_id)| {
|
|
323
|
+
let hierarchy_level = match cluster_id {
|
|
324
|
+
0 => HierarchyLevel::H1,
|
|
325
|
+
1 => HierarchyLevel::H2,
|
|
326
|
+
2 => HierarchyLevel::H3,
|
|
327
|
+
3 => HierarchyLevel::H4,
|
|
328
|
+
4 => HierarchyLevel::H5,
|
|
329
|
+
5 => HierarchyLevel::H6,
|
|
330
|
+
_ => HierarchyLevel::Body,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
HierarchyBlock {
|
|
334
|
+
text: block.text.clone(),
|
|
335
|
+
bbox: block.bbox,
|
|
336
|
+
font_size: block.font_size,
|
|
337
|
+
hierarchy_level,
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
.collect()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// Assign hierarchy levels to text blocks based on font size clusters.
|
|
344
|
+
///
|
|
345
|
+
/// Maps font size clusters to heading levels (H1-H6) and body text.
|
|
346
|
+
/// Larger font sizes are assigned higher hierarchy levels.
|
|
347
|
+
///
|
|
348
|
+
/// # Arguments
|
|
349
|
+
///
|
|
350
|
+
/// * `blocks` - Vector of TextBlock objects to assign levels to
|
|
351
|
+
/// * `clusters` - Vector of FontSizeCluster objects from clustering
|
|
352
|
+
///
|
|
353
|
+
/// # Returns
|
|
354
|
+
///
|
|
355
|
+
/// Vector of tuples containing (TextBlock, HierarchyLevel).
|
|
356
|
+
/// If blocks is empty or clusters is empty, returns empty vector.
|
|
357
|
+
/// All blocks get Body level if only one cluster exists.
|
|
358
|
+
pub fn assign_hierarchy_levels_from_clusters(
|
|
359
|
+
blocks: &[TextBlock],
|
|
360
|
+
clusters: &[FontSizeCluster],
|
|
361
|
+
) -> Vec<(TextBlock, HierarchyLevel)> {
|
|
362
|
+
// Edge cases: empty inputs
|
|
363
|
+
if blocks.is_empty() || clusters.is_empty() {
|
|
364
|
+
return Vec::new();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// If only one cluster, all text is body
|
|
368
|
+
if clusters.len() == 1 {
|
|
369
|
+
return blocks.iter().map(|b| (b.clone(), HierarchyLevel::Body)).collect();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Map clusters (sorted by centroid) to hierarchy levels
|
|
373
|
+
// We assign up to 6 heading levels, rest are body
|
|
374
|
+
let max_heading_levels = 6;
|
|
375
|
+
let num_headings = (clusters.len() - 1).min(max_heading_levels);
|
|
376
|
+
|
|
377
|
+
// Create a mapping from centroid to hierarchy level
|
|
378
|
+
let mut result = Vec::new();
|
|
379
|
+
|
|
380
|
+
for block in blocks {
|
|
381
|
+
// Find which cluster this block belongs to
|
|
382
|
+
let mut assigned_level = HierarchyLevel::Body;
|
|
383
|
+
|
|
384
|
+
for (idx, cluster) in clusters.iter().enumerate() {
|
|
385
|
+
// Check if block's font size is close to this cluster's centroid
|
|
386
|
+
let font_size = block.font_size;
|
|
387
|
+
if (font_size - cluster.centroid).abs() < 1.0 || cluster.members.contains(block) {
|
|
388
|
+
// Map cluster index to hierarchy level (largest centroid = H1)
|
|
389
|
+
if idx < num_headings {
|
|
390
|
+
assigned_level = HierarchyLevel::from_level(idx + 1);
|
|
391
|
+
} else {
|
|
392
|
+
assigned_level = HierarchyLevel::Body;
|
|
393
|
+
}
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
result.push((block.clone(), assigned_level));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
result
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/// Cluster text blocks by font size using k-means algorithm.
|
|
405
|
+
///
|
|
406
|
+
/// Uses k-means clustering to group text blocks by their font size, which helps
|
|
407
|
+
/// identify document hierarchy levels (H1, H2, Body, etc.). The algorithm:
|
|
408
|
+
/// 1. Extracts font sizes from text blocks
|
|
409
|
+
/// 2. Applies k-means clustering to group similar font sizes
|
|
410
|
+
/// 3. Sorts clusters by centroid size in descending order (largest = H1)
|
|
411
|
+
/// 4. Returns clusters with their member blocks
|
|
412
|
+
///
|
|
413
|
+
/// # Arguments
|
|
414
|
+
///
|
|
415
|
+
/// * `blocks` - Slice of TextBlock objects to cluster
|
|
416
|
+
/// * `k` - Number of clusters to create
|
|
417
|
+
///
|
|
418
|
+
/// # Returns
|
|
419
|
+
///
|
|
420
|
+
/// Result with vector of FontSizeCluster ordered by size (descending),
|
|
421
|
+
/// or an error if clustering fails
|
|
422
|
+
///
|
|
423
|
+
/// # Example
|
|
424
|
+
///
|
|
425
|
+
/// ```rust,no_run
|
|
426
|
+
/// # #[cfg(feature = "pdf")]
|
|
427
|
+
/// # {
|
|
428
|
+
/// use kreuzberg::pdf::hierarchy::{TextBlock, BoundingBox, cluster_font_sizes};
|
|
429
|
+
///
|
|
430
|
+
/// let blocks = vec![
|
|
431
|
+
/// TextBlock {
|
|
432
|
+
/// text: "Title".to_string(),
|
|
433
|
+
/// bbox: BoundingBox { left: 0.0, top: 0.0, right: 100.0, bottom: 24.0 },
|
|
434
|
+
/// font_size: 24.0,
|
|
435
|
+
/// },
|
|
436
|
+
/// TextBlock {
|
|
437
|
+
/// text: "Body".to_string(),
|
|
438
|
+
/// bbox: BoundingBox { left: 0.0, top: 30.0, right: 100.0, bottom: 42.0 },
|
|
439
|
+
/// font_size: 12.0,
|
|
440
|
+
/// },
|
|
441
|
+
/// ];
|
|
442
|
+
///
|
|
443
|
+
/// let clusters = cluster_font_sizes(&blocks, 2).unwrap();
|
|
444
|
+
/// assert_eq!(clusters.len(), 2);
|
|
445
|
+
/// assert_eq!(clusters[0].centroid, 24.0); // Largest is first
|
|
446
|
+
/// # }
|
|
447
|
+
/// ```
|
|
448
|
+
/// Helper function to assign blocks to their nearest centroid.
|
|
449
|
+
///
|
|
450
|
+
/// Iterates through blocks and finds the closest centroid for each block,
|
|
451
|
+
/// grouping them into clusters. Used in k-means clustering iterations.
|
|
452
|
+
///
|
|
453
|
+
/// # Arguments
|
|
454
|
+
///
|
|
455
|
+
/// * `blocks` - Slice of TextBlock objects to assign
|
|
456
|
+
/// * `centroids` - Slice of centroid values (one per cluster)
|
|
457
|
+
///
|
|
458
|
+
/// # Returns
|
|
459
|
+
///
|
|
460
|
+
/// A vector of clusters, where each cluster contains the TextBlock objects
|
|
461
|
+
/// assigned to that centroid
|
|
462
|
+
fn assign_blocks_to_centroids(blocks: &[TextBlock], centroids: &[f32]) -> Vec<Vec<TextBlock>> {
|
|
463
|
+
let mut clusters: Vec<Vec<TextBlock>> = vec![Vec::new(); centroids.len()];
|
|
464
|
+
|
|
465
|
+
for block in blocks {
|
|
466
|
+
let mut min_distance = f32::INFINITY;
|
|
467
|
+
let mut best_cluster = 0;
|
|
468
|
+
|
|
469
|
+
for (i, ¢roid) in centroids.iter().enumerate() {
|
|
470
|
+
let distance = (block.font_size - centroid).abs();
|
|
471
|
+
if distance < min_distance {
|
|
472
|
+
min_distance = distance;
|
|
473
|
+
best_cluster = i;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
clusters[best_cluster].push(block.clone());
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
clusters
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
pub fn cluster_font_sizes(blocks: &[TextBlock], k: usize) -> Result<Vec<FontSizeCluster>> {
|
|
484
|
+
if blocks.is_empty() {
|
|
485
|
+
return Ok(Vec::new());
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if k == 0 {
|
|
489
|
+
return Err(PdfError::TextExtractionFailed("K must be greater than 0".to_string()));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let actual_k = k.min(blocks.len());
|
|
493
|
+
|
|
494
|
+
// Extract unique font sizes for initialization
|
|
495
|
+
let mut font_sizes: Vec<f32> = blocks.iter().map(|b| b.font_size).collect();
|
|
496
|
+
font_sizes.sort_by(|a, b| b.partial_cmp(a).expect("Failed to compare font sizes during sorting")); // Sort descending
|
|
497
|
+
font_sizes.dedup(); // Remove duplicates to get unique font sizes
|
|
498
|
+
|
|
499
|
+
// Initialize centroids using actual font sizes from the data
|
|
500
|
+
// This is more robust than dividing the range uniformly
|
|
501
|
+
let mut centroids: Vec<f32> = Vec::new();
|
|
502
|
+
|
|
503
|
+
if font_sizes.len() >= actual_k {
|
|
504
|
+
// If we have at least k unique font sizes, pick them evenly spaced
|
|
505
|
+
let step = font_sizes.len() / actual_k;
|
|
506
|
+
for i in 0..actual_k {
|
|
507
|
+
let idx = i * step;
|
|
508
|
+
centroids.push(font_sizes[idx.min(font_sizes.len() - 1)]);
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
// If we have fewer unique sizes than k, use all of them and fill with interpolated values
|
|
512
|
+
centroids = font_sizes.clone();
|
|
513
|
+
|
|
514
|
+
// Add interpolated centroids between existing ones to reach desired k
|
|
515
|
+
let min_font = font_sizes[font_sizes.len() - 1];
|
|
516
|
+
let max_font = font_sizes[0];
|
|
517
|
+
let range = max_font - min_font;
|
|
518
|
+
|
|
519
|
+
while centroids.len() < actual_k {
|
|
520
|
+
let t = centroids.len() as f32 / (actual_k - 1) as f32;
|
|
521
|
+
let interpolated = max_font - t * range;
|
|
522
|
+
centroids.push(interpolated);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
centroids.sort_by(|a, b| b.partial_cmp(a).expect("Failed to compare centroids during sorting"));
|
|
526
|
+
// Keep sorted descending
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Run k-means clustering for a fixed number of iterations
|
|
530
|
+
for _ in 0..KMEANS_MAX_ITERATIONS {
|
|
531
|
+
// Assign blocks to nearest centroid
|
|
532
|
+
let clusters = assign_blocks_to_centroids(blocks, ¢roids);
|
|
533
|
+
|
|
534
|
+
// Update centroids
|
|
535
|
+
let mut new_centroids = Vec::with_capacity(actual_k);
|
|
536
|
+
for (i, cluster) in clusters.iter().enumerate() {
|
|
537
|
+
if !cluster.is_empty() {
|
|
538
|
+
new_centroids.push(cluster.iter().map(|b| b.font_size).sum::<f32>() / cluster.len() as f32);
|
|
539
|
+
} else {
|
|
540
|
+
new_centroids.push(centroids[i]);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Check for convergence
|
|
545
|
+
let converged = centroids
|
|
546
|
+
.iter()
|
|
547
|
+
.zip(new_centroids.iter())
|
|
548
|
+
.all(|(old, new)| (old - new).abs() < KMEANS_CONVERGENCE_THRESHOLD);
|
|
549
|
+
|
|
550
|
+
std::mem::swap(&mut centroids, &mut new_centroids);
|
|
551
|
+
|
|
552
|
+
if converged {
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Final assignment to create result
|
|
558
|
+
let clusters = assign_blocks_to_centroids(blocks, ¢roids);
|
|
559
|
+
|
|
560
|
+
// Create FontSizeCluster objects with centroids
|
|
561
|
+
let mut result: Vec<FontSizeCluster> = Vec::new();
|
|
562
|
+
|
|
563
|
+
for i in 0..actual_k {
|
|
564
|
+
if !clusters[i].is_empty() {
|
|
565
|
+
let centroid_value = centroids[i];
|
|
566
|
+
result.push(FontSizeCluster {
|
|
567
|
+
centroid: centroid_value,
|
|
568
|
+
members: clusters[i].clone(),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Sort by centroid size in descending order (largest font = H1)
|
|
574
|
+
result.sort_by(|a, b| {
|
|
575
|
+
b.centroid
|
|
576
|
+
.partial_cmp(&a.centroid)
|
|
577
|
+
.expect("Failed to compare centroids during final sort")
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
Ok(result)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/// Extract characters with fonts from a PDF page.
|
|
584
|
+
///
|
|
585
|
+
/// Iterates through all characters on a page, extracting text, position,
|
|
586
|
+
/// and font size information. Characters are returned in page order.
|
|
587
|
+
///
|
|
588
|
+
/// # Arguments
|
|
589
|
+
///
|
|
590
|
+
/// * `page` - PDF page to extract characters from
|
|
591
|
+
///
|
|
592
|
+
/// # Returns
|
|
593
|
+
///
|
|
594
|
+
/// Vector of CharData objects containing text and positioning information.
|
|
595
|
+
///
|
|
596
|
+
/// # Example
|
|
597
|
+
///
|
|
598
|
+
/// ```rust,no_run
|
|
599
|
+
/// # #[cfg(feature = "pdf")]
|
|
600
|
+
/// # {
|
|
601
|
+
/// use kreuzberg::pdf::hierarchy::extract_chars_with_fonts;
|
|
602
|
+
/// use pdfium_render::prelude::*;
|
|
603
|
+
///
|
|
604
|
+
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
605
|
+
/// let pdfium = Pdfium::default();
|
|
606
|
+
/// let document = pdfium.load_pdf_from_file("example.pdf", None)?;
|
|
607
|
+
/// let page = document.pages().get(0)?;
|
|
608
|
+
/// let chars = extract_chars_with_fonts(&page)?;
|
|
609
|
+
/// # Ok(())
|
|
610
|
+
/// # }
|
|
611
|
+
/// # }
|
|
612
|
+
/// ```
|
|
613
|
+
pub fn extract_chars_with_fonts(page: &PdfPage) -> Result<Vec<CharData>> {
|
|
614
|
+
let page_text = page
|
|
615
|
+
.text()
|
|
616
|
+
.map_err(|e| PdfError::TextExtractionFailed(format!("Failed to get page text: {}", e)))?;
|
|
617
|
+
|
|
618
|
+
let chars = page_text.chars();
|
|
619
|
+
let char_count = chars.len();
|
|
620
|
+
let mut char_data_list = Vec::with_capacity(char_count);
|
|
621
|
+
|
|
622
|
+
// Use indexed access instead of iterator to avoid potential PDFium issues
|
|
623
|
+
for i in 0..char_count {
|
|
624
|
+
let Ok(pdf_char) = chars.get(i) else {
|
|
625
|
+
continue;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// Get character unicode - skip if not available
|
|
629
|
+
let Some(ch) = pdf_char.unicode_char() else {
|
|
630
|
+
continue;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// Get font size - use DEFAULT_FONT_SIZE if not available
|
|
634
|
+
let font_size = pdf_char.unscaled_font_size().value;
|
|
635
|
+
let font_size = if font_size > 0.0 { font_size } else { DEFAULT_FONT_SIZE };
|
|
636
|
+
|
|
637
|
+
// Get character bounds - skip character if bounds not available
|
|
638
|
+
let Ok(bounds) = pdf_char.loose_bounds() else {
|
|
639
|
+
continue;
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// Extract position and size information
|
|
643
|
+
let char_data = CharData {
|
|
644
|
+
text: ch.to_string(),
|
|
645
|
+
x: bounds.left().value,
|
|
646
|
+
y: bounds.bottom().value,
|
|
647
|
+
width: bounds.width().value,
|
|
648
|
+
height: bounds.height().value,
|
|
649
|
+
font_size,
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
char_data_list.push(char_data);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
Ok(char_data_list)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/// Merge characters into text blocks using a greedy clustering algorithm.
|
|
659
|
+
///
|
|
660
|
+
/// Groups characters based on spatial proximity using weighted distance and
|
|
661
|
+
/// intersection ratio metrics. Characters are merged greedily based on their
|
|
662
|
+
/// proximity and overlap.
|
|
663
|
+
///
|
|
664
|
+
/// # Arguments
|
|
665
|
+
///
|
|
666
|
+
/// * `chars` - Vector of CharData to merge into blocks
|
|
667
|
+
///
|
|
668
|
+
/// # Returns
|
|
669
|
+
///
|
|
670
|
+
/// Vector of TextBlock objects containing merged characters
|
|
671
|
+
///
|
|
672
|
+
/// # Algorithm
|
|
673
|
+
///
|
|
674
|
+
/// The function uses a greedy approach:
|
|
675
|
+
/// 1. Create bounding boxes for each character
|
|
676
|
+
/// 2. Use weighted_distance (5.0 * dx + 1.0 * dy) with maximum threshold of ~2.5x font size
|
|
677
|
+
/// 3. Use intersection_ratio to detect overlapping or very close characters
|
|
678
|
+
/// 4. Merge characters into blocks based on proximity thresholds
|
|
679
|
+
/// 5. Return sorted blocks by position (top to bottom, left to right)
|
|
680
|
+
pub fn merge_chars_into_blocks(chars: Vec<CharData>) -> Vec<TextBlock> {
|
|
681
|
+
if chars.is_empty() {
|
|
682
|
+
return Vec::new();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Create bounding boxes for each character
|
|
686
|
+
let mut char_boxes: Vec<(CharData, BoundingBox)> = chars
|
|
687
|
+
.into_iter()
|
|
688
|
+
.map(|char_data| {
|
|
689
|
+
let bbox = BoundingBox {
|
|
690
|
+
left: char_data.x,
|
|
691
|
+
top: char_data.y - char_data.height,
|
|
692
|
+
right: char_data.x + char_data.width,
|
|
693
|
+
bottom: char_data.y,
|
|
694
|
+
};
|
|
695
|
+
(char_data, bbox)
|
|
696
|
+
})
|
|
697
|
+
.collect();
|
|
698
|
+
|
|
699
|
+
// Sort by position (top to bottom, then left to right)
|
|
700
|
+
char_boxes.sort_by(|a, b| {
|
|
701
|
+
let y_diff =
|
|
702
|
+
a.1.top
|
|
703
|
+
.partial_cmp(&b.1.top)
|
|
704
|
+
.expect("Failed to compare top coordinates");
|
|
705
|
+
if y_diff != std::cmp::Ordering::Equal {
|
|
706
|
+
y_diff
|
|
707
|
+
} else {
|
|
708
|
+
a.1.left
|
|
709
|
+
.partial_cmp(&b.1.left)
|
|
710
|
+
.expect("Failed to compare left coordinates")
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// Greedy merging using union-find-like approach
|
|
715
|
+
let mut blocks: Vec<Vec<CharData>> = Vec::new();
|
|
716
|
+
let mut used = vec![false; char_boxes.len()];
|
|
717
|
+
|
|
718
|
+
for i in 0..char_boxes.len() {
|
|
719
|
+
if used[i] {
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
let mut current_block = vec![char_boxes[i].0.clone()];
|
|
724
|
+
let mut block_bbox = char_boxes[i].1;
|
|
725
|
+
used[i] = true;
|
|
726
|
+
|
|
727
|
+
// Try to merge with nearby characters
|
|
728
|
+
let mut changed = true;
|
|
729
|
+
while changed {
|
|
730
|
+
changed = false;
|
|
731
|
+
|
|
732
|
+
for j in (i + 1)..char_boxes.len() {
|
|
733
|
+
if used[j] {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
let next_char = &char_boxes[j];
|
|
738
|
+
let next_bbox = char_boxes[j].1;
|
|
739
|
+
|
|
740
|
+
// Calculate merge thresholds based on font size
|
|
741
|
+
let avg_font_size = (block_bbox.bottom - block_bbox.top).max(next_bbox.bottom - next_bbox.top);
|
|
742
|
+
|
|
743
|
+
let intersection_ratio = block_bbox.intersection_ratio(&next_bbox);
|
|
744
|
+
|
|
745
|
+
// Check individual component distances
|
|
746
|
+
let (self_center_x, self_center_y) = block_bbox.center();
|
|
747
|
+
let (other_center_x, other_center_y) = next_bbox.center();
|
|
748
|
+
let dx = (self_center_x - other_center_x).abs();
|
|
749
|
+
let dy = (self_center_y - other_center_y).abs();
|
|
750
|
+
|
|
751
|
+
// Separate thresholds for X and Y to handle different scenarios
|
|
752
|
+
// Horizontal merging: allow up to 2-3 character widths apart (typical letter spacing)
|
|
753
|
+
// Width per character ≈ 0.6 * font_size, spacing between chars ≈ 0.3 * font_size
|
|
754
|
+
let x_threshold = avg_font_size * MERGE_X_THRESHOLD_MULTIPLIER;
|
|
755
|
+
// Vertical merging: allow characters on same line (Y threshold is font height)
|
|
756
|
+
let y_threshold = avg_font_size * MERGE_Y_THRESHOLD_MULTIPLIER;
|
|
757
|
+
|
|
758
|
+
// Merge if close enough in both dimensions or overlapping
|
|
759
|
+
let merge_by_distance = (dx < x_threshold) && (dy < y_threshold);
|
|
760
|
+
if merge_by_distance || intersection_ratio > MERGE_INTERSECTION_THRESHOLD {
|
|
761
|
+
current_block.push(next_char.0.clone());
|
|
762
|
+
// Expand bounding box
|
|
763
|
+
block_bbox.left = block_bbox.left.min(next_bbox.left);
|
|
764
|
+
block_bbox.top = block_bbox.top.min(next_bbox.top);
|
|
765
|
+
block_bbox.right = block_bbox.right.max(next_bbox.right);
|
|
766
|
+
block_bbox.bottom = block_bbox.bottom.max(next_bbox.bottom);
|
|
767
|
+
used[j] = true;
|
|
768
|
+
changed = true;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
blocks.push(current_block);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Convert blocks to TextBlock objects
|
|
777
|
+
blocks
|
|
778
|
+
.into_iter()
|
|
779
|
+
.map(|block| {
|
|
780
|
+
let text = block.iter().map(|c| c.text.clone()).collect::<String>();
|
|
781
|
+
|
|
782
|
+
// Calculate bounding box and average font size in a single fold operation
|
|
783
|
+
let (min_x, min_y, max_x, max_y, total_font_size) = block.iter().fold(
|
|
784
|
+
(f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY, 0.0),
|
|
785
|
+
|(min_x, min_y, max_x, max_y, total_font_size), char_data| {
|
|
786
|
+
(
|
|
787
|
+
min_x.min(char_data.x),
|
|
788
|
+
min_y.min(char_data.y - char_data.height),
|
|
789
|
+
max_x.max(char_data.x + char_data.width),
|
|
790
|
+
max_y.max(char_data.y),
|
|
791
|
+
total_font_size + char_data.font_size,
|
|
792
|
+
)
|
|
793
|
+
},
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
let avg_font_size = total_font_size / block.len() as f32;
|
|
797
|
+
|
|
798
|
+
// Bounding box coordinates (allow negative values from PDFs)
|
|
799
|
+
TextBlock {
|
|
800
|
+
text,
|
|
801
|
+
bbox: BoundingBox {
|
|
802
|
+
left: min_x,
|
|
803
|
+
top: min_y,
|
|
804
|
+
right: max_x,
|
|
805
|
+
bottom: max_y,
|
|
806
|
+
},
|
|
807
|
+
font_size: avg_font_size,
|
|
808
|
+
}
|
|
809
|
+
})
|
|
810
|
+
.collect()
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/// Determine whether OCR should be triggered based on text block coverage.
|
|
814
|
+
///
|
|
815
|
+
/// Analyzes the coverage of text blocks on a PDF page and decides if OCR
|
|
816
|
+
/// should be run. OCR is triggered when the text blocks cover less than a
|
|
817
|
+
/// certain percentage (default 50%) of the page area.
|
|
818
|
+
///
|
|
819
|
+
/// # Arguments
|
|
820
|
+
///
|
|
821
|
+
/// * `page` - The PDF page to analyze
|
|
822
|
+
/// * `blocks` - Slice of TextBlock objects present on the page
|
|
823
|
+
/// * `config` - Extraction configuration containing OCR and PDF settings
|
|
824
|
+
///
|
|
825
|
+
/// # Returns
|
|
826
|
+
///
|
|
827
|
+
/// `true` if OCR should be triggered (coverage below threshold), `false` otherwise.
|
|
828
|
+
pub fn should_trigger_ocr(page: &PdfPage, blocks: &[TextBlock], config: &ExtractionConfig) -> bool {
|
|
829
|
+
// Get page dimensions using width() and height() methods
|
|
830
|
+
let page_width = page.width().value;
|
|
831
|
+
let page_height = page.height().value;
|
|
832
|
+
let page_area = page_width * page_height;
|
|
833
|
+
|
|
834
|
+
// Handle edge case: invalid page area
|
|
835
|
+
if page_area <= 0.0 {
|
|
836
|
+
return true; // Trigger OCR for invalid pages
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Calculate total text block area
|
|
840
|
+
let text_area: f32 = blocks
|
|
841
|
+
.iter()
|
|
842
|
+
.map(|block| {
|
|
843
|
+
let width = (block.bbox.right - block.bbox.left).max(0.0);
|
|
844
|
+
let height = (block.bbox.bottom - block.bbox.top).max(0.0);
|
|
845
|
+
width * height
|
|
846
|
+
})
|
|
847
|
+
.sum();
|
|
848
|
+
|
|
849
|
+
// Calculate coverage ratio
|
|
850
|
+
let coverage = text_area / page_area;
|
|
851
|
+
|
|
852
|
+
// Get the OCR coverage threshold from config
|
|
853
|
+
// Try to get from hierarchy config first, then fall back to default 0.5 (50%)
|
|
854
|
+
let threshold = config
|
|
855
|
+
.pdf_options
|
|
856
|
+
.as_ref()
|
|
857
|
+
.and_then(|pdf_config| pdf_config.hierarchy.as_ref())
|
|
858
|
+
.and_then(|hierarchy_config| hierarchy_config.ocr_coverage_threshold)
|
|
859
|
+
.unwrap_or(0.5);
|
|
860
|
+
|
|
861
|
+
// Trigger OCR if coverage is below threshold
|
|
862
|
+
coverage < threshold
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
#[cfg(test)]
|
|
866
|
+
mod tests {
|
|
867
|
+
use super::*;
|
|
868
|
+
|
|
869
|
+
#[test]
|
|
870
|
+
fn test_char_data_creation() {
|
|
871
|
+
let char_data = CharData {
|
|
872
|
+
text: "A".to_string(),
|
|
873
|
+
x: 100.0,
|
|
874
|
+
y: 50.0,
|
|
875
|
+
font_size: 12.0,
|
|
876
|
+
width: 10.0,
|
|
877
|
+
height: 12.0,
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
assert_eq!(char_data.text, "A");
|
|
881
|
+
assert_eq!(char_data.x, 100.0);
|
|
882
|
+
assert_eq!(char_data.y, 50.0);
|
|
883
|
+
assert_eq!(char_data.font_size, 12.0);
|
|
884
|
+
assert_eq!(char_data.width, 10.0);
|
|
885
|
+
assert_eq!(char_data.height, 12.0);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
#[test]
|
|
889
|
+
fn test_char_data_clone() {
|
|
890
|
+
let char_data = CharData {
|
|
891
|
+
text: "B".to_string(),
|
|
892
|
+
x: 200.0,
|
|
893
|
+
y: 100.0,
|
|
894
|
+
font_size: 14.0,
|
|
895
|
+
width: 8.0,
|
|
896
|
+
height: 14.0,
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
let cloned = char_data.clone();
|
|
900
|
+
assert_eq!(cloned.text, char_data.text);
|
|
901
|
+
assert_eq!(cloned.font_size, char_data.font_size);
|
|
902
|
+
}
|
|
903
|
+
}
|